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.
This commit is contained in:
2026-08-29 02:17:32 +04:00
parent 2d28f7b462
commit 118ac9fbcb
9 changed files with 850 additions and 212 deletions
+5
View File
@@ -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
+34
View File
@@ -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`.
+31 -8
View File
@@ -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.
+3 -1
View File
@@ -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"
+5 -6
View File
@@ -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
+13 -13
View File
@@ -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'}))
})
})
+11 -12
View File
@@ -50,10 +50,10 @@ async function upload(body: string) {
}
async function session(): Promise<Account | undefined> {
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<Account>
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<Account>
}
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<Account>
return response.json() as Promise<Account>
}
async function logout() {
@@ -76,15 +76,14 @@ async function logout() {
}
export const api = {
session,
session,
login,
logout,
account: () => request<Account>('/v1/ui/account'),
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
request<Account>('/v1/ui/account', {
method: 'PUT',
body: JSON.stringify(body),
}),
updateAccount: (body: { current_password: string; username: string; new_password?: string }) =>
request<Account>('/v1/ui/account', {
method: 'PUT',
body: JSON.stringify(body),
}),
overview: () => request<Overview>('/v1/ui/overview'),
detail: (id: string) => request<Detail>(`/v1/ui/tasks/${id}`),
artifact: (ref: string) => text(`/v1/ui/artifacts/${ref}`),
+189 -163
View File
@@ -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<TaskState, string> = {
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<BlockReason, string> = {
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 }) {
<path d="M7 7h.01M7 17h.01" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Zm-3-10 2 2 4-5" />,
terminal: (
<>
<path d="m4 6 5 5-5 5M11 18h9" />
@@ -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
<span>Workers</span>
<b className="nav-count">{onlineWorkers}/{workers.length}</b>
</NavLink>
<NavLink to="/settings">
<Icon name="settings" />
<span>Settings</span>
</NavLink>
<NavLink to="/settings">
<Icon name="settings" />
<span>Settings</span>
</NavLink>
</nav>
<div className="sidebar-status">
<span className={onlineWorkers ? 'signal online' : 'signal'} />
@@ -538,17 +538,17 @@ function Shell({ children, account, onLogout }: { children: React.ReactNode; acc
aria-expanded={accountOpen}
onClick={() => setAccountOpen((open) => !open)}
>
<span className="avatar">{initials(account.username)}</span>
<span className="account-label">{account.username}</span>
<span className="avatar">{initials(account.username)}</span>
<span className="account-label">{account.username}</span>
</button>
{accountOpen && (
<div className="account-menu">
<div className="account-menu-user">
<span className="avatar">{initials(account.username)}</span>
<span><b>{account.username}</b><small>Operator account</small></span>
</div>
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
<div className="account-menu-user">
<span className="avatar">{initials(account.username)}</span>
<span><b>{account.username}</b><small>Operator account</small></span>
</div>
<Link to="/settings" onClick={() => setAccountOpen(false)}><Icon name="settings" size={15} /> Account settings</Link>
<button type="button" onClick={onLogout}><Icon name="arrow-left" size={15} /> Sign out</button>
</div>
)}
</div>
@@ -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 (
<main className="page settings-page">
<header className="page-header">
<div>
<span className="eyebrow">Operator identity</span>
<h1>Your account.</h1>
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
</div>
</header>
return (
<main className="page settings-page">
<header className="page-header">
<div>
<span className="eyebrow">Operator identity</span>
<h1>Your account.</h1>
<p>Change the credentials you use for this control plane. No environment hash is involved.</p>
</div>
</header>
<div className="settings-layout">
<aside className="profile-card">
<span className="profile-avatar">{initials(account.username)}</span>
<h2>{account.username}</h2>
<p>Full-control operator</p>
<dl>
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
</dl>
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestras data volume.</small></span></div>
</aside>
<div className="settings-layout">
<aside className="profile-card">
<span className="profile-avatar">{initials(account.username)}</span>
<h2>{account.username}</h2>
<p>Full-control operator</p>
<dl>
<div><dt>Account created</dt><dd>{date(account.created_at)}</dd></div>
<div><dt>Credentials updated</dt><dd>{date(account.updated_at)}</dd></div>
</dl>
<div className="database-badge"><Icon name="shield" size={17} /><span><b>Local credential database</b><small>Password hashes stay inside Orchestras data volume.</small></span></div>
</aside>
<section className="settings-card">
<header>
<span className="settings-icon"><Icon name="settings" /></span>
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
</header>
<form onSubmit={submit} noValidate>
<label htmlFor="account-username">Username</label>
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
<section className="settings-card">
<header>
<span className="settings-icon"><Icon name="settings" /></span>
<div><h2>Sign-in credentials</h2><p>Changing either field signs out every browser using this account.</p></div>
</header>
<form onSubmit={submit} noValidate>
<label htmlFor="account-username">Username</label>
<input id="account-username" autoComplete="username" value={username} onChange={(event) => { setUsername(event.target.value); setFormError('') }} />
<div className="settings-divider"><span>Optional password change</span></div>
<div className="form-row">
<label htmlFor="account-new-password">New password
<div className="password-field">
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
</div>
</label>
<label htmlFor="account-confirm-password">Confirm new password
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
</label>
</div>
{newPassword && (
<div className="password-rules" aria-live="polite">
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
</div>
)}
<div className="settings-divider"><span>Optional password change</span></div>
<div className="form-row">
<div className="settings-field">
<label htmlFor="account-new-password">New password</label>
<div className="password-field">
<input id="account-new-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={newPassword} onChange={(event) => { setNewPassword(event.target.value); setFormError('') }} placeholder="Leave blank to keep it" />
<button type="button" onClick={() => setVisible((value) => !value)}>{visible ? 'Hide' : 'Show'}</button>
</div>
</div>
<div className="settings-field">
<label htmlFor="account-confirm-password">Confirm new password</label>
<input id="account-confirm-password" type={visible ? 'text' : 'password'} autoComplete="new-password" value={confirmation} onChange={(event) => { setConfirmation(event.target.value); setFormError('') }} placeholder="Repeat new password" />
</div>
</div>
{newPassword && (
<div className="password-rules" aria-live="polite">
<span className={passwordLongEnough ? 'met' : ''}><Icon name="check" size={13} /> 10+ characters</span>
<span className={passwordWithinLimit ? 'met' : ''}><Icon name="check" size={13} /> 72 bytes or fewer</span>
<span className={passwordsMatch && !!confirmation ? 'met' : ''}><Icon name="check" size={13} /> Passwords match</span>
</div>
)}
<div className="current-password-block">
<label htmlFor="account-current-password">Current password</label>
<p>Required to save account changes.</p>
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
</div>
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
<footer className="settings-actions">
<span>Youll sign in again after saving.</span>
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
</footer>
</form>
</section>
</div>
</main>
)
<div className="current-password-block">
<label htmlFor="account-current-password">Current password</label>
<p>Required to save account changes.</p>
<input id="account-current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => { setCurrentPassword(event.target.value); setFormError('') }} />
</div>
{(formError || mutation.error) && <p className="form-error" role="alert"><Icon name="alert" size={15} /> {formError || errorMessage(mutation.error)}</p>}
<footer className="settings-actions">
<span>Youll sign in again after saving.</span>
<button type="submit" disabled={mutation.isPending || !changed}>{mutation.isPending ? 'Saving…' : 'Save credentials'}</button>
</footer>
</form>
</section>
</div>
</main>
)
}
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<HTMLInputElement>(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 (
<main className="login-page">
<div className="login-grid" aria-hidden="true" />
<section className="login-card" aria-labelledby="login-title">
<div className="login-layout">
<section className="login-showcase" aria-label="Orchestra overview">
<div className="showcase-brand"><Logo /><span><b>Orchestra</b><small>Unattended work, under control</small></span></div>
<div className="showcase-copy">
<span className="eyebrow">Operator console</span>
<h2>Keep every agent<br />on the same score.</h2>
<p>Dispatch work, inspect live sessions, resolve decisions, and retain the evidence that brought each task home.</p>
</div>
<div className="showcase-flow" aria-hidden="true">
<span><i className="flow-dot queued" /> Queue</span><b />
<span><i className="flow-dot active" /> Agent</span><b />
<span><i className="flow-dot review" /> Review</span><b />
<span><i className="flow-dot done" /> Done</span>
</div>
<footer><span className="signal online" /> Control plane ready</footer>
</section>
<section className="login-card" aria-labelledby="login-title">
<header className="login-brand"><Logo /><span><b>Orchestra</b><small>Control plane</small></span></header>
<div className="login-heading">
<span className="eyebrow">Operator access</span>
@@ -1799,10 +1818,11 @@ function Login({ onAuthenticated, message }: { onAuthenticated: () => void; mess
</button>
</form>
<footer className="login-security">
<span><Icon name="check" size={15} /></span>
<p>Credentials are verified server-side. This browser receives an HttpOnly session cookie that expires after 12 hours.</p>
<span><Icon name="shield" size={15} /></span>
<p>Your password is verified against Orchestras local operator database. The browser receives only a 12-hour HttpOnly session cookie.</p>
</footer>
</section>
</section>
</div>
</main>
)
}
@@ -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 (
<Shell onLogout={onLogout}>
<Shell account={account} onLogout={onLogout}>
<Routes>
<Route path="/" element={<OverviewPage />} />
<Route path="/tasks/:taskID" element={<TaskDetail />} />
<Route path="/workers" element={<Workers />} />
<Route path="/settings" element={<Settings account={account} onCredentialsChanged={onCredentialsChanged} />} />
<Route path="/artifacts/:ref" element={<Artifact />} />
<Route path="*" element={<NotFound />} />
</Routes>
@@ -1835,7 +1856,7 @@ function RoutesApp({ onLogout }: { onLogout: () => void }) {
}
function App() {
const [ready, setReady] = useState(false)
const [account, setAccount] = useState<Account>()
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
? <RoutesApp onLogout={logout} />
: <Login message={message} onAuthenticated={() => { client.clear(); setMessage(''); setReady(true) }} />
return account
? <RoutesApp account={account} onLogout={logout} onCredentialsChanged={credentialsChanged} />
: <Login message={message} onAuthenticated={(authenticated) => { client.clear(); setMessage(''); setAccount(authenticated) }} />
}
createRoot(document.getElementById('root')!).render(
+559 -9
View File
@@ -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;
}