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; }