diff --git a/DEVLOG.md b/DEVLOG.md index 12052be9..2f3dbcad 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,39 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-14 — Configuration scope hierarchy and safety fixes + +Completed a screenshot-first Product Design audit of Mission Control, +Personal/global settings, named Workspace settings, and Instance Admin at +desktop and mobile viewports. The durable audit now inventories every route and +control family by authoritative scope, audience, risk, prerequisites, +inheritance/default source, duplication, and navigation; it also records the +target sitemap, role model, ownership rules, redirects, deep links, and a +P0/P1/P2 backlog with acceptance criteria. + +Implemented the highest-confidence safety and navigation slice without changing +Forge's design language. Global Settings now opens a Personal overview with an +explicit scope header and coherent Personal/Resources groups. Workspace +settings name the tenant, collapse navigation behind a mobile Browse control, +expose current-page semantics and a labeled search, and own canonical API access +and developer-client routes. Instance authentication moved to +`/admin/auth` as Identity & sign-in, with `/settings/auth` preserved as a +redirect. The workspace dispatch page now persists its master toggle and +fall-through mode at the point of explanation; runtime cards use the +authoritative home workspace and no longer crowd four telemetry columns into a +half-width card. The workspace operations shelf is now Activity, and its +preferences control exposes dialog/expanded semantics, Escape close, and focus +return. + +Verification: current-state and implemented-state screenshots were captured +and inspected; TypeScript typecheck and production build passed; lint passed +with existing native-select/type-import warnings; focused workspace router +coverage passed **21/21**; the complete serial Vitest gate passed **1,264/1,264** +with one skipped test; and the complete Playwright gate passed **39/39**. The +browser run also exposed a pre-existing `/clear` race that could issue the +command before its mock response stream finished; the test now waits for the +completed stream and agent reply and passes with the full suite. + ## 2026-07-14 — Manual GitHub retry persistence and release context hygiene Addressed the post-merge Codex review on PR #29 before production deployment. @@ -85,6 +118,28 @@ fresh production build passed; and the full serial Playwright suite passed hard-coded two rail columns despite the existing work-count threshold; the assertion now follows the rendered layout mode and passed both alone and in the full suite. +## 2026-07-14 — Settings scope and information-architecture review + +Mapped Forge's personal/global, workspace, Mission Control/Activity, and +instance-admin configuration surfaces against their routes, permission gates, +and Prisma ownership model. The review proposes one settings frame with an +explicit Personal / Workspace / Instance scope control, preserves Mission +Control as the cross-workspace operating home, renames the in-workspace dock to +Activity, and reorganizes workspace configuration around work management, +agents and automation, integrations, and governance. + +The code inventory also found two functional dead ends to address in the first +implementation slice: auto-dispatch fall-through is displayed read-only and +points to a workspace control that does not exist, and the global runtime +inventory can link through a non-home workspace even though the runtime detail +router requires the home `workspaceId`. The durable review records current +ownership, naming changes, a phased migration, accessibility risks, and P0/P1/P2 +priorities under +`docs/audits/settings-information-architecture-2026-07-14/`. + +The first draft was code-grounded while browser permission was pending. The +implementation task subsequently received explicit Playwright permission and +replaced that limitation with a fresh, inspected desktop/mobile evidence set. ## 2026-07-13 — AXI-102 patient-wait spam + expandable Command Center cards diff --git a/docs/audits/settings-information-architecture-2026-07-14/README.md b/docs/audits/settings-information-architecture-2026-07-14/README.md new file mode 100644 index 00000000..6375274f --- /dev/null +++ b/docs/audits/settings-information-architecture-2026-07-14/README.md @@ -0,0 +1,589 @@ +# Forge settings information architecture review + +Date: 2026-07-14 +Status: screenshot-backed audit complete; first implementation slice in progress +Scope: Instance Admin, cross-workspace Mission Control/global settings, per-workspace settings + +## Executive decision + +Forge should keep its three ownership levels, but present them as one settings system with an explicit scope switcher: + +1. **Personal** — follows the signed-in person across workspaces. +2. **Workspace** — affects members and work inside one named workspace. +3. **Instance** — affects every workspace and is visible only to instance administrators. + +Mission Control should not be a fourth settings scope. It should remain the cross-workspace operating home. The floating in-workspace panel should be called **Activity**, and its gear should open **Activity preferences**. + +The recommended first implementation slice is navigation and naming, not a route rewrite: add a shared scope header and settings overview, fix the two functional dead ends, rename conflicting entries, and preserve the existing URLs behind the new structure. + +## Evidence and audit limit + +The audit used fresh captures from the seeded running product after explicit +approval to use Playwright CLI. Accepted evidence covers Mission Control, +Personal/global settings, named-workspace settings, Instance Admin, ambiguous +agent/runtime/client surfaces, and the floating operations preferences at +1440×900 and 390×844. Every accepted image was opened and inspected at original +resolution; three obstructed or incorrectly framed states were rejected and +recaptured. + +The strongest visible evidence is: + +- `09-authentication-in-account-shell-desktop.png`: an explicitly + instance-wide, instance-admin-only control inside the Account shell. +- `17-developer-access-in-account-shell-desktop.png`: workspace API keys inside + that same Account shell, with no named-workspace scope label. +- `06-dispatch-fall-through-desktop.png`: a read-only mode that instructs the + user to visit a Workspace control that does not exist. +- `10-instance-admin-overview-desktop.png`: the clearest existing pattern—scope, + audience, and blast radius are persistent. +- `13-workspace-settings-overview-mobile.png` and + `15-instance-admin-overview-mobile.png`: navigation consumes a large top + region and data surfaces overflow/crowd before the primary task begins. + +Capture log and full evidence index: [screenshots/README.md](screenshots/README.md) + +The evidence supports visible hierarchy, naming, reflow, density, and +obstruction findings. It does not by itself prove screen-reader output, +contrast ratios, or every permission path. Those remain covered by DOM/axe, +keyboard, and role-specific tests in the implementation phase. Figma and Orca +were not used. + +## User goal and target + +An operator should be able to answer these questions before changing anything: + +- Who will this setting affect: me, this workspace, or the whole Forge instance? +- Do I have permission to change it? +- Where do I go back to after saving it? +- Is this a definition, a workspace binding, or an instance policy? +- If a setting inherits a broader default, what is the effective value here? + +The target is a keyboard-accessible, responsive settings experience with explicit scope, predictable navigation, role-aware editability, and no duplicate names for different concepts. + +## Current architecture inventory + +### 1. Mission Control and global shell + +| Surface | Route | Actual responsibility | Current issue | +|---|---|---|---| +| Mission Control home | `/` | Cross-workspace read-only operating overview | Correct responsibility, but shares its name with the in-workspace floating panel. | +| Global inbox | `/inbox` | Cross-workspace mentions and assignments | Operational, not configuration. | +| Global activity | `/activity` | Cross-workspace live run feed | The intended name for activity already exists, while the floating activity panel is still called Mission Control. | +| Global Settings entry | `/settings/agents` | Opens global agent profiles | There is no global settings overview; “Settings” unexpectedly starts with Agents. | +| Floating “Mission Control” | mounted in each workspace | Live, queue, agents, chat, sound, and default-tab preference | Duplicates the name of `/`; its generic gear looks like another app settings entry. | + +Code anchors: + +- `src/components/global-shell/global-shell.tsx:112-116` names `/` Mission Control and `/activity` Activity. +- `src/components/global-shell/global-shell.tsx:277-286` sends the global Settings entry directly to `/settings/agents`. +- `src/components/mission-control/mission-control.tsx:622-705` still labels the floating panel and collapse action “Mission Control.” +- `src/components/mission-control/settings-popover.tsx:34-143` mixes browser-local sound with per-user and per-workspace default-tab preferences. + +### 2. Personal and cross-workspace settings + +The bare `/settings/*` rail contains nine entries in one group labeled **Account**, even though they have four different ownership and permission models. + +| Current label | Route | Real scope / owner | Recommended label and home | +|---|---|---|---| +| Agents | `/settings/agents` | User-owned profile definitions; creation/approval is instance-admin governed | **My agent profiles** under Personal; approval/share/disable under Instance → Agent governance | +| Agent Clients | `/settings/clients` | User-owned MCP sessions and keys | **Developer clients** under Personal → Developer tools | +| Runtimes | `/settings/runtimes` | Cross-workspace inventory of user-owned hosts, but mutation is workspace-scoped | **Runtime inventory** under Operations, with a canonical detail route | +| Connections | `/settings/connections` | User-owned OAuth identities | **Connected accounts** under Personal → Integrations | +| Profile | `/settings/account` | User profile, regional preferences, theme, Pomodoro | **Profile & regional** under Personal | +| Appearance | `/settings/appearance` | User UI preferences | **Appearance** under Personal | +| Developer access | `/settings/access` | Workspace API keys plus external-agent setup presented in a global shell | Split into **Personal tokens** and **Workspace API access** based on the key's real scope | +| Authentication | `/settings/auth` | Whole-instance sign-in providers, instance-admin-only | Move to **Instance → Identity & sign-in** | +| Workspaces | `/settings/workspaces` | Workspace directory, creation, switching, archive/delete | **Workspaces** as a global directory; keep creation policy explicit | + +Key structural problems: + +- Instance-wide authentication appears inside a rail labeled “Account” (`src/components/settings/settings-nav.ts:202-240`). +- Global resources are prepended to that same Account group without subgroups (`src/components/settings/settings-rail.tsx:193-249`). +- The account settings shell always returns to Mission Control, even when entered from a workspace (`src/app/(app)/settings/layout.tsx:5-30`). +- Workspace-scoped redirect stubs erase the originating workspace and return context. + +### 3. Workspace settings + +The workspace settings inventory is coherent at the route level, but the grouping and permission language are inconsistent. + +| Group | Current entries | Actual responsibility | Recommendation | +|---|---|---|---| +| Workspace | General, Members | Workspace identity, cadence, storage, issue defaults, agent SLA, budgets, lifecycle, AI, danger zone; member access | Split the overloaded General page into Workspace profile, Issue lifecycle, Reliability & safety, and AI & triage. | +| Workflow | Statuses, Labels, Templates, Saved views, Recurring | Issue planning configuration | Rename group **Work management**; show “Read only” or “Admin” based on the caller's role. | +| Automation | Agents, Dispatch rules | Agent bindings, per-workspace policy, routing, engagement modes | Rename **Agents & automation**; add Dispatch & routing and make the effective fall-through mode editable. | +| Connections | Connections, GitHub Apps, Plugins, Webhook deliveries | OAuth mappings, runtime GitHub auth, extensions, outbound delivery queue | Rename **Integrations** and distinguish Connected account, Mapping, GitHub runtime access, and Webhook delivery. | +| Admin | Admin portal, Data import/export | Workspace audit/events/deliveries and portability | Rename **Data & governance**; “Admin portal” is too easily confused with Instance Admin. | + +The workspace overview then appends the Account group directly beneath workspace groups (`src/app/(app)/w/[slug]/settings/page.tsx:54-74`). That creates a scope jump without a scope control. A person can click Appearance while inside AXI settings and land in a different shell whose only back link is Mission Control. + +The General page is also too broad. It combines at least these concepts: + +- identity and danger zone; +- sprint cadence; +- time tracking and attachment quota; +- default issue assignee; +- agent liveness, acknowledgements, SLA and review timeouts; +- token, cost and time budgets; +- assignment, review and completion transitions; +- completion automation; +- AI provider and Coach behavior. + +This makes “General” a catch-all rather than a useful destination. + +### 4. Instance Admin + +| Route | Current label | Responsibility | Recommended label / placement | +|---|---|---|---| +| `/admin` | Overview | Instance health and totals | Keep **Overview** | +| `/admin/tenants` | Workspaces | All tenants | Keep **Workspaces** | +| `/admin/move-issues` | Move issues | Cross-workspace data operation | Place under **Workspaces → Move data** or **Data operations** | +| `/admin/users` | Users | Users and instance roles | **Users & roles** | +| `/admin/agents` | Agent policy | Share, disable, approve global profiles | **Agent governance** | +| `/admin/runtimes` | Runtimes | Instance-wide runtime inventory/policy | **Runtime governance** | +| `/admin/audit` | Audit log | Cross-workspace audit | Keep **Audit log** | +| `/admin/system` | System | Build and instance totals | **System & backup** once backup is functional | + +Instance Admin is the clearest of the three current shells because it has a persistent instance-scope warning and a distinct visual tone. Preserve that safety signal inside the unified settings frame. The main gap is that instance configuration is split: sign-in providers live in Account settings, while users, agents, runtimes, audit, and system live under `/admin`. + +## Complete control-surface mapping + +Frequency uses **H/M/L** for high/medium/low; risk uses **L/M/H/C** for low, +medium, high, or critical blast radius. “Source” names the authoritative value +or inheritance chain. “Entry/duplication” records current discoverability and +conflicts. + +### Personal, account, and cross-workspace resources + +| Route / controls | Authoritative scope | Audience | Freq / risk | Prerequisite | Effective value / source | Current entry / duplication | +|---|---|---|---|---|---|---| +| `/settings` | Personal | Any signed-in user | H / L | Session | None; route absent | Global Settings jumps to Agents; no overview. | +| `/settings/account` — profile, timezone, locale, time format, theme, Pomodoro | Personal | Signed-in user | L / L | Session | `User` preferences | Account rail; theme overlaps Appearance. | +| `/settings/appearance` — theme, density, text size, motion, background | Personal | Signed-in user | L / L | Session | `User`; applied by `AppearanceProvider` | Account rail; theme duplicated in Profile. | +| `/settings/connections` — OAuth identities | Personal identity | Connection owner | L / M | Provider client configured | `UserConnection`; workspace mappings consume it | “Connections” also names a workspace mapping surface. | +| `/settings/agents` + detail — profile definitions | User-owned definition with instance governance | Owners; instance admin for create/share/disable | M / H | Provider/runtime as applicable | `AgentProfile` plus instance sharing/disabled policy | “Agents” also means roster and governance. | +| `/settings/runtimes` — owned inventory, health, detail hop | User-owned inventory; current mutation/details remain workspace-bound | Runtime owner/operator | M / H | Registered runtime and home workspace | Runtime home workspace + health signals | “Global to your account” copy conflicts with workspace authorization; detail can use wrong workspace. | +| `/settings/workspaces` — create, rename, switch, archive | Cross-workspace directory; writes affect a workspace | Members/admins | M / H | Membership; admin for destructive writes | `Workspace` + `WorkspaceMember` | Correctly global to discovery, but archive ownership is tenant scope. | +| `/settings/access` — agent, personal, session API keys; scopes/context/expiry/rotate/revoke | **Workspace** (`ApiKey.workspaceId`) despite Account shell | Workspace admins/developers | M / C | Implicit last-workspace cookie | Explicit key scopes + project/label/initiative narrowing; no broader inheritance | Account rail hides named workspace; duplicated by Agent Clients. | +| `/settings/clients` — derived MCP clients and revocation | **Workspace**, derived from workspace keys | Workspace admins/developers | M / H | Implicit last-workspace cookie | Same `ApiKey` rows as Developer access | Account rail; duplicates access inventory/actions. | +| `/settings/auth` — OIDC/GitHub/Google providers, discovery, enable/disable/delete | **Instance** | Instance admin | L / C | Instance-admin role; provider credentials | `SsoProvider`; email/password fallback | Account rail beside personal preferences; missing from Instance nav. | +| Personal notifications (router/schema only) | Personal default with workspace override | Signed-in user | M / L | Notification capability | `UserNotificationPreference` + membership override | No durable settings page; some preferences leak into Activity popover. | + +### Mission Control as Operations + +| Surface / controls | Authoritative scope | Audience | Freq / risk | Prerequisite | Effective value / source | Current entry / duplication | +|---|---|---|---|---|---|---| +| `/` — global Mission Control overview | Cross-workspace read-only operations | Operators/members | H / L | One or more memberships | Aggregated runs, queues, agents, runtimes, activity | Global home; correct operational responsibility. | +| `/activity` | Cross-workspace activity operations | Operators | H / L | Membership | Activity/event stream | Name collides conceptually with workspace dock currently called Mission Control. | +| `/w/:slug/command-center` | Workspace intervention | Workspace operators/admins | H / H | Workspace membership; action-specific permission | Live runs, approvals, recoveries, incidents | Durable home for operational intervention. | +| Floating “Mission Control” — Live, Queue, Agents, Chat | Workspace quick operations | Workspace members/operators | H / M | Workspace context | Live workspace state; durable actions deep-link elsewhere | Duplicates global product name and sidebar entry. | +| Floating gear — sound, global default tab, workspace default tab | Local browser + Personal + Workspace override | Signed-in user | L / L | Workspace context | localStorage sound; `User` default; membership override; effective override shown | Three scopes mixed in one unlabeled popover; generic Settings name. | + +### Named Workspace settings + +| Route / controls | Authoritative scope | Audience | Freq / risk | Prerequisite | Effective value / source | Current entry / duplication | +|---|---|---|---|---|---|---| +| `/w/:slug/settings` — overview | Workspace index plus cross-scope Account links | Members | M / L | Membership | Nav catalog only | Sidebar Settings; repeats full rail and introduces unannounced Account scope jump. | +| `/workspace` — identity/avatar, sprint cadence/cooldown, time/storage, default assignee, liveness/SLA/ack/review, budgets, lifecycle, completion, AI, danger | Workspace | Mostly workspace admins | L / C | Admin role; status/member/provider prerequisites by control | `Workspace` columns; many `0`/`null` sentinel defaults; project completion may override | “General” catch-all; no inheritance summary; destructive controls buried at bottom. | +| `/members` — invite, roles, remove | Workspace access policy | Workspace admins | M / C | Admin role | `WorkspaceMember` | Marked admin-only; no global user-role cross-link. | +| `/statuses` — pipeline states/categories/order | Workspace workflow | Admins/editors as router permits | L / H | Existing default statuses | Workspace statuses; issue status resolves directly | Workflow nav; permission badge absent. | +| `/labels` — create/rename/recolor/delete | Workspace workflow | Admins/editors as router permits | M / M | Membership | Workspace labels | Workflow nav. | +| `/templates` — issue/project templates | Workspace work management | Workspace members/admins | M / M | Relevant statuses/projects | Workspace template rows | “Templates” aliases old project-template redirect. | +| `/views` — personal/shared saved views | Mixed Personal or Workspace sharing | Members | M / M | Membership | Owner plus shared flag | Listed only under Workspace without personal/shared scope signal. | +| `/recurring` — cadence and auto-create | Workspace automation | Workspace admins/operators | M / H | Template/workflow targets | Recurring rule rows | Under Workflow despite automation behavior. | +| `/agents` — bind profile, runtime/provider, capability/capacity/eligibility, approval/start policy | Workspace binding | Workspace admins/operators; members read | M / H | Available profile; optional runtime/connection | Profile definition → binding override → instance policy | Strongest existing explanation; generic “Agents” duplicates two other scopes. | +| `/dispatch-rules` — ordered matching rules, engagement defaults, mention policy, fall-through | Workspace automation policy | Workspace admins/operators | H / H | Bound eligible agents; labels/projects for conditions | First matching rule, then `Workspace.autoDispatchMode` | Fall-through read-only and points to nonexistent control; settings split from General. | +| `/connections` — provider mapping, GitHub status repair/reconciliation defaults | Workspace integration mapping/policy | Workspace admins | M / H | Personal OAuth identity and repository access | `Connection` mapping + workspace reconciliation defaults | Generic “Connections” duplicates Personal identities. | +| `/github-apps` — shared App credentials/install state for runtime tokens | Workspace integration/runtime access | Workspace admins | L / C | GitHub App and installation | Workspace GitHub App config | Ambiguous between instance credential, workspace mapping, and runtime prerequisite. | +| `/plugins` + detail — install/approve/suspend/scopes | Workspace extensions | Workspace admins | L / C | Plugin manifest and declared scopes | Manifest ceiling + issued key scopes | No instance default/allowlist is visible. | +| `/deliveries` — queue, failures, DLQ, requeue | Workspace operational diagnostics | Workspace admins/operators | M / H | Webhooks/plugins configured | Durable `WebhookDelivery` rows | Operational surface under settings; should deep-link from Operations and retain canonical diagnostics home. | +| `/admin` — audit, activity, delivery observability | Workspace governance/operations | Workspace admins | M / H | Admin role | Workspace audit/activity/delivery data | “Admin portal” easily confused with Instance Admin. | +| `/data` — export/import | Workspace data governance | Workspace admins | L / C | Admin role; valid snapshot for import | Workspace snapshot schema | Admin group; archive/delete remain in General. | +| `/runtimes` + detail (not in rail) — runtime secrets/self-test/config | Runtime home workspace | Workspace admins/runtime operators | M / C | Runtime belongs to current workspace | Runtime row scoped by `workspaceId` | Hidden from workspace nav while global inventory deep-links here. | + +### Instance Administration + +| Route / controls | Authoritative scope | Audience | Freq / risk | Prerequisite | Effective value / source | Current entry / duplication | +|---|---|---|---|---|---|---| +| `/admin` — health, license, tenant/user/run/runtime totals, events | Instance | Instance admin | H / L | Instance-admin role | Cross-tenant aggregates/build metadata | Strong persistent Instance scope warning. | +| `/admin/tenants` — tenant inventory/create | Instance tenancy | Instance admin | M / C | Instance-admin role | All `Workspace` rows | “Workspaces” overlaps personal directory but authority is clear in admin shell. | +| `/admin/move-issues` — cross-workspace move | Instance data operation | Instance admin | L / C | Source/target workspace and compatible mappings | Explicit move input; no inheritance | Separate top-level item despite tenancy relationship. | +| `/admin/users` — users and instance roles | Instance access | Instance admin | M / C | Instance-admin role | `User` role + memberships | Should name roles explicitly. | +| `/admin/agents` — share/unshare/disable/remove profiles | Instance agent governance | Instance admin | M / C | Existing profile | Profile owner definition + instance shared/disabled policy | Correct authority; generic “Agent policy” underspecifies governance. | +| `/admin/runtimes` — inventory, health, instance actions | Instance runtime governance | Instance admin/operator | M / C | Registered runtimes | Cross-workspace runtime view + instance policy | Relationship to personal inventory and workspace detail is not cross-linked. | +| `/admin/audit` | Instance audit/security | Instance admin/security | H / H | Instance-admin role | Cross-tenant audit log | Correct scope; search/export affordances limited. | +| `/admin/system` — version/build/regions/totals | Instance infrastructure | Instance admin/operator | M / H | Instance-admin role | Build/runtime environment | “System & backup” implied by overview CTA but backup readiness is not authoritative here. | +| `/admin/auth` (target; absent initially) | Instance identity & sign-in | Instance admin/security | L / C | Instance-admin role + provider credentials | `SsoProvider` | Authoritative page missing; existing control lives at `/settings/auth`. | + +## What is working + +- The data model already distinguishes user, workspace/binding, and instance policy. The IA can align to real ownership instead of inventing new concepts. +- Workspace settings navigation and the workspace overview share one source of truth, preventing simple inventory drift. +- The account rail has search and a `/` shortcut. +- Workspace agent copy already teaches the useful **definition → binding → instance policy** model. +- The Instance Admin shell visibly warns that writes affect every tenant. +- Old workspace account routes redirect, reducing broken bookmarks during migration. + +## Numbered walkthrough health report + +These labels combine the inspected screenshots with route, permission, and +data-model evidence. + +1. **Mission Control → Settings — at risk.** Screenshot 01 shows a clear global + home, but the persistent Settings entry opens Agent profiles instead of an + overview. Screenshot 12 exposes a second generic gear inside another surface + also called Mission Control. +2. **Personal/global settings — poor.** Screenshots 02, 09, 17, and 18 show + personal definitions, instance SSO, and workspace API keys in one Account + rail. Scope cannot be determined from the shell. +3. **Workspace settings overview — mixed.** Screenshot 04 shows a useful grouped + index and shared navigation source, but repeats the rail and later appends + Account destinations. Screenshot 13 shows that mobile users spend roughly + the first third of the viewport traversing a separately scrolling rail. +4. **Agent, Runtime, and Connection configuration — mixed.** Screenshot 07 + provides the best explanatory model—definition → binding → instance policy. + Screenshots 02, 03, 08, 11, and 17 show that the same nouns elsewhere omit + that qualifier. The global runtime detail hop may authorize against the wrong + workspace, and its four-column telemetry visibly collides at desktop width. +5. **Instance Admin — generally healthy, with one serious split.** Screenshots + 10 and 11 make scope, audience, and destructive authority obvious. Screenshot + 09 proves that sign-in provider configuration is stranded outside it. +6. **Switching scope and returning — poor.** There is no scope switcher, account + settings always returns to Mission Control, and the originating workspace is + lost. +7. **Mobile and responsive behavior — poor.** Screenshots 13–16 confirm capped + nested navigation, crowded/clipped admin tables, and an operations shelf that + covers nearly the full workspace. The Activity preferences popover itself + reflows within 390px, but it obscures its parent and has no dialog semantics. +8. **Keyboard and assistive technology — at risk.** Visible focus styles and + several shortcuts are strengths. Search lacks a programmatic label, active + nav links lack `aria-current`, and the Activity preferences popover lacks + Escape/focus-return semantics. Screenshot evidence alone cannot certify + screen-reader behavior or contrast. + +## Highest-impact findings + +### P0 — functional and safety gaps + +1. **Auto-dispatch fall-through cannot be changed from the UI it points to.** + Dispatch Rules renders the current `autoDispatchMode` read-only and tells the operator to change it under Settings → Workspace (`dispatch-rules/page.tsx:670-710`). `workspace.update` does not accept `autoDispatchMode` (`workspace.ts:273-314`), and the Workspace page has no control for it. This is a functional dead end. Put the editable master toggle and mode in **Workspace → Agents & automation → Dispatch & routing**. + +2. **A global runtime card can link to a workspace where its detail is guaranteed to 404.** + The global inventory prefers `workspacesInUse[0]` over the runtime's home workspace (`settings/runtimes/page.tsx:125-155`), while `runtime.byId` requires `Runtime.workspaceId === ctx.workspaceId` (`runtime.ts:208-230`). Create `/settings/runtimes/[id]` with owner/admin authorization, or always link to `homeWorkspace` until Runtime is truly globalized. + +3. **Instance-wide authentication is presented as personal Account configuration.** + A high-impact write that changes sign-in for every tenant should not sit beside theme and Pomodoro. Move the route into the Instance scope and preserve `/settings/auth` as an instance-admin redirect. + +4. **The UI does not consistently expose effective scope before a write.** + The workspace overview mixes workspace and account destinations, while account settings drops workspace context. Add an always-visible scope chip/switcher and a page-level “Affects …” line before expanding functionality. + +### P1 — navigation, naming, and missing controls + +1. **Mission Control names two different products.** Keep Mission Control for `/`; rename the in-workspace panel and its actions to Activity / Activity preferences. This also completes the intent recorded in `docs/plans/multiws-restructure.md`. +2. **Global Settings starts on Agents.** Add `/settings` as a Personal settings overview or redirect to `/settings/account`; never make an operational resource the implicit settings home. +3. **The account rail is not actually an Account group.** Break it into Personal, Developer tools, and Cross-workspace resources; render Instance as a separate scope for authorized users. +4. **Notification preferences exist in the router and schema but have no UI.** Add Personal → Notifications with global defaults and workspace overrides. Fold Activity sound/default-tab preferences into it, while retaining a quick popover shortcut. +5. **Workspace General is overloaded.** Split lifecycle, reliability/safety, and AI into named pages. Keep identity/cadence/storage/default assignee in Workspace profile. +6. **The same nouns mean different levels.** Use Agent profiles / Workspace agent roster / Agent governance; Runtime inventory / Workspace runtime access / Runtime governance; Connected accounts / Integration mappings. +7. **Admin badges understate edit restrictions.** Many workspace settings use `adminProcedure`, but the rail marks only Members, Webhook deliveries, Admin portal, and Data as admin-only. Show effective access consistently: Admin, Read only, or Personal. +8. **Project completion policy is a hidden lower-scope override.** Project edit supports inherit/off/recommend/auto, but the settings system has no inheritance map. Workspace lifecycle settings should link to projects with overrides and show the effective resolution chain. + +### P2 — polish and resilience + +1. Add settings search keywords and synonyms, not just label/description substring matching. +2. Preserve a `returnTo` context when crossing scopes so Back returns to the originating workspace. +3. Add route breadcrumbs that include scope: `Settings / AXI / Dispatch & routing`. +4. Put destructive workspace actions in a separate Danger zone route or a collapsed final section with explicit workspace identity. +5. Replace remaining visually inconsistent native selects where the shared Combobox improves search/keyboard behavior, without sacrificing native semantics. +6. Show inherited values inline: `Workspace default: Recommend`, `Project override: Auto when safe`, `Effective: Auto when safe`. +7. Add a “Why can't I edit this?” permission explanation instead of discovering authorization only after submitting. + +## Proposed settings hierarchy + +```mermaid +flowchart TD + S["Settings"] --> P["Personal / Account"] + S --> W["Workspace: named tenant"] + S --> I["Instance Administration"] + MC["Mission Control"] --> O["Operations: observe and intervene"] + O -. "deep-links durable policy" .-> W + O -. "deep-links platform policy" .-> I + P --> P1["Profile · Appearance · Notifications"] + P --> P2["Connected accounts · Developer tools"] + P --> P3["My agent profiles · Workspaces"] + W --> W1["Workspace · Members"] + W --> W2["Work management"] + W --> W3["Agents & automation"] + W --> W4["Integrations"] + W --> W5["Data & governance"] + I --> I1["Tenancy · Users · Identity"] + I --> I2["Agent/runtime governance"] + I --> I3["Audit · System · Backup"] +``` + +### Shared settings frame + +Every settings page should use the same structural frame: + +- Header: **Settings** +- Scope control: **Personal · Workspace: AXI · Instance** +- Page breadcrumb: `Settings / Workspace: AXI / Dispatch & routing` +- Scope description: `Changes affect everyone in AXI.` +- Role state: `Workspace admin` or `Read only` +- Left rail: only the selected scope's groups +- Return link: preserve the surface from which settings was opened + +The Instance option is omitted for non-instance-admins. Its selected state retains the distinct graphite tone and “affects every workspace” warning. + +### Personal + +1. **Profile & regional** — name, avatar, timezone, locale, time format. +2. **Appearance** — theme, density, text size, motion, background. +3. **Notifications** — browser push, event delivery matrix, Activity sound, global default Activity tab, workspace overrides. +4. **Connected accounts** — user OAuth identities. +5. **Developer tools** + - Developer clients + - Personal access tokens + - MCP setup +6. **My agent profiles** — definitions owned/requested by the user. +7. **Workspaces** — directory, switch, create; workspace archive/delete remains in that workspace's scope. + +### Workspace: {name} + +1. **Workspace** + - Overview — identity, cadence, time tracking, storage, issue defaults + - Members & roles +2. **Work management** + - Statuses + - Labels + - Templates + - Saved views + - Recurring issues +3. **Agents & automation** + - Agent roster — bind profiles, capabilities, capacity, approval policy + - Dispatch & routing — rules, master toggle, fall-through mode, assignment engagement + - Issue lifecycle — assignment/start, review, completion recommendations and status mapping + - Reliability & safety — no-ack, stale/quiet, SLA, review fallback, run budgets + - AI & triage — provider, model, triage, Coach +4. **Integrations** + - Integration mappings — repos/channels/webhooks mapped from connected accounts + - GitHub runtime access — GitHub Apps used to mint runtime tokens + - Plugins + - Webhook delivery +5. **Data & governance** + - Audit & activity + - Import / export + - Danger zone + +### Instance + +1. **Overview** — health, version, license, totals. +2. **Workspaces** — directory, creation policy, ownership, move data. +3. **Users & roles** — access and instance-admin role. +4. **Identity & sign-in** — OIDC, GitHub, Google providers. +5. **Agent governance** — requests, approvals, instance sharing, force-disable. +6. **Runtime governance** — instance-shared hosts, disable, ownership, health. +7. **Audit log** — cross-workspace events. +8. **System & backup** — build, regions, backup and restore readiness. + +## Ownership rules for ambiguous resources + +| Resource | Definition | Workspace use | Instance policy | +|---|---|---|---| +| Agent | Personal **Agent profile** | **Agent roster binding** with capability/capacity/engagement policy | **Agent governance** with approval/share/disable | +| Runtime | Owner-visible **Runtime inventory** | **Runtime access/config** only where the runtime is authorized | **Runtime governance** for instance sharing/disable | +| Connection | Personal **Connected account** | **Integration mapping** to repo/channel/webhook | Instance sign-in providers are separate and named **Identity & sign-in** | +| Completion | Workspace lifecycle default | Project-level override with explicit inheritance | No instance override | +| Notifications | Personal global default | Per-workspace override | Instance only configures delivery infrastructure, not personal choices | + +## Move, rename, merge, and deprecate map + +| Current surface | Action | Target / compatibility | +|---|---|---| +| Global Settings → `/settings/agents` | Move entry point | `/settings` overview; retain `/settings/agents`. | +| Account → Authentication | Move + rename | `/admin/auth` **Identity & sign-in**; `/settings/auth` server redirect for at least one release. | +| Account → Developer access / Agent Clients | Move workspace-bound rows | Canonical `/w/:slug/settings/access` and `/clients`; old routes resolve the last authorized workspace, preserve query strings, and redirect. Personal tokens may later split to `/settings/developer`. | +| Account → Agents | Rename | **My agent profiles**; route retained. | +| Account → Runtimes | Rename | **Runtime inventory**; route retained; detail always uses home workspace until a global detail exists. | +| Account → Connections | Rename | **Connected accounts**; route retained. | +| Workspace → General | Split progressively | **Workspace profile**, **Issue lifecycle**, **Reliability & safety**, **AI & triage**; keep `/workspace` as overview/alias during migration. | +| Workspace → Automation / Agents | Rename | **Agents & automation / Agent roster**; route retained. | +| Workspace → Dispatch rules | Merge controls + rename | **Dispatch & routing**, including master toggle and fall-through mode. | +| Workspace → Connections | Rename | **Integration mappings**; route retained. | +| Workspace → Admin portal | Rename | **Audit & activity** under Data & governance. | +| Floating Mission Control | Rename | **Activity**; gear becomes **Activity preferences**. `/` remains Mission Control. | +| Instance → Agent policy / Runtimes / Users | Rename | **Agent governance**, **Runtime governance**, **Users & roles**; routes retained. | + +## Role-based visibility and editability + +| Role | Personal | Workspace | Instance | Operations | +|---|---|---|---|---| +| Signed-in user | Own profile/preferences/connections; own agent definitions subject to governance | Only workspaces where a member | Hidden | Aggregates and workspace operations limited to memberships. | +| Workspace member | Same | Browse allowed surfaces; admin-gated pages visibly **Read only** or hidden when they reveal sensitive data | Hidden | Observe; interventions gated per action. | +| Workspace admin/owner | Same | Full tenant policy, access, integrations, data, and danger controls | Hidden unless separately instance admin | Observe and intervene; durable policy deep-links to named workspace settings. | +| Instance admin | Same | Only tenant rights granted by membership, except explicit cross-tenant admin tools | Full Instance nav; critical actions carry persistent blast-radius warning | Cross-tenant observe/intervene plus deep-links to Instance policy. | + +Visibility is not authorization: route/procedure gates remain authoritative. +Hidden navigation reduces leakage; page headers explain read-only state before a +user reaches a control. A workspace member must never discover permission only +after submitting a form. + +## Contextual deep-link and effective-value plan + +- Operations cards link to the durable owner of a policy, not a duplicate + editor: queue/routing → named Workspace Dispatch & routing; SSO incident → + Instance Identity & sign-in; runtime health → authorized home-workspace detail + or Instance governance. +- Cross-scope links carry `returnTo` only when it is an internal, validated + relative Forge URL. The settings frame renders “Back to {origin}.” +- Agent links name the level explicitly: profile definition, workspace roster + binding, or instance governance. +- Inherited controls render three lines when applicable: broader default, + local override, and **Effective** value. `null`/`0` sentinel semantics are + translated into human copy such as “Inherited” or “Unlimited.” +- Runtime, GitHub App, connection, and plugin prerequisites are linked inline + before an unavailable control, with the missing authority named. +- Old URLs preserve deep links with server redirects and query strings. Route + aliases remain for at least one release and are covered by E2E redirect tests; + telemetry/log searches determine when they can be removed. + +## Interaction and accessibility findings + +The responsive findings below are screenshot-confirmed; semantic findings are +confirmed from the rendered component structure and require automated/manual +assistive-technology validation after implementation. + +- Settings search uses placeholder text without a programmatic label (`settings-rail.tsx:87-100`). Add an accessible name. +- Active settings, global, workspace, and admin links do not expose `aria-current="page"`; color alone appears to carry active state. +- The Activity settings button has a `title` but no explicit `aria-label`, `aria-haspopup`, or `aria-expanded`. +- The Activity popover is a plain positioned `div`, not a menu/dialog; it has no Escape handler, focus management, or focus return. +- Mobile settings navigation is a persistent top region capped at 16rem (`settings-rail.tsx:73-74`) rather than a drawer. Screenshots 13 and 14 confirm that it obscures discovery and creates two scroll regions before content. +- Dense admin and settings rail links appear shorter than the recommended mobile touch target; verify actual rendered size. +- Admin/read-only state is inconsistently communicated. Keyboard and screen-reader users may reach controls that fail only after activation. +- Thirty-seven native ` setDraft({ ...draft, type: e.target.value as SsoTypeT })} - > - {SSO_TYPES.map((t) => ( - - ))} - - - {SSO_TYPES.find((t) => t.value === draft.type)?.hint} - - - )} - - - - {isOidc && ( - - )} - - - - - - {isOidc && ( - - )} - - - - {!editing && ( - - )} - -

