@checkstack/auth-frontend 0.6.7 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,127 @@
1
1
  # @checkstack/auth-frontend
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [13373ce]
8
+ - @checkstack/common@0.14.0
9
+ - @checkstack/auth-common@0.8.1
10
+ - @checkstack/frontend-api@0.7.1
11
+ - @checkstack/ui@1.13.1
12
+
13
+ ## 0.7.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 9dcc848: Add the AI platform: a transport-agnostic tool spine, an OAuth Authorization Server + read-only MCP server, a propose/apply flow with audit log, a streaming in-app chat agent, per-conversation permission modes, per-integration spend caps, and user-scoped tool authorization.
18
+
19
+ Two new packages, `@checkstack/ai-common` (the `AiTool` contract, `read`/`mutate`/`destructive` effect classification, the `ai.*` access rules, the OpenAI-compatible connection shape, and the wire contracts) and `@checkstack/ai-backend` (the tool registry, extension points, principal-to-tool resolver, shared zod-to-JSON-Schema serializer, and all transports). The OpenAI-compatible integration provider registers through the existing integration provider extension point, so its API key is stored in the Secrets Vault and configured in the generic Connections UI.
20
+
21
+ What ships:
22
+
23
+ - Tool spine and extension points: `aiToolExtensionPoint.registerTool` (hand-authored composite tools) and `aiToolProjectionExtensionPoint.expose` (opt-in projections of existing oRPC procedures). Authorization mirrors `autoAuthMiddleware` exactly - a tool is surfaced only when every `requiredAccessRules` entry is satisfied, so a scope-narrowed principal can only ever see fewer tools.
24
+ - OAuth + MCP: Checkstack can act as its own OAuth 2.1 Authorization Server (authorization code + PKCE, consent screen, Dynamic Client Registration) and expose a read-only MCP server over Streamable HTTP at `/api/ai/mcp`. Off by default, enabled by the admin `ai.mcp-oauth` setting. A Bearer OAuth-token branch is added to the auth strategy; token scopes are intersected live with the bound user's access rules on every call. A shared-Postgres rate limiter throttles the DCR endpoint per client IP. `getMcpOAuthSettings` / `setMcpOAuthSettings` contracts added to `@checkstack/auth-common`. A minimal OAuth consent page (`/auth/oauth-consent`) renders the requesting client and scopes.
25
+ - Propose/apply + audit: a transport-agnostic two-step service - `propose` re-checks authz, runs the tool's `dryRun` without mutating, and returns a single-use proposal token (the `proposed` audit row IS the token store, 10-minute TTL, atomic single-use); `apply` re-parses the server-stored payload, re-checks authz, and atomically commits. The `ai_tool_calls` audit table records every call across both transports with a SHA-256 args hash (never raw arguments) and stamps who proposed and who applied. An `ai.toolCalled` event carries metadata only.
26
+ - In-app chat: a server-side, provider-agnostic Vercel AI SDK agent loop (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio, ...). The model provider is built on the backend from the integration credentials, so the API key never leaves the backend. The loop offers only resolver-allowed tools, auto-runs read tools (re-entering the live router as the logged-in user) and routes mutating / destructive tools through propose/apply. Durable conversation persistence (`ai_conversations`, `ai_messages`, owner-scoped RPCs) plus a streaming chat UI with a confirm-card component and per-integration model picker.
27
+ - Per-conversation permission mode (Claude-Code-style approve/auto), a durable `permission_mode` column on `ai_conversations` (default `approve`). `read` always auto-runs in both modes; `mutate` inherits the mode (auto-applies server-side in `auto`, confirm-carded in `approve`); `destructive` ALWAYS requires the human `applyTool` in both modes. Security invariant (structural + tested): the mode is consulted only on the `mutate` branch, so no `(effect, mode)` pair routes a destructive tool to auto-apply.
28
+ - Per-integration LLM spend cap (optional `spendCap` = `tokenBudget` + `windowMinutes`, default OFF). Spend is tracked in a shared-Postgres `ai_spend` ledger; enforcement is a rolling-window SUM run before each turn (HTTP 429 over budget). Per-principal tool rate-limit budgets are a rolling COUNT over `ai_tool_calls`, enforced on both transports. An absent / empty / incomplete `spendCap` is treated as "no cap" rather than rejected.
29
+ - Full tool-call replay: `ai_messages.model_messages` (jsonb) persists the canonical AI-SDK `ResponseMessage[]` per turn and replays them verbatim on the next turn; legacy rows fall back to text-only replay.
30
+ - Enforced no-secret-leak scrubbing: `appendMessage` runs `scrubContent` on every write, redacting credential-shaped keys and high-confidence credential values; a canary regression test asserts injected secrets are stripped. A hardening test suite asserts no secret appears in any AI-surface DTO and that handler-side authz holds when the model misbehaves.
31
+ - Provider correctness: the chat provider uses `@ai-sdk/openai-compatible`'s `chatModel` (plain `/chat/completions`), so OpenAI-compatible gateways (OpenRouter, DeepSeek, Ollama, vLLM) no longer reject turns with `invalid_prompt`; `@ai-sdk/openai` is removed.
32
+
33
+ BREAKING CHANGES:
34
+
35
+ - The `AiTool` contract (`@checkstack/ai-common`) gained a `TRpc` type parameter, and both `dryRun` and `execute` now receive a USER-SCOPED `rpcClient` arg bound to the originating user. Every plugin procedure a tool calls re-enters the live router AS THAT USER, so handler-side authorization (access rules AND per-resource/team scope) is enforced exactly as a direct UI/RPC call - closing a prior privilege-escalation where tools captured a trusted service client at construction. A hand-authored tool MUST resolve its plugin client from this per-call arg and MUST NOT capture a trusted service client at factory scope. Tool factories that previously took `{ rpcClient }` should drop that parameter.
36
+ - `AiToolProjectionExtensionPoint.expose` no longer takes a second `pluginMetadata` argument; the owning metadata lives on `input.sourcePluginMetadata`. Callers must drop the second argument.
37
+
38
+ State and scale: conversations, messages, the audit log, proposal tokens, the rate-limit counter, and the spend ledger all live in shared Postgres, so every pod answers identically and the agent loop is resumable on any pod. The only pod-local state is the live MCP connection registry (bookkeeping, never a source of truth). Cross-pod conversation readback, the spend cap, and the tool budget are verified by env-gated two-pod integration tests.
39
+
40
+ This is a beta minor.
41
+
42
+ - 9dcc848: Cut initial-load JS: lazy plugin contributions, a hardened lazy-by-default contribution contract, on-demand Monaco, and a lighter icon/chart load.
43
+
44
+ - Lazy plugin route pages: each plugin's route `element` references a `React.lazy`-wrapped page rendered inside a shared `<Suspense>` boundary. Plugins still register synchronously, so nav, slots, commands, API factories, and `foreignSignals` are available on first paint. This moves ~37 route-page chunks (~600 KB) out of the entry; the entry chunk drops from ~2.4 MB to ~190 KB. Auth flow pages stay eager. The `@checkstack/scripts` scaffold template generates lazy route pages too.
45
+ - Hardened contribution contract (BREAKING, frontend plugin contract): plugins declare contributions lazily and let the framework own code-splitting, Suspense, and per-plugin error isolation. Routes use `load: () => import("./Page").then((m) => ({ default: m.Page }))` instead of `element: <Page />` (`element` is still accepted for the rare page that must paint without a chunk fetch; provide exactly one). Slot extensions accept either an eager `component` or a lazy `load`; new `getLazyContribution` + `ExtensionComponent` exports from `@checkstack/frontend-api` render either kind. This also fixes runtime-installed plugins: `ExtensionSlot` subscribes to the plugin registry, and the API registry rebuilds when the plugin set changes (`getPlugins()` returns an immutable snapshot via `useSyncExternalStore`). A per-plugin error boundary contains a bad contribution.
46
+ - On-demand Monaco: the `@checkstack/ui` barrel no longer pulls the `@codingame/*` / `monaco-languageclient` stack into the initial load. `CodeEditor` lazy-loads its Monaco-backed editor behind `React.lazy` + Suspense, `validateTypeScriptSources` imports the editor API via in-body `await import(...)`, and the "vscode services ready" signal moved to a Monaco-free module. The ~10 MB editor body loads only when a `CodeEditor` mounts. A `react-vendor` `manualChunks` split was added for stable vendor caching.
47
+ - lucide-react 1.x + lighter icons/charts (BREAKING for icon consumers): lucide-react unified from three drifting ranges to `^1.17.0`. lucide v1 removed brand icons, so the GitHub/GitLab marks are vendored in `@checkstack/ui` (`GithubIcon`, `GitlabIcon`, `brandIcons`); a new `IconName` type (`LucideIconName | BrandIconName`) in `@checkstack/common` is canonical, accepted by `AuthStrategy.icon` and the card components, so data-driven brand names keep working. `DynamicIcon` no longer eagerly imports lucide's ~1600-icon map (~1 MB) - it lives in a `React.lazy` `iconRegistry` chunk fetched on first data-driven render, while statically named-imported icons tree-shake normally. The recharts-backed health-check charts (~300 KB) and the `HealthCheckSystemOverview` drawer leave the initial load.
48
+
49
+ BREAKING CHANGES:
50
+
51
+ - Frontend plugin contract: routes/slot contributions are lazy-by-default (`load` instead of `element`/eager elements) as described above.
52
+ - Any external consumer importing a brand icon from `lucide-react` (e.g. `import { Github } from "lucide-react"`) must switch to the vendored `@checkstack/ui` brand icons or a custom SVG.
53
+
54
+ This is a beta minor.
55
+
56
+ - 9dcc848: Move primary navigation into a left sidebar, and serve the user guide in-app.
57
+
58
+ Feature navigation (a ~20-item user-menu dropdown) now lives in a persistent left sidebar (a slide-over drawer on mobile), grouped by section with the active route highlighted; the user menu keeps only account actions. A route opts into the sidebar with new `nav` metadata (`{ group, icon, label?, order?, accessRule? }`) on its registration, co-located with path + access + title. The sidebar filters entries with the same access check as page guards. `@checkstack/common` gains `isAccessRuleSatisfied` and a centralized set of in-app doc slugs (`APP_DOC_SLUGS` + `docsPath`, with a test asserting each resolves to a real docs page); `@checkstack/auth-frontend` exports `useAccessRules`.
59
+
60
+ The backend now serves the Astro Starlight docs build same-origin at `/checkstack/*` (the same artifact deployed to GitHub Pages), so the user guide is available inside the app including for self-hosted / air-gapped installs (served verbatim, no rebuild, no link rewriting; from `CHECKSTACK_DOCS_DIST`, before the SPA catch-all, degrading gracefully when absent; the Docker image builds and ships `docs/dist`; Vite proxies `/checkstack` in dev). The "Docs" link is a shell-owned external sidebar entry under the Documentation group (book icon), opening `/checkstack/user-guide/` in a new tab; the group renders even when no plugin route contributes to it.
61
+
62
+ BREAKING (plugin authors): `UserMenuItemsSlot` is no longer the way to add navigation - registering a top user-menu item no longer surfaces it anywhere. Add `nav` to the page's route instead. `UserMenuItemsBottomSlot` (account items) is unchanged. All bundled plugins have been migrated.
63
+
64
+ This is a beta minor.
65
+
66
+ - 9dcc848: Align workspace dependency versions and migrate React Router to v7.
67
+
68
+ BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
69
+
70
+ Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
71
+
72
+ Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
73
+
74
+ Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
75
+
76
+ ### Patch Changes
77
+
78
+ - 9dcc848: Assorted bug fixes and small hardening across the platform.
79
+
80
+ - announcement-backend: `updateAnnouncement` now invalidates the active-announcements and admin-list caches (it was missing the `invalidateAllActive` / `invalidateListAll` calls), so an edited announcement no longer stays stale up to the 45s TTL.
81
+ - anomaly-backend: anomaly/drift state transitions (confirmations, recoveries, self-resolutions) now log at `debug` instead of info/warn - they are already surfaced via the `ANOMALY_STATE_CHANGED` signal, so logging them louder just added noise; genuine failure paths stay `warn`.
82
+ - backend: the `/api/:pluginId/*` dispatcher now populates `requestHeaders` on the per-request RPC context, so a handler that re-enters the router as the originating user (e.g. an AI tool's user-scoped client) can forward the caller's session cookie / bearer - previously the loopback failed with "Authentication required". Guarded by a real end-to-end integration test. The HTTP server idle timeout is also raised (default 255s, configurable via `CHECKSTACK_SERVER_IDLE_TIMEOUT_SECONDS`, clamped 0-255, reset on each streamed chunk) so long AI chat SSE turns are not severed mid-stream.
83
+ - backend: a request for an unknown plugin id (`/api/<unknown>/...`) now returns `404 Not Found` instead of `500` (and logs at warn, not error, since it is a client request) - an unknown _procedure_ on a known plugin already 404'd. The in-app docs namespace `/checkstack/*` now serves Starlight's own `404.html` with a real 404 status for a missing doc, instead of falling through to the SPA catch-all and 200-ing the app shell. Both guarded by tests.
84
+ - automation-common: remove polynomial-time backtracking from `toShellEnvKey`'s underscore-trim (CodeQL `js/polynomial-redos`); a negative look-behind anchors the trailing run, keeping the trim linear.
85
+ - common + script-packages-common: the pure transport-safe sandbox-policy schema (`sandboxPolicySchema` and its sub-schemas + inferred types) moved to `@checkstack/common` (the neutral base), removing two inverted deps that existed only to reach the shape; `@checkstack/backend-api` continues to re-export it. The schema is no longer exported from `@checkstack/script-packages-common`. Pure refactor, no behavior change.
86
+ - catalog-backend: reject duplicate system names (a `CONFLICT` on create/rename, enforced by a pre-write check AND a new DB unique index on `systems.name`, migration 0004 which first resolves pre-existing duplicates by suffixing).
87
+ - catalog-frontend: detail-page cleanups (use `<NotFound />` not `<AccessDenied />` on the not-found branch, a readable key/value metadata list via `normalizeMetadata`, runtime locale via `formatDate`); and stop the browse view re-rendering on every health report (adopt a new statuses report only when a value actually changed, via `healthStatusesEqual`, so rows stay stable and interactive).
88
+ - healthcheck-backend: fix the daily-rollup retention step failing with an `ON CONFLICT` mismatch (SQLSTATE 42P10) after `environmentId` joined the `health_check_aggregates` unique constraint - the rollup now groups by (day, environmentId, sourceId) and uses a single exported conflict-target constant (`DAILY_AGGREGATE_CONFLICT_TARGET`) kept in lock-step with the schema by a unit test.
89
+ - automation-frontend: the service-account picker's "Learn more" links are now absolute URLs to the deployed Astro docs site (they 404ed as in-app relative paths). The Monaco script editor double-init crash is fixed (serialized cold init, a guarded `monacoGuard` accessor, theme/type effects gated on `apiReady`).
90
+ - auth-frontend: bound the desktop user-menu popover height (`max-h-[var(--radix-popover-content-available-height)]` + `overflow-y-auto`) so it no longer clips on short viewports, and fold the standalone `Account > Profile` item into a focusable name/email header (`profileHref` on `UserMenu`); the now-empty `Account` group no longer renders.
91
+ - satellite-frontend: picked up via the sidebar-nav migration (account-only user menu).
92
+
93
+ (Related UI fixes - the Monaco editor following the app theme, the `DynamicOptionsField` no-flash fix, the shared `Spinner`, GFM tables, and the user-menu popover bound - land their `@checkstack/ui` bump in the UI/perf changesets where `@checkstack/ui` is already minored.)
94
+
95
+ This is a beta patch.
96
+
97
+ - 9dcc848: Guard component animations behind isLowPower, and add a shared inline Spinner.
98
+
99
+ - `@checkstack/ui` shared components (`Tabs`, `ConfirmationModal`, `Accordion`, `CodeEditor` popout-button backdrop blur) now drop their `animate-*` / `backdrop-blur` classes when the device reports the low-power tier, matching `LoadingSpinner` / `Skeleton`. No public API change; normal-power rendering is unchanged.
100
+ - A new shared inline `Spinner` (`@checkstack/ui`) renders a lucide `Loader2` whose `animate-spin` is gated internally behind `usePerformance().isLowPower`, so call sites inherit the low-power guard. Props: `size` (`sm`/`md`/`lg`), `className`, rest spread to the icon; decorative by default (`aria-hidden`), `role="status"` when given `aria-label`. The hand-rolled `Loader2` button/table spinners in `HealthCheckDrawer`, `HealthCheckRunsTable`, `IncidentEditor`, `IncidentUpdateForm`, `ProviderConnectionsPage`, `MaintenanceEditor`, `MaintenanceUpdateForm`, `UserChannelCard`, and `DynamicOptionsField` are migrated onto it.
101
+ - Remaining unguarded `animate-*` / `animate-in` / blur classes across the auth, gitops, healthcheck, incident, integration, maintenance, and notification frontends are gated behind `usePerformance().isLowPower`, so effects degrade gracefully on low-power devices per the performance rule.
102
+
103
+ Normal-power behavior is unchanged; low-power rendering drops the animations.
104
+
105
+ This is a beta minor.
106
+
107
+ - Updated dependencies [9dcc848]
108
+ - Updated dependencies [9dcc848]
109
+ - Updated dependencies [9dcc848]
110
+ - Updated dependencies [9dcc848]
111
+ - Updated dependencies [9dcc848]
112
+ - Updated dependencies [9dcc848]
113
+ - Updated dependencies [9dcc848]
114
+ - Updated dependencies [9dcc848]
115
+ - Updated dependencies [9dcc848]
116
+ - Updated dependencies [9dcc848]
117
+ - Updated dependencies [9dcc848]
118
+ - Updated dependencies [9dcc848]
119
+ - Updated dependencies [9dcc848]
120
+ - @checkstack/ui@1.13.0
121
+ - @checkstack/auth-common@0.8.0
122
+ - @checkstack/common@0.13.0
123
+ - @checkstack/frontend-api@0.7.0
124
+
3
125
  ## 0.6.7
4
126
 
5
127
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-frontend",
3
- "version": "0.6.7",
3
+ "version": "0.7.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.tsx",
@@ -18,21 +18,21 @@
18
18
  "test:e2e": "bunx playwright test"
19
19
  },
