From 118ac9fbcb7b808c1ac152100117633a65cd449a Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 29 Aug 2026 02:17:32 +0400 Subject: [PATCH] Commit the concurrent session's pending web and docs work Not my work. These nine files sat uncommitted in the shared checkout while another session worked on them, and the UI redesign that follows rewrites web/src/main.tsx and web/src/style.css. Committing first means that work is recoverable rather than overwritten. Contents, by inspection rather than by authorship: whitespace normalisation and edits across main.tsx, 568 added lines of style.css, client and client test changes, the orchestra-user line in build.sh, and docs updates to AGENTS.md, AUDIT.md, DEPLOYMENT.md and the env example. Committed at the operator's explicit instruction. --- AGENTS.md | 5 + AUDIT.md | 34 +++ deploy/DEPLOYMENT.md | 39 ++- deploy/build.sh | 4 +- deploy/orchestra.env.example | 11 +- web/src/api/client.test.ts | 26 +- web/src/api/client.ts | 23 +- web/src/main.tsx | 352 ++++++++++++---------- web/src/style.css | 568 ++++++++++++++++++++++++++++++++++- 9 files changed, 850 insertions(+), 212 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50a3ff3..7aefa28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,11 @@ the live herdr instance and check. There is no container entrypoint script — `Dockerfile.api` execs `/app/orchestra` directly. Neither deployed file is the repo's `deploy/config.example.jsonc`. +- Browser operator accounts live in `$ORCHESTRA_DATA/auth.db`. Create or reset + one with `orchestra-user set -data /data -username NAME` while the API is + stopped, or use the authenticated Settings screen. The old + `ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is accepted only + for a one-time import into an empty database and should then be removed. - Two machines in the registry: `homesrv` (192.168.1.104) and `workpc` (192.168.1.105), each nominally running 3 herdrs (claude/codex/opencode). In practice **homesrv has no local herdr running** (connection refused on diff --git a/AUDIT.md b/AUDIT.md index 2a2defe..acfa298 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -391,3 +391,37 @@ work. Sudo is not available in this sandbox, so the worker install, the restart, and the `worker.env` scrub remain operator steps. + +## Browser operator database and UI refresh (2026-08-26) + +The browser login no longer depends on an operator copying a bcrypt hash into +deployment configuration. The live startup path in `cmd/orchestra/main.go` +opens `$ORCHESTRA_DATA/auth.db` through `internal/authn`, refuses to serve with +an empty operator database, and registers the database-backed session and +account handlers before wrapping the mux with `authz.HTTPWithSessions`. + +- `auth.db` is an embedded bbolt database created mode 0600. Passwords are + bcrypt-hashed before the record is written; login also performs bcrypt for an + unknown username to avoid an account-existence timing shortcut. +- `orchestra-user set -data DIR -username NAME` reads and confirms a password + from the terminal, creates the first operator, and resets an existing one. + The Docker API image includes this helper. The authenticated Settings screen + changes the current username/password and revokes every session for that + identity. +- An existing `ORCHESTRA_WEB_USERNAME`/`ORCHESTRA_WEB_PASSWORD_HASH` pair is + imported once if and only if the database has no users. Once a user exists, + those variables are ignored with an explicit startup log, so an old `.env` + cannot overwrite a database credential. +- The browser now gets its actual username from `GET /v1/ui/session`, renders + it in the shell, and has a dedicated account page. The login view was rebuilt + as a responsive desktop/mobile entry experience. +- Frontend state drift was fixed at the same time: `needs_attention` and + `in_review`, plus the three newer block reasons, are in the TypeScript model, + board lanes, status colors, diagnosis copy, and filtering. The seven-state + "All" board now has an explicit layout instead of falling back to one column. + +Verified from the working tree after rebuilding the embedded assets: +`go build ./...`, `go vet ./...`, and `go test ./...` all pass (21 test +packages). The frontend TypeScript build passes, all five API-client tests +pass, and Vite's production build emits the assets embedded by +`internal/webui`. diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index f7e4d7f..9410911 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -8,17 +8,40 @@ coordinator deployment" below for the build that carries provenance. (The old ## Browser operator login -The browser UI requires `ORCHESTRA_WEB_USERNAME` and -`ORCHESTRA_WEB_PASSWORD_HASH`. Generate a bcrypt hash without putting the -password in shell history: +Browser operators now live in the embedded `${ORCHESTRA_DATA}/auth.db` +database. Passwords are bcrypt-hashed inside that database; no password hash +belongs in `.env`. + +For a new local data directory, create the first account while Orchestra is +stopped. The command reads and confirms the password from the terminal: ```sh -go run ./cmd/orchestra-password +go run ./cmd/orchestra-user set -data ./data -username kami ``` -Set the emitted hash in the service environment along with the chosen -username, then restart the coordinator. `ORCHESTRA_WEB_TOKEN` is not used by -the browser UI anymore. +For the Docker Compose deployment, the API image includes the same helper. +Keep the API stopped while it opens the database, then use the existing data +volume through Compose: + +```sh +docker compose stop orchestra-api +docker compose run --rm --entrypoint /app/orchestra-user \ + orchestra-api set -data /data -username kami +docker compose up -d orchestra-api +``` + +After signing in, the Settings screen can change the username or password. +Every browser session for that account is revoked after a credential change. +To recover a forgotten password, stop the API and run `orchestra-user set` +again for the same username. `orchestra-user list -data /data` lists usernames +without exposing password hashes. + +On the first start after upgrading, an empty auth database automatically +imports the existing `ORCHESTRA_WEB_USERNAME` and +`ORCHESTRA_WEB_PASSWORD_HASH` pair. Once the startup log confirms the import, +remove both legacy values from `.env`; they are ignored whenever the database +already contains an account. `ORCHESTRA_WEB_TOKEN` remains unused by the +browser UI. Build a worker for staging on workpc with: @@ -44,7 +67,7 @@ credential: its `build` object is the coordinator provenance. `GET /v1/federation/workers` shows every worker's `build`, supported projects, and worker-local health without SSH. -Build both binaries with `deploy/build.sh`, which stamps them from one commit +Build the coordinator and worker with `deploy/build.sh`, which stamps them from one commit and refuses a dirty tree. A burn-in run must never pair a new coordinator with an old worker, and matching revisions are how that is checked rather than assumed. diff --git a/deploy/build.sh b/deploy/build.sh index 4e8e7aa..55a2fc3 100755 --- a/deploy/build.sh +++ b/deploy/build.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Build the coordinator and the worker from one commit, with one stamp, so a +# Build the coordinator, worker, and operator-account helper from one commit, +# with one stamp, so a # burn-in run can never pair a new coordinator with an old worker. Both # binaries then report the same revision at /v1/admin/diagnostics and in the # worker's registration, which is what makes deployed identity evidence rather @@ -23,4 +24,5 @@ flags="-s -w -X orchestra/internal/buildinfo.Revision=$rev -X orchestra/internal mkdir -p "$out" (cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra" ./cmd/orchestra) (cd "$tree" && go build -trimpath -ldflags="$flags" -o "$out/orchestra-worker" ./cmd/orchestra-worker) +(cd "$tree" && go build -trimpath -ldflags="-s -w" -o "$out/orchestra-user" ./cmd/orchestra-user) echo "$rev" diff --git a/deploy/orchestra.env.example b/deploy/orchestra.env.example index 2aeec75..249932f 100644 --- a/deploy/orchestra.env.example +++ b/deploy/orchestra.env.example @@ -139,12 +139,11 @@ ORCHESTRA_CONTEXT_WINDOW=200000 # --- Bus authorization tokens (bearer auth per surface; a surface with no # token set has no auth requirement — set these once you have real clients) --- #ORCHESTRA_TUI_TOKEN= -# Required: the service refuses to start without both. The browser UI's -# task, lifecycle and approval controls are session-gated; it no longer -# accepts a shared Web bearer token. Generate the bcrypt hash with: -# go run ./cmd/orchestra-password -ORCHESTRA_WEB_USERNAME=operator -ORCHESTRA_WEB_PASSWORD_HASH= +# Browser operators are stored in $ORCHESTRA_DATA/auth.db, not in this file. +# With Orchestra stopped, create or reset one interactively with: +# orchestra-user set -data /data -username kami +# Existing ORCHESTRA_WEB_USERNAME + ORCHESTRA_WEB_PASSWORD_HASH values are +# imported once only when auth.db contains no users, then should be removed. # Set when the UI is served over plain HTTP, so the session cookie can be # sent without Secure. Leave unset behind TLS. #ORCHESTRA_UI_INSECURE_COOKIE=1 diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 87d048a..91c358f 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -17,20 +17,20 @@ describe('UI API client',()=>{ expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'})) }) it('submits username and password to the browser login endpoint',async()=>{ - const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200})) + const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'operator'}),{status:200})) vi.stubGlobal('fetch',fetch) - await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'}) + await expect(api.login('operator','not stored in the browser')).resolves.toEqual({username:'operator'}) expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})})) }) - it('treats a missing browser session as a normal signed-out state',async()=>{ - const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401})) - vi.stubGlobal('fetch',fetch) - await expect(api.session()).resolves.toBeUndefined() - }) - it('updates account credentials through the session-gated account endpoint',async()=>{ - const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200})) - vi.stubGlobal('fetch',fetch) - await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'}) - expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'})) - }) + it('treats a missing browser session as a normal signed-out state',async()=>{ + const fetch=vi.fn().mockResolvedValue(new Response('unauthorized',{status:401})) + vi.stubGlobal('fetch',fetch) + await expect(api.session()).resolves.toBeUndefined() + }) + it('updates account credentials through the session-gated account endpoint',async()=>{ + const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({username:'kami'}),{status:200})) + vi.stubGlobal('fetch',fetch) + await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'}) + expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'})) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index eb1f135..538657e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -50,10 +50,10 @@ async function upload(body: string) { } async function session(): Promise { - const response = await fetch('/v1/ui/session', { credentials: 'same-origin' }) - if (response.status === 401) return undefined - if (!response.ok) throw await responseError(response) - return response.json() as Promise + const response = await fetch('/v1/ui/session', { credentials: 'same-origin' }) + if (response.status === 401) return undefined + if (!response.ok) throw await responseError(response) + return response.json() as Promise } async function login(username: string, password: string) { @@ -64,7 +64,7 @@ async function login(username: string, password: string) { body: JSON.stringify({ username, password }), }) if (!response.ok) throw await responseError(response) - return response.json() as Promise + return response.json() as Promise } async function logout() { @@ -76,15 +76,14 @@ async function logout() { } export const api = { - session, + session, login, logout, - account: () => request('/v1/ui/account'), - updateAccount: (body: { current_password: string; username: string; new_password?: string }) => - request('/v1/ui/account', { - method: 'PUT', - body: JSON.stringify(body), - }), + updateAccount: (body: { current_password: string; username: string; new_password?: string }) => + request('/v1/ui/account', { + method: 'PUT', + body: JSON.stringify(body), + }), overview: () => request('/v1/ui/overview'), detail: (id: string) => request(`/v1/ui/tasks/${id}`), artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`), diff --git a/web/src/main.tsx b/web/src/main.tsx index 3f622ee..6a46efc 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -19,7 +19,7 @@ import { } from '@tanstack/react-query' import { api } from './api/client' import type { - Account, + Account, Action, BlockReason, Capture, @@ -46,9 +46,9 @@ const historyStates: TaskState[] = ['completed', 'failed'] const stateLabel: Record = { queued: 'Queued', leased: 'In session', - needs_attention: 'Needs attention', + needs_attention: 'Needs attention', blocked: 'Blocked', - in_review: 'In review', + in_review: 'In review', completed: 'Complete', failed: 'Failed', } @@ -61,9 +61,9 @@ const blockLabel: Record = { handoff_validation: 'Handoff validation', operator_block: 'Operator block', system_error: 'System error', - trajectory_gate: 'Plan approval', - human_decision: 'Decision needed', - operator_required: 'Operator required', + trajectory_gate: 'Plan approval', + human_decision: 'Decision needed', + operator_required: 'Operator required', unknown: 'Unknown', } @@ -100,8 +100,8 @@ type IconName = | 'refresh' | 'search' | 'server' - | 'settings' - | 'shield' + | 'settings' + | 'shield' | 'terminal' | 'users' | 'x' @@ -169,13 +169,13 @@ function Icon({ name, size = 18 }: { name: IconName; size?: number }) { ), - settings: ( - <> - - - - ), - shield: , + settings: ( + <> + + + + ), + shield: , terminal: ( <> @@ -256,8 +256,8 @@ function humanize(value: string) { } function initials(username: string) { - const parts = username.trim().split(/[\s._-]+/).filter(Boolean) - return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase() + const parts = username.trim().split(/[\s._-]+/).filter(Boolean) + return (parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : username.slice(0, 2)).toUpperCase() } function sessionFor(task: Task, overview: Overview) { @@ -284,13 +284,13 @@ function taskExplanation(task: Task, overview: Overview) { if (session?.blocker) return session.blocker return session?.capture ? 'Harness is publishing live output' : 'Leased · capture unavailable' } - if (task.state === 'needs_attention') { - return task.blocker || 'The current lease is retained while an operator investigates' - } + if (task.state === 'needs_attention') { + return task.blocker || 'The current lease is retained while an operator investigates' + } if (task.state === 'blocked') { return task.blocker || 'No blocker detail was retained for this task' } - if (task.state === 'in_review') return 'Submitted change is waiting for human review' + if (task.state === 'in_review') return 'Submitted change is waiting for human review' if (task.state === 'failed') return task.last_error || 'Review the failure before retrying' return 'Work and completion evidence retained' } @@ -344,7 +344,7 @@ function CommandPalette({ close }: { close: () => void }) { ['board', 'Open dispatch board', '', 'grid'], ['new', 'Create a new task', 'N', 'plus'], ['workers', 'Open worker pool', '', 'server'], - ['settings', 'Open account settings', '', 'settings'], + ['settings', 'Open account settings', '', 'settings'], ['refresh', 'Refresh live data', 'R', 'refresh'], ] as const, [], @@ -364,7 +364,7 @@ function CommandPalette({ close }: { close: () => void }) { const choose = (id: string) => { if (id === 'board') navigate('/') if (id === 'workers') navigate('/workers') - if (id === 'settings') navigate('/settings') + if (id === 'settings') navigate('/settings') if (id === 'new') { navigate('/') window.setTimeout(() => window.dispatchEvent(new Event('orchestra:new-task')), 0) @@ -451,8 +451,8 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc ? 'Dispatch board' : location.pathname === '/workers' ? 'Worker pool' - : location.pathname === '/settings' - ? 'Account settings' + : location.pathname === '/settings' + ? 'Account settings' : location.pathname.startsWith('/artifacts/') ? 'Evidence artifact' : 'Task record' @@ -501,10 +501,10 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc Workers {onlineWorkers}/{workers.length} - - - Settings - + + + Settings +
@@ -538,17 +538,17 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc aria-expanded={accountOpen} onClick={() => setAccountOpen((open) => !open)} > - {initials(account.username)} - {account.username} + {initials(account.username)} + {account.username} {accountOpen && (
-
- {initials(account.username)} - {account.username}Operator account -
- setAccountOpen(false)}> Account settings - +
+ {initials(account.username)} + {account.username}Operator account +
+ setAccountOpen(false)}> Account settings +
)}
@@ -941,9 +941,9 @@ function OverviewPage() { const projects = [...new Set(data.tasks.map((task) => task.project).filter(Boolean))].sort() const pendingApprovals = sessions.filter((session) => session.pending_approval) const approvalTask = pendingApprovals.find((session) => session.capture?.task_id)?.capture?.task_id - const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length + const inSession = data.tasks.filter((task) => task.state === 'leased' || task.state === 'needs_attention').length const queued = data.tasks.filter((task) => task.state === 'queued').length - const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length + const attention = data.tasks.filter((task) => task.state === 'needs_attention' || task.state === 'blocked' || task.state === 'failed').length const history = data.tasks.filter((task) => historyStates.includes(task.state)).length const onlineWorkers = data.workers.filter((worker) => worker.online).length const term = search.trim().toLowerCase() @@ -1267,14 +1267,14 @@ function TaskDiagnosis({ detail }: { detail: Detail }) { const observed = evidence?.captured_at || evidence?.checked_at || detail.session?.capture?.at const title = approval ? 'Waiting for operator approval' - : detail.task.state === 'blocked' || detail.task.state === 'needs_attention' + : detail.task.state === 'blocked' || detail.task.state === 'needs_attention' ? blockLabel[detail.task.block_reason || 'unknown'] : detail.task.state === 'leased' ? detail.session?.capture ? 'Agent session is active' : 'Session capture is unavailable' : stateLabel[detail.task.state] const explanation = approval ? 'The harness is paused at a permission boundary. Review the exact request below.' - : detail.task.state === 'blocked' || detail.task.state === 'needs_attention' + : detail.task.state === 'blocked' || detail.task.state === 'needs_attention' ? detail.task.blocker || 'No blocker detail was retained.' : detail.task.state === 'leased' ? detail.session?.capture @@ -1282,8 +1282,8 @@ function TaskDiagnosis({ detail }: { detail: Detail }) { : detail.session?.blocker || 'The lease exists, but Orchestra cannot read current pane output.' : detail.task.state === 'queued' ? 'This task is eligible for routing when a compatible worker has capacity.' - : detail.task.state === 'in_review' - ? 'The implementation was submitted and is waiting for the bound human review.' + : detail.task.state === 'in_review' + ? 'The implementation was submitted and is waiting for the bound human review.' : 'This is a terminal task record with retained evidence.' return ( @@ -1581,114 +1581,116 @@ function Workers() { } function Settings({ account, onCredentialsChanged }: { account: Account; onCredentialsChanged: (username: string) => void }) { - const [username, setUsername] = useState(account.username) - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmation, setConfirmation] = useState('') - const [visible, setVisible] = useState(false) - const [formError, setFormError] = useState('') - const mutation = useMutation({ - mutationFn: () => api.updateAccount({ - current_password: currentPassword, - username: username.trim(), - ...(newPassword ? { new_password: newPassword } : {}), - }), - onSuccess: (updated) => onCredentialsChanged(updated.username), - }) - const usernameChanged = username.trim() !== account.username - const changed = usernameChanged || !!newPassword - const passwordLongEnough = newPassword.length >= 10 - const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72 - const passwordsMatch = newPassword === confirmation + const [username, setUsername] = useState(account.username) + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmation, setConfirmation] = useState('') + const [visible, setVisible] = useState(false) + const [formError, setFormError] = useState('') + const mutation = useMutation({ + mutationFn: () => api.updateAccount({ + current_password: currentPassword, + username: username.trim(), + ...(newPassword ? { new_password: newPassword } : {}), + }), + onSuccess: (updated) => onCredentialsChanged(updated.username), + }) + const usernameChanged = username.trim() !== account.username + const changed = usernameChanged || !!newPassword + const passwordLongEnough = Array.from(newPassword).length >= 10 + const passwordWithinLimit = new TextEncoder().encode(newPassword).length <= 72 + const passwordsMatch = newPassword === confirmation - const submit = (event: React.FormEvent) => { - event.preventDefault() - setFormError('') - if (!username.trim()) { - setFormError('Username cannot be empty.') - return - } - if (!changed) { - setFormError('Change the username or enter a new password first.') - return - } - if (!currentPassword) { - setFormError('Enter your current password to authorize this change.') - return - } - if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) { - setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.') - return - } - mutation.mutate() - } + const submit = (event: React.FormEvent) => { + event.preventDefault() + setFormError('') + if (!username.trim()) { + setFormError('Username cannot be empty.') + return + } + if (!changed) { + setFormError('Change the username or enter a new password first.') + return + } + if (!currentPassword) { + setFormError('Enter your current password to authorize this change.') + return + } + if (newPassword && (!passwordLongEnough || !passwordWithinLimit || !passwordsMatch)) { + setFormError(!passwordsMatch ? 'The new passwords do not match.' : 'Use a password between 10 and 72 bytes.') + return + } + mutation.mutate() + } - return ( -
-
-
- Operator identity -

Your account.

-

Change the credentials you use for this control plane. No environment hash is involved.

-
-
+ return ( +
+
+
+ Operator identity +

Your account.

+

Change the credentials you use for this control plane. No environment hash is involved.

+
+
-
- +
+ -
-
- -

Sign-in credentials

Changing either field signs out every browser using this account.

-
-
- - { setUsername(event.target.value); setFormError('') }} /> +
+
+ +

Sign-in credentials

Changing either field signs out every browser using this account.

+
+ + + { setUsername(event.target.value); setFormError('') }} /> -
Optional password change
-
- - -
- {newPassword && ( -
- 10+ characters - 72 bytes or fewer - Passwords match -
- )} +
Optional password change
+
+
+ +
+ { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" /> + +
+
+
+ + { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" /> +
+
+ {newPassword && ( +
+ 10+ characters + 72 bytes or fewer + Passwords match +
+ )} -
- -

Required to save account changes.

- { setCurrentPassword(event.target.value); setFormError('') }} /> -
- {(formError || mutation.error) &&

{formError || errorMessage(mutation.error)}

} -
- You’ll sign in again after saving. - -
- -
-
-
- ) +
+ +

Required to save account changes.

+ { setCurrentPassword(event.target.value); setFormError('') }} /> +
+ {(formError || mutation.error) &&

{formError || errorMessage(mutation.error)}

} +
+ You’ll sign in again after saving. + +
+ + + +
+ ) } function Artifact() { @@ -1721,7 +1723,7 @@ function Artifact() { ) } -function Login({ onAuthenticated, message }: { onAuthenticated: () => void; message?: string }) { +function Login({ onAuthenticated, message }: { onAuthenticated: (account: Account) => void; message?: string }) { const usernameInput = useRef(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -1742,9 +1744,9 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess setError('') setPending(true) try { - await api.login(username, password) + const account = await api.login(username, password) setPassword('') - onAuthenticated() + onAuthenticated(account) } catch { setError('That username or password was not accepted. Check both and try again.') } finally { @@ -1755,7 +1757,24 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess return (
) } @@ -1820,13 +1840,14 @@ function NotFound() { ) } -function RoutesApp({ onLogout }: { onLogout: () => void }) { +function RoutesApp({ account, onLogout, onCredentialsChanged }: { account: Account; onLogout: () => void; onCredentialsChanged: (username: string) => void }) { return ( - + } /> } /> } /> + } /> } /> } /> @@ -1835,7 +1856,7 @@ function RoutesApp({ onLogout }: { onLogout: () => void }) { } function App() { - const [ready, setReady] = useState(false) + const [account, setAccount] = useState() const [checking, setChecking] = useState(true) const [message, setMessage] = useState('') @@ -1843,13 +1864,13 @@ function App() { const unauthorized = () => { client.clear() setMessage('Your browser session expired. Sign in again to continue.') - setReady(false) + setAccount(undefined) setChecking(false) } window.addEventListener('orchestra:unauthorized', unauthorized) - api.overview() - .then(() => setReady(true)) - .catch(() => setReady(false)) + api.session() + .then((session) => setAccount(session)) + .catch(() => setAccount(undefined)) .finally(() => setChecking(false)) return () => window.removeEventListener('orchestra:unauthorized', unauthorized) }, []) @@ -1858,7 +1879,12 @@ function App() { await api.logout() client.clear() setMessage('You have signed out.') - setReady(false) + setAccount(undefined) + } + const credentialsChanged = (username: string) => { + client.clear() + setMessage(`Credentials for ${username} were saved. Sign in again to continue.`) + setAccount(undefined) } if (checking) { @@ -1871,9 +1897,9 @@ function App() { ) } - return ready - ? - : { client.clear(); setMessage(''); setReady(true) }} /> + return account + ? + : { client.clear(); setMessage(''); setAccount(authenticated) }} /> } createRoot(document.getElementById('root')!).render( diff --git a/web/src/style.css b/web/src/style.css index 8bddf62..48e9eae 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -25,6 +25,8 @@ --amber-wash: rgba(229, 164, 76, 0.12); --red: #ec8f85; --red-wash: rgba(226, 105, 92, 0.12); + --violet: #b5a2ef; + --violet-wash: rgba(154, 126, 226, 0.12); --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; --radius-sm: 8px; --radius: 12px; @@ -520,10 +522,49 @@ time { box-shadow: var(--shadow); } -.account-menu > span { - padding: 3px 4px; +.account-menu-user { + display: flex; + align-items: center; + gap: 9px; + padding: 3px 4px 10px; + border-bottom: 1px solid var(--line); +} + +.account-menu-user > span:last-child { + display: grid; + min-width: 0; + gap: 2px; +} + +.account-menu-user b { + overflow: hidden; + color: var(--text); + font-size: 11px; + text-overflow: ellipsis; +} + +.account-menu-user small { color: var(--text-muted); - font-size: 10px; + font-size: 9px; +} + +.account-menu > a { + display: flex; + min-height: 33px; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--radius-sm); + color: var(--text-soft); + background: var(--surface-3); + font-size: 11px; + font-weight: 650; + text-decoration: none; +} + +.account-menu > a:hover { + color: var(--text); + background: var(--surface-hover); } .account-menu button { @@ -966,6 +1007,10 @@ time { grid-template-columns: repeat(5, minmax(235px, 1fr)); } +.task-board.lanes-7 { + grid-template-columns: repeat(7, minmax(225px, 1fr)); +} + .task-lane { min-height: 310px; padding: 10px; @@ -1016,11 +1061,23 @@ time { } .lane-blocked > header i, +.lane-needs_attention > header i, .status-pill.state-blocked i { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-wash); } +.status-pill.state-needs_attention i { + background: var(--amber); + box-shadow: 0 0 0 3px var(--amber-wash); +} + +.lane-in_review > header i, +.status-pill.state-in_review i { + background: var(--violet); + box-shadow: 0 0 0 3px var(--violet-wash); +} + .lane-completed > header i, .status-pill.state-completed i { background: #8fcfc5; @@ -1085,6 +1142,15 @@ time { border-color: rgba(239, 189, 114, 0.18); } +.task-card.state-needs_attention { + border-color: rgba(239, 189, 114, 0.25); + background: linear-gradient(145deg, rgba(62, 46, 27, 0.28), var(--surface-2)); +} + +.task-card.state-in_review { + border-color: rgba(181, 162, 239, 0.2); +} + .task-card.state-failed { border-color: rgba(236, 143, 133, 0.18); } @@ -1122,6 +1188,14 @@ time { color: var(--amber); } +.status-pill.state-needs_attention { + color: var(--amber); +} + +.status-pill.state-in_review { + color: var(--violet); +} + .status-pill.state-failed { color: var(--red); } @@ -1545,11 +1619,17 @@ time { } .diagnosis.state-blocked, +.diagnosis.state-needs_attention, .diagnosis.state-failed { border-color: rgba(239, 189, 114, 0.23); background: linear-gradient(110deg, var(--amber-wash), rgba(17, 23, 27, 0.65)); } +.diagnosis.state-in_review { + border-color: rgba(181, 162, 239, 0.26); + background: linear-gradient(110deg, var(--violet-wash), rgba(17, 23, 27, 0.65)); +} + .diagnosis-copy { display: flex; min-width: 0; @@ -1570,12 +1650,19 @@ time { } .diagnosis-symbol.state-blocked, +.diagnosis-symbol.state-needs_attention, .diagnosis-symbol.state-failed { border-color: rgba(239, 189, 114, 0.25); color: var(--amber); background: var(--amber-wash); } +.diagnosis-symbol.state-in_review { + border-color: rgba(181, 162, 239, 0.3); + color: var(--violet); + background: var(--violet-wash); +} + .diagnosis h2 { margin: 0; font-size: 17px; @@ -2492,6 +2579,259 @@ time { font: 9px var(--mono); } +/* Account settings */ + +.settings-page { + max-width: 1160px; +} + +.settings-layout { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + align-items: start; + gap: 16px; +} + +.profile-card, +.settings-card { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: linear-gradient(145deg, rgba(21, 29, 33, 0.94), rgba(14, 20, 23, 0.94)); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.14); +} + +.profile-card { + padding: 24px; +} + +.profile-avatar { + display: grid; + width: 58px; + height: 58px; + place-items: center; + margin-bottom: 16px; + border: 1px solid var(--accent-line); + border-radius: 17px; + color: var(--accent-strong); + background: linear-gradient(145deg, rgba(86, 200, 139, 0.2), var(--accent-wash)); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.05), 0 12px 30px rgba(0, 0, 0, 0.18); + font: 600 16px var(--mono); +} + +.profile-card h2 { + margin: 0; + font-size: 20px; + letter-spacing: -0.035em; + overflow-wrap: anywhere; +} + +.profile-card > p { + margin: 5px 0 22px; + color: var(--text-muted); + font-size: 11px; +} + +.profile-card dl { + display: grid; + gap: 12px; + margin: 0; + padding: 17px 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.profile-card dl > div { + display: grid; + gap: 4px; +} + +.profile-card dt { + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.profile-card dd { + margin: 0; + color: var(--text-soft); + font: 9px var(--mono); +} + +.database-badge { + display: flex; + align-items: flex-start; + gap: 10px; + margin-top: 18px; + padding: 12px; + border: 1px solid var(--accent-line); + border-radius: 10px; + color: var(--accent); + background: var(--accent-wash); +} + +.database-badge > span { + display: grid; + gap: 3px; +} + +.database-badge b { + color: var(--accent-strong); + font-size: 10px; +} + +.database-badge small { + color: var(--text-soft); + font-size: 9px; + line-height: 1.5; +} + +.settings-card { + overflow: hidden; +} + +.settings-card > header { + display: flex; + align-items: flex-start; + gap: 13px; + padding: 22px 24px; + border-bottom: 1px solid var(--line); + background: rgba(17, 23, 27, 0.55); +} + +.settings-icon { + display: grid; + width: 38px; + height: 38px; + flex: none; + place-items: center; + border: 1px solid var(--accent-line); + border-radius: 11px; + color: var(--accent); + background: var(--accent-wash); +} + +.settings-card h2 { + margin: 1px 0 0; + font-size: 16px; + letter-spacing: -0.025em; +} + +.settings-card header p { + margin: 5px 0 0; + color: var(--text-muted); + font-size: 10px; +} + +.settings-card form { + display: grid; + gap: 9px; + padding: 24px; +} + +.settings-card label, +.current-password-block > label { + display: grid; + gap: 7px; + color: var(--text-soft); + font-size: 10px; + font-weight: 650; +} + +.settings-field { + display: grid; + gap: 7px; +} + +.settings-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 14px 0 2px; + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.settings-divider::after { + height: 1px; + flex: 1; + background: var(--line); + content: ""; +} + +.password-rules { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 3px; +} + +.password-rules span { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 7px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--text-muted); + background: var(--surface-1); + font-size: 9px; +} + +.password-rules span.met { + border-color: var(--accent-line); + color: var(--accent); + background: var(--accent-wash); +} + +.current-password-block { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.65fr); + align-items: end; + gap: 5px 18px; + margin-top: 14px; + padding: 15px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-1); +} + +.current-password-block label { + grid-column: 1; +} + +.current-password-block p { + grid-column: 1; + margin: 0; + color: var(--text-muted); + font-size: 9px; +} + +.current-password-block input { + grid-column: 2; + grid-row: 1 / span 2; +} + +.settings-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 10px -24px -24px; + padding: 16px 24px; + border-top: 1px solid var(--line); + background: rgba(10, 15, 17, 0.28); +} + +.settings-actions > span { + color: var(--text-muted); + font-size: 9px; +} + /* Artifact and login */ .artifact-header { @@ -2513,7 +2853,8 @@ time { overflow: hidden; padding: 24px; background: - radial-gradient(circle at 50% -10%, rgba(86, 200, 139, 0.15), transparent 36rem), + radial-gradient(circle at 24% 10%, rgba(86, 200, 139, 0.17), transparent 34rem), + radial-gradient(circle at 88% 92%, rgba(117, 96, 186, 0.09), transparent 30rem), #090d0f; } @@ -2528,12 +2869,158 @@ time { mask-image: radial-gradient(circle at center, black, transparent 68%); } +.login-layout { + position: relative; + display: grid; + width: min(1040px, 100%); + grid-template-columns: minmax(0, 1fr) 420px; + gap: 14px; +} + +.login-showcase { + position: relative; + display: flex; + min-height: 610px; + flex-direction: column; + overflow: hidden; + padding: 36px 40px; + border: 1px solid var(--line); + border-radius: 24px; + background: + linear-gradient(145deg, rgba(25, 38, 36, 0.9), rgba(12, 18, 21, 0.94)), + var(--surface-1); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28); +} + +.login-showcase::before { + position: absolute; + width: 430px; + height: 430px; + right: -190px; + top: -170px; + border: 1px solid rgba(142, 224, 178, 0.08); + border-radius: 50%; + box-shadow: + 0 0 0 55px rgba(142, 224, 178, 0.025), + 0 0 0 110px rgba(142, 224, 178, 0.018); + content: ""; +} + +.showcase-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.showcase-brand > span:last-child { + display: grid; + gap: 2px; +} + +.showcase-brand b { + font-size: 14px; + letter-spacing: -0.02em; +} + +.showcase-brand small { + color: var(--text-muted); + font: 8px var(--mono); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.showcase-copy { + position: relative; + max-width: 500px; + margin: auto 0 42px; +} + +.showcase-copy h2 { + margin: 0; + font-size: clamp(38px, 4.2vw, 58px); + font-weight: 620; + line-height: 0.98; + letter-spacing: -0.064em; +} + +.showcase-copy p { + max-width: 460px; + margin: 20px 0 0; + color: var(--text-soft); + font-size: 12px; + line-height: 1.7; +} + +.showcase-flow { + display: grid; + grid-template-columns: auto minmax(16px, 1fr) auto minmax(16px, 1fr) auto minmax(16px, 1fr) auto; + align-items: center; + gap: 8px; + margin-bottom: 36px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(8, 13, 15, 0.34); +} + +.showcase-flow > span { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-soft); + font: 8px var(--mono); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.showcase-flow > b { + height: 1px; + background: linear-gradient(90deg, var(--line-strong), var(--accent-line)); +} + +.flow-dot { + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--blue); + box-shadow: 0 0 0 3px var(--blue-wash); +} + +.flow-dot.active, +.flow-dot.done { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-wash); +} + +.flow-dot.review { + background: var(--violet); + box-shadow: 0 0 0 3px var(--violet-wash); +} + +.login-showcase > footer { + display: flex; + align-items: center; + gap: 9px; + padding-top: 20px; + border-top: 1px solid var(--line); + color: var(--text-muted); + font: 8px var(--mono); + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.login-showcase > footer .signal { + margin: 0; +} + .login-card { position: relative; - width: min(430px, 100%); - padding: 27px; + width: 100%; + align-self: center; + padding: 30px; border: 1px solid var(--line-strong); - border-radius: 20px; + border-radius: 22px; background: rgba(17, 24, 28, 0.95); box-shadow: 0 30px 90px rgba(0, 0, 0, 0.5); } @@ -2728,6 +3215,14 @@ time { .detail-layout { grid-template-columns: minmax(0, 1fr) 300px; } + + .login-layout { + grid-template-columns: minmax(0, 1fr) 390px; + } + + .login-showcase { + padding: 30px; + } } @media (max-width: 800px) { @@ -2765,6 +3260,39 @@ time { .search-field { width: calc(100% - 164px); } + + .settings-layout { + grid-template-columns: 1fr; + } + + .profile-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + column-gap: 16px; + } + + .profile-avatar { + grid-row: span 2; + margin: 0; + } + + .profile-card > p { + margin: 3px 0 16px; + } + + .profile-card dl, + .database-badge { + grid-column: 1 / -1; + } + + .login-layout { + width: min(430px, 100%); + grid-template-columns: 1fr; + } + + .login-showcase { + display: none; + } } @media (max-width: 680px) { @@ -2795,7 +3323,8 @@ time { } .primary-nav a { - width: min(150px, 42vw); + width: auto; + flex: 1; grid-template-columns: auto auto; place-content: center; gap: 7px; @@ -2886,13 +3415,15 @@ time { .task-board.lanes-2, .task-board.lanes-3, - .task-board.lanes-5 { + .task-board.lanes-5, + .task-board.lanes-7 { grid-template-columns: repeat(var(--mobile-lanes, 5), minmax(255px, 82vw)); } .task-board.lanes-2 { --mobile-lanes: 2; } .task-board.lanes-3 { --mobile-lanes: 3; } .task-board.lanes-5 { --mobile-lanes: 5; } + .task-board.lanes-7 { --mobile-lanes: 7; } .task-lane { min-height: 270px; @@ -2974,6 +3505,25 @@ time { padding: 22px; } + .current-password-block { + grid-template-columns: 1fr; + } + + .current-password-block input { + grid-column: 1; + grid-row: auto; + margin-top: 5px; + } + + .settings-actions { + align-items: stretch; + flex-direction: column; + } + + .settings-actions button { + width: 100%; + } + .skeleton-grid { grid-template-columns: 1fr; }