create-astram-app 0.1.3 → 0.2.0

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.
Files changed (83) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +122 -25
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/frontend/FRONTEND.md +92 -0
  7. package/template/frontend/_env.example +6 -0
  8. package/template/frontend/_gitignore +9 -0
  9. package/template/frontend/eslint.config.js +71 -0
  10. package/template/frontend/index.html +13 -0
  11. package/template/frontend/package.json +52 -0
  12. package/template/frontend/public/astram-logo-full.png +0 -0
  13. package/template/frontend/public/astram-logo-white.png +0 -0
  14. package/template/frontend/public/favicon.png +0 -0
  15. package/template/frontend/src/App.jsx +167 -0
  16. package/template/frontend/src/auth/AuthContext.jsx +338 -0
  17. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  18. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  19. package/template/frontend/src/components/EntityList.jsx +480 -0
  20. package/template/frontend/src/components/PageHeader.jsx +54 -0
  21. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  22. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  23. package/template/frontend/src/components/ui/alert.tsx +83 -0
  24. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  25. package/template/frontend/src/components/ui/badge.tsx +56 -0
  26. package/template/frontend/src/components/ui/button.tsx +67 -0
  27. package/template/frontend/src/components/ui/card.tsx +92 -0
  28. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  29. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  30. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  31. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  32. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  33. package/template/frontend/src/components/ui/input.tsx +21 -0
  34. package/template/frontend/src/components/ui/label.tsx +46 -0
  35. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  36. package/template/frontend/src/components/ui/select.tsx +199 -0
  37. package/template/frontend/src/components/ui/separator.tsx +26 -0
  38. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  39. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  40. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  41. package/template/frontend/src/components/ui/table.tsx +181 -0
  42. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  43. package/template/frontend/src/config/branding.js +80 -0
  44. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  45. package/template/frontend/src/index.css +265 -0
  46. package/template/frontend/src/lib/utils.js +7 -0
  47. package/template/frontend/src/main.jsx +27 -0
  48. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  49. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  50. package/template/frontend/src/pages/Login.jsx +393 -0
  51. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  52. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  53. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  54. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  55. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  56. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  57. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  58. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  59. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  60. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  61. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  62. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  63. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  64. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  65. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  66. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  67. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  68. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  69. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  70. package/template/frontend/src/platform/httpClient.js +125 -0
  71. package/template/frontend/src/platform/returnPathStore.js +43 -0
  72. package/template/frontend/src/platform/sessionStore.js +29 -0
  73. package/template/frontend/src/platform/shieldTransport.js +172 -0
  74. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  75. package/template/frontend/src/shell/AppShell.jsx +237 -0
  76. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  77. package/template/frontend/src/shell/nav.js +90 -0
  78. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  79. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  80. package/template/frontend/tsconfig.json +22 -0
  81. package/template/frontend/vite.config.js +30 -0
  82. package/template/server/_env.example +27 -15
  83. package/template/server/package.json +7 -4