20
20
  "dependencies": {
21
- "@checkstack/frontend-api": "0.6.0",
22
- "@checkstack/common": "0.12.0",
23
- "@checkstack/ui": "1.11.0",
24
- "react": "^18.2.0",
25
- "react-router-dom": "^6.22.0",
26
- "lucide-react": "^0.344.0",
27
- "better-auth": "^1.1.8",
28
- "@checkstack/auth-common": "0.7.2"
21
+ "@checkstack/frontend-api": "0.7.0",
22
+ "@checkstack/common": "0.13.0",
23
+ "@checkstack/ui": "1.13.0",
24
+ "react": "^18.3.1",
25
+ "react-router-dom": "^7.16.0",
26
+ "lucide-react": "^1.17.0",
27
+ "better-auth": "^1.6.13",
28
+ "@checkstack/auth-common": "0.8.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "typescript": "^5.0.0",
32
32
  "@types/react": "^18.2.0",
33
- "@playwright/test": "^1.49.0",
34
- "@checkstack/test-utils-frontend": "0.0.5",
33
+ "@playwright/test": "^1.60.0",
34
+ "@checkstack/test-utils-frontend": "0.1.0",
35
35
  "@checkstack/tsconfig": "0.0.7",
36
- "@checkstack/scripts": "0.3.4"
36
+ "@checkstack/scripts": "0.4.0"
37
37
  }
