cortena-ui 1.4.2 → 1.6.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 (138) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/LICENSE +7 -0
  3. package/README.md +235 -3
  4. package/dist/a2ui/views.js +2 -2
  5. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  6. package/dist/agent-chat/a2ui-block.js +69 -0
  7. package/dist/agent-chat/a2ui-block.js.map +1 -0
  8. package/dist/agent-chat/agui-client.d.ts +40 -0
  9. package/dist/agent-chat/agui-client.js +251 -0
  10. package/dist/agent-chat/agui-client.js.map +1 -0
  11. package/dist/agent-chat/bridge.d.ts +109 -0
  12. package/dist/agent-chat/bridge.js +353 -0
  13. package/dist/agent-chat/bridge.js.map +1 -0
  14. package/dist/agent-chat/session.d.ts +79 -0
  15. package/dist/agent-chat/session.js +391 -0
  16. package/dist/agent-chat/session.js.map +1 -0
  17. package/dist/agent-chat/step-label.d.ts +99 -0
  18. package/dist/agent-chat/step-label.js +116 -0
  19. package/dist/agent-chat/step-label.js.map +1 -0
  20. package/dist/agent-chat/store.d.ts +102 -0
  21. package/dist/agent-chat/store.js +876 -0
  22. package/dist/agent-chat/store.js.map +1 -0
  23. package/dist/agent-chat/types.d.ts +277 -0
  24. package/dist/agent-chat/types.js +17 -0
  25. package/dist/agent-chat/types.js.map +1 -0
  26. package/dist/agent-chat.d.ts +11 -0
  27. package/dist/agent-chat.js +11 -0
  28. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  29. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  30. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  31. package/dist/components/admin-permissions/context.d.ts +70 -0
  32. package/dist/components/admin-permissions/context.js +258 -0
  33. package/dist/components/admin-permissions/context.js.map +1 -0
  34. package/dist/components/admin-permissions/index.d.ts +10 -0
  35. package/dist/components/admin-permissions/licence.d.ts +15 -0
  36. package/dist/components/admin-permissions/licence.js +78 -0
  37. package/dist/components/admin-permissions/licence.js.map +1 -0
  38. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  39. package/dist/components/admin-permissions/matrix.js +191 -0
  40. package/dist/components/admin-permissions/matrix.js.map +1 -0
  41. package/dist/components/admin-permissions/members.d.ts +18 -0
  42. package/dist/components/admin-permissions/members.js +185 -0
  43. package/dist/components/admin-permissions/members.js.map +1 -0
  44. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  45. package/dist/components/admin-permissions/role-assignment.js +174 -0
  46. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  47. package/dist/components/admin-permissions/roles.d.ts +25 -0
  48. package/dist/components/admin-permissions/roles.js +168 -0
  49. package/dist/components/admin-permissions/roles.js.map +1 -0
  50. package/dist/components/admin-permissions/types.d.ts +152 -0
  51. package/dist/components/admin-permissions/types.js +63 -0
  52. package/dist/components/admin-permissions/types.js.map +1 -0
  53. package/dist/components/agent-chat-popup.d.ts +29 -0
  54. package/dist/components/agent-chat-popup.js +188 -0
  55. package/dist/components/agent-chat-popup.js.map +1 -0
  56. package/dist/components/agent-chat.d.ts +163 -0
  57. package/dist/components/agent-chat.js +673 -0
  58. package/dist/components/agent-chat.js.map +1 -0
  59. package/dist/components/app-shell.d.ts +126 -0
  60. package/dist/components/app-shell.js +297 -0
  61. package/dist/components/app-shell.js.map +1 -0
  62. package/dist/components/badge.d.ts +1 -1
  63. package/dist/components/button-link.js +1 -1
  64. package/dist/components/button.d.ts +1 -1
  65. package/dist/components/checkbox.d.ts +1 -1
  66. package/dist/components/combobox.d.ts +1 -1
  67. package/dist/components/combobox.js +1 -1
  68. package/dist/components/consent-screen.d.ts +65 -0
  69. package/dist/components/consent-screen.js +123 -0
  70. package/dist/components/consent-screen.js.map +1 -0
  71. package/dist/components/data-table/data-table.d.ts +15 -1
  72. package/dist/components/data-table/data-table.js +18 -4
  73. package/dist/components/data-table/data-table.js.map +1 -1
  74. package/dist/components/data-table/index.d.ts +4 -4
  75. package/dist/components/data-table/parts.d.ts +27 -3
  76. package/dist/components/data-table/parts.js +175 -55
  77. package/dist/components/data-table/parts.js.map +1 -1
  78. package/dist/components/data-table/types.d.ts +61 -0
  79. package/dist/components/data-table/use-data-table.js +91 -6
  80. package/dist/components/data-table/use-data-table.js.map +1 -1
  81. package/dist/components/data-table/use-server-source.js +119 -28
  82. package/dist/components/data-table/use-server-source.js.map +1 -1
  83. package/dist/components/help-panel.d.ts +131 -0
  84. package/dist/components/help-panel.js +545 -0
  85. package/dist/components/help-panel.js.map +1 -0
  86. package/dist/components/login-screen.d.ts +127 -0
  87. package/dist/components/login-screen.js +339 -0
  88. package/dist/components/login-screen.js.map +1 -0
  89. package/dist/components/session-guard.d.ts +268 -0
  90. package/dist/components/session-guard.js +632 -0
  91. package/dist/components/session-guard.js.map +1 -0
  92. package/dist/components/toast.d.ts +1 -1
  93. package/dist/core.d.ts +5 -1
  94. package/dist/core.js +11 -7
  95. package/dist/data-table.d.ts +13 -4
  96. package/dist/data-table.js +10 -2
  97. package/dist/hooks/use-cortena-theme.js +49 -3
  98. package/dist/hooks/use-cortena-theme.js.map +1 -1
  99. package/dist/index.d.ts +17 -4
  100. package/dist/index.js +21 -8
  101. package/dist/markdown.d.ts +2 -1
  102. package/dist/markdown.js +2 -1
  103. package/package.json +18 -5
  104. package/src/agent-chat/a2ui-block.ts +118 -0
  105. package/src/agent-chat/agui-client.ts +405 -0
  106. package/src/agent-chat/bridge.ts +445 -0
  107. package/src/agent-chat/session.ts +549 -0
  108. package/src/agent-chat/step-label.ts +177 -0
  109. package/src/agent-chat/store.ts +1234 -0
  110. package/src/agent-chat/types.ts +308 -0
  111. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  112. package/src/components/admin-permissions/context.tsx +376 -0
  113. package/src/components/admin-permissions/index.tsx +32 -0
  114. package/src/components/admin-permissions/licence.tsx +84 -0
  115. package/src/components/admin-permissions/matrix.tsx +257 -0
  116. package/src/components/admin-permissions/members.tsx +204 -0
  117. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  118. package/src/components/admin-permissions/roles.tsx +169 -0
  119. package/src/components/admin-permissions/types.ts +231 -0
  120. package/src/components/agent-chat-popup.tsx +289 -0
  121. package/src/components/agent-chat.tsx +1006 -0
  122. package/src/components/app-shell.tsx +502 -0
  123. package/src/components/consent-screen.tsx +239 -0
  124. package/src/components/data-table/data-table.tsx +36 -0
  125. package/src/components/data-table/index.tsx +6 -1
  126. package/src/components/data-table/parts.tsx +223 -47
  127. package/src/components/data-table/types.ts +68 -0
  128. package/src/components/data-table/use-data-table.ts +152 -4
  129. package/src/components/data-table/use-server-source.ts +150 -12
  130. package/src/components/help-panel.tsx +765 -0
  131. package/src/components/login-screen.tsx +479 -0
  132. package/src/components/session-guard.tsx +1071 -0
  133. package/src/entries/agent-chat.ts +137 -0
  134. package/src/entries/core.ts +8 -0
  135. package/src/entries/data-table.ts +41 -0
  136. package/src/entries/markdown.ts +25 -0
  137. package/src/hooks/use-cortena-theme.ts +63 -4
  138. package/src/index.ts +6 -0
