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.
- package/CHANGELOG.md +107 -0
- package/LICENSE +7 -0
- package/README.md +235 -3
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +353 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +79 -0
- package/dist/agent-chat/session.js +391 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +102 -0
- package/dist/agent-chat/store.js +876 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +277 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +11 -0
- package/dist/agent-chat.js +11 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +163 -0
- package/dist/components/agent-chat.js +673 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +18 -5
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +445 -0
- package/src/agent-chat/session.ts +549 -0
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +1234 -0
- package/src/agent-chat/types.ts +308 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +1006 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +137 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- 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
|
+
}
|