38
38
  }
@@ -2,7 +2,6 @@ import { useCallback, useMemo } from "react";
2
2
  import {
3
3
  StrategyConfigCard,
4
4
  type ConfigSection,
5
- type LucideIconName,
6
5
  type OptionsResolver,
7
6
  } from "@checkstack/ui";
8
7
  import { usePluginClient } from "@checkstack/frontend-api";
@@ -87,7 +86,7 @@ export function AuthStrategyCard({
87
86
  id: strategy.id,
88
87
  displayName: strategy.displayName,
89
88
  description: strategy.description,
90
- icon: strategy.icon as LucideIconName,
89
+ icon: strategy.icon,
91
90
  enabled: strategy.enabled,
92
91
  }}
93
92
  configSections={configSections}
@@ -4,12 +4,9 @@ import { LogIn, LogOut, AlertCircle, ArrowLeft } from "lucide-react";
4
4
  import {
5
5
  useApi,
6
6
  ExtensionSlot,
7
- pluginRegistry,
8
7
  usePluginClient,
9
- UserMenuItemsSlot,
10
8
  UserMenuItemsBottomSlot,
11
9
  UserMenuItemsContext,
12
- Extension,
13
10
  } from "@checkstack/frontend-api";
14
11
  import { AuthApi, authRoutes } from "@checkstack/auth-common";
