Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup - Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access - Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal) - Login rate limiting (exponential backoff 1s–30s cap) per IP - Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping) - Every account can manage every other account (no roles in v1) - Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check - Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed - Account tab for personal token management; sign-out button in topbar - Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, ApiError } from './api'
|
||||
import type { APIToken, Me } from './types'
|
||||
import { Notice } from './ui'
|
||||
|
||||
export function Account({ me }: { me: Me }) {
|
||||
const [tokens, setTokens] = useState<APIToken[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
// Set only right after a create; the plaintext is never retrievable again.
|
||||
const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setTokens(await api.tokens())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const create = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const tok = await api.createToken(trimmed)
|
||||
setRevealed({ name: tok.name, token: tok.token })
|
||||
setName('')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!confirm('Revoke this token? Anything using it stops working immediately.')) return
|
||||
await api.revokeToken(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 720 }}>
|
||||
<div className="section" style={{ padding: 0, border: 'none' }}>
|
||||
<h3>signed in as</h3>
|
||||
<div className="mono">{me.username}</div>
|
||||
</div>
|
||||
|
||||
<div className="section" style={{ padding: 0, border: 'none' }}>
|
||||
<h3>personal API tokens</h3>
|
||||
<div className="small faint">
|
||||
For scripted access: pass a token as <span className="mono">X-Auth-Token</span> or{' '}
|
||||
<span className="mono">Authorization: Bearer …</span>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="err">{error}</Notice>}
|
||||
|
||||
{revealed && (
|
||||
<Notice kind="warn">
|
||||
Token <b>{revealed.name}</b> — copy it now, it will not be shown again:
|
||||
<div className="fingerprint" style={{ marginTop: 6 }}>{revealed.token}</div>
|
||||
<button className="btn tiny" style={{ marginTop: 6 }} onClick={() => setRevealed(null)}>done</button>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="token name, e.g. ci-backup"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && create()}
|
||||
/>
|
||||
<button className="btn primary" onClick={create} disabled={busy || !name.trim()}>create token</button>
|
||||
</div>
|
||||
|
||||
{tokens.length === 0 ? (
|
||||
<div className="empty">no personal tokens yet</div>
|
||||
) : (
|
||||
<table className="mount-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th style={{ width: 90 }}>ends in</th>
|
||||
<th style={{ width: 170 }}>created</th>
|
||||
<th style={{ width: 170 }}>last used</th>
|
||||
<th style={{ width: 80 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.name}</td>
|
||||
<td className="mono">…{t.hint}</td>
|
||||
<td className="small faint">{new Date(t.createdAt).toLocaleString()}</td>
|
||||
<td className="small faint">{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}</td>
|
||||
<td>
|
||||
<button className="btn tiny danger" onClick={() => revoke(t.id)}>revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { api } from './api'
|
||||
import type {
|
||||
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, Source, SourceResponse,
|
||||
Connection, Health, ItemSelection, JobSnapshot, Me, Options, PackageInfo, Plan, Source, SourceResponse,
|
||||
SourceStatus, TargetInventory,
|
||||
} from './types'
|
||||
import { Notice } from './ui'
|
||||
@@ -10,10 +10,11 @@ import { SourcePanel } from './SourcePanel'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Jobs } from './Jobs'
|
||||
import { Packages } from './Packages'
|
||||
import { Account } from './Account'
|
||||
|
||||
type View = 'containers' | 'jobs' | 'packages'
|
||||
type View = 'containers' | 'jobs' | 'packages' | 'account'
|
||||
|
||||
export default function App() {
|
||||
export default function App({ me, logout }: { me: Me; logout: () => void }) {
|
||||
const [health, setHealth] = useState<Health | null>(null)
|
||||
const [source, setSource] = useState<SourceResponse | null>(null)
|
||||
const [sel, setSel] = useState<Record<string, ItemSelection>>({})
|
||||
@@ -174,6 +175,9 @@ export default function App() {
|
||||
Packages
|
||||
{packages.length > 0 && <span className="count">{packages.length}</span>}
|
||||
</button>
|
||||
<button className={`tab${view === 'account' ? ' active' : ''}`} onClick={() => setView('account')}>
|
||||
Account
|
||||
</button>
|
||||
</nav>
|
||||
<div className="topbar-right">
|
||||
{health && (
|
||||
@@ -187,6 +191,8 @@ export default function App() {
|
||||
<button className="btn tiny" onClick={loadSource} disabled={loading}>
|
||||
{loading ? 'loading…' : 'refresh'}
|
||||
</button>
|
||||
<span className="small faint">{me.username}</span>
|
||||
<button className="btn tiny ghost" onClick={logout}>sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -232,6 +238,7 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
{view === 'packages' && <Packages packages={packages} reload={loadPackages} />}
|
||||
{view === 'account' && <Account me={me} />}
|
||||
</main>
|
||||
|
||||
{view === 'containers' && (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import App from './App'
|
||||
import { api, setUnauthorizedHandler } from './api'
|
||||
import { Login } from './Login'
|
||||
import type { Me } from './types'
|
||||
|
||||
/**
|
||||
* AuthGate decides, on load and after every 401, whether to show the setup
|
||||
* screen, a login form, or the app itself. It owns the one GET /api/me call
|
||||
* the app needs before it can render anything real.
|
||||
*/
|
||||
export function AuthGate() {
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setMe(await api.me())
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// A 401 elsewhere in the app (an expired or revoked session) means the
|
||||
// server no longer considers us logged in; drop straight to the login form
|
||||
// instead of waiting for the next unrelated re-render to notice.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setMe((prev) => (prev ? { ...prev, authenticated: false } : prev)))
|
||||
return () => setUnauthorizedHandler(null)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout().catch(() => undefined)
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
if (!me) {
|
||||
return <div className="empty">{error || 'loading…'}</div>
|
||||
}
|
||||
if (!me.authenticated) {
|
||||
return <Login needsSetup={!!me.needsSetup} onDone={load} />
|
||||
}
|
||||
return <App me={me} logout={logout} />
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { api, ApiError } from './api'
|
||||
import { Notice } from './ui'
|
||||
|
||||
/**
|
||||
* Login is the one form for both first-run setup and every login after that:
|
||||
* the server itself has no separate "register" concept, just an account
|
||||
* store that starts empty.
|
||||
*/
|
||||
export function Login({ needsSetup, onDone }: { needsSetup: boolean; onDone: () => void }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (needsSetup) {
|
||||
await api.setup(username, password)
|
||||
} else {
|
||||
await api.login(username, password)
|
||||
}
|
||||
onDone()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<form className="login-box" onSubmit={submit}>
|
||||
<div className="brand"><img src="/logo-icon.png" alt="" className="brand-logo" />DockMV</div>
|
||||
<h1>{needsSetup ? 'Create your account' : 'Sign in'}</h1>
|
||||
|
||||
{needsSetup && (
|
||||
<Notice kind="info">
|
||||
No account exists yet. Create the first one to finish setup — every account can manage every
|
||||
other one, there are no separate roles yet.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>username</span>
|
||||
<input
|
||||
type="text" autoFocus autoComplete="username"
|
||||
value={username} onChange={(e) => setUsername(e.target.value)} required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>password</span>
|
||||
<input
|
||||
type="password" autoComplete={needsSetup ? 'new-password' : 'current-password'}
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <Notice kind="err">{error}</Notice>}
|
||||
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{busy ? 'please wait…' : needsSetup ? 'create account' : 'sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+34
-24
@@ -1,23 +1,8 @@
|
||||
import type {
|
||||
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
|
||||
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory,
|
||||
APIToken, Connection, Health, HostKeyInfo, JobSnapshot, Me, PackageInfo, Plan,
|
||||
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory, User,
|
||||
} from './types'
|
||||
|
||||
// The token, when the server requires one, arrives as a query parameter the
|
||||
// first time and is kept for the tab afterwards.
|
||||
function readToken(): string {
|
||||
const fromUrl = new URLSearchParams(location.search).get('token')
|
||||
if (fromUrl) {
|
||||
sessionStorage.setItem('dm.token', fromUrl)
|
||||
const clean = location.pathname + location.hash
|
||||
history.replaceState(null, '', clean)
|
||||
return fromUrl
|
||||
}
|
||||
return sessionStorage.getItem('dm.token') ?? ''
|
||||
}
|
||||
|
||||
const token = readToken()
|
||||
|
||||
/** ApiError carries the server's message plus anything it attached to it. */
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
@@ -33,12 +18,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Paths whose own job is to report or resolve "not authenticated" — a 401
|
||||
// from one of these is the expected outcome of a bad login, not a session
|
||||
// that dropped out from under the app, so it must not trigger the global
|
||||
// handler below.
|
||||
const authPaths = new Set(['/api/me', '/api/login', '/api/setup'])
|
||||
|
||||
// Set by AuthGate so a 401 on any other endpoint (a session that expired or
|
||||
// was revoked mid-use) drops the app back to the login screen, without every
|
||||
// call site having to check for it.
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null) {
|
||||
onUnauthorized = fn
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
if (init?.body) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(path, { ...init, headers })
|
||||
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
|
||||
if (res.status === 401 && !authPaths.has(path)) onUnauthorized?.()
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const text = await res.text()
|
||||
@@ -65,6 +64,19 @@ export const api = {
|
||||
source: () => request<SourceResponse>('/api/source'),
|
||||
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
|
||||
|
||||
me: () => request<Me>('/api/me'),
|
||||
setup: (username: string, password: string) => post<Me>('/api/setup', { username, password }),
|
||||
login: (username: string, password: string) => post<Me>('/api/login', { username, password }),
|
||||
logout: () => request<void>('/api/logout', { method: 'POST' }),
|
||||
|
||||
users: () => request<User[]>('/api/users'),
|
||||
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
|
||||
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
tokens: () => request<APIToken[]>('/api/tokens'),
|
||||
createToken: (name: string) => post<APIToken & { token: string }>('/api/tokens', { name }),
|
||||
revokeToken: (id: string) => request<void>(`/api/tokens/${id}`, { method: 'DELETE' }),
|
||||
|
||||
sources: () => request<SourcesResponse>('/api/sources'),
|
||||
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
|
||||
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
|
||||
@@ -92,10 +104,8 @@ export const api = {
|
||||
|
||||
packages: () => request<PackageInfo[]>('/api/packages'),
|
||||
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
downloadUrl: (name: string) =>
|
||||
`/api/packages/${encodeURIComponent(name)}/download` + (token ? `?token=${encodeURIComponent(token)}` : ''),
|
||||
downloadUrl: (name: string) => `/api/packages/${encodeURIComponent(name)}/download`,
|
||||
|
||||
/** Opens the live progress stream for a job. */
|
||||
jobEvents: (id: string) =>
|
||||
new EventSource(`/api/jobs/${id}/events` + (token ? `?token=${encodeURIComponent(token)}` : '')),
|
||||
/** Opens the live progress stream for a job. Cookies ride along automatically: it's a same-origin request. */
|
||||
jobEvents: (id: string) => new EventSource(`/api/jobs/${id}/events`),
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { AuthGate } from './AuthGate'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AuthGate />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -311,3 +311,14 @@ label.field > span { display: block; font-size: 11px; color: var(--text-dim); ma
|
||||
background: var(--bg-sunken); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius); padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* ---------- login ---------- */
|
||||
|
||||
.login { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.login-box {
|
||||
width: min(360px, 90vw); display: flex; flex-direction: column; gap: 14px;
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 28px;
|
||||
}
|
||||
.login-box .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||
.login-box h1 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
|
||||
+24
-1
@@ -286,5 +286,28 @@ export interface Health {
|
||||
packageDir: string
|
||||
dataDir: string
|
||||
knownHosts: string
|
||||
authRequired: boolean
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
createdAt: string
|
||||
lastLoginAt?: string
|
||||
}
|
||||
|
||||
export interface APIToken {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
hint: string
|
||||
createdAt: string
|
||||
lastUsedAt?: string
|
||||
}
|
||||
|
||||
/** The response from GET /api/me, /api/setup and /api/login. */
|
||||
export interface Me {
|
||||
id?: string
|
||||
username?: string
|
||||
authenticated: boolean
|
||||
needsSetup?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user