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.
- package/dist/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +167 -0
- package/template/frontend/src/auth/AuthContext.jsx +338 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +90 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- 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
|
+
}
|