@@ -0,0 +1,1071 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Button } from "@/components/button";
5
+ import {
6
+ Dialog,
7
+ DialogBody,
8
+ DialogContent,
9
+ type DialogContentProps,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "@/components/dialog";
15
+ import { cn } from "@/lib/cn";
16
+
17
+ /**
18
+ * SessionGuard — the shared session-expiry behaviour every Cortena surface
19
+ * wears (how-to-create-a-cortena-extension §9.4, audit rule P-37, decision
20
+ * DESIGN-D18).
21
+ *
22
+ * A user is warned before their session ends — Continue or Log out — and is
23
+ * logged out if they do nothing. Identical in every extension, in cortenaweb
24
+ * and in the agent pop-up, because an idle timeout that differs in length,
25
+ * warning or cross-tab behaviour between surfaces is one a user cannot learn.
26
+ *
27
+ * ## Two clocks, and two authorities
28
+ *
29
+ * There are two clocks and they are not the same clock:
30
+ *
31
+ * the access token plumbing. One hour, refreshed proactively at 75% of its
32
+ * life while the user is active. The user never sees it.
33
+ * Refreshing lazily on a 401 is the failure this component
34
+ * exists to remove: the first click after any pause fails,
35
+ * and if the refresh token has itself gone the screen is
36
+ * left half-dead — panels rendered from cache, every
37
+ * action silently doing nothing, no message anywhere.
38
+ * the idle timeout the user's. Reset by the user's hands, never by a
39
+ * background refresh, and always announced before it fires.
40
+ *
41
+ * And two authorities. **This guard is the experience; cortena-auth is the
42
+ * truth.** The countdown, the modal and the cross-tab handshake are here so the
43
+ * screen behaves; the decision that a session has actually ended is the refresh
44
+ * endpoint's, which refuses with `session_idle_expired` or `session_expired`.
45
+ * A browser clock can be wrong, paused by a sleeping laptop or moved by the
46
+ * user, so nothing here is a security boundary. A refresh that fails, for any
47
+ * reason and at any moment, is a signed-out state.
48
+ *
49
+ * ## The JWT claim contract it consumes
50
+ *
51
+ * The guard reads three claims out of the access token's payload, and it does
52
+ * so **by decoding, not by verifying**: it splits on `.`, base64url-decodes the
53
+ * middle segment and parses the JSON. No signature is checked, and none can be
54
+ * — the browser has no key, and it has no need of one, because nothing in this
55
+ * component is a decision the server will honour. A forged token buys an
56
+ * attacker a longer countdown on their own screen and nothing else; every
57
+ * request it is attached to is still verified by cortena-auth.
58
+ *
59
+ * `iat` epoch seconds. When the token was minted. The origin for the
60
+ * proactive refresh and for the absolute cap.
61
+ * `exp` epoch seconds. When it expires. Refresh fires at
62
+ * `iat + 0.75 * (exp - iat)`.
63
+ * `idle_exp` epoch seconds. When cortena-auth will call the session idle.
64
+ * Seeds the idle clock at mount, so a page reload after a long
65
+ * pause does not hand the user a fresh eight hours.
66
+ * `session_exp` epoch seconds. The absolute cap, equal to the refresh
67
+ * token's lifetime. It does not move, however active the user.
68
+ *
69
+ * Anything malformed, absent or unparseable falls back to the policy defaults
70
+ * (8 h idle, 12 h absolute, a 2 min warning) rather than throwing: a token this
71
+ * component cannot read must not be what stops a user working.
72
+ *
73
+ * An explicit `policy` value **replaces** the matching claim outright. That is
74
+ * the one rule; it is what makes the guide's twelve-second session and the
75
+ * tests' deterministic windows possible without a fabricated token.
76
+ *
77
+ * ## What resets what
78
+ *
79
+ * pointer, keyboard, `visibilitychange` reset the idle clock, while the
80
+ * state is `active`. Not network traffic — a polling screen left open on
81
+ * a desk is not a user. Not while the warning is up either: a modal that
82
+ * a stray mouse movement dismisses has not asked the user anything.
83
+ * Continue records activity and refreshes the token. The only thing that
84
+ * clears the warning.
85
+ * a refresh moves the access-token clock and nothing else. If a background
86
+ * refresh reset the idle clock, the idle timeout would never fire.
87
+ *
88
+ * Everything is shared across tabs over `BroadcastChannel`, so activity in one
89
+ * tab keeps them all alive and **one Continue answers every open tab**. Without
90
+ * it a user with three tabs open gets three modals and can dismiss the wrong
91
+ * one.
92
+ *
93
+ * ```tsx
94
+ * <SessionGuard
95
+ * token={{ accessToken }}
96
+ * refresh={() => auth.refresh()} // POST /api/auth/refresh
97
+ * logout={() => auth.logout()} // POST /api/auth/logout
98
+ * onSignedOut={(reason) => setSignedOut(reason)}
99
+ * />
100
+ * ```
101
+ *
102
+ * `onSignedOut` is where the app renders `LoginScreen` with `signedOut` and a
103
+ * `returnTo` (§7.4). Never a broken screen, never a spinner that never
104
+ * resolves — which is why the guard hands the reason back rather than
105
+ * redirecting itself.
106
+ */
107
+
108
+ /* ── the contract ────────────────────────────────────────────────────────── */
109
+
110
+ /** Why the session ended. Handed to `onSignedOut` so the app can word it. */
111
+ export type SessionSignOutReason = "idle" | "absolute" | "refresh_failed" | "user";
112
+
113
+ /** What the guard shows: counting quietly, counting out loud, or over. */
114
+ export type SessionState = "active" | "warning" | "signedOut";
115
+
116
+ export interface SessionToken {
117
+ /** The current access token. Decoded, never verified; see the note above. */
118
+ accessToken: string;
119
+ /**
120
+ * When it expires, for a token whose `exp` cannot be read — an opaque token,
121
+ * or one this guard failed to parse. Epoch **milliseconds**; a value below
122
+ * `1e11` is read as epoch seconds instead, since no millisecond timestamp has
123
+ * been that small since 1973 and every second-based one will be until 5138.
124
+ */
125
+ expiresAt?: number;
126
+ }
127
+
128
+ export interface SessionPolicy {
129
+ /** How long an unattended screen stays signed in. Default 8 hours. */
130
+ idleMs: number;
131
+ /** The cap, however active the user has been. Default 12 hours. */
132
+ absoluteMs: number;
133
+ /** How long before the end the modal appears. Default 2 minutes. */
134
+ warnMs: number;
135
+ }
136
+
137
+ /** The org policy's defaults, used when neither `policy` nor a claim says otherwise. */
138
+ export const DEFAULT_SESSION_POLICY: SessionPolicy = /* @__PURE__ */ Object.freeze({
139
+ idleMs: 8 * 60 * 60 * 1000,
140
+ absoluteMs: 12 * 60 * 60 * 1000,
141
+ warnMs: 2 * 60 * 1000,
142
+ });
143
+
144
+ /**
145
+ * `setTimeout`/`clearTimeout`, injectable so a test can drive a whole session
146
+ * in a millisecond without the flake a real clock brings.
147
+ */
148
+ export interface SessionTimers {
149
+ setTimeout: (handler: () => void, ms: number) => unknown;
150
+ clearTimeout: (handle: unknown) => void;
151
+ }
152
+
153
+ const DEFAULT_TIMERS: SessionTimers = /* @__PURE__ */ Object.freeze({
154
+ setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms),
155
+ clearTimeout: (handle: unknown) => {
156
+ globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>);
157
+ },
158
+ });
159
+
160
+ /** The default `BroadcastChannel` name. One channel per origin is the point. */
161
+ export const SESSION_CHANNEL_NAME = "cortena-session";
162
+
163
+ /** What tabs say to each other. Deliberately tiny: no token ever crosses it. */
164
+ export type SessionGuardMessage =
165
+ | { type: "activity"; at: number }
166
+ | { type: "continue"; at: number }
167
+ /**
168
+ * One tab rotated the credential. The token itself never crosses — only
169
+ * when the new one expires, which is all another tab needs to re-plan its
170
+ * own proactive refresh instead of racing to rotate again.
171
+ */
172
+ | { type: "refreshed"; expiresAt?: number }
173
+ /**
174
+ * The fallback claim, for a browser with no `navigator.locks`. `nonce` is
175
+ * the ballot: the highest one wins the election and rotates, and every other
176
+ * tab waits for that tab's `refreshed`. A claim without one comes from a tab
177
+ * running an older build and loses every election it takes part in, which
178
+ * still leaves exactly one refresher.
179
+ */
180
+ | { type: "refresh-claim"; at: number; nonce?: string }
181
+ | { type: "signed-out"; reason: SessionSignOutReason };
182
+
183
+ /* ── the claims ──────────────────────────────────────────────────────────── */
184
+
185
+ /** Epoch milliseconds, whichever unit the claim was written in. */
186
+ const SECONDS_CEILING = 1e11;
187
+
188
+ function epochMs(value: unknown): number | undefined {
189
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
190
+ return undefined;
191
+ }
192
+ return value < SECONDS_CEILING ? value * 1000 : value;
193
+ }
194
+
195
+ export interface SessionClaims {
196
+ /** `iat`, in epoch milliseconds. */
197
+ issuedAt?: number;
198
+ /** `exp`, in epoch milliseconds. */
199
+ expiresAt?: number;
200
+ /** `idle_exp`, in epoch milliseconds. */
201
+ idleExpiresAt?: number;
202
+ /** `session_exp`, in epoch milliseconds. */
203
+ sessionExpiresAt?: number;
204
+ }
205
+
206
+ /**
207
+ * The four session claims out of a JWT payload, **decoded and not verified**.
208
+ *
209
+ * Returns `{}` for anything that is not a readable JWT — an opaque token, a
210
+ * truncated one, a payload that is not JSON. It never throws: a token the guard
211
+ * cannot read falls back to the policy defaults, because a parse error must not
212
+ * be what ends a user's session.
213
+ */
214
+ export function decodeSessionClaims(accessToken: string | undefined): SessionClaims {
215
+ const payload = readJwtPayload(accessToken);
216
+ if (!payload) {
217
+ return {};
218
+ }
219
+ return {
220
+ issuedAt: epochMs(payload.iat),
221
+ expiresAt: epochMs(payload.exp),
222
+ idleExpiresAt: epochMs(payload.idle_exp),
223
+ sessionExpiresAt: epochMs(payload.session_exp),
224
+ };
225
+ }
226
+
227
+ function readJwtPayload(accessToken: string | undefined): Record<string, unknown> | undefined {
228
+ if (typeof accessToken !== "string") {
229
+ return undefined;
230
+ }
231
+ const segment = accessToken.split(".")[1];
232
+ if (!segment) {
233
+ return undefined;
234
+ }
235
+ try {
236
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
237
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
238
+ const binary = globalThis.atob(padded);
239
+ // The payload is UTF-8; atob gives bytes, so decode them rather than
240
+ // trusting that every claim happened to be ASCII.
241
+ const json = decodeURIComponent(
242
+ Array.from(binary, (char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""),
243
+ );
244
+ const parsed: unknown = JSON.parse(json);
245
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
246
+ } catch {
247
+ return undefined;
248
+ }
249
+ }
250
+
251
+ /* ── the hook ────────────────────────────────────────────────────────────── */
252
+
253
+ /** Refresh at three quarters of the access token's life, never on a 401. */
254
+ const REFRESH_AT_FRACTION = 0.75;
255
+ /** At most one activity broadcast per this many ms; the local clock is exact. */
256
+ const ACTIVITY_BROADCAST_MS = 5000;
257
+ /** The countdown redraws once a second, which is the resolution it shows. */
258
+ const COUNTDOWN_TICK_MS = 1000;
259
+ /**
260
+ * At most one idle-clock reset per second from the pointer.
261
+ *
262
+ * `pointermove` fires on every frame a mouse is in motion — hundreds a second
263
+ * on a 120 Hz display — and each one re-entered the whole tick, recomputed the
264
+ * deadlines and tore down and rebuilt the timer. The idle clock has a
265
+ * resolution of hours; a second is finer than it can express.
266
+ */
267
+ const ACTIVITY_THROTTLE_MS = 1000;
268
+ /**
269
+ * The largest delay `setTimeout` can express. Anything above it wraps to a
270
+ * 32-bit signed integer and fires IMMEDIATELY, which turns a long deadline
271
+ * into a busy loop rather than a long wait.
272
+ */
273
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
274
+ /**
275
+ * The longest idle window a claim may ask for. Beyond this the claim is not a
276
+ * policy, it is a mistake — seconds parsed as milliseconds, a fixture with a
277
+ * year 3000 expiry, a clock that has jumped — and honouring it means a session
278
+ * that never idles out at all. Absent is safer than absurd: the default
279
+ * applies, and cortena-auth remains the authority either way.
280
+ */
281
+ const MAX_PLAUSIBLE_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
282
+
283
+ /** Whether `idle_exp` describes a window this guard will act on. */
284
+ function plausibleIdleMs(ms: number | undefined): number | undefined {
285
+ return ms !== undefined && ms > 0 && ms <= MAX_PLAUSIBLE_IDLE_MS ? ms : undefined;
286
+ }
287
+
288
+ /** The Web Lock the proactive refresh is taken under. One rotation per origin. */
289
+ export const REFRESH_LOCK_NAME = "cortena-session-refresh";
290
+ /**
291
+ * How long a broadcast claim holds, for a browser with no `navigator.locks`.
292
+ * Comfortably longer than a refresh round-trip and far shorter than the gap
293
+ * between two proactive refreshes.
294
+ */
295
+ const REFRESH_CLAIM_MS = 30_000;
296
+ /**
297
+ * How long the broadcast election runs before the winner rotates.
298
+ *
299
+ * One tick is all it takes: `BroadcastChannel` delivers within the same task
300
+ * queue turn, so every tab that reached 75% of the same token's life at the
301
+ * same instant has posted its ballot by the time this elapses. Long enough to
302
+ * hear them, short enough that nobody notices the token rotated 50ms late.
303
+ */
304
+ const REFRESH_ELECTION_MS = 50;
305
+ /**
306
+ * How long a tab that lost the election waits for the winner's `refreshed`.
307
+ *
308
+ * A leader can vanish between claiming and rotating — the user closed the tab,
309
+ * the renderer was killed — and every other tab would then sit waiting for a
310
+ * message that is never coming, until its token expired. This is the takeover:
311
+ * comfortably longer than a refresh round-trip, far shorter than the life of
312
+ * the credential it protects.
313
+ */
314
+ const REFRESH_TAKEOVER_MS = 10_000;
315
+
316
+ /**
317
+ * A ballot for one election. `Math.random` on purpose: this breaks a tie
318
+ * between tabs of one user in one browser, and nothing is defended by its
319
+ * unpredictability. Fixed width so the comparison is over the whole ballot.
320
+ */
321
+ function refreshNonce(): string {
322
+ return Math.random().toString(36).slice(2, 12).padEnd(10, "0");
323
+ }
324
+
325
+ export interface UseSessionGuardOptions {
326
+ token: SessionToken;
327
+ /** `POST /api/auth/refresh`. Resolves with the rotated access token. */
328
+ refresh: () => Promise<{ accessToken: string }>;
329
+ /** `POST /api/auth/logout`. Revokes the refresh token and clears storage. */
330
+ logout: () => Promise<void>;
331
+ /**
332
+ * The session is over. Render `LoginScreen` with `signedOut` and a
333
+ * `returnTo`; the guard does not navigate.
334
+ */
335
+ onSignedOut: (reason: SessionSignOutReason) => void;
336
+ /** Org policy from `GET /api/orgs/:orgId/session-policy`. Replaces the claims. */
337
+ policy?: Partial<SessionPolicy>;
338
+ /** The `BroadcastChannel` name. One per origin unless two apps must not share. */
339
+ channelName?: string;
340
+ /** The clock. Injected by tests and by the guide; `Date.now` otherwise. */
341
+ now?: () => number;
342
+ /** The timers. Injected by tests and by the guide; `setTimeout` otherwise. */
343
+ timers?: SessionTimers;
344
+ }
345
+
346
+ export interface UseSessionGuardResult {
347
+ /** `active` while it counts quietly, `warning` while the modal is up. */
348
+ state: SessionState;
349
+ /** Milliseconds left until the session ends. `0` once it has. */
350
+ remainingMs: number;
351
+ /**
352
+ * Whether Continue can buy the user anything. False once the absolute cap is
353
+ * the binding deadline, because no amount of activity moves that one.
354
+ */
355
+ canContinue: boolean;
356
+ /** Set once the session has ended. */
357
+ reason?: SessionSignOutReason;
358
+ /** The idle deadline, epoch ms. Moves with the user's hands. */
359
+ idleDeadline: number;
360
+ /** The absolute cap, epoch ms. Does not move. */
361
+ absoluteDeadline: number;
362
+ /** Whichever of the two comes first. */
363
+ deadline: number;
364
+ /** Record activity and refresh the token. What Continue calls. */
365
+ continueSession: () => void;
366
+ /** End the session now. What Log out calls, with `"user"`. */
367
+ signOut: (reason?: SessionSignOutReason) => void;
368
+ /** Record activity by hand, for an app with an activity signal of its own. */
369
+ recordActivity: () => void;
370
+ }
371
+
372
+ interface Engine {
373
+ lastActivity: number;
374
+ sessionStart: number;
375
+ absoluteDeadline: number;
376
+ idleMs: number;
377
+ refreshAt?: number;
378
+ refreshing: boolean;
379
+ ended: boolean;
380
+ timer: unknown;
381
+ lastBroadcast: number;
382
+ /** The last pointer event credited. See `ACTIVITY_THROTTLE_MS`. */
383
+ lastPointer: number;
384
+ /** When some tab last claimed, or completed, a rotation. */
385
+ lastRefreshClaim: number;
386
+ /** The broadcast election in flight, where there are no Web Locks. */
387
+ election?: { nonce: string; best: string; timer?: unknown };
388
+ /** `exp` of the token currently adopted, epoch ms. */
389
+ expiresAt?: number;
390
+ channel?: BroadcastChannel;
391
+ seenToken?: string;
392
+ }
393
+
394
+ export function useSessionGuard(options: UseSessionGuardOptions): UseSessionGuardResult {
395
+ const { token, policy, channelName = SESSION_CHANNEL_NAME } = options;
396
+
397
+ const warnMs = policy?.warnMs ?? DEFAULT_SESSION_POLICY.warnMs;
398
+ const policyIdleMs = policy?.idleMs;
399
+ const policyAbsoluteMs = policy?.absoluteMs;
400
+
401
+ // Everything the engine reads from props goes through one ref, so the timer
402
+ // loop never has to be torn down and rebuilt when a callback identity changes.
403
+ const latest = React.useRef(options);
404
+ latest.current = options;
405
+
406
+ const now = React.useCallback(() => (latest.current.now ?? Date.now)(), []);
407
+ const timers = React.useCallback(() => latest.current.timers ?? DEFAULT_TIMERS, []);
408
+
409
+ const [state, setState] = React.useState<SessionState>("active");
410
+ const [remainingMs, setRemainingMs] = React.useState(0);
411
+ const [reason, setReason] = React.useState<SessionSignOutReason | undefined>(undefined);
412
+
413
+ const engineRef = React.useRef<Engine | null>(null);
414
+ if (engineRef.current === null) {
415
+ const startedAt = now();
416
+ const claims = decodeSessionClaims(token.accessToken);
417
+ // An absurd `idle_exp` is treated as absent rather than honoured: a claim
418
+ // that asks for a year is a bug somewhere upstream, and acting on it means
419
+ // a session that never idles out.
420
+ const claimedIdleMs =
421
+ claims.idleExpiresAt !== undefined && claims.issuedAt !== undefined
422
+ ? plausibleIdleMs(claims.idleExpiresAt - claims.issuedAt)
423
+ : undefined;
424
+ const idleMs = policyIdleMs ?? claimedIdleMs ?? DEFAULT_SESSION_POLICY.idleMs;
425
+ const sessionStart = claims.issuedAt ?? startedAt;
426
+ engineRef.current = {
427
+ idleMs,
428
+ sessionStart,
429
+ // Seed from `idle_exp` so a reload after a long pause does not hand the
430
+ // user a fresh idle window. A policy override replaces the claim, so a
431
+ // twelve-second session starts twelve seconds from now.
432
+ lastActivity:
433
+ policyIdleMs === undefined && claimedIdleMs !== undefined && claims.idleExpiresAt !== undefined
434
+ ? claims.idleExpiresAt - idleMs
435
+ : startedAt,
436
+ absoluteDeadline:
437
+ policyAbsoluteMs !== undefined
438
+ ? sessionStart + policyAbsoluteMs
439
+ : (claims.sessionExpiresAt ?? sessionStart + DEFAULT_SESSION_POLICY.absoluteMs),
440
+ refreshing: false,
441
+ ended: false,
442
+ timer: undefined,
443
+ lastBroadcast: 0,
444
+ lastPointer: 0,
445
+ lastRefreshClaim: 0,
446
+ };
447
+ }
448
+ const engine = engineRef.current;
449
+
450
+ /* — the loop — */
451
+
452
+ const tickRef = React.useRef<() => void>(() => {});
453
+
454
+ const clearTimer = React.useCallback(() => {
455
+ if (engine.timer !== undefined) {
456
+ timers().clearTimeout(engine.timer);
457
+ engine.timer = undefined;
458
+ }
459
+ }, [engine, timers]);
460
+
461
+ const schedule = React.useCallback(
462
+ (ms: number) => {
463
+ clearTimer();
464
+ // Clamped: above 2^31-1 `setTimeout` wraps and fires immediately, so a
465
+ // twelve-hour deadline became a tight loop. Waking early is harmless —
466
+ // `tick` recomputes and schedules the remainder.
467
+ engine.timer = timers().setTimeout(() => {
468
+ engine.timer = undefined;
469
+ tickRef.current();
470
+ }, Math.min(Math.max(0, ms), MAX_TIMEOUT_MS));
471
+ },
472
+ [clearTimer, engine, timers],
473
+ );
474
+
475
+ const post = React.useCallback(
476
+ (message: SessionGuardMessage) => {
477
+ try {
478
+ engine.channel?.postMessage(message);
479
+ } catch {
480
+ // A closed channel is not a reason to break the tab that closed it.
481
+ }
482
+ },
483
+ [engine],
484
+ );
485
+
486
+ const end = React.useCallback(
487
+ (why: SessionSignOutReason, opts?: { logout?: boolean; broadcast?: boolean }) => {
488
+ if (engine.ended) {
489
+ return;
490
+ }
491
+ engine.ended = true;
492
+ clearTimer();
493
+ setState("signedOut");
494
+ setReason(why);
495
+ setRemainingMs(0);
496
+ if (opts?.broadcast !== false) {
497
+ post({ type: "signed-out", reason: why });
498
+ }
499
+ const done = () => latest.current.onSignedOut(why);
500
+ // A failed refresh means the credential is already gone; asking
501
+ // cortena-auth to revoke it again only produces a second failure to
502
+ // swallow. Every other ending revokes first, then tells the app.
503
+ if (opts?.logout === false || why === "refresh_failed") {
504
+ done();
505
+ return;
506
+ }
507
+ void Promise.resolve()
508
+ .then(() => latest.current.logout())
509
+ .catch(() => {
510
+ // The user is signed out either way. A logout that cannot reach the
511
+ // server must not leave them on a screen that no longer works.
512
+ })
513
+ .finally(done);
514
+ },
515
+ [clearTimer, engine, post],
516
+ );
517
+
518
+ const adoptToken = React.useCallback(
519
+ (accessToken: string, expiresAtProp: number | undefined, at: number) => {
520
+ engine.seenToken = accessToken;
521
+ const claims = decodeSessionClaims(accessToken);
522
+ const expiresAt = claims.expiresAt ?? epochMs(expiresAtProp);
523
+ const issuedAt = claims.issuedAt ?? at;
524
+ engine.expiresAt = expiresAt;
525
+ engine.refreshAt =
526
+ expiresAt !== undefined && expiresAt > issuedAt
527
+ ? issuedAt + (expiresAt - issuedAt) * REFRESH_AT_FRACTION
528
+ : undefined;
529
+ // `session_exp` is the truth about the cap and does not move across a
530
+ // rotation; an explicit policy replaces it. The idle clock is untouched:
531
+ // a refresh is not the user.
532
+ if (policyAbsoluteMs === undefined && claims.sessionExpiresAt !== undefined) {
533
+ engine.absoluteDeadline = claims.sessionExpiresAt;
534
+ }
535
+ },
536
+ [engine, policyAbsoluteMs],
537
+ );
538
+
539
+ const doRefresh = React.useCallback((): Promise<void> => {
540
+ if (engine.refreshing || engine.ended) {
541
+ return Promise.resolve();
542
+ }
543
+ engine.refreshing = true;
544
+ engine.lastRefreshClaim = now();
545
+ return Promise.resolve()
546
+ .then(() => latest.current.refresh())
547
+ .then((next) => {
548
+ engine.refreshing = false;
549
+ if (engine.ended) {
550
+ return;
551
+ }
552
+ adoptToken(next.accessToken, undefined, now());
553
+ // No token crosses the channel — only when the new one expires, which
554
+ // is all a sibling tab needs to push its own proactive refresh out
555
+ // instead of rotating a credential that was just rotated.
556
+ post({
557
+ type: "refreshed",
558
+ ...(engine.expiresAt === undefined ? {} : { expiresAt: engine.expiresAt }),
559
+ });
560
+ tickRef.current();
561
+ })
562
+ .catch(() => {
563
+ engine.refreshing = false;
564
+ // §9.4.1: a failed refresh, at any time, is a signed-out state and must
565
+ // be shown as one. Never a half-dead screen.
566
+ end("refresh_failed");
567
+ });
568
+ }, [adoptToken, end, engine, now, post]);
569
+
570
+ /** Abandon any election in flight, and its timer. */
571
+ const clearElection = React.useCallback(() => {
572
+ if (engine.election?.timer !== undefined) {
573
+ timers().clearTimeout(engine.election.timer);
574
+ }
575
+ engine.election = undefined;
576
+ }, [engine, timers]);
577
+
578
+ /**
579
+ * The ballots are in.
580
+ *
581
+ * The highest one rotates. Everybody else waits for its `refreshed` — and
582
+ * takes the rotation over if the winner never sends one, because a leader
583
+ * that is closed between claiming and refreshing would otherwise leave every
584
+ * remaining tab waiting for a message nobody is going to send.
585
+ */
586
+ const settleElection = React.useCallback(() => {
587
+ const election = engine.election;
588
+ if (!election || engine.ended) {
589
+ return;
590
+ }
591
+ if (election.best === election.nonce) {
592
+ engine.election = undefined;
593
+ // The same three questions the Web Lock path asks once it holds the
594
+ // lock: the world may have moved while the ballots were being counted.
595
+ if (engine.refreshing) {
596
+ return;
597
+ }
598
+ if (engine.refreshAt !== undefined && now() < engine.refreshAt) {
599
+ return;
600
+ }
601
+ void doRefresh();
602
+ return;
603
+ }
604
+ election.timer = timers().setTimeout(() => {
605
+ engine.election = undefined;
606
+ if (engine.ended || engine.refreshing) {
607
+ return;
608
+ }
609
+ // The winner's `refreshed` moves `refreshAt` past now, and clears the
610
+ // election with it. Reaching here means it never arrived.
611
+ if (engine.refreshAt !== undefined && now() < engine.refreshAt) {
612
+ return;
613
+ }
614
+ void doRefresh();
615
+ }, REFRESH_TAKEOVER_MS);
616
+ }, [doRefresh, engine, now, timers]);
617
+
618
+ /**
619
+ * The proactive refresh, once per origin rather than once per tab.
620
+ *
621
+ * Every open tab reaches 75% of the same token's life at the same instant,
622
+ * so without a claim a user with four tabs fires four rotations at once.
623
+ * The refresh token rotates on use, so three of them present a token that
624
+ * has just been superseded — and a failed refresh is a signed-out state, by
625
+ * design. Four tabs, one of them signed in.
626
+ *
627
+ * `navigator.locks` is the right instrument and is available everywhere
628
+ * Cortena runs. `ifAvailable` means a tab that does not get the lock does
629
+ * NOT queue behind it: the rotation is already happening, and a second one
630
+ * afterwards is exactly what this prevents.
631
+ *
632
+ * Where locks are absent, an election over the channel the guard already
633
+ * owns. Claiming and refreshing in the same tick was no coordination at all:
634
+ * every tab reaches the deadline on the same clock, so every tab posted its
635
+ * claim and rotated before any of them could hear another's — the exact
636
+ * simultaneous rotation the lock exists to prevent, in the browsers with no
637
+ * lock to take. So a tab posts a ballot, waits one tick to hear the others,
638
+ * and only the highest ballot rotates. The losers wait for its `refreshed`,
639
+ * and take over if it never comes.
640
+ */
641
+ const refreshOnce = React.useCallback(() => {
642
+ if (engine.refreshing || engine.ended) {
643
+ return;
644
+ }
645
+ const locks = globalThis.navigator?.locks;
646
+ if (locks && typeof locks.request === "function") {
647
+ void locks
648
+ .request(REFRESH_LOCK_NAME, { ifAvailable: true }, async (lock) => {
649
+ // Another tab holds it and is rotating now.
650
+ if (!lock) {
651
+ return;
652
+ }
653
+ // It may have finished while we waited: `refreshed` moves `refreshAt`
654
+ // past now, and there is then nothing to do.
655
+ if (engine.ended || engine.refreshing) {
656
+ return;
657
+ }
658
+ if (engine.refreshAt !== undefined && now() < engine.refreshAt) {
659
+ return;
660
+ }
661
+ await doRefresh();
662
+ })
663
+ .catch(() => {
664
+ // A browser that refuses the lock must not be a browser that never
665
+ // refreshes. Fall back to rotating unguarded.
666
+ void doRefresh();
667
+ });
668
+ return;
669
+ }
670
+ const at = now();
671
+ if (engine.election || at - engine.lastRefreshClaim < REFRESH_CLAIM_MS) {
672
+ return;
673
+ }
674
+ engine.lastRefreshClaim = at;
675
+ const nonce = refreshNonce();
676
+ engine.election = { nonce, best: nonce };
677
+ post({ type: "refresh-claim", at, nonce });
678
+ engine.election.timer = timers().setTimeout(settleElection, REFRESH_ELECTION_MS);
679
+ }, [doRefresh, engine, now, post, settleElection, timers]);
680
+
681
+ const tick = React.useCallback(() => {
682
+ if (engine.ended) {
683
+ return;
684
+ }
685
+ const at = now();
686
+ const idleDeadline = engine.lastActivity + engine.idleMs;
687
+ const deadline = Math.min(idleDeadline, engine.absoluteDeadline);
688
+
689
+ if (at >= deadline) {
690
+ end(engine.absoluteDeadline <= idleDeadline ? "absolute" : "idle");
691
+ return;
692
+ }
693
+
694
+ const warnAt = deadline - warnMs;
695
+ if (at >= warnAt) {
696
+ setState("warning");
697
+ setRemainingMs(deadline - at);
698
+ schedule(Math.min(COUNTDOWN_TICK_MS, deadline - at));
699
+ return;
700
+ }
701
+
702
+ setState("active");
703
+ setRemainingMs(deadline - at);
704
+
705
+ const { refreshAt } = engine;
706
+ if (refreshAt !== undefined && at >= refreshAt && !engine.refreshing) {
707
+ refreshOnce();
708
+ }
709
+ // The next interesting instant: the warning, or the proactive refresh if it
710
+ // is still ahead of us. Nothing wakes this component in between.
711
+ const wakeAt =
712
+ refreshAt !== undefined && refreshAt > at && !engine.refreshing
713
+ ? Math.min(warnAt, refreshAt)
714
+ : warnAt;
715
+ schedule(wakeAt - at);
716
+ }, [end, engine, now, refreshOnce, schedule, warnMs]);
717
+
718
+ tickRef.current = tick;
719
+
720
+ /* — activity — */
721
+
722
+ // What `recordActivity` consults to decide whether a stray event may reset
723
+ // the clock. A ref, not the state, because the listeners are bound once.
724
+ const stateRef = React.useRef(state);
725
+ stateRef.current = state;
726
+
727
+ const recordActivity = React.useCallback(
728
+ (at: number, opts?: { force?: boolean; broadcast?: boolean }) => {
729
+ if (engine.ended) {
730
+ return;
731
+ }
732
+ // A stray mouse movement must not answer a question the user was asked.
733
+ // Only Continue (`force`) clears the warning.
734
+ if (!opts?.force && stateRef.current !== "active") {
735
+ return;
736
+ }
737
+ if (at > engine.lastActivity) {
738
+ engine.lastActivity = at;
739
+ }
740
+ if (opts?.broadcast !== false && at - engine.lastBroadcast >= ACTIVITY_BROADCAST_MS) {
741
+ engine.lastBroadcast = at;
742
+ post({ type: "activity", at });
743
+ }
744
+ tickRef.current();
745
+ },
746
+ [engine, post],
747
+ );
748
+
749
+ const continueSession = React.useCallback(() => {
750
+ if (engine.ended) {
751
+ return;
752
+ }
753
+ const at = now();
754
+ engine.lastBroadcast = at;
755
+ post({ type: "continue", at });
756
+ recordActivity(at, { force: true, broadcast: false });
757
+ doRefresh();
758
+ }, [doRefresh, engine, now, post, recordActivity]);
759
+
760
+ const signOut = React.useCallback(
761
+ (why: SessionSignOutReason = "user") => {
762
+ end(why);
763
+ },
764
+ [end],
765
+ );
766
+
767
+ /* — mounting the engine — */
768
+
769
+ React.useEffect(() => {
770
+ // Constructed the way `post` posts: a browser that refuses the channel —
771
+ // storage blocked for the origin, a partitioned third-party frame — must
772
+ // give a tab that coordinates alone, not a shell that throws on mount and
773
+ // takes the whole application down with it.
774
+ let channel: BroadcastChannel | undefined;
775
+ try {
776
+ channel =
777
+ typeof globalThis.BroadcastChannel === "function"
778
+ ? new globalThis.BroadcastChannel(channelName)
779
+ : undefined;
780
+ } catch {
781
+ channel = undefined;
782
+ }
783
+ engine.channel = channel;
784
+
785
+ if (channel) {
786
+ channel.onmessage = (event: MessageEvent<SessionGuardMessage>) => {
787
+ const message = event.data;
788
+ if (!message || typeof message !== "object") {
789
+ return;
790
+ }
791
+ if (message.type === "activity") {
792
+ recordActivity(message.at, { broadcast: false });
793
+ } else if (message.type === "continue") {
794
+ // One Continue answers every open tab. Only the tab the user pressed
795
+ // it in refreshes; a second rotation would race the first.
796
+ recordActivity(message.at, { force: true, broadcast: false });
797
+ } else if (message.type === "refreshed") {
798
+ // Another tab rotated the credential; ours is the same session. Plan
799
+ // the next proactive refresh off the new token's life rather than
800
+ // racing to rotate one that has just been superseded.
801
+ engine.lastRefreshClaim = now();
802
+ // Whoever won the election has done the job; nobody needs to take
803
+ // it over, and no loser's timer should rotate on top of it.
804
+ clearElection();
805
+ if (message.expiresAt !== undefined) {
806
+ const at = now();
807
+ engine.refreshAt = at + (message.expiresAt - at) * REFRESH_AT_FRACTION;
808
+ }
809
+ tickRef.current();
810
+ } else if (message.type === "refresh-claim") {
811
+ engine.lastRefreshClaim = Math.max(engine.lastRefreshClaim, message.at);
812
+ // A ballot. Only the highest one refreshes; this tab discovers it
813
+ // lost when the election settles a tick from now.
814
+ const ballot = message.nonce ?? "";
815
+ if (engine.election && ballot > engine.election.best) {
816
+ engine.election.best = ballot;
817
+ }
818
+ } else if (message.type === "signed-out") {
819
+ end(message.reason, { logout: false, broadcast: false });
820
+ }
821
+ };
822
+ }
823
+
824
+ // `pointermove` fires on every frame the mouse is in motion. Crediting
825
+ // each one re-entered the tick, recomputed both deadlines and rebuilt the
826
+ // timer hundreds of times a second, to move a clock measured in hours.
827
+ const onPointer = () => {
828
+ const at = now();
829
+ if (at - engine.lastPointer < ACTIVITY_THROTTLE_MS) {
830
+ return;
831
+ }
832
+ engine.lastPointer = at;
833
+ recordActivity(at);
834
+ };
835
+ const onKey = () => recordActivity(now());
836
+
837
+ /**
838
+ * The tab came back. **Judge the clock before crediting the hand.**
839
+ *
840
+ * A laptop lid closed at 5pm and opened at 9am delivers exactly one
841
+ * `visibilitychange`, and the timer that should have signed the user out
842
+ * at 1am never ran — a background tab's timers are throttled, and a
843
+ * suspended machine's do not run at all. Crediting the wake as activity
844
+ * first reset `lastActivity` to now and bought a fully idle session a
845
+ * fresh eight hours, every morning, for ever. So the deadline that
846
+ * elapsed while nobody was looking is evaluated against the OLD
847
+ * `lastActivity`, and only a session that survives it is credited.
848
+ */
849
+ const onWake = () => {
850
+ if (engine.ended) {
851
+ return;
852
+ }
853
+ const at = now();
854
+ const idleDeadline = engine.lastActivity + engine.idleMs;
855
+ const deadline = Math.min(idleDeadline, engine.absoluteDeadline);
856
+ if (at >= deadline) {
857
+ end(engine.absoluteDeadline <= idleDeadline ? "absolute" : "idle");
858
+ return;
859
+ }
860
+ recordActivity(at);
861
+ };
862
+ const onVisibility = () => {
863
+ if (typeof document !== "undefined" && document.visibilityState === "visible") {
864
+ onWake();
865
+ }
866
+ };
867
+ // Pointer, keyboard, visibilitychange and focus. Not network traffic: a
868
+ // polling screen left open on a desk is not a user.
869
+ window.addEventListener("pointerdown", onPointer, { passive: true });
870
+ window.addEventListener("pointermove", onPointer, { passive: true });
871
+ window.addEventListener("keydown", onKey, { passive: true });
872
+ window.addEventListener("focus", onWake);
873
+ document.addEventListener("visibilitychange", onVisibility);
874
+
875
+ tickRef.current();
876
+
877
+ return () => {
878
+ window.removeEventListener("pointerdown", onPointer);
879
+ window.removeEventListener("pointermove", onPointer);
880
+ window.removeEventListener("keydown", onKey);
881
+ window.removeEventListener("focus", onWake);
882
+ document.removeEventListener("visibilitychange", onVisibility);
883
+ if (channel) {
884
+ channel.onmessage = null;
885
+ channel.close();
886
+ }
887
+ engine.channel = undefined;
888
+ clearElection();
889
+ clearTimer();
890
+ };
891
+ // The loop reads its inputs through refs; only the channel identity and the
892
+ // engine can require a rebuild.
893
+ }, [channelName, clearElection, clearTimer, end, engine, now, recordActivity]);
894
+
895
+ // A token the caller replaced — a sign-in, or a refresh the app ran itself.
896
+ React.useEffect(() => {
897
+ if (engine.seenToken === token.accessToken) {
898
+ return;
899
+ }
900
+ adoptToken(token.accessToken, token.expiresAt, now());
901
+ tickRef.current();
902
+ }, [adoptToken, engine, now, token.accessToken, token.expiresAt]);
903
+
904
+ const recordActivityNow = React.useCallback(() => recordActivity(now()), [now, recordActivity]);
905
+
906
+ const idleDeadline = engine.lastActivity + engine.idleMs;
907
+ const deadline = Math.min(idleDeadline, engine.absoluteDeadline);
908
+
909
+ return {
910
+ state,
911
+ remainingMs: state === "signedOut" ? 0 : remainingMs,
912
+ canContinue: idleDeadline < engine.absoluteDeadline,
913
+ reason,
914
+ idleDeadline,
915
+ absoluteDeadline: engine.absoluteDeadline,
916
+ deadline,
917
+ continueSession,
918
+ signOut,
919
+ recordActivity: recordActivityNow,
920
+ };
921
+ }
922
+
923
+ /* ── the modal ───────────────────────────────────────────────────────────── */
924
+
925
+ /** `m:ss`, rounded up, so the countdown reaches 0:00 exactly as it ends. */
926
+ export function formatCountdown(ms: number): string {
927
+ const seconds = Math.max(0, Math.ceil(ms / 1000));
928
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
929
+ }
930
+
931
+ export interface SessionExpiryDialogProps {
932
+ open: boolean;
933
+ /** Milliseconds left. Rendered as `m:ss`. */
934
+ remainingMs: number;
935
+ /** Whether Continue is offered. False under the absolute cap, which no activity moves. */
936
+ canContinue?: boolean;
937
+ onContinue?: () => void;
938
+ onLogout?: () => void;
939
+ /** Non-modal for the guide, where three theme frames must not fight over focus. */
940
+ modal?: boolean;
941
+ /** Where to portal. The guide frames it; the product does not pass this. */
942
+ container?: DialogContentProps["container"];
943
+ }
944
+
945
+ /**
946
+ * The countdown. `role="alertdialog"` rather than `dialog`, because it is not a
947
+ * surface the user opened — it interrupts them to ask a question with a
948
+ * deadline, and assistive technology announces it as such.
949
+ *
950
+ * It is deliberately undismissable: no Escape, no outside press, no close X.
951
+ * Both ways out are decisions — Continue or Log out — and a modal you can wave
952
+ * away without answering is one that ends a session by accident. Focus opens on
953
+ * Continue, which is the answer nearly every user wants.
954
+ */
955
+ export function SessionExpiryDialog({
956
+ open,
957
+ remainingMs,
958
+ canContinue = true,
959
+ onContinue,
960
+ onLogout,
961
+ modal = true,
962
+ container,
963
+ }: SessionExpiryDialogProps) {
964
+ const continueRef = React.useRef<HTMLButtonElement | null>(null);
965
+ return (
966
+ <Dialog
967
+ open={open}
968
+ modal={modal}
969
+ disablePointerDismissal
970
+ // Controlled and never closed from the inside: Escape and an outside
971
+ // press both land here and are ignored on purpose.
972
+ onOpenChange={() => {}}
973
+ >
974
+ <DialogContent
975
+ role="alertdialog"
976
+ data-slot="session-expiry-dialog"
977
+ container={container}
978
+ initialFocus={canContinue ? continueRef : undefined}
979
+ className="max-w-[420px]"
980
+ >
981
+ <DialogHeader>
982
+ <div className="flex items-center gap-3">
983
+ <span
984
+ data-slot="session-expiry-glyph"
985
+ className={cn(
986
+ "flex size-9 shrink-0 items-center justify-center",
987
+ "rounded-[var(--ds-radius-full)] bg-[var(--ds-warning-soft)]",
988
+ "text-[color:var(--ds-warning)]",
989
+ )}
990
+ >
991
+ <CountdownGlyph />
992
+ </span>
993
+ <DialogTitle data-slot="session-expiry-title">
994
+ Your session ends in{" "}
995
+ <span data-slot="session-expiry-countdown" className="font-mono tabular-nums">
996
+ {formatCountdown(remainingMs)}
997
+ </span>
998
+ </DialogTitle>
999
+ </div>
1000
+ </DialogHeader>
1001
+ <DialogBody>
1002
+ {/* A Description, not a plain <p>: it is what `aria-describedby`
1003
+ points at, so the alertdialog announces the question and not only
1004
+ the countdown in its title. */}
1005
+ <DialogDescription data-slot="session-expiry-message">
1006
+ {canContinue
1007
+ ? "You have been inactive for a while. Continue to stay signed in — anything you have not saved is kept."
1008
+ : "This session has reached its maximum length and cannot be extended. Save anything you need before it ends."}
1009
+ </DialogDescription>
1010
+ </DialogBody>
1011
+ <DialogFooter>
1012
+ <Button variant="secondary" onClick={onLogout} data-slot="session-expiry-logout">
1013
+ Log out
1014
+ </Button>
1015
+ {canContinue ? (
1016
+ <Button ref={continueRef} onClick={onContinue} data-slot="session-expiry-continue">
1017
+ Continue
1018
+ </Button>
1019
+ ) : null}
1020
+ </DialogFooter>
1021
+ </DialogContent>
1022
+ </Dialog>
1023
+ );
1024
+ }
1025
+
1026
+ /** A clock face. Inline rather than a lucide import: one icon, one shape. */
1027
+ function CountdownGlyph() {
1028
+ return (
1029
+ <svg
1030
+ viewBox="0 0 24 24"
1031
+ fill="none"
1032
+ stroke="currentColor"
1033
+ strokeWidth="2"
1034
+ className="size-5"
1035
+ aria-hidden
1036
+ focusable="false"
1037
+ >
1038
+ <circle cx="12" cy="12" r="9" />
1039
+ <path d="M12 7v5l3 2" strokeLinecap="round" strokeLinejoin="round" />
1040
+ </svg>
1041
+ );
1042
+ }
1043
+
1044
+ /* ── the component ───────────────────────────────────────────────────────── */
1045
+
1046
+ export interface SessionGuardProps extends UseSessionGuardOptions {
1047
+ /** Non-modal, for the guide. The product leaves this alone. */
1048
+ modal?: boolean;
1049
+ /** Where to portal the modal — an agent pop-up's own root, say. */
1050
+ container?: DialogContentProps["container"];
1051
+ }
1052
+
1053
+ /**
1054
+ * Mount one per surface: the shell mounts it, and so does the agent pop-up
1055
+ * (§17). A user whose extension warns them while the chat pop-up beside it does
1056
+ * not has learned nothing about how long a Cortena session lasts.
1057
+ */
1058
+ export function SessionGuard({ modal, container, ...options }: SessionGuardProps) {
1059
+ const session = useSessionGuard(options);
1060
+ return (
1061
+ <SessionExpiryDialog
1062
+ open={session.state === "warning"}
1063
+ remainingMs={session.remainingMs}
1064
+ canContinue={session.canContinue}
1065
+ onContinue={session.continueSession}
1066
+ onLogout={() => session.signOut("user")}
1067
+ modal={modal}
1068
+ container={container}
1069
+ />
1070
+ );
1071
+ }