Feature 1: Local accounts, replacing shared token
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:
2026-08-24 15:13:54 +02:00
co-authored by Claude Sonnet 5
parent 17a83e737f
commit 8d20d9be84
28 changed files with 1635 additions and 137 deletions
+115
View File
@@ -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
View File
@@ -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' && (
+49
View File
@@ -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} />
}
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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>,
)
+11
View File
@@ -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
View File
@@ -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
}