@@ -0,0 +1,172 @@
1
+ /**
2
+ * shieldTransport — the WEB `Transport` implementation for `@astram/sdk`'s
3
+ * `ShieldClient` (Phase E slice 1). Wraps the SAME injected `http` client every other
4
+ * frontend service uses (`createWebHttpClient` — see `platform/httpClient.js`), mapping
5
+ * HTTP outcomes to the SDK's `Transport` contract (`packages/sdk/src/transport.ts`).
6
+ *
7
+ * ⛔ THE CRITICAL FRAMING: this transport (and the projection it fetches) is a UX
8
+ * CONVENIENCE, NEVER the security boundary. The server's `decide()` on every
9
+ * `@Classified` route is the REAL enforcement — a tampered/stale frontend simply hits a
10
+ * 403 there. This file must never grow security logic; it only classifies an HTTP
11
+ * response into the SDK's fail-closed vocabulary so the UI can hide/disable actions the
12
+ * caller can't use.
13
+ *
14
+ * HTTP -> outcome classification for `fetchProjection` (per the SDK's ADR-0016 contract):
15
+ * - fetch threw / network failure → `http.get` never throws; it resolves
16
+ * `{ ok:false, status:0, data:null }` (see httpClient.js's catch branches) — TRANSIENT.
17
+ * - status 0 (network/transport failure) → TRANSIENT → throw `TransportUnavailableError`.
18
+ * - status >= 500 (server outage, incl. our own 503 on a loadCoarseGrants store outage)
19
+ * → TRANSIENT → throw `TransportUnavailableError`.
20
+ * - status 401 (missing/invalid/expired token) → a SESSION concern, NOT a permission
21
+ * deny — the engine must not read "no token" as "no permissions" and cache an empty
22
+ * deny-everything projection past a token refresh. Treated as TRANSIENT (throw), so
23
+ * the engine holds its last-good/empty projection and the NEXT session-established
24
+ * fetch (post-refresh/re-login) supersedes it, rather than baking in a deny.
25
+ * - a real 403 (or any other non-5xx !ok) → TERMINAL deny → return an EMPTY projection
26
+ * `{ principalId:'', facts:[], projectedAt: Date.now() }` (deny-all).
27
+ * - 200 with a body → return `r.data` (the projection) AS-IS.
28
+ *
29
+ * `check` / `issueStepUp` / `satisfyStepUp` are DEFERRED (locked decision — slice 1 does
30
+ * not implement per-check gating or step-up). Each stays a throwing/deny stub; the SDK
31
+ * engine already fail-closes around an absent/erroring capability (see `client.ts`'s
32
+ * `checkOnce` — a throw that isn't `TransportUnavailableError` degrades to a safe
33
+ * `deny`, and `issueStepUpForScope` swallows a throw to `null`).
34
+ *
35
+ * `currentVersion()` (Phase E slice 2 — ADR-0011 frontend twin) IS implemented, opting
36
+ * this transport into `client.ts`'s `isVersionAware` duck-type so `ShieldClient` can
37
+ * detect a backend permission-version BUMP and auto-refetch the projection.
38
+ *
39
+ * ⚠️ CONTRACT MATCHED (read `client.ts`'s `isProjectionFresh`, packages/sdk/src/client.ts
40
+ * lines 385-395, before touching this): `isProjectionFresh` calls
41
+ * `await transport.currentVersion()` completely UNGUARDED — no try/catch wraps that call,
42
+ * neither in `isProjectionFresh` itself nor at its one call site in
43
+ * `fetchAndCacheProjection` (the surrounding try/catch there only wraps the LATER
44
+ * `transport.fetchProjection` call). A `currentVersion()` THROW would therefore propagate
45
+ * all the way out of `getAuthFacts` UNCAUGHT — breaking the SDK's documented "getAuthFacts
46
+ * never throws" fail-closed guarantee that `AuthContext.refreshAuthFacts` relies on. So
47
+ * this implementation MUST NEVER THROW.
48
+ *
49
+ * Freshness is decided by `cached.permissionsVersion >= current` (client.ts:392) — a
50
+ * cache is stale (refetch) whenever `current` is NOT LESS than the cached version. So the
51
+ * fail-closed way to say "I can't tell you the real version" is to RETURN a number that
52
+ * can never compare `<=` any real cached version: `Number.POSITIVE_INFINITY`. That makes
53
+ * every comparison `cached.permissionsVersion >= Infinity` evaluate `false` — always
54
+ * stale, always refetch — mirroring the SDK's own NaN-version adversarial test
55
+ * (`adversarial.test.ts` "ATTACK 2": a version that can never compare `>=` current is
56
+ * always treated as stale, never phantom-fresh). This is fail-closed WITHOUT throwing:
57
+ * an unreadable version can never be mistaken for "cache is fresh".
58
+ *
59
+ * A non-ok probe (status 0 network failure, 401 session concern, >=500 server/store
60
+ * outage) is exactly this "can't-know" case → returns `Number.POSITIVE_INFINITY` (forces
61
+ * a refetch on the NEXT `getAuthFacts` call; it does NOT itself throw or fetch — the
62
+ * projection refetch, if it also fails transiently, is `fetchProjection`'s concern, which
63
+ * DOES throw `TransportUnavailableError` and IS caught by the SDK's try/catch).
64
+ * A 200 with a well-formed numeric `data.version` returns that number. The whole body
65
+ * is wrapped in try/catch as a defensive backstop (the real `http.get` never rejects,
66
+ * but this keeps the never-throw contract robust even against a misbehaving/mocked
67
+ * `http` dependency) — any unexpected throw ALSO resolves `Number.POSITIVE_INFINITY`
68
+ * rather than escaping.
69
+ */
70
+ import { TransportUnavailableError } from '@astram/sdk';
71
+
72
+ const AUTH_FACTS_PATH = '/identity/auth-facts';
73
+ const PERMISSIONS_VERSION_PATH = '/identity/permissions-version';
74
+
75
+ /**
76
+ * @param {{ http: import('@astram/frontend-core').HttpClient, getToken: () => string | null | undefined }} deps
77
+ * `getToken` is a GETTER (not a captured value) so the transport — built ONCE at
78
+ * AuthContext construction — always reads the CURRENT access token, surviving a
79
+ * token refresh without being re-created (mirrors AuthContext's `reenrollRef` /
80
+ * ref-based-not-snapshot pattern for the same reason).
81
+ * @returns {import('@astram/sdk').Transport}
82
+ */
83
+ export function createWebShieldTransport({ http, getToken }) {
84
+ return {
85
+ /** @param {string} principalId */
86
+ async fetchProjection(principalId) {
87
+ const r = await http.get(AUTH_FACTS_PATH, { bearerToken: getToken() });
88
+
89
+ // status 0 (network/fetch failure) or a 5xx (incl. our 503 loadCoarseGrants
90
+ // outage) or 401 (session concern, not a deny) — all TRANSIENT, never a deny.
91
+ if (r.status === 0 || r.status >= 500 || r.status === 401) {
92
+ throw new TransportUnavailableError(
93
+ `auth-facts projection temporarily unavailable (status ${r.status})`,
94
+ );
95
+ }
96
+
97
+ // A real 403 (or any other non-2xx that isn't transient) — TERMINAL deny.
98
+ // Empty projection: no facts for anyone, including the requested principal (the
99
+ // engine's own emptyProjection uses the SAME shape — see client.ts).
100
+ if (!r.ok) {
101
+ return { principalId: '', facts: [], projectedAt: Date.now() };
102
+ }
103
+
104
+ // 200 — the projection as returned by the server, unchanged. `void principalId`:
105
+ // the endpoint is self-scoped (derives the caller from the bearer token server-
106
+ // side), so the requested principalId is never sent as a param — see
107
+ // auth-facts.controller.ts. Keeping the parameter (frozen Transport signature)
108
+ // but not using it to build the request is intentional, not an oversight.
109
+ void principalId;
110
+ return r.data;
111
+ },
112
+
113
+ /**
114
+ * DEFERRED (locked decision — slice 1 does not wire per-check gating). Always
115
+ * resolves `deny` rather than throwing, matching the SDK's own no-transport
116
+ * fail-closed default (`ShieldClient.check`) — a stub capability behaves exactly
117
+ * like an absent one.
118
+ */
119
+ async check(_action, _resource) {
120
+ void _action;
121
+ void _resource;
122
+ return { kind: 'deny', reasonCode: 'not_implemented' };
123
+ },
124
+
125
+ /** DEFERRED (locked decision — no step-up in slice 1). Never issues a challenge. */
126
+ async issueStepUp(_input) {
127
+ void _input;
128
+ return null;
129
+ },
130
+
131
+ /** DEFERRED (locked decision — no step-up in slice 1). Never satisfies. */
132
+ async satisfyStepUp(input) {
133
+ return { challengeId: input.challengeId, satisfied: false };
134
+ },
135
+
136
+ /**
137
+ * Phase E slice 2 (ADR-0011 frontend twin) — the cheap current-permission-version
138
+ * probe `client.ts`'s `isVersionAware` duck-types for. MUST NEVER THROW (see the
139
+ * file-header contract note above: `isProjectionFresh`'s call to this is unguarded
140
+ * by any try/catch, so a throw here would escape `getAuthFacts` uncaught).
141
+ *
142
+ * A non-ok probe (status 0 / 401 / >=500 — the SAME "can't-know" set
143
+ * `fetchProjection` treats as transient) returns `Number.POSITIVE_INFINITY`: it can
144
+ * never compare `<=` a real cached version, so `cached.permissionsVersion >= current`
145
+ * is always `false` — always stale, always refetch. Fail-closed WITHOUT throwing:
146
+ * an unreadable version is never mistaken for "the cache is fresh".
147
+ *
148
+ * A malformed 200 body (missing/non-numeric `data.version`) is treated the same
149
+ * way — `Number.POSITIVE_INFINITY` — rather than trusting a shape a hostile/broken
150
+ * server didn't actually promise.
151
+ *
152
+ * Defensively wrapped in try/catch: the real `createWebHttpClient.get` never
153
+ * rejects (it catches internally and resolves `{ok:false,status:0,data:null}` —
154
+ * see httpClient.js), but this guards the never-throw contract even against a
155
+ * misbehaving/mocked `http` dependency, rather than relying solely on that
156
+ * upstream guarantee.
157
+ *
158
+ * @returns {Promise<number>}
159
+ */
160
+ async currentVersion() {
161
+ try {
162
+ const r = await http.get(PERMISSIONS_VERSION_PATH, { bearerToken: getToken() });
163
+ if (!r.ok || typeof r.data?.version !== 'number' || Number.isNaN(r.data.version)) {
164
+ return Number.POSITIVE_INFINITY;
165
+ }
166
+ return r.data.version;
167
+ } catch {
168
+ return Number.POSITIVE_INFINITY;
169
+ }
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,245 @@
1
+ // shieldTransport.test.mjs — Phase E slice 1: the web Transport's HTTP -> outcome
2
+ // classification (ADR-0016 fail-closed contract). Plain JS over a MOCK http client (the
3
+ // same `{ok, status, data}` shape `createWebHttpClient` resolves — see httpClient.js),
4
+ // no React/DOM/browser globals involved, so this runs directly under Node's built-in
5
+ // test runner with zero new dependencies/config:
6
+ //
7
+ // node --test src/platform/shieldTransport.test.mjs
8
+ //
9
+ // (There is no existing frontend/shared test harness in this repo at all yet — no
10
+ // vitest/jest config — so this intentionally uses `node:test` + `node:assert` rather
11
+ // than introducing a new toolchain dependency for one file.)
12
+ import { test, describe } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { createWebShieldTransport } from './shieldTransport.js';
15
+ import { TransportUnavailableError } from '@astram/sdk';
16
+
17
+ /** A mock `http` whose `.get` resolves exactly the given `{ok, status, data}` result —
18
+ * mirrors `createWebHttpClient`'s contract (never throws). */
19
+ function mockHttp(result) {
20
+ return {
21
+ get: async (path, opts) => {
22
+ mockHttp.lastCall = { path, opts };
23
+ return result;
24
+ },
25
+ };
26
+ }
27
+
28
+ describe('shieldTransport.fetchProjection — HTTP -> outcome classification', () => {
29
+ test('status 0 (network/fetch failure) -> throws TransportUnavailableError (transient)', async () => {
30
+ const transport = createWebShieldTransport({
31
+ http: mockHttp({ ok: false, status: 0, data: null }),
32
+ getToken: () => 'tok',
33
+ });
34
+ await assert.rejects(
35
+ () => transport.fetchProjection('principal-1'),
36
+ (err) => err instanceof TransportUnavailableError,
37
+ );
38
+ });
39
+
40
+ test('a 500 (server outage) -> throws TransportUnavailableError (transient)', async () => {
41
+ const transport = createWebShieldTransport({
42
+ http: mockHttp({ ok: false, status: 500, data: null }),
43
+ getToken: () => 'tok',
44
+ });
45
+ await assert.rejects(
46
+ () => transport.fetchProjection('principal-1'),
47
+ (err) => err instanceof TransportUnavailableError,
48
+ );
49
+ });
50
+
51
+ test('a 503 (our own loadCoarseGrants outage mapping) -> throws TransportUnavailableError', async () => {
52
+ const transport = createWebShieldTransport({
53
+ http: mockHttp({ ok: false, status: 503, data: { message: 'permission facts store unavailable' } }),
54
+ getToken: () => 'tok',
55
+ });
56
+ await assert.rejects(
57
+ () => transport.fetchProjection('principal-1'),
58
+ (err) => err instanceof TransportUnavailableError,
59
+ );
60
+ });
61
+
62
+ test('a 401 (missing/expired token) -> throws TransportUnavailableError, NOT a deny', async () => {
63
+ // A 401 is a SESSION concern, not "you have no permissions" — must not resolve to
64
+ // an empty/deny projection (that would look identical to a real permission deny).
65
+ const transport = createWebShieldTransport({
66
+ http: mockHttp({ ok: false, status: 401, data: { error: 'unauthorized' } }),
67
+ getToken: () => null,
68
+ });
69
+ await assert.rejects(
70
+ () => transport.fetchProjection('principal-1'),
71
+ (err) => err instanceof TransportUnavailableError,
72
+ );
73
+ });
74
+
75
+ test('a real 403 (terminal deny) -> resolves an EMPTY projection, does NOT throw', async () => {
76
+ const transport = createWebShieldTransport({
77
+ http: mockHttp({ ok: false, status: 403, data: { error: 'forbidden' } }),
78
+ getToken: () => 'tok',
79
+ });
80
+ const projection = await transport.fetchProjection('principal-1');
81
+ assert.deepEqual(projection.facts, []);
82
+ assert.equal(projection.principalId, '');
83
+ assert.equal(typeof projection.projectedAt, 'number');
84
+ });
85
+
86
+ test('any other non-5xx !ok status (e.g. 400) -> resolves an EMPTY projection (terminal, not thrown)', async () => {
87
+ const transport = createWebShieldTransport({
88
+ http: mockHttp({ ok: false, status: 400, data: null }),
89
+ getToken: () => 'tok',
90
+ });
91
+ const projection = await transport.fetchProjection('principal-1');
92
+ assert.deepEqual(projection.facts, []);
93
+ });
94
+
95
+ test('200 with a body -> returns the projection AS-IS', async () => {
96
+ const serverProjection = {
97
+ principalId: 'principal-1',
98
+ facts: [{ action: 'identity:company:read', allowed: true }],
99
+ projectedAt: 999,
100
+ permissionsVersion: 4,
101
+ };
102
+ const transport = createWebShieldTransport({
103
+ http: mockHttp({ ok: true, status: 200, data: serverProjection }),
104
+ getToken: () => 'tok',
105
+ });
106
+ const projection = await transport.fetchProjection('principal-1');
107
+ assert.deepEqual(projection, serverProjection);
108
+ });
109
+
110
+ test('reads the CURRENT token via the getToken getter (not a captured snapshot)', async () => {
111
+ let currentToken = 'first-token';
112
+ const http = {
113
+ get: async (_path, opts) => {
114
+ http.seenToken = opts.bearerToken;
115
+ return { ok: true, status: 200, data: { principalId: 'p', facts: [], projectedAt: 1 } };
116
+ },
117
+ };
118
+ const transport = createWebShieldTransport({ http, getToken: () => currentToken });
119
+
120
+ await transport.fetchProjection('p');
121
+ assert.equal(http.seenToken, 'first-token');
122
+
123
+ currentToken = 'refreshed-token'; // simulate a token refresh AFTER construction
124
+ await transport.fetchProjection('p');
125
+ assert.equal(http.seenToken, 'refreshed-token');
126
+ });
127
+ });
128
+
129
+ describe('shieldTransport — deferred capabilities stay fail-closed stubs (slice 1 scope)', () => {
130
+ test('check() resolves a deny outcome, never throws, never allows', async () => {
131
+ const transport = createWebShieldTransport({ http: mockHttp({}), getToken: () => 'tok' });
132
+ const outcome = await transport.check('identity:company:read');
133
+ assert.equal(outcome.kind, 'deny');
134
+ });
135
+
136
+ test('issueStepUp() resolves null (no challenge)', async () => {
137
+ const transport = createWebShieldTransport({ http: mockHttp({}), getToken: () => 'tok' });
138
+ const challenge = await transport.issueStepUp({ action: 'x' });
139
+ assert.equal(challenge, null);
140
+ });
141
+
142
+ test('satisfyStepUp() resolves { satisfied: false }', async () => {
143
+ const transport = createWebShieldTransport({ http: mockHttp({}), getToken: () => 'tok' });
144
+ const result = await transport.satisfyStepUp({
145
+ challengeId: 'c1',
146
+ factor: 'totp',
147
+ proof: '000000',
148
+ });
149
+ assert.deepEqual(result, { challengeId: 'c1', satisfied: false });
150
+ });
151
+
152
+ });
153
+
154
+ describe('shieldTransport.currentVersion — ADR-0011 bump-driven refetch probe (Phase E slice 2)', () => {
155
+ test('is implemented (opts this transport into the SDK\'s isVersionAware duck-type)', () => {
156
+ const transport = createWebShieldTransport({ http: mockHttp({}), getToken: () => 'tok' });
157
+ assert.equal(typeof transport.currentVersion, 'function');
158
+ });
159
+
160
+ test('200 with a numeric version -> resolves that number', async () => {
161
+ const transport = createWebShieldTransport({
162
+ http: mockHttp({ ok: true, status: 200, data: { version: 7 } }),
163
+ getToken: () => 'tok',
164
+ });
165
+ const version = await transport.currentVersion();
166
+ assert.equal(version, 7);
167
+ });
168
+
169
+ test('status 0 (network failure) -> resolves +Infinity (never throws — forces stale/refetch)', async () => {
170
+ const transport = createWebShieldTransport({
171
+ http: mockHttp({ ok: false, status: 0, data: null }),
172
+ getToken: () => 'tok',
173
+ });
174
+ const version = await transport.currentVersion();
175
+ assert.equal(version, Number.POSITIVE_INFINITY);
176
+ });
177
+
178
+ test('a 401 (missing/expired token) -> resolves +Infinity, does NOT throw', async () => {
179
+ const transport = createWebShieldTransport({
180
+ http: mockHttp({ ok: false, status: 401, data: { error: 'unauthorized' } }),
181
+ getToken: () => null,
182
+ });
183
+ const version = await transport.currentVersion();
184
+ assert.equal(version, Number.POSITIVE_INFINITY);
185
+ });
186
+
187
+ test('a 503 (permission-version store outage) -> resolves +Infinity, does NOT throw', async () => {
188
+ const transport = createWebShieldTransport({
189
+ http: mockHttp({ ok: false, status: 503, data: { message: 'permission version store unavailable' } }),
190
+ getToken: () => 'tok',
191
+ });
192
+ const version = await transport.currentVersion();
193
+ assert.equal(version, Number.POSITIVE_INFINITY);
194
+ });
195
+
196
+ test('a 500 -> resolves +Infinity, does NOT throw', async () => {
197
+ const transport = createWebShieldTransport({
198
+ http: mockHttp({ ok: false, status: 500, data: null }),
199
+ getToken: () => 'tok',
200
+ });
201
+ const version = await transport.currentVersion();
202
+ assert.equal(version, Number.POSITIVE_INFINITY);
203
+ });
204
+
205
+ test('a 200 with a malformed body (missing/non-numeric version) -> resolves +Infinity, does NOT throw', async () => {
206
+ const transport = createWebShieldTransport({
207
+ http: mockHttp({ ok: true, status: 200, data: { version: 'not-a-number' } }),
208
+ getToken: () => 'tok',
209
+ });
210
+ const version = await transport.currentVersion();
211
+ assert.equal(version, Number.POSITIVE_INFINITY);
212
+ });
213
+
214
+ test('reads the CURRENT token via the getToken getter (not a captured snapshot)', async () => {
215
+ let currentToken = 'first-token';
216
+ const http = {
217
+ get: async (_path, opts) => {
218
+ http.seenToken = opts.bearerToken;
219
+ return { ok: true, status: 200, data: { version: 1 } };
220
+ },
221
+ };
222
+ const transport = createWebShieldTransport({ http, getToken: () => currentToken });
223
+
224
+ await transport.currentVersion();
225
+ assert.equal(http.seenToken, 'first-token');
226
+
227
+ currentToken = 'refreshed-token';
228
+ await transport.currentVersion();
229
+ assert.equal(http.seenToken, 'refreshed-token');
230
+ });
231
+
232
+ test('never throws, even if http.get itself rejects unexpectedly (defensive backstop)', async () => {
233
+ // The real createWebHttpClient never rejects (it catches internally), but
234
+ // currentVersion() is defensively wrapped so it stays contract-safe even against
235
+ // a misbehaving/mocked `http` dependency — this is the CRITICAL property: the
236
+ // SDK's client.ts calls currentVersion() completely unguarded (no try/catch at
237
+ // its call site), so a throw here would escape getAuthFacts uncaught.
238
+ const transport = createWebShieldTransport({
239
+ http: { get: async () => { throw new Error('boom'); } },
240
+ getToken: () => 'tok',
241
+ });
242
+ const version = await transport.currentVersion();
243
+ assert.equal(version, Number.POSITIVE_INFINITY);
244
+ });
245
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * AppShell — the post-login layout: collapsible sidebar (desktop rail / mobile
3
+ * drawer) + a sticky header (mobile menu button, user menu with theme control,
4
+ * sign-out) + the routed content. Collapse state is persisted to localStorage.
5
+ */
6
+ import { useCallback, useEffect, useState } from 'react';
7
+ import { useLocation, useNavigate } from 'react-router-dom';
8
+ import { saveReturnPath } from '@/platform/returnPathStore';
9
+ import {
10
+ Bell,
11
+ HelpCircle,
12
+ LogOut,
13
+ Menu,
14
+ PanelLeft,
15
+ Search,
16
+ Settings,
17
+ X,
18
+ } from 'lucide-react';
19
+ import { useAuth } from '@/auth/AuthContext';
20
+ import { Button } from '@/components/ui/button';
21
+ import { Input } from '@/components/ui/input';
22
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
23
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
24
+ import {
25
+ DropdownMenu,
26
+ DropdownMenuContent,
27
+ DropdownMenuItem,
28
+ DropdownMenuLabel,
29
+ DropdownMenuSeparator,
30
+ DropdownMenuTrigger,
31
+ } from '@/components/ui/dropdown-menu';
32
+ import ThemeToggle from '@/theme/ThemeToggle';
33
+ import Sidebar from './Sidebar';
34
+
35
+ /** A header icon-action button with a tooltip (ffs pattern). */
36
+ function IconAction({ icon: Icon, label, dot }) {
37
+ return (
38
+ <Tooltip>
39
+ <TooltipTrigger asChild>
40
+ <button className="relative flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground">
41
+ <Icon className="size-[18px]" />
42
+ {dot && (
43
+ <span className="absolute right-2 top-2 size-2 rounded-full bg-destructive ring-2 ring-background" />
44
+ )}
45
+ </button>
46
+ </TooltipTrigger>
47
+ <TooltipContent>{label}</TooltipContent>
48
+ </Tooltip>
49
+ );
50
+ }
51
+
52
+ const COLLAPSE_KEY = 'app-sidebar-collapsed';
53
+
54
+ // `title` / `section` are still accepted from callers (every page passes them) but are
55
+ // no longer rendered here — the breadcrumb moved into each page's <PageHeader>. They're
56
+ // destructured to `_`-prefixed names so they're consumed-and-ignored without an
57
+ // unused-var lint error, and callers don't need to change.
58
+ export default function AppShell({ children, title: _title, section: _section }) {
59
+ const { logout, identityHint, profile } = useAuth();
60
+ const navigate = useNavigate();
61
+ const location = useLocation();
62
+
63
+ const [collapsed, setCollapsed] = useState(() => {
64
+ try {
65
+ return localStorage.getItem(COLLAPSE_KEY) === '1';
66
+ } catch {
67
+ return false;
68
+ }
69
+ });
70
+ const [drawerOpen, setDrawerOpen] = useState(false);
71
+
72
+ const toggleCollapse = useCallback(() => {
73
+ setCollapsed((c) => {
74
+ const next = !c;
75
+ try {
76
+ localStorage.setItem(COLLAPSE_KEY, next ? '1' : '0');
77
+ } catch {
78
+ /* ignore */
79
+ }
80
+ return next;
81
+ });
82
+ }, []);
83
+
84
+ // Close the mobile drawer on Escape.
85
+ useEffect(() => {
86
+ if (!drawerOpen) return undefined;
87
+ const onKey = (e) => e.key === 'Escape' && setDrawerOpen(false);
88
+ window.addEventListener('keydown', onKey);
89
+ return () => window.removeEventListener('keydown', onKey);
90
+ }, [drawerOpen]);
91
+
92
+ const onLogout = async () => {
93
+ // Remember the current page so the next login returns here (OCA-style). Explicit
94
+ // logout doesn't pass through RequireAuth, so save it directly before clearing.
95
+ saveReturnPath(location.pathname + location.search);
96
+ await logout();
97
+ navigate('/login', { replace: true });
98
+ };
99
+
100
+ // Human-readable display: prefer the resolved profile name from /auth/me (a display
101
+ // NAME, never a UUID); fall back to the interim login hint, then a generic label.
102
+ const displayName = profile?.name || identityHint || 'Signed in';
103
+ const companyName = profile?.company?.name || '';
104
+ // Prefer the part before "@" for a cleaner label + initials.
105
+ const shortName = displayName.includes('@') ? displayName.split('@')[0] : displayName;
106
+ const initials = shortName.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
107
+
108
+ return (
109
+ <div className="flex h-screen overflow-hidden bg-background">
110
+ {/* Desktop sidebar */}
111
+ <div className="hidden lg:block">
112
+ <Sidebar collapsed={collapsed} onLogout={onLogout} />
113
+ </div>
114
+
115
+ {/* Mobile drawer */}
116
+ {drawerOpen && (
117
+ <div className="fixed inset-0 z-50 lg:hidden">
118
+ <button
119
+ className="modal-overlay absolute inset-0"
120
+ onClick={() => setDrawerOpen(false)}
121
+ aria-label="Close menu"
122
+ />
123
+ <div className="absolute inset-y-0 left-0 flex">
124
+ <Sidebar mobile onLogout={onLogout} onNavigate={() => setDrawerOpen(false)} />
125
+ <button
126
+ className="absolute right-2 top-3 rounded-md p-1.5 text-sidebar-foreground/70 hover:bg-sidebar-accent"
127
+ onClick={() => setDrawerOpen(false)}
128
+ aria-label="Close"
129
+ >
130
+ <X className="size-5" />
131
+ </button>
132
+ </div>
133
+ </div>
134
+ )}
135
+
136
+ {/* Main column */}
137
+ <div className="flex min-w-0 flex-1 flex-col">
138
+ <TooltipProvider delayDuration={100}>
139
+ <header className="z-10 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background px-4 shadow-sm">
140
+ {/* Mobile hamburger */}
141
+ <Button
142
+ variant="ghost"
143
+ size="icon"
144
+ className="lg:hidden"
145
+ onClick={() => setDrawerOpen(true)}
146
+ aria-label="Open menu"
147
+ >
148
+ <Menu className="size-5" />
149
+ </Button>
150
+
151
+ {/* Desktop collapse toggle (moved here from the sidebar footer). */}
152
+ <Tooltip>
153
+ <TooltipTrigger asChild>
154
+ <button
155
+ onClick={toggleCollapse}
156
+ className="hidden size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground lg:flex"
157
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
158
+ >
159
+ <PanelLeft className="size-[18px]" />
160
+ </button>
161
+ </TooltipTrigger>
162
+ <TooltipContent>{collapsed ? 'Expand sidebar' : 'Collapse sidebar'}</TooltipContent>
163
+ </Tooltip>
164
+
165
+ {/* Search — left-aligned right after the sidebar toggle, stretching to fill
166
+ (OCA `.dash-topbar-l` search pattern). BORDERLESS + transparent by default
167
+ (just the icon + placeholder); on focus it reveals a light border + a subtle
168
+ secondary background. The header breadcrumb was removed — the page-level
169
+ breadcrumb now lives in each page's header. */}
170
+ <div className="relative hidden max-w-xs flex-1 md:block">
171
+ <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
172
+ <Input
173
+ placeholder="Search…"
174
+ aria-label="Search"
175
+ className="h-9 w-full border-transparent bg-transparent pl-9 text-sm shadow-none transition-colors focus-visible:border-border focus-visible:bg-secondary focus-visible:ring-0"
176
+ />
177
+ </div>
178
+
179
+ <div className="flex-1" />
180
+
181
+ {/* Icon actions */}
182
+ <IconAction icon={Bell} label="Notifications" dot />
183
+ <IconAction icon={Settings} label="Settings" />
184
+ <IconAction icon={HelpCircle} label="Help" />
185
+
186
+ <div className="mx-1 h-6 w-px bg-border" />
187
+
188
+ {/* User menu — avatar only in the header; identity lives in the dropdown. */}
189
+ <DropdownMenu>
190
+ <DropdownMenuTrigger asChild>
191
+ <button
192
+ className="rounded-full transition-opacity hover:opacity-80"
193
+ aria-label="Account menu"
194
+ >
195
+ <Avatar className="size-8">
196
+ <AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
197
+ {initials}
198
+ </AvatarFallback>
199
+ </Avatar>
200
+ </button>
201
+ </DropdownMenuTrigger>
202
+ <DropdownMenuContent align="end" className="w-64">
203
+ <DropdownMenuLabel className="font-normal">
204
+ <div className="flex items-center gap-2.5">
205
+ <Avatar className="size-9">
206
+ <AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">
207
+ {initials}
208
+ </AvatarFallback>
209
+ </Avatar>
210
+ <div className="min-w-0 leading-tight">
211
+ <div className="truncate text-sm font-semibold">{displayName}</div>
212
+ <div className="truncate text-[11px] text-muted-foreground">
213
+ {companyName || 'Signed in'}
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </DropdownMenuLabel>
218
+ <DropdownMenuSeparator />
219
+ <div className="px-2 py-2">
220
+ <div className="mb-1.5 text-[11px] font-medium text-muted-foreground">Theme</div>
221
+ <ThemeToggle />
222
+ </div>
223
+ <DropdownMenuSeparator />
224
+ <DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
225
+ <LogOut className="mr-2 size-4" />
226
+ Sign out
227
+ </DropdownMenuItem>
228
+ </DropdownMenuContent>
229
+ </DropdownMenu>
230
+ </header>
231
+ </TooltipProvider>
232
+
233
+ <main className="engineering-grid flex-1 overflow-y-auto">{children}</main>
234
+ </div>
235
+ </div>
236
+ );
237
+ }