15
12
  import { resolveRoute } from "@checkstack/common";
@@ -25,8 +22,6 @@ import {
25
22
  CardFooter,
26
23
  UserMenu,
27
24
  DropdownMenuItem,
28
- DropdownMenuLabel,
29
- DropdownMenuSeparator,
30
25
  Alert,
31
26
  AlertIcon,
32
27
  AlertContent,
@@ -37,6 +32,8 @@ import {
37
32
  InfoBannerContent,
38
33
  InfoBannerTitle,
39
34
  InfoBannerDescription,
35
+ usePerformance,
36
+ cn,
40
37
  } from "@checkstack/ui";
41
38
  import { useToast } from "@checkstack/ui";
42
39
  import { extractErrorMessage } from "@checkstack/common";
@@ -62,6 +59,7 @@ export const LoginPage = () => {
62
59
  const authClient = usePluginClient(AuthApi);
63
60
  const { strategies, loading: strategiesLoading } = useEnabledStrategies();
64
61
  const toast = useToast();
62
+ const { isLowPower } = usePerformance();
65
63
 
66
64
  // Query: Registration status
67
65
  const { data: registrationData } = authClient.getRegistrationStatus.useQuery(
@@ -165,10 +163,30 @@ export const LoginPage = () => {
165
163
  <Card className="w-full max-w-md">
166
164
  <CardContent className="pt-6">
167
165
  <div className="space-y-4">
168
- <div className="h-4 bg-muted animate-pulse rounded" />
169
- <div className="h-10 bg-muted animate-pulse rounded" />
170
- <div className="h-10 bg-muted animate-pulse rounded" />
171
- <div className="h-10 bg-muted animate-pulse rounded" />
166
+ <div
167
+ className={cn(
168
+ "h-4 bg-muted rounded",
169
+ !isLowPower && "animate-pulse",
170
+ )}
171
+ />
172
+ <div
173
+ className={cn(
174
+ "h-10 bg-muted rounded",
175
+ !isLowPower && "animate-pulse",
176
+ )}
177
+ />
178
+ <div
179
+ className={cn(
180
+ "h-10 bg-muted rounded",
181
+ !isLowPower && "animate-pulse",
182
+ )}
183
+ />
184
+ <div
185
+ className={cn(
186
+ "h-10 bg-muted rounded",
187
+ !isLowPower && "animate-pulse",
188
+ )}
189
+ />
172
190
  </div>
173
191
  </CardContent>
174
192
  </Card>
@@ -418,41 +436,6 @@ export const LogoutMenuItem = (_props: UserMenuItemsContext) => {
418
436
  );
419
437
  };
420
438
 
421
- type UserMenuExtension = Extension<typeof UserMenuItemsSlot>;
422
-
423
- function groupTopExtensions(
424
- extensions: UserMenuExtension[],
425
- ): Array<{ label: string | undefined; items: UserMenuExtension[] }> {
426
- const buckets = new Map<string, UserMenuExtension[]>();
427
- const ungrouped: UserMenuExtension[] = [];
428
-
429
- for (const ext of extensions) {
430
- const groupName = ext.metadata?.group;
431
- if (!groupName) {
432
- ungrouped.push(ext);
433
- continue;
434
- }
435
- const list = buckets.get(groupName);
436
- if (list) {
437
- list.push(ext);
438
- } else {
439
- buckets.set(groupName, [ext]);
440
- }
441
- }
442
-
443
- // Groups are displayed alphabetically by label (items keep their order
444
- // within a group).
445
- const result: Array<{ label: string | undefined; items: UserMenuExtension[] }> =
446
- [...buckets.entries()]
447
- .toSorted(([a], [b]) => a.localeCompare(b))
448
- .map(([name, items]) => ({ label: name, items }));
449
- // Untagged extensions go last with no header.
450
- if (ungrouped.length > 0) {
451
- result.push({ label: undefined, items: ungrouped });
452
- }
453
- return result;
454
- }
455
-
456
439
  export const LoginNavbarAction = () => {
457
440
  const authApi = useApi(authApiRef);
458
441
  const { data: session, isPending } = authApi.useSession();
@@ -460,6 +443,7 @@ export const LoginNavbarAction = () => {
460
443
  const [hasCredentialAccount, setHasCredentialAccount] =
461
444
  useState<boolean>(false);
462
445
  const [credentialLoading, setCredentialLoading] = useState(true);
446
+ const { isLowPower } = usePerformance();
463
447
 
464
448
  useEffect(() => {
465
449
  if (!session?.user) {
@@ -478,36 +462,30 @@ export const LoginNavbarAction = () => {
478
462
  }, [session?.user]);
479
463
 
480
464
  if (isPending || accessRulesLoading || credentialLoading) {
481
- return <div className="w-20 h-9 bg-muted animate-pulse rounded-full" />;
465
+ return (
466
+ <div
467
+ className={cn(
468
+ "w-20 h-9 bg-muted rounded-full",
469
+ !isLowPower && "animate-pulse",
470
+ )}
471
+ />
472
+ );
482
473
  }
483
474
 
484
475
  if (session?.user) {
485
- const topExtensions = pluginRegistry.getExtensions(
486
- UserMenuItemsSlot.id,
487
- ) as UserMenuExtension[];
488
- const bottomExtensions = pluginRegistry.getExtensions(
489
- UserMenuItemsBottomSlot.id,
490
- );
491
- const hasBottomItems = bottomExtensions.length > 0;
476
+ // The user menu is account-only now: profile header (rendered by UserMenu)
477
+ // plus the bottom slot (About, theme, low-power, logout). Feature navigation
478
+ // lives in the left sidebar (routes that declare `nav` metadata).
492
479
  const menuContext: UserMenuItemsContext = {
493
480
  accessRules,
494
481
  hasCredentialAccount,
495
482
  };
496
- const groups = groupTopExtensions(topExtensions);
497
483
 
498
484
  return (
499
- <UserMenu user={session.user}>
500
- {groups.map(({ label, items }, groupIndex) => (
501
- <React.Fragment key={label ?? `__group-${groupIndex}`}>
502
- {groupIndex > 0 && <DropdownMenuSeparator />}
503
- {label && <DropdownMenuLabel>{label}</DropdownMenuLabel>}
504
- {items.map((ext) => {
505
- const Component = ext.component as React.ComponentType<UserMenuItemsContext>;
506
- return <Component key={ext.id} {...menuContext} />;
507
- })}
508
- </React.Fragment>
509
- ))}
510
- {hasBottomItems && <DropdownMenuSeparator />}
485
+ <UserMenu
486
+ user={session.user}
487
+ profileHref={resolveRoute(authRoutes.routes.profile)}
488
+ >
511
489
  <ExtensionSlot slot={UserMenuItemsBottomSlot} context={menuContext} />
512
490
  </UserMenu>
513
491
  );
@@ -0,0 +1,177 @@
1
+ import { useState } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
+ import { ShieldCheck, AlertCircle } from "lucide-react";
4
+ import { extractErrorMessage } from "@checkstack/common";
5
+ import { getCachedRuntimeConfig } from "@checkstack/frontend-api";
6
+ import {
7
+ Button,
8
+ Card,
9
+ CardHeader,
10
+ CardTitle,
11
+ CardDescription,
12
+ CardContent,
13
+ CardFooter,
14
+ Alert,
15
+ AlertIcon,
16
+ AlertContent,
17
+ AlertTitle,
18
+ AlertDescription,
19
+ Badge,
20
+ } from "@checkstack/ui";
21
+
22
+ /**
23
+ * OAuth 2.1 consent screen for the AI platform's Authorization Server.
24
+ *
25
+ * better-auth's `oidcProvider` redirects the interactive authorization-code
26
+ * flow to `consentPage` (configured as `/auth/oauth-consent`) with the
27
+ * `consent_code`, `client_id`, and granted `scope` as query params. Without a
28
+ * route here the flow 404s; this minimal page renders the requested client and
29
+ * scopes, then POSTs the user's decision to the better-auth consent endpoint
30
+ * and follows the returned `redirectURI`.
31
+ *
32
+ * The decision endpoint requires the user's session cookie (it runs behind
33
+ * `sessionMiddleware`), so the POST uses `credentials: "include"`.
34
+ */
35
+ export const OAuthConsentPage = () => {
36
+ const [searchParams] = useSearchParams();
37
+ const consentCode = searchParams.get("consent_code");
38
+ const clientId = searchParams.get("client_id");
39
+ const scope = searchParams.get("scope") ?? "";
40
+
41
+ const [loading, setLoading] = useState<"accept" | "deny" | undefined>();
42
+ const [error, setError] = useState<string>();
43
+
44
+ const baseUrl = getCachedRuntimeConfig()?.baseUrl ?? globalThis.location.origin;
45
+ const scopes = scope.split(" ").filter(Boolean);
46
+
47
+ const submit = async (accept: boolean) => {
48
+ setError(undefined);
49
+ setLoading(accept ? "accept" : "deny");
50
+ try {
51
+ const res = await fetch(`${baseUrl}/api/auth/oauth2/consent`, {
52
+ method: "POST",
53
+ headers: { "content-type": "application/json" },
54
+ credentials: "include",
55
+ body: JSON.stringify({ accept, consent_code: consentCode }),
56
+ });
57
+ if (!res.ok) {
58
+ let detail: string | undefined;
59
+ try {
60
+ const body = (await res.json()) as {
61
+ error_description?: string;
62
+ message?: string;
63
+ };
64
+ detail = body.error_description ?? body.message;
65
+ } catch {
66
+ // Non-JSON error body — fall back to the generic message below.
67
+ }
68
+ setError(
69
+ detail ??
70
+ "Consent could not be processed. The request may have expired.",
71
+ );
72
+ setLoading(undefined);
73
+ return;
74
+ }
75
+ const data = (await res.json()) as { redirectURI?: string };
76
+ if (data.redirectURI) {
77
+ globalThis.location.href = data.redirectURI;
78
+ return;
79
+ }
80
+ setError("The authorization server did not return a redirect.");
81
+ } catch (error_) {
82
+ setError(extractErrorMessage(error_, "Consent request failed."));
83
+ } finally {
84
+ setLoading(undefined);
85
+ }
86
+ };
87
+
88
+ if (!consentCode || !clientId) {
89
+ return (
90
+ <div className="min-h-[80vh] flex items-center justify-center">
91
+ <Card className="w-full max-w-md">
92
+ <CardHeader className="space-y-1 text-center">
93
+ <div className="mx-auto w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mb-2">
94
+ <AlertCircle className="h-6 w-6 text-destructive" />
95
+ </div>
96
+ <CardTitle className="text-2xl font-bold">Invalid Request</CardTitle>
97
+ <CardDescription>
98
+ This authorization request is missing required parameters. Start
99
+ the connection again from your client application.
100
+ </CardDescription>
101
+ </CardHeader>
102
+ </Card>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <div className="min-h-[80vh] flex items-center justify-center">
109
+ <Card className="w-full max-w-md">
110
+ <CardHeader className="space-y-1 text-center">
111
+ <div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-2">
112
+ <ShieldCheck className="h-6 w-6 text-primary" />
113
+ </div>
114
+ <CardTitle className="text-2xl font-bold">Authorize access</CardTitle>
115
+ <CardDescription>
116
+ <span className="font-medium text-foreground">{clientId}</span> is
117
+ requesting access to your Checkstack account.
118
+ </CardDescription>
119
+ </CardHeader>
120
+ <CardContent className="space-y-4">
121
+ {error && (
122
+ <Alert variant="error">
123
+ <AlertIcon>
124
+ <AlertCircle className="h-4 w-4" />
125
+ </AlertIcon>
126
+ <AlertContent>
127
+ <AlertTitle>Error</AlertTitle>
128
+ <AlertDescription>{error}</AlertDescription>
129
+ </AlertContent>
130
+ </Alert>
131
+ )}
132
+
133
+ <div className="space-y-2">
134
+ <p className="text-sm text-muted-foreground">
135
+ This application will be able to act on your behalf with the
136
+ following scopes:
137
+ </p>
138
+ {scopes.length > 0 ? (
139
+ <div className="flex flex-wrap gap-2">
140
+ {scopes.map((s) => (
141
+ <Badge key={s} variant="secondary">
142
+ {s}
143
+ </Badge>
144
+ ))}
145
+ </div>
146
+ ) : (
147
+ <p className="text-sm text-muted-foreground italic">
148
+ No specific scopes requested.
149
+ </p>
150
+ )}
151
+ <p className="text-xs text-muted-foreground pt-2">
152
+ Access is always limited to what your own account already permits.
153
+ </p>
154
+ </div>
155
+ </CardContent>
156
+ <CardFooter className="flex gap-3">
157
+ <Button
158
+ variant="outline"
159
+ className="flex-1"
160
+ disabled={loading !== undefined}
161
+ onClick={() => submit(false)}
162
+ >
163
+ {loading === "deny" ? "Denying..." : "Deny"}
164
+ </Button>
165
+ <Button
166
+ variant="primary"
167
+ className="flex-1"
168
+ disabled={loading !== undefined}
169
+ onClick={() => submit(true)}
170
+ >
171
+ {loading === "accept" ? "Authorizing..." : "Allow"}
172
+ </Button>
173
+ </CardFooter>
174
+ </Card>
175
+ </div>
176
+ );
177
+ };
@@ -19,6 +19,8 @@ import {
19
19
  AlertContent,
20
20
  AlertTitle,
21
21
  AlertDescription,
22
+ usePerformance,
23
+ cn,
22
24
  } from "@checkstack/ui";
23
25
  import { getAuthClientLazy } from "../lib/auth-client";
24
26
 
@@ -37,6 +39,7 @@ export const OnboardingPage = () => {
37
39
  const completeOnboardingMutation =
38
40
  authClient.completeOnboarding.useMutation();
39
41
  const betterAuthClient = getAuthClientLazy();
42
+ const { isLowPower } = usePerformance();
40
43
 
41
44
  // Check if onboarding is needed
42
45
  const { data: onboardingStatus, isLoading: checkingStatus } =
@@ -125,9 +128,24 @@ export const OnboardingPage = () => {
125
128
  <Card className="w-full max-w-md">
126
129
  <CardContent className="pt-6">
127
130
  <div className="space-y-4">
128
- <div className="h-4 bg-muted animate-pulse rounded" />
129
- <div className="h-10 bg-muted animate-pulse rounded" />
130
- <div className="h-10 bg-muted animate-pulse rounded" />
131
+ <div
132
+ className={cn(
133
+ "h-4 bg-muted rounded",
134
+ !isLowPower && "animate-pulse",
135
+ )}
136
+ />
137
+ <div
138
+ className={cn(
139
+ "h-10 bg-muted rounded",
140
+ !isLowPower && "animate-pulse",
141
+ )}
142
+ />
143
+ <div
144
+ className={cn(
145
+ "h-10 bg-muted rounded",
146
+ !isLowPower && "animate-pulse",
147
+ )}
148
+ />
131
149
  </div>
132
150
  </CardContent>
133
151
  </Card>
@@ -26,6 +26,8 @@ import {
26
26
  AlertContent,
27
27
  AlertTitle,
28
28
  AlertDescription,
29
+ usePerformance,
30
+ cn,
29
31
  } from "@checkstack/ui";
30
32
 
31
33
  export const ProfilePage = () => {
@@ -40,6 +42,7 @@ export const ProfilePage = () => {
40
42
  const [hasCredentialAccount, setHasCredentialAccount] = useState(false);
41
43
 
42
44
  const authClient = usePluginClient(AuthApi);
45
+ const { isLowPower } = usePerformance();
43
46
 
44
47
  // Fetch current user profile
45
48
  const { data: profile, isLoading: loadingProfile } =
@@ -104,9 +107,24 @@ export const ProfilePage = () => {
104
107
  <Card className="w-full max-w-md">
105
108
  <CardContent className="pt-6">
106
109
  <div className="space-y-4">
107
- <div className="h-4 bg-muted animate-pulse rounded" />
108
- <div className="h-10 bg-muted animate-pulse rounded" />
109
- <div className="h-10 bg-muted animate-pulse rounded" />
110
+ <div
111
+ className={cn(
112
+ "h-4 bg-muted rounded",
113
+ !isLowPower && "animate-pulse",
114
+ )}
115
+ />
116
+ <div
117
+ className={cn(
118
+ "h-10 bg-muted rounded",
119
+ !isLowPower && "animate-pulse",
120
+ )}
121
+ />
122
+ <div
123
+ className={cn(
124
+ "h-10 bg-muted rounded",
125
+ !isLowPower && "animate-pulse",
126
+ )}
127
+ />
110
128
  </div>
111
129
  </CardContent>
112
130
  </Card>
@@ -21,6 +21,8 @@ import {
21
21
  InfoBannerTitle,
22
22
  InfoBannerDescription,
23
23
  useToast,
24
+ usePerformance,
25
+ cn,
24
26
  } from "@checkstack/ui";
25
27
  import { useEnabledStrategies } from "../hooks/useEnabledStrategies";
26
28
  import { SocialProviderButton } from "./SocialProviderButton";
@@ -37,6 +39,7 @@ export const RegisterPage = () => {
37
39
  const { strategies, loading: strategiesLoading } = useEnabledStrategies();
38
40
  const authBetterClient = getAuthClientLazy();
39
41
  const toast = useToast();
42
+ const { isLowPower } = usePerformance();
40
43
 
41
44
  // Derive validation errors directly from password (no state/effect needed)
42
45
  const validationErrors = useMemo(() => {
@@ -100,10 +103,30 @@ export const RegisterPage = () => {
100
103
  <Card className="w-full max-w-md">
101
104
  <CardContent className="pt-6">
102
105
  <div className="space-y-4">
103
- <div className="h-4 bg-muted animate-pulse rounded" />
104
- <div className="h-10 bg-muted animate-pulse rounded" />
105
- <div className="h-10 bg-muted animate-pulse rounded" />
106
- <div className="h-10 bg-muted animate-pulse rounded" />
106
+ <div
107
+ className={cn(
108
+ "h-4 bg-muted rounded",
109
+ !isLowPower && "animate-pulse",
110
+ )}
111
+ />
112
+ <div
113
+ className={cn(
114
+ "h-10 bg-muted rounded",
115
+ !isLowPower && "animate-pulse",
116
+ )}
117
+ />
118
+ <div
119
+ className={cn(
120
+ "h-10 bg-muted rounded",
121
+ !isLowPower && "animate-pulse",
122
+ )}
123
+ />
124
+ <div
125
+ className={cn(
126
+ "h-10 bg-muted rounded",
127
+ !isLowPower && "animate-pulse",
128
+ )}
129
+ />
107
130
  </div>
108
131
  </CardContent>
109
132
  </Card>
package/src/index.tsx CHANGED
@@ -1,11 +1,9 @@
1
- import React from "react";
2
1
  import {
3
2
  ApiRef,
4
3
  accessApiRef,
5
4
  createFrontendPlugin,
6
5
  createSlotExtension,
7
6
  NavbarRightSlot,
8
- UserMenuItemsSlot,
9
7
  UserMenuItemsBottomSlot,
10
8
  NavbarLeftSlot,
11
9
  } from "@checkstack/frontend-api";
@@ -21,15 +19,13 @@ import { ResetPasswordPage } from "./components/ResetPasswordPage";
21
19
  import { ChangePasswordPage } from "./components/ChangePasswordPage";
22
20
  import { OnboardingPage } from "./components/OnboardingPage";
23
21
  import { ProfilePage } from "./components/ProfilePage";
22
+ import { OAuthConsentPage } from "./components/OAuthConsentPage";
24
23
  import { authApiRef, AuthApi, AuthSession } from "./api";
25
24
  import { getAuthClientLazy } from "./lib/auth-client";
26
25
  import { AuthAccessApi } from "./lib/AuthAccessApi";
27
26
  import { useSessionContext } from "./lib/SessionProvider";
28
27
 
29
- import { useNavigate } from "react-router-dom";
30
- import { Settings2, User } from "lucide-react";
31
- import { DropdownMenuItem } from "@checkstack/ui";
32
- import { UserMenuItemsContext } from "@checkstack/frontend-api";
28
+ import { Settings2 } from "lucide-react";
33
29
  import { AuthSettingsPage } from "./components/AuthSettingsPage";
34
30
  import {
35
31
  authAccess,
@@ -120,6 +116,10 @@ export type { TeamAccessEditorProps } from "./components/TeamAccessEditor";
120
116
  // Re-export SessionProvider for App.tsx to wrap the component tree
121
117
  export { SessionProvider } from "./lib/SessionProvider";
122
118
 
119
+ // Re-export the access-rules hook so the app shell (sidebar) can resolve
120
+ // nav visibility from the granted rules in one place.
121
+ export { useAccessRules } from "./hooks/useAccessRules";
122
+
123
123
  export const authPlugin = createFrontendPlugin({
124
124
  metadata: pluginMetadata,
125
125
  apis: [
@@ -148,6 +148,12 @@ export const authPlugin = createFrontendPlugin({
148
148
  {
149
149
  route: authRoutes.routes.settings,
150
150
  element: <AuthSettingsPage />,
151
+ nav: {
152
+ group: "Configuration",
153
+ icon: Settings2,
154
+ label: "Auth Settings",
155
+ accessRule: authAccess.strategies,
156
+ },
151
157
  },
152
158
  {
153
159
  route: authRoutes.routes.forgotPassword,
@@ -169,6 +175,10 @@ export const authPlugin = createFrontendPlugin({
169
175
  route: authRoutes.routes.onboarding,
170
176
  element: <OnboardingPage />,
171
177
  },
178
+ {
179
+ route: authRoutes.routes.oauthConsent,
180
+ element: <OAuthConsentPage />,
181
+ },
172
182
  ],
173
183
  extensions: [
174
184
  {
@@ -176,45 +186,6 @@ export const authPlugin = createFrontendPlugin({
176
186
  slot: NavbarRightSlot,
177
187
  component: LoginNavbarAction,
178
188
  },
179
- createSlotExtension(UserMenuItemsSlot, {
180
- id: "auth.user-menu.settings",
181
- metadata: { group: "Configuration" },
182
- component: ({ accessRules: userPerms }: UserMenuItemsContext) => {
183
- // eslint-disable-next-line react-hooks/rules-of-hooks -- Inline component used via createSlotExtension
184
- const navigate = useNavigate();
185
- const qualifiedId = `${pluginMetadata.pluginId}.${authAccess.strategies.id}`;
186
- const canManage =
187
- userPerms.includes("*") || userPerms.includes(qualifiedId);
188
-
189
- if (!canManage) return <React.Fragment />;
190
-
191
- return (
192
- <DropdownMenuItem
193
- onClick={() => navigate(resolveRoute(authRoutes.routes.settings))}
194
- icon={<Settings2 className="h-4 w-4" />}
195
- >
196
- Auth Settings
197
- </DropdownMenuItem>
198
- );
199
- },
200
- }),
201
- createSlotExtension(UserMenuItemsSlot, {
202
- id: "auth.user-menu.profile",
203
- metadata: { group: "Account" },
204
- component: () => {
205
- // eslint-disable-next-line react-hooks/rules-of-hooks -- Inline component used via createSlotExtension
206
- const navigate = useNavigate();
207
-
208
- return (
209
- <DropdownMenuItem
210
- onClick={() => navigate(resolveRoute(authRoutes.routes.profile))}
211
- icon={<User className="h-4 w-4" />}
212
- >
213
- Profile
214
- </DropdownMenuItem>
215
- );
216
- },
217
- }),
218
189
  createSlotExtension(UserMenuItemsBottomSlot, {
219
190
  id: "auth.user-menu.logout",
220
191
  component: LogoutMenuItem,
@@ -1,6 +1,6 @@
1
1
  import { AccessApi } from "@checkstack/frontend-api";
2
2
  import { useAccessRules } from "../hooks/useAccessRules";
3
- import type { AccessRule } from "@checkstack/common";
3
+ import { isAccessRuleSatisfied, type AccessRule } from "@checkstack/common";
4
4
 
5
5
  /**
6
6
  * Unified access API implementation.
@@ -20,19 +20,9 @@ export class AuthAccessApi implements AccessApi {
20
20
  return { loading: false, allowed: false };
21
21
  }
22
22
 
23
- const accessRuleId = accessRule.id;
24
-
25
- // Check wildcard, exact match, or manage implies read
26
- const isWildcard = accessRules.includes("*");
27
- const hasExact = accessRules.includes(accessRuleId);
28
-
29
- // For read actions, also check if user has manage access for the same resource
30
- const hasManage =
31
- accessRule.level === "read"
32
- ? accessRules.includes(`${accessRule.resource}.manage`)
33
- : false;
34
-
35
- const allowed = isWildcard || hasExact || hasManage;
36
- return { loading: false, allowed };
23
+ return {
24
+ loading: false,
25
+ allowed: isAccessRuleSatisfied(accessRules, accessRule),
26
+ };
37
27
  }
38
28
  }