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,632 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { Button } from "./button.js";
|
|
5
|
+
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./dialog.js";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
//#region src/components/session-guard.tsx
|
|
9
|
+
/** The org policy's defaults, used when neither `policy` nor a claim says otherwise. */
|
|
10
|
+
const DEFAULT_SESSION_POLICY = /* @__PURE__ */ Object.freeze({
|
|
11
|
+
idleMs: 288e5,
|
|
12
|
+
absoluteMs: 432e5,
|
|
13
|
+
warnMs: 12e4
|
|
14
|
+
});
|
|
15
|
+
const DEFAULT_TIMERS = /* @__PURE__ */ Object.freeze({
|
|
16
|
+
setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms),
|
|
17
|
+
clearTimeout: (handle) => {
|
|
18
|
+
globalThis.clearTimeout(handle);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
/** The default `BroadcastChannel` name. One channel per origin is the point. */
|
|
22
|
+
const SESSION_CHANNEL_NAME = "cortena-session";
|
|
23
|
+
/** Epoch milliseconds, whichever unit the claim was written in. */
|
|
24
|
+
const SECONDS_CEILING = 1e11;
|
|
25
|
+
function epochMs(value) {
|
|
26
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return;
|
|
27
|
+
return value < SECONDS_CEILING ? value * 1e3 : value;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The four session claims out of a JWT payload, **decoded and not verified**.
|
|
31
|
+
*
|
|
32
|
+
* Returns `{}` for anything that is not a readable JWT — an opaque token, a
|
|
33
|
+
* truncated one, a payload that is not JSON. It never throws: a token the guard
|
|
34
|
+
* cannot read falls back to the policy defaults, because a parse error must not
|
|
35
|
+
* be what ends a user's session.
|
|
36
|
+
*/
|
|
37
|
+
function decodeSessionClaims(accessToken) {
|
|
38
|
+
const payload = readJwtPayload(accessToken);
|
|
39
|
+
if (!payload) return {};
|
|
40
|
+
return {
|
|
41
|
+
issuedAt: epochMs(payload.iat),
|
|
42
|
+
expiresAt: epochMs(payload.exp),
|
|
43
|
+
idleExpiresAt: epochMs(payload.idle_exp),
|
|
44
|
+
sessionExpiresAt: epochMs(payload.session_exp)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function readJwtPayload(accessToken) {
|
|
48
|
+
if (typeof accessToken !== "string") return;
|
|
49
|
+
const segment = accessToken.split(".")[1];
|
|
50
|
+
if (!segment) return;
|
|
51
|
+
try {
|
|
52
|
+
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
53
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
54
|
+
const binary = globalThis.atob(padded);
|
|
55
|
+
const json = decodeURIComponent(Array.from(binary, (char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""));
|
|
56
|
+
const parsed = JSON.parse(json);
|
|
57
|
+
return parsed && typeof parsed === "object" ? parsed : void 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Refresh at three quarters of the access token's life, never on a 401. */
|
|
63
|
+
const REFRESH_AT_FRACTION = .75;
|
|
64
|
+
/** At most one activity broadcast per this many ms; the local clock is exact. */
|
|
65
|
+
const ACTIVITY_BROADCAST_MS = 5e3;
|
|
66
|
+
/** The countdown redraws once a second, which is the resolution it shows. */
|
|
67
|
+
const COUNTDOWN_TICK_MS = 1e3;
|
|
68
|
+
/**
|
|
69
|
+
* At most one idle-clock reset per second from the pointer.
|
|
70
|
+
*
|
|
71
|
+
* `pointermove` fires on every frame a mouse is in motion — hundreds a second
|
|
72
|
+
* on a 120 Hz display — and each one re-entered the whole tick, recomputed the
|
|
73
|
+
* deadlines and tore down and rebuilt the timer. The idle clock has a
|
|
74
|
+
* resolution of hours; a second is finer than it can express.
|
|
75
|
+
*/
|
|
76
|
+
const ACTIVITY_THROTTLE_MS = 1e3;
|
|
77
|
+
/**
|
|
78
|
+
* The largest delay `setTimeout` can express. Anything above it wraps to a
|
|
79
|
+
* 32-bit signed integer and fires IMMEDIATELY, which turns a long deadline
|
|
80
|
+
* into a busy loop rather than a long wait.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
83
|
+
/**
|
|
84
|
+
* The longest idle window a claim may ask for. Beyond this the claim is not a
|
|
85
|
+
* policy, it is a mistake — seconds parsed as milliseconds, a fixture with a
|
|
86
|
+
* year 3000 expiry, a clock that has jumped — and honouring it means a session
|
|
87
|
+
* that never idles out at all. Absent is safer than absurd: the default
|
|
88
|
+
* applies, and cortena-auth remains the authority either way.
|
|
89
|
+
*/
|
|
90
|
+
const MAX_PLAUSIBLE_IDLE_MS = 2592e6;
|
|
91
|
+
/** Whether `idle_exp` describes a window this guard will act on. */
|
|
92
|
+
function plausibleIdleMs(ms) {
|
|
93
|
+
return ms !== void 0 && ms > 0 && ms <= MAX_PLAUSIBLE_IDLE_MS ? ms : void 0;
|
|
94
|
+
}
|
|
95
|
+
/** The Web Lock the proactive refresh is taken under. One rotation per origin. */
|
|
96
|
+
const REFRESH_LOCK_NAME = "cortena-session-refresh";
|
|
97
|
+
/**
|
|
98
|
+
* How long a broadcast claim holds, for a browser with no `navigator.locks`.
|
|
99
|
+
* Comfortably longer than a refresh round-trip and far shorter than the gap
|
|
100
|
+
* between two proactive refreshes.
|
|
101
|
+
*/
|
|
102
|
+
const REFRESH_CLAIM_MS = 3e4;
|
|
103
|
+
/**
|
|
104
|
+
* How long the broadcast election runs before the winner rotates.
|
|
105
|
+
*
|
|
106
|
+
* One tick is all it takes: `BroadcastChannel` delivers within the same task
|
|
107
|
+
* queue turn, so every tab that reached 75% of the same token's life at the
|
|
108
|
+
* same instant has posted its ballot by the time this elapses. Long enough to
|
|
109
|
+
* hear them, short enough that nobody notices the token rotated 50ms late.
|
|
110
|
+
*/
|
|
111
|
+
const REFRESH_ELECTION_MS = 50;
|
|
112
|
+
/**
|
|
113
|
+
* How long a tab that lost the election waits for the winner's `refreshed`.
|
|
114
|
+
*
|
|
115
|
+
* A leader can vanish between claiming and rotating — the user closed the tab,
|
|
116
|
+
* the renderer was killed — and every other tab would then sit waiting for a
|
|
117
|
+
* message that is never coming, until its token expired. This is the takeover:
|
|
118
|
+
* comfortably longer than a refresh round-trip, far shorter than the life of
|
|
119
|
+
* the credential it protects.
|
|
120
|
+
*/
|
|
121
|
+
const REFRESH_TAKEOVER_MS = 1e4;
|
|
122
|
+
/**
|
|
123
|
+
* A ballot for one election. `Math.random` on purpose: this breaks a tie
|
|
124
|
+
* between tabs of one user in one browser, and nothing is defended by its
|
|
125
|
+
* unpredictability. Fixed width so the comparison is over the whole ballot.
|
|
126
|
+
*/
|
|
127
|
+
function refreshNonce() {
|
|
128
|
+
return Math.random().toString(36).slice(2, 12).padEnd(10, "0");
|
|
129
|
+
}
|
|
130
|
+
function useSessionGuard(options) {
|
|
131
|
+
const { token, policy, channelName = SESSION_CHANNEL_NAME } = options;
|
|
132
|
+
const warnMs = policy?.warnMs ?? DEFAULT_SESSION_POLICY.warnMs;
|
|
133
|
+
const policyIdleMs = policy?.idleMs;
|
|
134
|
+
const policyAbsoluteMs = policy?.absoluteMs;
|
|
135
|
+
const latest = React.useRef(options);
|
|
136
|
+
latest.current = options;
|
|
137
|
+
const now = React.useCallback(() => (latest.current.now ?? Date.now)(), []);
|
|
138
|
+
const timers = React.useCallback(() => latest.current.timers ?? DEFAULT_TIMERS, []);
|
|
139
|
+
const [state, setState] = React.useState("active");
|
|
140
|
+
const [remainingMs, setRemainingMs] = React.useState(0);
|
|
141
|
+
const [reason, setReason] = React.useState(void 0);
|
|
142
|
+
const engineRef = React.useRef(null);
|
|
143
|
+
if (engineRef.current === null) {
|
|
144
|
+
const startedAt = now();
|
|
145
|
+
const claims = decodeSessionClaims(token.accessToken);
|
|
146
|
+
const claimedIdleMs = claims.idleExpiresAt !== void 0 && claims.issuedAt !== void 0 ? plausibleIdleMs(claims.idleExpiresAt - claims.issuedAt) : void 0;
|
|
147
|
+
const idleMs = policyIdleMs ?? claimedIdleMs ?? DEFAULT_SESSION_POLICY.idleMs;
|
|
148
|
+
const sessionStart = claims.issuedAt ?? startedAt;
|
|
149
|
+
engineRef.current = {
|
|
150
|
+
idleMs,
|
|
151
|
+
sessionStart,
|
|
152
|
+
lastActivity: policyIdleMs === void 0 && claimedIdleMs !== void 0 && claims.idleExpiresAt !== void 0 ? claims.idleExpiresAt - idleMs : startedAt,
|
|
153
|
+
absoluteDeadline: policyAbsoluteMs !== void 0 ? sessionStart + policyAbsoluteMs : claims.sessionExpiresAt ?? sessionStart + DEFAULT_SESSION_POLICY.absoluteMs,
|
|
154
|
+
refreshing: false,
|
|
155
|
+
ended: false,
|
|
156
|
+
timer: void 0,
|
|
157
|
+
lastBroadcast: 0,
|
|
158
|
+
lastPointer: 0,
|
|
159
|
+
lastRefreshClaim: 0
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const engine = engineRef.current;
|
|
163
|
+
const tickRef = React.useRef(() => {});
|
|
164
|
+
const clearTimer = React.useCallback(() => {
|
|
165
|
+
if (engine.timer !== void 0) {
|
|
166
|
+
timers().clearTimeout(engine.timer);
|
|
167
|
+
engine.timer = void 0;
|
|
168
|
+
}
|
|
169
|
+
}, [engine, timers]);
|
|
170
|
+
const schedule = React.useCallback((ms) => {
|
|
171
|
+
clearTimer();
|
|
172
|
+
engine.timer = timers().setTimeout(() => {
|
|
173
|
+
engine.timer = void 0;
|
|
174
|
+
tickRef.current();
|
|
175
|
+
}, Math.min(Math.max(0, ms), MAX_TIMEOUT_MS));
|
|
176
|
+
}, [
|
|
177
|
+
clearTimer,
|
|
178
|
+
engine,
|
|
179
|
+
timers
|
|
180
|
+
]);
|
|
181
|
+
const post = React.useCallback((message) => {
|
|
182
|
+
try {
|
|
183
|
+
engine.channel?.postMessage(message);
|
|
184
|
+
} catch {}
|
|
185
|
+
}, [engine]);
|
|
186
|
+
const end = React.useCallback((why, opts) => {
|
|
187
|
+
if (engine.ended) return;
|
|
188
|
+
engine.ended = true;
|
|
189
|
+
clearTimer();
|
|
190
|
+
setState("signedOut");
|
|
191
|
+
setReason(why);
|
|
192
|
+
setRemainingMs(0);
|
|
193
|
+
if (opts?.broadcast !== false) post({
|
|
194
|
+
type: "signed-out",
|
|
195
|
+
reason: why
|
|
196
|
+
});
|
|
197
|
+
const done = () => latest.current.onSignedOut(why);
|
|
198
|
+
if (opts?.logout === false || why === "refresh_failed") {
|
|
199
|
+
done();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
Promise.resolve().then(() => latest.current.logout()).catch(() => {}).finally(done);
|
|
203
|
+
}, [
|
|
204
|
+
clearTimer,
|
|
205
|
+
engine,
|
|
206
|
+
post
|
|
207
|
+
]);
|
|
208
|
+
const adoptToken = React.useCallback((accessToken, expiresAtProp, at) => {
|
|
209
|
+
engine.seenToken = accessToken;
|
|
210
|
+
const claims = decodeSessionClaims(accessToken);
|
|
211
|
+
const expiresAt = claims.expiresAt ?? epochMs(expiresAtProp);
|
|
212
|
+
const issuedAt = claims.issuedAt ?? at;
|
|
213
|
+
engine.expiresAt = expiresAt;
|
|
214
|
+
engine.refreshAt = expiresAt !== void 0 && expiresAt > issuedAt ? issuedAt + (expiresAt - issuedAt) * REFRESH_AT_FRACTION : void 0;
|
|
215
|
+
if (policyAbsoluteMs === void 0 && claims.sessionExpiresAt !== void 0) engine.absoluteDeadline = claims.sessionExpiresAt;
|
|
216
|
+
}, [engine, policyAbsoluteMs]);
|
|
217
|
+
const doRefresh = React.useCallback(() => {
|
|
218
|
+
if (engine.refreshing || engine.ended) return Promise.resolve();
|
|
219
|
+
engine.refreshing = true;
|
|
220
|
+
engine.lastRefreshClaim = now();
|
|
221
|
+
return Promise.resolve().then(() => latest.current.refresh()).then((next) => {
|
|
222
|
+
engine.refreshing = false;
|
|
223
|
+
if (engine.ended) return;
|
|
224
|
+
adoptToken(next.accessToken, void 0, now());
|
|
225
|
+
post({
|
|
226
|
+
type: "refreshed",
|
|
227
|
+
...engine.expiresAt === void 0 ? {} : { expiresAt: engine.expiresAt }
|
|
228
|
+
});
|
|
229
|
+
tickRef.current();
|
|
230
|
+
}).catch(() => {
|
|
231
|
+
engine.refreshing = false;
|
|
232
|
+
end("refresh_failed");
|
|
233
|
+
});
|
|
234
|
+
}, [
|
|
235
|
+
adoptToken,
|
|
236
|
+
end,
|
|
237
|
+
engine,
|
|
238
|
+
now,
|
|
239
|
+
post
|
|
240
|
+
]);
|
|
241
|
+
/** Abandon any election in flight, and its timer. */
|
|
242
|
+
const clearElection = React.useCallback(() => {
|
|
243
|
+
if (engine.election?.timer !== void 0) timers().clearTimeout(engine.election.timer);
|
|
244
|
+
engine.election = void 0;
|
|
245
|
+
}, [engine, timers]);
|
|
246
|
+
/**
|
|
247
|
+
* The ballots are in.
|
|
248
|
+
*
|
|
249
|
+
* The highest one rotates. Everybody else waits for its `refreshed` — and
|
|
250
|
+
* takes the rotation over if the winner never sends one, because a leader
|
|
251
|
+
* that is closed between claiming and refreshing would otherwise leave every
|
|
252
|
+
* remaining tab waiting for a message nobody is going to send.
|
|
253
|
+
*/
|
|
254
|
+
const settleElection = React.useCallback(() => {
|
|
255
|
+
const election = engine.election;
|
|
256
|
+
if (!election || engine.ended) return;
|
|
257
|
+
if (election.best === election.nonce) {
|
|
258
|
+
engine.election = void 0;
|
|
259
|
+
if (engine.refreshing) return;
|
|
260
|
+
if (engine.refreshAt !== void 0 && now() < engine.refreshAt) return;
|
|
261
|
+
doRefresh();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
election.timer = timers().setTimeout(() => {
|
|
265
|
+
engine.election = void 0;
|
|
266
|
+
if (engine.ended || engine.refreshing) return;
|
|
267
|
+
if (engine.refreshAt !== void 0 && now() < engine.refreshAt) return;
|
|
268
|
+
doRefresh();
|
|
269
|
+
}, REFRESH_TAKEOVER_MS);
|
|
270
|
+
}, [
|
|
271
|
+
doRefresh,
|
|
272
|
+
engine,
|
|
273
|
+
now,
|
|
274
|
+
timers
|
|
275
|
+
]);
|
|
276
|
+
/**
|
|
277
|
+
* The proactive refresh, once per origin rather than once per tab.
|
|
278
|
+
*
|
|
279
|
+
* Every open tab reaches 75% of the same token's life at the same instant,
|
|
280
|
+
* so without a claim a user with four tabs fires four rotations at once.
|
|
281
|
+
* The refresh token rotates on use, so three of them present a token that
|
|
282
|
+
* has just been superseded — and a failed refresh is a signed-out state, by
|
|
283
|
+
* design. Four tabs, one of them signed in.
|
|
284
|
+
*
|
|
285
|
+
* `navigator.locks` is the right instrument and is available everywhere
|
|
286
|
+
* Cortena runs. `ifAvailable` means a tab that does not get the lock does
|
|
287
|
+
* NOT queue behind it: the rotation is already happening, and a second one
|
|
288
|
+
* afterwards is exactly what this prevents.
|
|
289
|
+
*
|
|
290
|
+
* Where locks are absent, an election over the channel the guard already
|
|
291
|
+
* owns. Claiming and refreshing in the same tick was no coordination at all:
|
|
292
|
+
* every tab reaches the deadline on the same clock, so every tab posted its
|
|
293
|
+
* claim and rotated before any of them could hear another's — the exact
|
|
294
|
+
* simultaneous rotation the lock exists to prevent, in the browsers with no
|
|
295
|
+
* lock to take. So a tab posts a ballot, waits one tick to hear the others,
|
|
296
|
+
* and only the highest ballot rotates. The losers wait for its `refreshed`,
|
|
297
|
+
* and take over if it never comes.
|
|
298
|
+
*/
|
|
299
|
+
const refreshOnce = React.useCallback(() => {
|
|
300
|
+
if (engine.refreshing || engine.ended) return;
|
|
301
|
+
const locks = globalThis.navigator?.locks;
|
|
302
|
+
if (locks && typeof locks.request === "function") {
|
|
303
|
+
locks.request(REFRESH_LOCK_NAME, { ifAvailable: true }, async (lock) => {
|
|
304
|
+
if (!lock) return;
|
|
305
|
+
if (engine.ended || engine.refreshing) return;
|
|
306
|
+
if (engine.refreshAt !== void 0 && now() < engine.refreshAt) return;
|
|
307
|
+
await doRefresh();
|
|
308
|
+
}).catch(() => {
|
|
309
|
+
doRefresh();
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const at = now();
|
|
314
|
+
if (engine.election || at - engine.lastRefreshClaim < REFRESH_CLAIM_MS) return;
|
|
315
|
+
engine.lastRefreshClaim = at;
|
|
316
|
+
const nonce = refreshNonce();
|
|
317
|
+
engine.election = {
|
|
318
|
+
nonce,
|
|
319
|
+
best: nonce
|
|
320
|
+
};
|
|
321
|
+
post({
|
|
322
|
+
type: "refresh-claim",
|
|
323
|
+
at,
|
|
324
|
+
nonce
|
|
325
|
+
});
|
|
326
|
+
engine.election.timer = timers().setTimeout(settleElection, REFRESH_ELECTION_MS);
|
|
327
|
+
}, [
|
|
328
|
+
doRefresh,
|
|
329
|
+
engine,
|
|
330
|
+
now,
|
|
331
|
+
post,
|
|
332
|
+
settleElection,
|
|
333
|
+
timers
|
|
334
|
+
]);
|
|
335
|
+
tickRef.current = React.useCallback(() => {
|
|
336
|
+
if (engine.ended) return;
|
|
337
|
+
const at = now();
|
|
338
|
+
const idleDeadline = engine.lastActivity + engine.idleMs;
|
|
339
|
+
const deadline = Math.min(idleDeadline, engine.absoluteDeadline);
|
|
340
|
+
if (at >= deadline) {
|
|
341
|
+
end(engine.absoluteDeadline <= idleDeadline ? "absolute" : "idle");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const warnAt = deadline - warnMs;
|
|
345
|
+
if (at >= warnAt) {
|
|
346
|
+
setState("warning");
|
|
347
|
+
setRemainingMs(deadline - at);
|
|
348
|
+
schedule(Math.min(COUNTDOWN_TICK_MS, deadline - at));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
setState("active");
|
|
352
|
+
setRemainingMs(deadline - at);
|
|
353
|
+
const { refreshAt } = engine;
|
|
354
|
+
if (refreshAt !== void 0 && at >= refreshAt && !engine.refreshing) refreshOnce();
|
|
355
|
+
const wakeAt = refreshAt !== void 0 && refreshAt > at && !engine.refreshing ? Math.min(warnAt, refreshAt) : warnAt;
|
|
356
|
+
schedule(wakeAt - at);
|
|
357
|
+
}, [
|
|
358
|
+
end,
|
|
359
|
+
engine,
|
|
360
|
+
now,
|
|
361
|
+
refreshOnce,
|
|
362
|
+
schedule,
|
|
363
|
+
warnMs
|
|
364
|
+
]);
|
|
365
|
+
const stateRef = React.useRef(state);
|
|
366
|
+
stateRef.current = state;
|
|
367
|
+
const recordActivity = React.useCallback((at, opts) => {
|
|
368
|
+
if (engine.ended) return;
|
|
369
|
+
if (!opts?.force && stateRef.current !== "active") return;
|
|
370
|
+
if (at > engine.lastActivity) engine.lastActivity = at;
|
|
371
|
+
if (opts?.broadcast !== false && at - engine.lastBroadcast >= ACTIVITY_BROADCAST_MS) {
|
|
372
|
+
engine.lastBroadcast = at;
|
|
373
|
+
post({
|
|
374
|
+
type: "activity",
|
|
375
|
+
at
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
tickRef.current();
|
|
379
|
+
}, [engine, post]);
|
|
380
|
+
const continueSession = React.useCallback(() => {
|
|
381
|
+
if (engine.ended) return;
|
|
382
|
+
const at = now();
|
|
383
|
+
engine.lastBroadcast = at;
|
|
384
|
+
post({
|
|
385
|
+
type: "continue",
|
|
386
|
+
at
|
|
387
|
+
});
|
|
388
|
+
recordActivity(at, {
|
|
389
|
+
force: true,
|
|
390
|
+
broadcast: false
|
|
391
|
+
});
|
|
392
|
+
doRefresh();
|
|
393
|
+
}, [
|
|
394
|
+
doRefresh,
|
|
395
|
+
engine,
|
|
396
|
+
now,
|
|
397
|
+
post,
|
|
398
|
+
recordActivity
|
|
399
|
+
]);
|
|
400
|
+
const signOut = React.useCallback((why = "user") => {
|
|
401
|
+
end(why);
|
|
402
|
+
}, [end]);
|
|
403
|
+
React.useEffect(() => {
|
|
404
|
+
let channel;
|
|
405
|
+
try {
|
|
406
|
+
channel = typeof globalThis.BroadcastChannel === "function" ? new globalThis.BroadcastChannel(channelName) : void 0;
|
|
407
|
+
} catch {
|
|
408
|
+
channel = void 0;
|
|
409
|
+
}
|
|
410
|
+
engine.channel = channel;
|
|
411
|
+
if (channel) channel.onmessage = (event) => {
|
|
412
|
+
const message = event.data;
|
|
413
|
+
if (!message || typeof message !== "object") return;
|
|
414
|
+
if (message.type === "activity") recordActivity(message.at, { broadcast: false });
|
|
415
|
+
else if (message.type === "continue") recordActivity(message.at, {
|
|
416
|
+
force: true,
|
|
417
|
+
broadcast: false
|
|
418
|
+
});
|
|
419
|
+
else if (message.type === "refreshed") {
|
|
420
|
+
engine.lastRefreshClaim = now();
|
|
421
|
+
clearElection();
|
|
422
|
+
if (message.expiresAt !== void 0) {
|
|
423
|
+
const at = now();
|
|
424
|
+
engine.refreshAt = at + (message.expiresAt - at) * REFRESH_AT_FRACTION;
|
|
425
|
+
}
|
|
426
|
+
tickRef.current();
|
|
427
|
+
} else if (message.type === "refresh-claim") {
|
|
428
|
+
engine.lastRefreshClaim = Math.max(engine.lastRefreshClaim, message.at);
|
|
429
|
+
const ballot = message.nonce ?? "";
|
|
430
|
+
if (engine.election && ballot > engine.election.best) engine.election.best = ballot;
|
|
431
|
+
} else if (message.type === "signed-out") end(message.reason, {
|
|
432
|
+
logout: false,
|
|
433
|
+
broadcast: false
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
const onPointer = () => {
|
|
437
|
+
const at = now();
|
|
438
|
+
if (at - engine.lastPointer < ACTIVITY_THROTTLE_MS) return;
|
|
439
|
+
engine.lastPointer = at;
|
|
440
|
+
recordActivity(at);
|
|
441
|
+
};
|
|
442
|
+
const onKey = () => recordActivity(now());
|
|
443
|
+
/**
|
|
444
|
+
* The tab came back. **Judge the clock before crediting the hand.**
|
|
445
|
+
*
|
|
446
|
+
* A laptop lid closed at 5pm and opened at 9am delivers exactly one
|
|
447
|
+
* `visibilitychange`, and the timer that should have signed the user out
|
|
448
|
+
* at 1am never ran — a background tab's timers are throttled, and a
|
|
449
|
+
* suspended machine's do not run at all. Crediting the wake as activity
|
|
450
|
+
* first reset `lastActivity` to now and bought a fully idle session a
|
|
451
|
+
* fresh eight hours, every morning, for ever. So the deadline that
|
|
452
|
+
* elapsed while nobody was looking is evaluated against the OLD
|
|
453
|
+
* `lastActivity`, and only a session that survives it is credited.
|
|
454
|
+
*/
|
|
455
|
+
const onWake = () => {
|
|
456
|
+
if (engine.ended) return;
|
|
457
|
+
const at = now();
|
|
458
|
+
const idleDeadline = engine.lastActivity + engine.idleMs;
|
|
459
|
+
if (at >= Math.min(idleDeadline, engine.absoluteDeadline)) {
|
|
460
|
+
end(engine.absoluteDeadline <= idleDeadline ? "absolute" : "idle");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
recordActivity(at);
|
|
464
|
+
};
|
|
465
|
+
const onVisibility = () => {
|
|
466
|
+
if (typeof document !== "undefined" && document.visibilityState === "visible") onWake();
|
|
467
|
+
};
|
|
468
|
+
window.addEventListener("pointerdown", onPointer, { passive: true });
|
|
469
|
+
window.addEventListener("pointermove", onPointer, { passive: true });
|
|
470
|
+
window.addEventListener("keydown", onKey, { passive: true });
|
|
471
|
+
window.addEventListener("focus", onWake);
|
|
472
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
473
|
+
tickRef.current();
|
|
474
|
+
return () => {
|
|
475
|
+
window.removeEventListener("pointerdown", onPointer);
|
|
476
|
+
window.removeEventListener("pointermove", onPointer);
|
|
477
|
+
window.removeEventListener("keydown", onKey);
|
|
478
|
+
window.removeEventListener("focus", onWake);
|
|
479
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
480
|
+
if (channel) {
|
|
481
|
+
channel.onmessage = null;
|
|
482
|
+
channel.close();
|
|
483
|
+
}
|
|
484
|
+
engine.channel = void 0;
|
|
485
|
+
clearElection();
|
|
486
|
+
clearTimer();
|
|
487
|
+
};
|
|
488
|
+
}, [
|
|
489
|
+
channelName,
|
|
490
|
+
clearElection,
|
|
491
|
+
clearTimer,
|
|
492
|
+
end,
|
|
493
|
+
engine,
|
|
494
|
+
now,
|
|
495
|
+
recordActivity
|
|
496
|
+
]);
|
|
497
|
+
React.useEffect(() => {
|
|
498
|
+
if (engine.seenToken === token.accessToken) return;
|
|
499
|
+
adoptToken(token.accessToken, token.expiresAt, now());
|
|
500
|
+
tickRef.current();
|
|
501
|
+
}, [
|
|
502
|
+
adoptToken,
|
|
503
|
+
engine,
|
|
504
|
+
now,
|
|
505
|
+
token.accessToken,
|
|
506
|
+
token.expiresAt
|
|
507
|
+
]);
|
|
508
|
+
const recordActivityNow = React.useCallback(() => recordActivity(now()), [now, recordActivity]);
|
|
509
|
+
const idleDeadline = engine.lastActivity + engine.idleMs;
|
|
510
|
+
const deadline = Math.min(idleDeadline, engine.absoluteDeadline);
|
|
511
|
+
return {
|
|
512
|
+
state,
|
|
513
|
+
remainingMs: state === "signedOut" ? 0 : remainingMs,
|
|
514
|
+
canContinue: idleDeadline < engine.absoluteDeadline,
|
|
515
|
+
reason,
|
|
516
|
+
idleDeadline,
|
|
517
|
+
absoluteDeadline: engine.absoluteDeadline,
|
|
518
|
+
deadline,
|
|
519
|
+
continueSession,
|
|
520
|
+
signOut,
|
|
521
|
+
recordActivity: recordActivityNow
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/** `m:ss`, rounded up, so the countdown reaches 0:00 exactly as it ends. */
|
|
525
|
+
function formatCountdown(ms) {
|
|
526
|
+
const seconds = Math.max(0, Math.ceil(ms / 1e3));
|
|
527
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* The countdown. `role="alertdialog"` rather than `dialog`, because it is not a
|
|
531
|
+
* surface the user opened — it interrupts them to ask a question with a
|
|
532
|
+
* deadline, and assistive technology announces it as such.
|
|
533
|
+
*
|
|
534
|
+
* It is deliberately undismissable: no Escape, no outside press, no close X.
|
|
535
|
+
* Both ways out are decisions — Continue or Log out — and a modal you can wave
|
|
536
|
+
* away without answering is one that ends a session by accident. Focus opens on
|
|
537
|
+
* Continue, which is the answer nearly every user wants.
|
|
538
|
+
*/
|
|
539
|
+
function SessionExpiryDialog({ open, remainingMs, canContinue = true, onContinue, onLogout, modal = true, container }) {
|
|
540
|
+
const continueRef = React.useRef(null);
|
|
541
|
+
return /* @__PURE__ */ jsx(Dialog, {
|
|
542
|
+
open,
|
|
543
|
+
modal,
|
|
544
|
+
disablePointerDismissal: true,
|
|
545
|
+
onOpenChange: () => {},
|
|
546
|
+
children: /* @__PURE__ */ jsxs(DialogContent, {
|
|
547
|
+
role: "alertdialog",
|
|
548
|
+
"data-slot": "session-expiry-dialog",
|
|
549
|
+
container,
|
|
550
|
+
initialFocus: canContinue ? continueRef : void 0,
|
|
551
|
+
className: "max-w-[420px]",
|
|
552
|
+
children: [
|
|
553
|
+
/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsxs("div", {
|
|
554
|
+
className: "flex items-center gap-3",
|
|
555
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
556
|
+
"data-slot": "session-expiry-glyph",
|
|
557
|
+
className: cn("flex size-9 shrink-0 items-center justify-center", "rounded-[var(--ds-radius-full)] bg-[var(--ds-warning-soft)]", "text-[color:var(--ds-warning)]"),
|
|
558
|
+
children: /* @__PURE__ */ jsx(CountdownGlyph, {})
|
|
559
|
+
}), /* @__PURE__ */ jsxs(DialogTitle, {
|
|
560
|
+
"data-slot": "session-expiry-title",
|
|
561
|
+
children: [
|
|
562
|
+
"Your session ends in",
|
|
563
|
+
" ",
|
|
564
|
+
/* @__PURE__ */ jsx("span", {
|
|
565
|
+
"data-slot": "session-expiry-countdown",
|
|
566
|
+
className: "font-mono tabular-nums",
|
|
567
|
+
children: formatCountdown(remainingMs)
|
|
568
|
+
})
|
|
569
|
+
]
|
|
570
|
+
})]
|
|
571
|
+
}) }),
|
|
572
|
+
/* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx(DialogDescription, {
|
|
573
|
+
"data-slot": "session-expiry-message",
|
|
574
|
+
children: canContinue ? "You have been inactive for a while. Continue to stay signed in — anything you have not saved is kept." : "This session has reached its maximum length and cannot be extended. Save anything you need before it ends."
|
|
575
|
+
}) }),
|
|
576
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [/* @__PURE__ */ jsx(Button, {
|
|
577
|
+
variant: "secondary",
|
|
578
|
+
onClick: onLogout,
|
|
579
|
+
"data-slot": "session-expiry-logout",
|
|
580
|
+
children: "Log out"
|
|
581
|
+
}), canContinue ? /* @__PURE__ */ jsx(Button, {
|
|
582
|
+
ref: continueRef,
|
|
583
|
+
onClick: onContinue,
|
|
584
|
+
"data-slot": "session-expiry-continue",
|
|
585
|
+
children: "Continue"
|
|
586
|
+
}) : null] })
|
|
587
|
+
]
|
|
588
|
+
})
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/** A clock face. Inline rather than a lucide import: one icon, one shape. */
|
|
592
|
+
function CountdownGlyph() {
|
|
593
|
+
return /* @__PURE__ */ jsxs("svg", {
|
|
594
|
+
viewBox: "0 0 24 24",
|
|
595
|
+
fill: "none",
|
|
596
|
+
stroke: "currentColor",
|
|
597
|
+
strokeWidth: "2",
|
|
598
|
+
className: "size-5",
|
|
599
|
+
"aria-hidden": true,
|
|
600
|
+
focusable: "false",
|
|
601
|
+
children: [/* @__PURE__ */ jsx("circle", {
|
|
602
|
+
cx: "12",
|
|
603
|
+
cy: "12",
|
|
604
|
+
r: "9"
|
|
605
|
+
}), /* @__PURE__ */ jsx("path", {
|
|
606
|
+
d: "M12 7v5l3 2",
|
|
607
|
+
strokeLinecap: "round",
|
|
608
|
+
strokeLinejoin: "round"
|
|
609
|
+
})]
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Mount one per surface: the shell mounts it, and so does the agent pop-up
|
|
614
|
+
* (§17). A user whose extension warns them while the chat pop-up beside it does
|
|
615
|
+
* not has learned nothing about how long a Cortena session lasts.
|
|
616
|
+
*/
|
|
617
|
+
function SessionGuard({ modal, container, ...options }) {
|
|
618
|
+
const session = useSessionGuard(options);
|
|
619
|
+
return /* @__PURE__ */ jsx(SessionExpiryDialog, {
|
|
620
|
+
open: session.state === "warning",
|
|
621
|
+
remainingMs: session.remainingMs,
|
|
622
|
+
canContinue: session.canContinue,
|
|
623
|
+
onContinue: session.continueSession,
|
|
624
|
+
onLogout: () => session.signOut("user"),
|
|
625
|
+
modal,
|
|
626
|
+
container
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
//#endregion
|
|
630
|
+
export { DEFAULT_SESSION_POLICY, REFRESH_LOCK_NAME, SESSION_CHANNEL_NAME, SessionExpiryDialog, SessionGuard, decodeSessionClaims, formatCountdown, useSessionGuard };
|
|
631
|
+
|
|
632
|
+
//# sourceMappingURL=session-guard.js.map
|