From 2e2423576794b49c79de0e579ca0e937e7f64431 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 14 Jul 2026 16:31:11 -0400 Subject: [PATCH 1/2] docs: audit settings hierarchy --- DEVLOG.md | 22 + .../README.md | 397 ++++++++++++++++++ .../screenshots/README.md | 24 ++ 3 files changed, 443 insertions(+) create mode 100644 docs/audits/settings-information-architecture-2026-07-14/README.md create mode 100644 docs/audits/settings-information-architecture-2026-07-14/screenshots/README.md diff --git a/DEVLOG.md b/DEVLOG.md index 12052be9..8795c56e 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -85,6 +85,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..6db1398f --- /dev/null +++ b/docs/audits/settings-information-architecture-2026-07-14/README.md @@ -0,0 +1,397 @@ +# Forge settings information architecture review + +Date: 2026-07-14 +Status: code-based review complete; screenshot-backed product audit blocked +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 Product Design audit workflow requires screenshots captured and inspected during the current run. No screenshot was accepted in this run: + +- The Codex in-app Browser (`iab`) was not exposed to this task. +- The permitted fallback, the user's existing Chrome session, could not be attached because Chrome remote debugging was disabled and no `DevToolsActivePort` was available. +- Playwright was not substituted because the workflow requires explicit user permission before using it as a browser replacement. +- Figma was not used or offered, matching the explicit product-design preference. + +Therefore, this document is **not a screenshot-backed visual or WCAG audit**. It is a code-grounded information-architecture, discoverability, permission, and likely-accessibility review. Visual hierarchy, actual focus order, contrast, zoom/reflow, screen-reader output, and touch behavior still require a browser walkthrough. + +Current-run capture log: [screenshots/README.md](screenshots/README.md) + +## 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`. + +## 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. + +## Code-derived journey health + +These health labels describe route and interaction structure found in code; they +do not replace the blocked screenshot walkthrough. + +1. **Mission Control → Settings — at risk.** The persistent entry is easy to + find, but it opens Agent profiles instead of a settings overview, and the + floating panel exposes a second generic Settings gear. +2. **Personal/global settings — poor.** Nine destinations with different + ownership and permissions appear under one Account heading; Authentication + is actually instance-wide. +3. **Workspace settings overview — mixed.** The workspace groups share a single + source of truth, but account links are appended inside the workspace index + and cause an unexplained shell/scope jump. +4. **Agent, Runtime, and Connection configuration — poor.** The underlying + definition/binding/governance model is sound, but repeated generic labels and + a broken possible runtime-detail hop obscure it. +5. **Instance Admin — generally healthy, with one serious split.** The shell, + warning, and gate are clear; sign-in provider configuration lives elsewhere + and breaks the instance boundary. +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, keyboard, and assistive technology — unverified / at risk.** The + code includes focus styles and some shortcuts, but active-state semantics, + popover behavior, the 16rem mobile settings rail, reflow, and announcements + require browser testing. + +## 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 + +### 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 | + +## Interaction and accessibility risks to verify in-browser + +These are code-indicated risks, not screenshot-confirmed failures: + +- 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. Verify that it does not obscure the page, trap two independent scroll regions, or make later settings hard to reach. +- 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 `` elements remain across settings surfaces. Native selects are not inherently inaccessible, but label association, error association, and consistent focus styling should be tested. - Scope changes and mutations announce success mainly through toasts; verify live-region behavior and that errors are attached to their fields. - Reduced-motion code exists, but persistent Activity and status animations must be checked with OS and in-app reduced-motion settings. -### Required browser walkthrough +### Remaining browser validation -When browser access is available, capture and inspect at least these steps at desktop (1440×900), tablet (768×1024), mobile (390×844), 200% zoom, keyboard only, and reduced motion: +Desktop and mobile visual walkthroughs are complete. Before release, repeat the +changed flows at tablet (768×1024), 200% zoom, keyboard-only, and reduced-motion +settings, then exercise member/admin/instance-admin roles: 1. Open Mission Control and locate global Settings. 2. Open Personal settings and switch among profile, notifications, connected accounts, and developer tools. @@ -307,6 +480,25 @@ When browser access is available, capture and inspect at least these steps at de 9. Search settings with synonyms and no results. 10. Verify read-only/member and admin experiences separately. +## P0 / P1 / P2 implementation backlog + +| Priority | Work item | Acceptance criteria | +|---|---|---| +| P0 | Canonicalize Instance Identity & sign-in | `/admin/auth` is gated by the admin layout; `/settings/auth` redirects; Account nav has no SSO controls; instance warning is persistent. | +| P0 | Fix Dispatch & routing fall-through | Admin can enable/disable auto-dispatch and select every persisted `AutoDispatchMode`; reload shows the saved value; read-only dead-end copy is gone; mutation tests cover validation. | +| P0 | Fix runtime detail authority | Every inventory settings link uses a workspace authorized by `runtime.byId`; home-workspace fallback is deterministic; no valid card 404s. | +| P0 | Make scope visible before writes | Personal pages say Personal; workspace pages name the workspace; instance pages say Instance and blast radius. Scope remains visible on mobile. | +| P1 | Add Personal settings overview and coherent groups | Global Settings opens `/settings`; Personal, Developer tools, and Resources are separate groups; search finds aliases; no item is filed under a false Account scope. | +| P1 | Clarify agent/runtime/integration ownership | Definition, binding/mapping, and governance labels appear in rails, headers, and cross-links; the same unqualified noun is not used for multiple scopes. | +| P1 | Rename workspace dock to Activity | “Mission Control” only names `/`; dock, region labels, controls, shortcuts, tests, and quick preferences say Activity. | +| P1 | Repair responsive settings/admin navigation | At 390px and 200% zoom, the page heading/content appears without traversing a 16rem rail; nav is keyboard accessible; admin tables do not cause page-level clipping. | +| P1 | Accessibility semantics | Current links expose `aria-current`; search and icon buttons have names; preferences popover supports Escape, initial focus, focus containment/return, and expanded state. | +| P1 | Preserve cross-scope return context | Valid internal `returnTo` survives settings cross-links and redirects; unsafe/external values are ignored; Back returns to the originating named workspace. | +| P2 | Split Workspace General | Lifecycle, reliability/safety, AI/triage, and Danger zone gain named destinations without breaking `/workspace`; unsaved edits are guarded. | +| P2 | Surface inheritance and prerequisites | Effective source/override appears for completion, dispatch, notifications, runtimes, GitHub Apps, and instance defaults; missing prerequisites link to their owner. | +| P2 | Permission-aware discoverability | Member/admin/instance-admin E2E fixtures verify hidden, read-only, and editable states; no sensitive count/name leaks through hidden navigation. | +| P2 | Retire aliases safely | Redirects retain queries for at least one release; usage is checked before removal; release notes document canonical routes. | + ## Migration plan ### Phase 0 — close dead ends and establish language diff --git a/docs/audits/settings-information-architecture-2026-07-14/screenshots/README.md b/docs/audits/settings-information-architecture-2026-07-14/screenshots/README.md index e6fe9c13..f534c30e 100644 --- a/docs/audits/settings-information-architecture-2026-07-14/screenshots/README.md +++ b/docs/audits/settings-information-architecture-2026-07-14/screenshots/README.md @@ -1,24 +1,65 @@ -# Screenshot capture log +# Current-state screenshot log -No screenshot was accepted during this audit run. +Captured 2026-07-14 from the seeded Forge E2E instance after the user explicitly +approved Playwright CLI for this audit. The app ran against the isolated +`forge_e2e` Postgres database and Redis stack. Desktop captures use 1440×900; +mobile captures use 390×844. Every accepted image was opened and visually +inspected at original resolution. -## Attempt 1 — Codex in-app Browser +Artifact directory: -- Required surface: `iab` -- Result: unavailable in the task's exposed tools -- Saved file: none +`/home/bailey/.codex/visualizations/2026/07/15/019f6345-3f4d-7f72-8ff8-d13cb01139af/configuration-ia-current` -## Attempt 2 — user's existing Chrome +## Accepted evidence -- Tool: Chrome CDP attachment through the approved Browser fallback -- Result: blocked before reading or navigating a tab -- Diagnostic: Chrome remote debugging was disabled; `DevToolsActivePort` was not found in any recognized Chrome/Chromium profile -- Saved file: none +| # | File | Viewport | Evidence | +|---|---|---:|---| +| 1 | `01-mission-control-desktop.png` | 1440×900 | Cross-workspace Mission Control and its global navigation entry points. | +| 2 | `02-account-settings-agents-desktop.png` | 1440×900 | Mixed-ownership Account rail and agent-profile definition copy. | +| 3 | `03-global-runtime-inventory-desktop.png` | 1440×900 | Global runtime inventory, scope wording, detail links, and card crowding. | +| 4 | `04-workspace-settings-overview-desktop.png` | 1440×900 | Named-workspace overview, duplicated rail/index, and account scope jump. | +| 5 | `05-workspace-general-desktop.png` | 1440×900 | Overloaded Workspace General page. | +| 6 | `06-dispatch-fall-through-desktop.png` | 1440×900 | Read-only fall-through mode and dead-end instruction. | +| 7 | `07-workspace-agent-bindings-desktop.png` | 1440×900 | Definition → binding → instance-policy model. | +| 8 | `08-workspace-connections-desktop.png` | 1440×900 | Workspace mapping/policy surface and integration naming. | +| 9 | `09-authentication-in-account-shell-desktop.png` | 1440×900 | Instance-wide authentication rendered inside Account settings. | +| 10 | `10-instance-admin-overview-desktop.png` | 1440×900 | Strong instance scope/risk signal and admin hierarchy. | +| 11 | `11-instance-agent-policy-desktop.png` | 1440×900 | Instance agent governance and destructive controls. | +| 12 | `12-workspace-mission-control-preferences-desktop.png` | 1440×900 | Duplicate Mission Control name and mixed local/personal/workspace preferences. | +| 13 | `13-workspace-settings-overview-mobile.png` | 390×844 | 16rem settings rail before content and nested scrolling. | +| 14 | `14-account-settings-agents-mobile.png` | 390×844 | Account rail exposes only two destinations before its capped scroll region. | +| 15 | `15-instance-admin-overview-mobile.png` | 390×844 | Admin navigation height plus clipped/crowded data tables. | +| 16 | `16-workspace-mission-control-preferences-mobile.png` | 390×844 | Near-full-screen operations shelf and preferences popover reflow. | +| 17 | `17-developer-access-in-account-shell-desktop.png` | 1440×900 | Workspace API keys presented in the Account shell. | +| 18 | `18-agent-clients-in-account-shell-desktop.png` | 1440×900 | Clients derived from workspace keys but labeled as account-level. | -## Deliberately not attempted +## Rejected and recaptured states -- Playwright CLI: requires explicit user permission as a browser substitute under the Product Design workflow. -- Figma: explicitly prohibited by the user's saved preference and current instructions. -- Old screenshots: prohibited as audit evidence because this run required current-state capture. +- The first workspace-overview capture was rejected because the PWA offline + prompt obscured the page. The prompt was dismissed through its documented + local state and the image was recaptured. +- The first fall-through capture did not include the audited control. The + exact section was centered and recaptured. +- The first mobile workspace capture inherited the expanded operations shelf + from the desktop walkthrough. Persisted shelf state was reset and the page + was recaptured. -The parent document is therefore a code-based IA review, not a screenshot-backed visual/accessibility audit. +## Audit boundary + +Screenshots support visible hierarchy, reflow, naming, density, and obstruction +findings. They do not by themselves prove screen-reader behavior, contrast +ratios, focus order, permission behavior for every role, or full WCAG +conformance. Those require DOM/axe, keyboard, and role-specific E2E checks. +Figma and Orca were not used. + +## Implemented-state comparison + +After the first P0/P1 slice, four additional captures were inspected at the +same viewports under: + +`/home/bailey/.codex/visualizations/2026/07/15/019f6345-3f4d-7f72-8ff8-d13cb01139af/configuration-ia-after` + +They show the new Personal overview and scope header, editable authoritative +Dispatch & routing defaults, Instance Identity & sign-in inside the admin +shell, and the collapsed mobile settings navigation that exposes content +immediately. diff --git a/playwright.config.ts b/playwright.config.ts index 6b84a2b0..6a17ccfd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ url: BASE_URL, reuseExistingServer: !process.env.CI, // First boot migrates + seeds + runs a full `next build`, so allow headroom. - timeout: 360_000, + timeout: Number(process.env.E2E_WEB_TIMEOUT_MS ?? 360_000), stdout: "pipe", stderr: "pipe", }, diff --git a/src/app/(app)/admin/auth/page.tsx b/src/app/(app)/admin/auth/page.tsx new file mode 100644 index 00000000..82335c9f --- /dev/null +++ b/src/app/(app)/admin/auth/page.tsx @@ -0,0 +1,14 @@ +import { AdminPage } from "@/components/admin-shell/admin-shell"; +import IdentitySettings from "@/components/admin-shell/identity-settings"; + +export default function AdminIdentityPage() { + return ( + + + + ); +} diff --git a/src/app/(app)/settings/access/page.tsx b/src/app/(app)/settings/access/page.tsx index 63ecdad6..b06a7e70 100644 --- a/src/app/(app)/settings/access/page.tsx +++ b/src/app/(app)/settings/access/page.tsx @@ -28,6 +28,7 @@ import { type McpOnboardingProvider, } from "@/components/mcp-integration-blocks"; import { trpc } from "@/lib/trpc"; +import { useMaybeWorkspace } from "@/hooks/use-workspace"; import { relativeTime } from "@/lib/utils"; const ALL_SCOPES = [ @@ -141,6 +142,8 @@ type RevealKeyState = { }; export default function AccessPage() { + const workspace = useMaybeWorkspace(); + const clientsHref = workspace ? `/w/${workspace.slug}/settings/clients` : "/settings/clients"; const searchParams = useSearchParams(); const handledDeepLinkRef = useRef(null); const { data: keys, refetch } = trpc.access.list.useQuery(); @@ -497,7 +500,7 @@ export default function AccessPage() { <> Register agent @@ -506,7 +509,7 @@ export default function AccessPage() { />
- + {/* ── Registered Agents ── */}
Use this page to issue or rotate secrets. Use{" "} Agent Clients diff --git a/src/app/(app)/settings/auth/page.tsx b/src/app/(app)/settings/auth/page.tsx index 202d8c4e..2b5b4d4d 100644 --- a/src/app/(app)/settings/auth/page.tsx +++ b/src/app/(app)/settings/auth/page.tsx @@ -1,467 +1,5 @@ -"use client"; -import { useState } from "react"; -import { toast } from "sonner"; -import { KeyRound, Plus, Trash2, Pencil, ShieldAlert, Shield, CheckCircle2, XCircle } from "lucide-react"; -import { Topbar } from "@/components/topbar"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { CenterModal, Confirm } from "@/components/ui/modal"; -import { Section } from "@/components/settings/section"; -import { Card } from "@/components/settings/card"; -import { EmptyState } from "@/components/settings/empty-state"; -import { trpc } from "@/lib/trpc"; +import { redirect } from "next/navigation"; -type SsoTypeT = "OIDC" | "GITHUB" | "GOOGLE"; -const SSO_TYPES: { value: SsoTypeT; label: string; hint: string }[] = [ - { value: "OIDC", label: "OpenID Connect", hint: "Authelia, Authentik, Keycloak, Okta, Azure AD — any OIDC IdP." }, - { value: "GITHUB", label: "GitHub", hint: "GitHub OAuth app." }, - { value: "GOOGLE", label: "Google", hint: "Google OAuth client." }, -]; - -type Draft = { - id?: string; - type: SsoTypeT; - name: string; - issuer: string; - clientId: string; - clientSecret: string; - scopes: string; - allowLinking: boolean; - enabled: boolean; -}; - -const BLANK: Draft = { - type: "OIDC", - name: "", - issuer: "", - clientId: "", - clientSecret: "", - scopes: "", - allowLinking: false, - enabled: false, -}; - -function originForCallback(path: string): string { - if (typeof window === "undefined") return path; - return `${window.location.origin}${path}`; -} - -export default function AuthSettingsPage() { - const utils = trpc.useUtils(); - const list = trpc.sso.list.useQuery(undefined, { retry: false }); - - const [modalOpen, setModalOpen] = useState(false); - const [draft, setDraft] = useState(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 }); - }} - /> -
    - ); +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) {
    -