- After saving, add this redirect URI to your provider:{" "} - - {originForCallback( - isOidc - ? "/api/auth/callback/" - : `/api/auth/callback/${draft.type.toLowerCase()}`, - )} - - {isOidc && " — the exact id is shown on the provider row after it's created."} -

- - - - !o && setDeleteId(null)} - title="Remove provider?" - description="People will no longer be able to sign in with this provider. This can't be undone." - primaryLabel="Remove" - variant="destructive" - loading={remove.isPending} - onConfirm={async () => { - if (deleteId) await remove.mutateAsync({ id: deleteId }); - }} - /> - - ); +export default function LegacyAuthenticationSettings() { + redirect("/admin/auth"); } diff --git a/src/app/(app)/settings/clients/page.tsx b/src/app/(app)/settings/clients/page.tsx index 1cbae548..8c56f7cf 100644 --- a/src/app/(app)/settings/clients/page.tsx +++ b/src/app/(app)/settings/clients/page.tsx @@ -13,6 +13,7 @@ import { Card } from "@/components/settings/card"; import { EmptyState } from "@/components/settings/empty-state"; import { Section } from "@/components/settings/section"; import { trpc } from "@/lib/trpc"; +import { useMaybeWorkspace } from "@/hooks/use-workspace"; import { cn, relativeTime } from "@/lib/utils"; type AccessKey = { @@ -117,6 +118,8 @@ function statusClasses(tone: ClientStatus["tone"]) { } export default function AgentClientsPage() { + const workspace = useMaybeWorkspace(); + const accessHref = workspace ? `/w/${workspace.slug}/settings/access` : "/settings/access"; const [revokeTarget, setRevokeTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const { data: keys, isLoading, refetch } = trpc.access.list.useQuery(); @@ -146,10 +149,10 @@ export default function AgentClientsPage() { <> @@ -164,21 +167,21 @@ export default function AgentClientsPage() { icon={Terminal} title="Ephemeral client" body="Short-lived key for Claude Code, Codex CLI, or a one-off local agent session." - href="/settings/access?create=session" + href={`${accessHref}?create=session`} action="Generate session key" /> @@ -192,7 +195,7 @@ export default function AgentClientsPage() { {activeCount} active Developer access @@ -265,7 +268,7 @@ export default function AgentClientsPage() {
Manage @@ -300,7 +303,7 @@ export default function AgentClientsPage() {
Need the raw MCP config again? Rotate the key from{" "} - + Developer access . Forge stores only a hash after the creation or rotation reveal. diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx new file mode 100644 index 00000000..7b1e7cb8 --- /dev/null +++ b/src/app/(app)/settings/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { Bot, PlugZap, Server } from "lucide-react"; +import { Topbar } from "@/components/topbar"; +import { Card } from "@/components/settings/card"; +import { Section } from "@/components/settings/section"; +import { ACCOUNT_SETTINGS_GROUP } from "@/components/settings/settings-nav"; + +const RESOURCES = [ + { href: "/settings/agents", label: "My agent profiles", hint: "Definitions you own; workspace use is configured through an agent roster.", icon: Bot }, + { href: "/settings/runtimes", label: "Runtime inventory", hint: "Hosts you own, their health, and their authoritative home workspace.", icon: Server }, + { href: "/settings/connections", label: "Connected accounts", hint: "OAuth identities that workspace integration mappings can use.", icon: PlugZap }, +] as const; + +function OverviewLink({ href, label, hint, icon: Icon }: { href: string; label: string; hint: string; icon: typeof Bot }) { + return ( +
  • + + + + {label} + {hint} + + → + +
  • + ); +} + +export default function PersonalSettingsOverview() { + return ( + <> + +
    +
    +
    + + {ACCOUNT_SETTINGS_GROUP.items.map((item) => ( + + ))} + +
    +
    + + {RESOURCES.map((item) => )} + +
    +
    +
    + + ); +} diff --git a/src/app/(app)/settings/runtimes/page.tsx b/src/app/(app)/settings/runtimes/page.tsx index f4902503..78d5e7f7 100644 --- a/src/app/(app)/settings/runtimes/page.tsx +++ b/src/app/(app)/settings/runtimes/page.tsx @@ -122,11 +122,11 @@ export default function RuntimesPage() { : r.health.tone === "warning" ? "border-warning/30 bg-warning/5" : "border-border bg-card/40"; - // Prefer a workspace the runtime is actively used in, but - // always fall back to its home workspace so the settings link - // is never a dead-end (a freshly registered daemon has no - // workspacesInUse yet but still needs self-test / secrets). - const settingsWorkspace = r.workspacesInUse[0] ?? r.homeWorkspace; + // Runtime detail authorization is currently home-workspace + // scoped. Until a canonical global detail exists, always use + // that authoritative workspace rather than an arbitrary + // workspace where the runtime happened to run. + const settingsWorkspace = r.homeWorkspace; return (
    @@ -161,7 +161,7 @@ export default function RuntimesPage() { )}
    -
    +
    Last signal
    {r.health.lastSignal}
    diff --git a/src/app/(app)/w/[slug]/settings/access/page.tsx b/src/app/(app)/w/[slug]/settings/access/page.tsx index 3c8af360..9305487e 100644 --- a/src/app/(app)/w/[slug]/settings/access/page.tsx +++ b/src/app/(app)/w/[slug]/settings/access/page.tsx @@ -1,4 +1 @@ -import { redirect } from "next/navigation"; -export default function Redirect() { - redirect("/settings/access"); -} +export { default } from "@/app/(app)/settings/access/page"; diff --git a/src/app/(app)/w/[slug]/settings/clients/page.tsx b/src/app/(app)/w/[slug]/settings/clients/page.tsx new file mode 100644 index 00000000..f9024cbc --- /dev/null +++ b/src/app/(app)/w/[slug]/settings/clients/page.tsx @@ -0,0 +1 @@ +export { default } from "@/app/(app)/settings/clients/page"; diff --git a/src/app/(app)/w/[slug]/settings/dispatch-rules/page.tsx b/src/app/(app)/w/[slug]/settings/dispatch-rules/page.tsx index 5443ab42..d847bb73 100644 --- a/src/app/(app)/w/[slug]/settings/dispatch-rules/page.tsx +++ b/src/app/(app)/w/[slug]/settings/dispatch-rules/page.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { toast } from "sonner"; import { Route, GripVertical, MoreHorizontal } from "lucide-react"; import { useWorkspace } from "@/hooks/use-workspace"; -import { MentionEngagementPolicy, Priority } from "@prisma/client"; +import { AutoDispatchMode, MentionEngagementPolicy, Priority } from "@prisma/client"; import type { EngagementMode } from "@prisma/client"; import { Topbar } from "@/components/topbar"; import { Button } from "@/components/ui/button"; @@ -63,17 +63,11 @@ const EMPTY_EDITING: EditingState = { targetAgentId: "", }; -// Human-readable labels for the workspace auto-dispatch (fall-through) -// modes. The page can read the current mode from `workspace.current`, -// but the `workspace.update` mutation does not (yet) accept -// `autoDispatchMode`, so the fall-through control is rendered read-only -// — see the "Fall-through" section below. Setting it lives under -// Settings → Workspace. -const FALL_THROUGH_OPTIONS: { value: string; label: string }[] = [ - { value: "ROUND_ROBIN", label: "Round robin" }, - { value: "CAPABILITY_MATCH", label: "Capability match" }, - { value: "PRIORITY_MATCH", label: "Priority match" }, - { value: "MANUAL_ONLY", label: "Manual only" }, +const FALL_THROUGH_OPTIONS: { value: AutoDispatchMode; label: string }[] = [ + { value: AutoDispatchMode.ROUND_ROBIN, label: "Round robin" }, + { value: AutoDispatchMode.CAPABILITY_MATCH, label: "Capability match" }, + { value: AutoDispatchMode.PRIORITY_MATCH, label: "Priority match" }, + { value: AutoDispatchMode.MANUAL_ONLY, label: "Manual only" }, ]; const MENTION_POLICY_OPTIONS: { @@ -212,7 +206,7 @@ export default function DispatchRulesPage() { // chosen value through `workspace.current`. const updateWorkspace = trpc.workspace.update.useMutation({ onSuccess: () => { - toast.success("Engagement defaults saved."); + toast.success("Dispatch defaults saved."); utils.workspace.current.invalidate(); }, onError: (e) => toast.error(e.message), @@ -667,46 +661,59 @@ export default function DispatchRulesPage() { - {/* Fall-through mode. The current value comes from - `workspace.current` (which exposes `autoDispatchMode`), but - `workspace.update` does not accept `autoDispatchMode`, so this - is rendered read-only — changing it lives under Settings → - Workspace. Wiring an inline mutation would require a new - backend field, which is out of scope here. */}
    - + +
    {FALL_THROUGH_OPTIONS.map((o) => { const active = workspace?.autoDispatchMode === o.value; return ( - updateWorkspace.mutate({ autoDispatchMode: o.value })} className={ - "rounded-[0.3125rem] px-3 py-1 text-xs transition-colors " + + "focus-ring rounded-[0.3125rem] px-3 py-1 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 " + (active ? "bg-ember text-ember-foreground" : "text-muted-foreground") } > {o.label} - + ); })}

    - Unmatched issues fall through to the workspace’s - auto-dispatch mode. This reflects the current setting; change - it under Settings → Workspace. + Effective value: {workspace?.autoDispatch + ? FALL_THROUGH_OPTIONS.find((option) => option.value === workspace.autoDispatchMode)?.label + : "Manual assignment (automatic dispatch is off)"}.

    diff --git a/src/app/(app)/w/[slug]/settings/layout.tsx b/src/app/(app)/w/[slug]/settings/layout.tsx index 041d1d66..763ad9ef 100644 --- a/src/app/(app)/w/[slug]/settings/layout.tsx +++ b/src/app/(app)/w/[slug]/settings/layout.tsx @@ -17,7 +17,7 @@ export default function WorkspaceSettingsLayout({ return (
    - +
    {children}
    ); diff --git a/src/app/(app)/w/[slug]/settings/page.tsx b/src/app/(app)/w/[slug]/settings/page.tsx index c3ac08dc..d6301aec 100644 --- a/src/app/(app)/w/[slug]/settings/page.tsx +++ b/src/app/(app)/w/[slug]/settings/page.tsx @@ -3,11 +3,8 @@ import Link from "next/link"; import { Topbar } from "@/components/topbar"; import { Section } from "@/components/settings/section"; import { Card } from "@/components/settings/card"; -import { - ACCOUNT_SETTINGS_GROUP, - WORKSPACE_SETTINGS_GROUPS, - type SettingsNavItem, -} from "@/components/settings/settings-nav"; +import { WORKSPACE_SETTINGS_GROUPS, type SettingsNavItem } from "@/components/settings/settings-nav"; +import { User } from "lucide-react"; import { useWorkspace } from "@/hooks/use-workspace"; import { trpc } from "@/lib/trpc"; @@ -64,12 +61,16 @@ export default function SettingsPage() { ))} -
    - - {ACCOUNT_SETTINGS_GROUP.items.map((item) => ( - // Account paths are already rooted at `/settings`. - - ))} +
    + + + + + Open personal settings + Profile, appearance, connected accounts, owned agent profiles, and runtime inventory. + + → +
    diff --git a/src/components/admin-shell/admin-shell.tsx b/src/components/admin-shell/admin-shell.tsx index db396976..30e27fd3 100644 --- a/src/components/admin-shell/admin-shell.tsx +++ b/src/components/admin-shell/admin-shell.tsx @@ -9,6 +9,7 @@ import { Server, FileText, Settings, + KeyRound, Shield, MoveRight, ChevronRight, @@ -31,6 +32,7 @@ const ADMIN_NAV: { href: string; icon: LucideIcon; label: string; hint: string } { href: "/admin/tenants", icon: Layers, label: "Workspaces", hint: "All tenants" }, { href: "/admin/move-issues", icon: MoveRight, label: "Move issues", hint: "Between workspaces" }, { href: "/admin/users", icon: Users, label: "Users", hint: "Across instance" }, + { href: "/admin/auth", icon: KeyRound, label: "Identity & sign-in", hint: "Instance authentication" }, { href: "/admin/agents", icon: Bot, label: "Agent policy", hint: "Shared & disabled" }, { href: "/admin/runtimes", icon: Server, label: "Runtimes", hint: "Instance-wide" }, { href: "/admin/audit", icon: FileText, label: "Audit log", hint: "Cross-tenant" }, @@ -76,6 +78,7 @@ function AdminSidebar({ activePath, instanceUrl }: { activePath: string; instanc (BLANK); + const [deleteId, setDeleteId] = useState(null); + const [discovery, setDiscovery] = useState<{ ok: boolean; msg: string } | null>(null); + + const invalidate = () => utils.sso.list.invalidate(); + + const create = trpc.sso.create.useMutation({ + onSuccess: () => { + toast.success("Provider added."); + setModalOpen(false); + invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + const update = trpc.sso.update.useMutation({ + onSuccess: () => { + toast.success("Provider updated."); + setModalOpen(false); + invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + const setEnabled = trpc.sso.setEnabled.useMutation({ + onSuccess: invalidate, + onError: (e) => toast.error(e.message), + }); + const remove = trpc.sso.remove.useMutation({ + onSuccess: () => { + toast.success("Provider removed."); + setDeleteId(null); + invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + const testDiscovery = trpc.sso.testDiscovery.useMutation({ + onSuccess: (r) => + setDiscovery( + r.ok + ? { ok: true, msg: `Reachable — token endpoint ${r.tokenEndpoint}` } + : { ok: false, msg: r.error }, + ), + onError: (e) => setDiscovery({ ok: false, msg: e.message }), + }); + + // Instance-admin gate surfaces as a FORBIDDEN from the tRPC procedure. + if (list.error?.data?.code === "FORBIDDEN") { + return ( +
    + +
    + +
    +
    + ); + } + + const providers = list.data ?? []; + const isOidc = draft.type === "OIDC"; + const editing = Boolean(draft.id); + + function openCreate() { + setDraft(BLANK); + setDiscovery(null); + setModalOpen(true); + } + function openEdit(p: (typeof providers)[number]) { + setDraft({ + id: p.id, + type: p.type as SsoTypeT, + name: p.name, + issuer: p.issuer ?? "", + clientId: p.clientId, + clientSecret: "", + scopes: p.scopes ?? "", + allowLinking: p.allowLinking, + enabled: p.enabled, + }); + setDiscovery(null); + setModalOpen(true); + } + + function submit() { + if (!draft.name.trim() || !draft.clientId.trim()) { + toast.error("Name and client ID are required."); + return; + } + if (draft.type === "OIDC" && !draft.issuer.trim()) { + toast.error("OIDC providers need an issuer URL."); + return; + } + if (editing) { + update.mutate({ + id: draft.id!, + name: draft.name, + issuer: draft.issuer, + clientId: draft.clientId, + clientSecret: draft.clientSecret, // empty = unchanged + scopes: draft.scopes, + allowLinking: draft.allowLinking, + }); + } else { + if (!draft.clientSecret.trim()) { + toast.error("Client secret is required."); + return; + } + create.mutate({ + type: draft.type, + name: draft.name, + issuer: draft.issuer || undefined, + clientId: draft.clientId, + clientSecret: draft.clientSecret, + scopes: draft.scopes || undefined, + allowLinking: draft.allowLinking, + enabled: draft.enabled, + }); + } + } + + return ( +
    + + Add provider + + } + /> + +
    +
    + {/* Instance-admin notice — these providers are global to the + Forge instance, not per-workspace. */} +
    + +
    + Instance admin only.{" "} + + Sign-in providers are global to this Forge instance. Changes apply + within ~30 seconds without a redeploy. + +
    +
    + +
    0 ? ( + + {providers.length} configured · {providers.filter((p) => p.enabled).length} enabled + + ) : undefined + } + > + {providers.length === 0 ? ( + + Add provider + + } + /> + ) : ( + + {providers.map((p) => ( +
  • +
    + {p.type === "OIDC" ? p.name.slice(0, 2).toUpperCase() : p.type === "GITHUB" ? "GH" : "G"} +
    +
    +
    + {p.name} + {p.type} + {p.enabled ? ( + enabled + ) : ( + disabled + )} + {p.allowLinking && ( + links by email + )} +
    +
    + callback: {p.callbackPath} +
    +
    + + + +
  • + ))} +
    + )} +
    + + {/* Teach: what you can connect. Mirrors the SSO_TYPES catalog so + the copy stays in sync with the create modal's type picker. */} +
    +
    + {SSO_TYPES.map((t) => ( +
    +
    {t.label}
    +

    + {t.hint} +

    +
    + ))} +
    +
    +
    +
    + + {/* Add / edit modal */} + +
    + {!editing && ( + + )} + + + + {isOidc && ( + + )} + + + + + + {isOidc && ( + + )} + + + + {!editing && ( + + )} + +

    + After saving, add this redirect URI to your provider:{" "} + + {originForCallback( + isOidc + ? "/api/auth/callback/" + : `/api/auth/callback/${draft.type.toLowerCase()}`, + )} + + {isOidc && " — the exact id is shown on the provider row after it's created."} +

    +
    +
    + + !o && setDeleteId(null)} + title="Remove provider?" + description="People will no longer be able to sign in with this provider. This can't be undone." + primaryLabel="Remove" + variant="destructive" + loading={remove.isPending} + onConfirm={async () => { + if (deleteId) await remove.mutateAsync({ id: deleteId }); + }} + /> +
    + ); +} diff --git a/src/components/global-shell/global-shell.tsx b/src/components/global-shell/global-shell.tsx index bd46f4f8..6dec7dd3 100644 --- a/src/components/global-shell/global-shell.tsx +++ b/src/components/global-shell/global-shell.tsx @@ -274,7 +274,7 @@ function GlobalNavContent({
    - Mission Control + Activity {hasStalled && ( {stalledRuns.length} stalled @@ -633,7 +633,7 @@ export function MissionControl() {
    {props.scope === "account" && ( -
    +
    )} -
    +
    setQuery(e.target.value)} placeholder="Search settings" + aria-label="Search settings" className="focus-ring h-7 w-full rounded-md border border-border bg-background px-2 pl-7 text-xs text-foreground placeholder:text-muted-foreground" /> @@ -101,7 +128,14 @@ export function SettingsRail(props: SettingsRailProps) {
    -