@the-portland-company/shell 0.36.0 → 0.36.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-chrome.cjs +215 -94
- package/dist/app-chrome.cjs.map +1 -1
- package/dist/app-chrome.js +40 -3
- package/dist/app-chrome.js.map +1 -1
- package/dist/{chunk-QX2UNWSE.js → chunk-LIGKGXFT.js} +97 -95
- package/dist/chunk-LIGKGXFT.js.map +1 -0
- package/dist/chunk-LL6LSJAH.js +92 -0
- package/dist/chunk-LL6LSJAH.js.map +1 -0
- package/dist/logout-signal.cjs +99 -0
- package/dist/logout-signal.cjs.map +1 -0
- package/dist/logout-signal.d.cts +56 -0
- package/dist/logout-signal.d.ts +56 -0
- package/dist/logout-signal.js +3 -0
- package/dist/logout-signal.js.map +1 -0
- package/dist/native.cjs +95 -93
- package/dist/native.cjs.map +1 -1
- package/dist/native.js +1 -1
- package/package.json +6 -1
- package/dist/chunk-QX2UNWSE.js.map +0 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/logout-signal.ts
|
|
2
|
+
var LOGOUT_COOKIE = "politogy_logout_at";
|
|
3
|
+
var LOGOUT_CHANNEL = "politogy-logout";
|
|
4
|
+
var POLL_MS = 1e3;
|
|
5
|
+
function parseTimestamp(value) {
|
|
6
|
+
if (!value) return null;
|
|
7
|
+
const n = Number(value);
|
|
8
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
9
|
+
}
|
|
10
|
+
function isSessionRevoked(logoutCookieValue, authTime) {
|
|
11
|
+
const loggedOutAt = parseTimestamp(logoutCookieValue);
|
|
12
|
+
if (loggedOutAt === null) return false;
|
|
13
|
+
if (authTime == null || !Number.isFinite(authTime) || authTime <= 0) return true;
|
|
14
|
+
const authMs = authTime < 1e12 ? authTime * 1e3 : authTime;
|
|
15
|
+
return authMs < loggedOutAt;
|
|
16
|
+
}
|
|
17
|
+
function readLogoutCookie() {
|
|
18
|
+
if (typeof document === "undefined") return null;
|
|
19
|
+
try {
|
|
20
|
+
const m = document.cookie.match(new RegExp(`(?:^|;\\s*)${LOGOUT_COOKIE}=([^;]+)`));
|
|
21
|
+
return parseTimestamp(m ? decodeURIComponent(m[1]) : null);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function broadcastLogout(at = Date.now()) {
|
|
27
|
+
if (typeof document === "undefined" || typeof window === "undefined") return;
|
|
28
|
+
const host = window.location.hostname;
|
|
29
|
+
const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
|
|
30
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
31
|
+
document.cookie = `${LOGOUT_COOKIE}=${at}; Path=/; Max-Age=2592000; SameSite=Lax${domainAttr}${secure}`;
|
|
32
|
+
try {
|
|
33
|
+
new BroadcastChannel(LOGOUT_CHANNEL).postMessage(at);
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function clearLogoutSignal() {
|
|
38
|
+
if (typeof document === "undefined" || typeof window === "undefined") return;
|
|
39
|
+
const host = window.location.hostname;
|
|
40
|
+
const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
|
|
41
|
+
document.cookie = `${LOGOUT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${domainAttr}`;
|
|
42
|
+
}
|
|
43
|
+
function watchLogout(onLogout, options = {}) {
|
|
44
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
45
|
+
return () => {
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const { authTime = null, intervalMs = POLL_MS } = options;
|
|
49
|
+
const baseline = readLogoutCookie() ?? 0;
|
|
50
|
+
let done = false;
|
|
51
|
+
const fire = (at) => {
|
|
52
|
+
if (done) return;
|
|
53
|
+
if (at <= baseline) return;
|
|
54
|
+
if (authTime != null && !isSessionRevoked(String(at), authTime)) return;
|
|
55
|
+
done = true;
|
|
56
|
+
cleanup();
|
|
57
|
+
onLogout();
|
|
58
|
+
};
|
|
59
|
+
const check = () => {
|
|
60
|
+
const at = readLogoutCookie();
|
|
61
|
+
if (at !== null) fire(at);
|
|
62
|
+
};
|
|
63
|
+
let channel = null;
|
|
64
|
+
try {
|
|
65
|
+
channel = new BroadcastChannel(LOGOUT_CHANNEL);
|
|
66
|
+
channel.onmessage = (event) => {
|
|
67
|
+
const at = typeof event.data === "number" ? event.data : readLogoutCookie();
|
|
68
|
+
if (at !== null) fire(at);
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
channel = null;
|
|
72
|
+
}
|
|
73
|
+
const timer = window.setInterval(check, intervalMs);
|
|
74
|
+
window.addEventListener("visibilitychange", check);
|
|
75
|
+
window.addEventListener("focus", check);
|
|
76
|
+
window.addEventListener("pageshow", check);
|
|
77
|
+
function cleanup() {
|
|
78
|
+
window.clearInterval(timer);
|
|
79
|
+
window.removeEventListener("visibilitychange", check);
|
|
80
|
+
window.removeEventListener("focus", check);
|
|
81
|
+
window.removeEventListener("pageshow", check);
|
|
82
|
+
try {
|
|
83
|
+
channel?.close();
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return cleanup;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export { LOGOUT_COOKIE, broadcastLogout, clearLogoutSignal, isSessionRevoked, readLogoutCookie, watchLogout };
|
|
91
|
+
//# sourceMappingURL=chunk-LL6LSJAH.js.map
|
|
92
|
+
//# sourceMappingURL=chunk-LL6LSJAH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/logout-signal.ts"],"names":[],"mappings":"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","file":"chunk-LL6LSJAH.js","sourcesContent":["// Universal sign-out propagation — the ONE cross-app logout contract.\n//\n// Every Politogy app holds its OWN host-only session cookie with a long sliding\n// window, so signing out of one app used to leave every other open tab — and\n// every other subdomain — signed in until its cookie happened to expire. A user\n// who signs out in Contacts and switches to the Forms tab they already had open\n// is still fully authenticated there. That is the bug this module closes.\n//\n// The vehicle mirrors the active-org contract: a `.politogyvrm.com` cookie that\n// every subdomain can read. It carries a single monotonic value — the epoch\n// milliseconds at which the user signed out.\n//\n// politogy_logout_at = <epoch ms of the most recent sign-out>\n//\n// Two layers cooperate, and BOTH are required:\n//\n// 1. SERVER (authoritative). Each app's middleware compares this cookie\n// against the session's `auth_time`. A session minted BEFORE the last\n// sign-out is dead, whatever its own expiry says — so a reload, a\n// bookmark, or a restored tab can never resurrect it. See\n// `isSessionRevoked`.\n//\n// 2. CLIENT (immediacy). Open tabs are not making requests, so the server\n// layer alone only takes effect on their next navigation. `watchLogout`\n// notices the cookie change within ~1s and navigates the tab out.\n//\n// The cookie is deliberately NOT HttpOnly: it holds no secret — just a\n// timestamp — and every tab on every subdomain must be able to read it from\n// JavaScript. It grants nothing; it only ever revokes.\n//\n// Server-safe (no 'use client'): the constants and `isSessionRevoked` are\n// importable from middleware and server components; the browser helpers no-op\n// on the server.\n\nexport const LOGOUT_COOKIE = 'politogy_logout_at'\n\n/** Same-origin instant channel; cross-subdomain falls back to the cookie poll. */\nconst LOGOUT_CHANNEL = 'politogy-logout'\n\n/** How often a tab re-reads the cookie. Cheap: a string compare, no I/O. */\nconst POLL_MS = 1000\n\nfunction parseTimestamp(value: string | null | undefined): number | null {\n if (!value) return null\n const n = Number(value)\n return Number.isFinite(n) && n > 0 ? n : null\n}\n\n/**\n * Server-side authority. Given the logout-cookie value and the moment this\n * session was authenticated, is the session revoked?\n *\n * `authTime` is the OIDC `auth_time` (seconds) or an equivalent millisecond\n * timestamp captured when the session was minted. Sessions with no recorded\n * auth time are treated as revoked whenever a logout is on record: we cannot\n * prove they postdate it, and failing closed is the only safe direction for a\n * sign-out.\n */\nexport function isSessionRevoked(\n logoutCookieValue: string | null | undefined,\n authTime: number | null | undefined,\n): boolean {\n const loggedOutAt = parseTimestamp(logoutCookieValue)\n if (loggedOutAt === null) return false\n if (authTime == null || !Number.isFinite(authTime) || authTime <= 0) return true\n // Accept either seconds (OIDC auth_time) or milliseconds.\n const authMs = authTime < 1e12 ? authTime * 1000 : authTime\n return authMs < loggedOutAt\n}\n\n/** Read the shared logout timestamp (browser only; null on server or unset). */\nexport function readLogoutCookie(): number | null {\n if (typeof document === 'undefined') return null\n try {\n const m = document.cookie.match(new RegExp(`(?:^|;\\\\s*)${LOGOUT_COOKIE}=([^;]+)`))\n return parseTimestamp(m ? decodeURIComponent(m[1]) : null)\n } catch {\n return null\n }\n}\n\n/**\n * Record a sign-out for every tab on every Politogy subdomain. Call this\n * BEFORE navigating to the app's sign-out route, so tabs that are already open\n * see it immediately rather than after the round trip.\n *\n * Scoping mirrors the active-org and theme cookies: parent-domain on\n * *.politogyvrm.com so all app subdomains read it, host-only anywhere else\n * (localhost, *.pages.dev previews).\n */\nexport function broadcastLogout(at: number = Date.now()): void {\n if (typeof document === 'undefined' || typeof window === 'undefined') return\n const host = window.location.hostname\n const domainAttr = /(^|\\.)politogyvrm\\.com$/i.test(host) ? '; Domain=.politogyvrm.com' : ''\n const secure = window.location.protocol === 'https:' ? '; Secure' : ''\n // 30 days: comfortably longer than the longest app session window, so a tab\n // resumed from sleep still sees the revocation. Shorter than that and a stale\n // session could outlive the signal that killed it.\n document.cookie = `${LOGOUT_COOKIE}=${at}; Path=/; Max-Age=2592000; SameSite=Lax${domainAttr}${secure}`\n try {\n new BroadcastChannel(LOGOUT_CHANNEL).postMessage(at)\n } catch {\n // No BroadcastChannel (older Safari) — the cookie poll covers it.\n }\n}\n\n/**\n * Clear the signal. Call after a successful LOGIN: the new session postdates\n * the old sign-out, and leaving the cookie behind would make every future\n * `isSessionRevoked` check depend on clock skew between the two.\n */\nexport function clearLogoutSignal(): void {\n if (typeof document === 'undefined' || typeof window === 'undefined') return\n const host = window.location.hostname\n const domainAttr = /(^|\\.)politogyvrm\\.com$/i.test(host) ? '; Domain=.politogyvrm.com' : ''\n document.cookie = `${LOGOUT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${domainAttr}`\n}\n\nexport type WatchLogoutOptions = {\n /**\n * Sessions minted at or before this moment are revoked by a sign-out. Pass\n * the session's auth time; when omitted, ANY logout signal observed after the\n * watcher starts fires the callback (correct for an already-open tab, which\n * by definition authenticated earlier).\n */\n authTime?: number | null\n /** Poll interval override, for tests. */\n intervalMs?: number\n}\n\n/**\n * Watch for a sign-out that happened in ANY other tab or window, on this or any\n * sibling Politogy subdomain, and invoke `onLogout` once.\n *\n * Returns an unsubscribe function.\n *\n * Three signals, cheapest first:\n * - BroadcastChannel — instant, same-origin only.\n * - visibility/focus/pageshow — the moment a backgrounded tab is looked at.\n * - a ~1s cookie poll — the cross-subdomain path, and the backstop for a\n * visible tab the user is staring at but not interacting with.\n */\nexport function watchLogout(\n onLogout: () => void,\n options: WatchLogoutOptions = {},\n): () => void {\n if (typeof document === 'undefined' || typeof window === 'undefined') {\n return () => {}\n }\n\n const { authTime = null, intervalMs = POLL_MS } = options\n // Anything already on record when this tab started is not news: the server\n // layer decides whether THIS session is revoked, and it has already let the\n // page render. Only a signal newer than our baseline means \"someone just\n // signed out\".\n const baseline = readLogoutCookie() ?? 0\n let done = false\n\n const fire = (at: number) => {\n if (done) return\n if (at <= baseline) return\n if (authTime != null && !isSessionRevoked(String(at), authTime)) return\n done = true\n cleanup()\n onLogout()\n }\n\n const check = () => {\n const at = readLogoutCookie()\n if (at !== null) fire(at)\n }\n\n let channel: BroadcastChannel | null = null\n try {\n channel = new BroadcastChannel(LOGOUT_CHANNEL)\n channel.onmessage = (event) => {\n const at = typeof event.data === 'number' ? event.data : readLogoutCookie()\n if (at !== null) fire(at)\n }\n } catch {\n channel = null\n }\n\n const timer = window.setInterval(check, intervalMs)\n window.addEventListener('visibilitychange', check)\n window.addEventListener('focus', check)\n window.addEventListener('pageshow', check)\n\n function cleanup() {\n window.clearInterval(timer)\n window.removeEventListener('visibilitychange', check)\n window.removeEventListener('focus', check)\n window.removeEventListener('pageshow', check)\n try {\n channel?.close()\n } catch {\n /* already closed */\n }\n }\n\n return cleanup\n}\n"]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/logout-signal.ts
|
|
4
|
+
var LOGOUT_COOKIE = "politogy_logout_at";
|
|
5
|
+
var LOGOUT_CHANNEL = "politogy-logout";
|
|
6
|
+
var POLL_MS = 1e3;
|
|
7
|
+
function parseTimestamp(value) {
|
|
8
|
+
if (!value) return null;
|
|
9
|
+
const n = Number(value);
|
|
10
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
11
|
+
}
|
|
12
|
+
function isSessionRevoked(logoutCookieValue, authTime) {
|
|
13
|
+
const loggedOutAt = parseTimestamp(logoutCookieValue);
|
|
14
|
+
if (loggedOutAt === null) return false;
|
|
15
|
+
if (authTime == null || !Number.isFinite(authTime) || authTime <= 0) return true;
|
|
16
|
+
const authMs = authTime < 1e12 ? authTime * 1e3 : authTime;
|
|
17
|
+
return authMs < loggedOutAt;
|
|
18
|
+
}
|
|
19
|
+
function readLogoutCookie() {
|
|
20
|
+
if (typeof document === "undefined") return null;
|
|
21
|
+
try {
|
|
22
|
+
const m = document.cookie.match(new RegExp(`(?:^|;\\s*)${LOGOUT_COOKIE}=([^;]+)`));
|
|
23
|
+
return parseTimestamp(m ? decodeURIComponent(m[1]) : null);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function broadcastLogout(at = Date.now()) {
|
|
29
|
+
if (typeof document === "undefined" || typeof window === "undefined") return;
|
|
30
|
+
const host = window.location.hostname;
|
|
31
|
+
const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
|
|
32
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
33
|
+
document.cookie = `${LOGOUT_COOKIE}=${at}; Path=/; Max-Age=2592000; SameSite=Lax${domainAttr}${secure}`;
|
|
34
|
+
try {
|
|
35
|
+
new BroadcastChannel(LOGOUT_CHANNEL).postMessage(at);
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function clearLogoutSignal() {
|
|
40
|
+
if (typeof document === "undefined" || typeof window === "undefined") return;
|
|
41
|
+
const host = window.location.hostname;
|
|
42
|
+
const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
|
|
43
|
+
document.cookie = `${LOGOUT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${domainAttr}`;
|
|
44
|
+
}
|
|
45
|
+
function watchLogout(onLogout, options = {}) {
|
|
46
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
47
|
+
return () => {
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const { authTime = null, intervalMs = POLL_MS } = options;
|
|
51
|
+
const baseline = readLogoutCookie() ?? 0;
|
|
52
|
+
let done = false;
|
|
53
|
+
const fire = (at) => {
|
|
54
|
+
if (done) return;
|
|
55
|
+
if (at <= baseline) return;
|
|
56
|
+
if (authTime != null && !isSessionRevoked(String(at), authTime)) return;
|
|
57
|
+
done = true;
|
|
58
|
+
cleanup();
|
|
59
|
+
onLogout();
|
|
60
|
+
};
|
|
61
|
+
const check = () => {
|
|
62
|
+
const at = readLogoutCookie();
|
|
63
|
+
if (at !== null) fire(at);
|
|
64
|
+
};
|
|
65
|
+
let channel = null;
|
|
66
|
+
try {
|
|
67
|
+
channel = new BroadcastChannel(LOGOUT_CHANNEL);
|
|
68
|
+
channel.onmessage = (event) => {
|
|
69
|
+
const at = typeof event.data === "number" ? event.data : readLogoutCookie();
|
|
70
|
+
if (at !== null) fire(at);
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
channel = null;
|
|
74
|
+
}
|
|
75
|
+
const timer = window.setInterval(check, intervalMs);
|
|
76
|
+
window.addEventListener("visibilitychange", check);
|
|
77
|
+
window.addEventListener("focus", check);
|
|
78
|
+
window.addEventListener("pageshow", check);
|
|
79
|
+
function cleanup() {
|
|
80
|
+
window.clearInterval(timer);
|
|
81
|
+
window.removeEventListener("visibilitychange", check);
|
|
82
|
+
window.removeEventListener("focus", check);
|
|
83
|
+
window.removeEventListener("pageshow", check);
|
|
84
|
+
try {
|
|
85
|
+
channel?.close();
|
|
86
|
+
} catch {
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return cleanup;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
exports.LOGOUT_COOKIE = LOGOUT_COOKIE;
|
|
93
|
+
exports.broadcastLogout = broadcastLogout;
|
|
94
|
+
exports.clearLogoutSignal = clearLogoutSignal;
|
|
95
|
+
exports.isSessionRevoked = isSessionRevoked;
|
|
96
|
+
exports.readLogoutCookie = readLogoutCookie;
|
|
97
|
+
exports.watchLogout = watchLogout;
|
|
98
|
+
//# sourceMappingURL=logout-signal.cjs.map
|
|
99
|
+
//# sourceMappingURL=logout-signal.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/logout-signal.ts"],"names":[],"mappings":"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","file":"logout-signal.cjs","sourcesContent":["// Universal sign-out propagation — the ONE cross-app logout contract.\n//\n// Every Politogy app holds its OWN host-only session cookie with a long sliding\n// window, so signing out of one app used to leave every other open tab — and\n// every other subdomain — signed in until its cookie happened to expire. A user\n// who signs out in Contacts and switches to the Forms tab they already had open\n// is still fully authenticated there. That is the bug this module closes.\n//\n// The vehicle mirrors the active-org contract: a `.politogyvrm.com` cookie that\n// every subdomain can read. It carries a single monotonic value — the epoch\n// milliseconds at which the user signed out.\n//\n// politogy_logout_at = <epoch ms of the most recent sign-out>\n//\n// Two layers cooperate, and BOTH are required:\n//\n// 1. SERVER (authoritative). Each app's middleware compares this cookie\n// against the session's `auth_time`. A session minted BEFORE the last\n// sign-out is dead, whatever its own expiry says — so a reload, a\n// bookmark, or a restored tab can never resurrect it. See\n// `isSessionRevoked`.\n//\n// 2. CLIENT (immediacy). Open tabs are not making requests, so the server\n// layer alone only takes effect on their next navigation. `watchLogout`\n// notices the cookie change within ~1s and navigates the tab out.\n//\n// The cookie is deliberately NOT HttpOnly: it holds no secret — just a\n// timestamp — and every tab on every subdomain must be able to read it from\n// JavaScript. It grants nothing; it only ever revokes.\n//\n// Server-safe (no 'use client'): the constants and `isSessionRevoked` are\n// importable from middleware and server components; the browser helpers no-op\n// on the server.\n\nexport const LOGOUT_COOKIE = 'politogy_logout_at'\n\n/** Same-origin instant channel; cross-subdomain falls back to the cookie poll. */\nconst LOGOUT_CHANNEL = 'politogy-logout'\n\n/** How often a tab re-reads the cookie. Cheap: a string compare, no I/O. */\nconst POLL_MS = 1000\n\nfunction parseTimestamp(value: string | null | undefined): number | null {\n if (!value) return null\n const n = Number(value)\n return Number.isFinite(n) && n > 0 ? n : null\n}\n\n/**\n * Server-side authority. Given the logout-cookie value and the moment this\n * session was authenticated, is the session revoked?\n *\n * `authTime` is the OIDC `auth_time` (seconds) or an equivalent millisecond\n * timestamp captured when the session was minted. Sessions with no recorded\n * auth time are treated as revoked whenever a logout is on record: we cannot\n * prove they postdate it, and failing closed is the only safe direction for a\n * sign-out.\n */\nexport function isSessionRevoked(\n logoutCookieValue: string | null | undefined,\n authTime: number | null | undefined,\n): boolean {\n const loggedOutAt = parseTimestamp(logoutCookieValue)\n if (loggedOutAt === null) return false\n if (authTime == null || !Number.isFinite(authTime) || authTime <= 0) return true\n // Accept either seconds (OIDC auth_time) or milliseconds.\n const authMs = authTime < 1e12 ? authTime * 1000 : authTime\n return authMs < loggedOutAt\n}\n\n/** Read the shared logout timestamp (browser only; null on server or unset). */\nexport function readLogoutCookie(): number | null {\n if (typeof document === 'undefined') return null\n try {\n const m = document.cookie.match(new RegExp(`(?:^|;\\\\s*)${LOGOUT_COOKIE}=([^;]+)`))\n return parseTimestamp(m ? decodeURIComponent(m[1]) : null)\n } catch {\n return null\n }\n}\n\n/**\n * Record a sign-out for every tab on every Politogy subdomain. Call this\n * BEFORE navigating to the app's sign-out route, so tabs that are already open\n * see it immediately rather than after the round trip.\n *\n * Scoping mirrors the active-org and theme cookies: parent-domain on\n * *.politogyvrm.com so all app subdomains read it, host-only anywhere else\n * (localhost, *.pages.dev previews).\n */\nexport function broadcastLogout(at: number = Date.now()): void {\n if (typeof document === 'undefined' || typeof window === 'undefined') return\n const host = window.location.hostname\n const domainAttr = /(^|\\.)politogyvrm\\.com$/i.test(host) ? '; Domain=.politogyvrm.com' : ''\n const secure = window.location.protocol === 'https:' ? '; Secure' : ''\n // 30 days: comfortably longer than the longest app session window, so a tab\n // resumed from sleep still sees the revocation. Shorter than that and a stale\n // session could outlive the signal that killed it.\n document.cookie = `${LOGOUT_COOKIE}=${at}; Path=/; Max-Age=2592000; SameSite=Lax${domainAttr}${secure}`\n try {\n new BroadcastChannel(LOGOUT_CHANNEL).postMessage(at)\n } catch {\n // No BroadcastChannel (older Safari) — the cookie poll covers it.\n }\n}\n\n/**\n * Clear the signal. Call after a successful LOGIN: the new session postdates\n * the old sign-out, and leaving the cookie behind would make every future\n * `isSessionRevoked` check depend on clock skew between the two.\n */\nexport function clearLogoutSignal(): void {\n if (typeof document === 'undefined' || typeof window === 'undefined') return\n const host = window.location.hostname\n const domainAttr = /(^|\\.)politogyvrm\\.com$/i.test(host) ? '; Domain=.politogyvrm.com' : ''\n document.cookie = `${LOGOUT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${domainAttr}`\n}\n\nexport type WatchLogoutOptions = {\n /**\n * Sessions minted at or before this moment are revoked by a sign-out. Pass\n * the session's auth time; when omitted, ANY logout signal observed after the\n * watcher starts fires the callback (correct for an already-open tab, which\n * by definition authenticated earlier).\n */\n authTime?: number | null\n /** Poll interval override, for tests. */\n intervalMs?: number\n}\n\n/**\n * Watch for a sign-out that happened in ANY other tab or window, on this or any\n * sibling Politogy subdomain, and invoke `onLogout` once.\n *\n * Returns an unsubscribe function.\n *\n * Three signals, cheapest first:\n * - BroadcastChannel — instant, same-origin only.\n * - visibility/focus/pageshow — the moment a backgrounded tab is looked at.\n * - a ~1s cookie poll — the cross-subdomain path, and the backstop for a\n * visible tab the user is staring at but not interacting with.\n */\nexport function watchLogout(\n onLogout: () => void,\n options: WatchLogoutOptions = {},\n): () => void {\n if (typeof document === 'undefined' || typeof window === 'undefined') {\n return () => {}\n }\n\n const { authTime = null, intervalMs = POLL_MS } = options\n // Anything already on record when this tab started is not news: the server\n // layer decides whether THIS session is revoked, and it has already let the\n // page render. Only a signal newer than our baseline means \"someone just\n // signed out\".\n const baseline = readLogoutCookie() ?? 0\n let done = false\n\n const fire = (at: number) => {\n if (done) return\n if (at <= baseline) return\n if (authTime != null && !isSessionRevoked(String(at), authTime)) return\n done = true\n cleanup()\n onLogout()\n }\n\n const check = () => {\n const at = readLogoutCookie()\n if (at !== null) fire(at)\n }\n\n let channel: BroadcastChannel | null = null\n try {\n channel = new BroadcastChannel(LOGOUT_CHANNEL)\n channel.onmessage = (event) => {\n const at = typeof event.data === 'number' ? event.data : readLogoutCookie()\n if (at !== null) fire(at)\n }\n } catch {\n channel = null\n }\n\n const timer = window.setInterval(check, intervalMs)\n window.addEventListener('visibilitychange', check)\n window.addEventListener('focus', check)\n window.addEventListener('pageshow', check)\n\n function cleanup() {\n window.clearInterval(timer)\n window.removeEventListener('visibilitychange', check)\n window.removeEventListener('focus', check)\n window.removeEventListener('pageshow', check)\n try {\n channel?.close()\n } catch {\n /* already closed */\n }\n }\n\n return cleanup\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
declare const LOGOUT_COOKIE = "politogy_logout_at";
|
|
2
|
+
/**
|
|
3
|
+
* Server-side authority. Given the logout-cookie value and the moment this
|
|
4
|
+
* session was authenticated, is the session revoked?
|
|
5
|
+
*
|
|
6
|
+
* `authTime` is the OIDC `auth_time` (seconds) or an equivalent millisecond
|
|
7
|
+
* timestamp captured when the session was minted. Sessions with no recorded
|
|
8
|
+
* auth time are treated as revoked whenever a logout is on record: we cannot
|
|
9
|
+
* prove they postdate it, and failing closed is the only safe direction for a
|
|
10
|
+
* sign-out.
|
|
11
|
+
*/
|
|
12
|
+
declare function isSessionRevoked(logoutCookieValue: string | null | undefined, authTime: number | null | undefined): boolean;
|
|
13
|
+
/** Read the shared logout timestamp (browser only; null on server or unset). */
|
|
14
|
+
declare function readLogoutCookie(): number | null;
|
|
15
|
+
/**
|
|
16
|
+
* Record a sign-out for every tab on every Politogy subdomain. Call this
|
|
17
|
+
* BEFORE navigating to the app's sign-out route, so tabs that are already open
|
|
18
|
+
* see it immediately rather than after the round trip.
|
|
19
|
+
*
|
|
20
|
+
* Scoping mirrors the active-org and theme cookies: parent-domain on
|
|
21
|
+
* *.politogyvrm.com so all app subdomains read it, host-only anywhere else
|
|
22
|
+
* (localhost, *.pages.dev previews).
|
|
23
|
+
*/
|
|
24
|
+
declare function broadcastLogout(at?: number): void;
|
|
25
|
+
/**
|
|
26
|
+
* Clear the signal. Call after a successful LOGIN: the new session postdates
|
|
27
|
+
* the old sign-out, and leaving the cookie behind would make every future
|
|
28
|
+
* `isSessionRevoked` check depend on clock skew between the two.
|
|
29
|
+
*/
|
|
30
|
+
declare function clearLogoutSignal(): void;
|
|
31
|
+
type WatchLogoutOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* Sessions minted at or before this moment are revoked by a sign-out. Pass
|
|
34
|
+
* the session's auth time; when omitted, ANY logout signal observed after the
|
|
35
|
+
* watcher starts fires the callback (correct for an already-open tab, which
|
|
36
|
+
* by definition authenticated earlier).
|
|
37
|
+
*/
|
|
38
|
+
authTime?: number | null;
|
|
39
|
+
/** Poll interval override, for tests. */
|
|
40
|
+
intervalMs?: number;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Watch for a sign-out that happened in ANY other tab or window, on this or any
|
|
44
|
+
* sibling Politogy subdomain, and invoke `onLogout` once.
|
|
45
|
+
*
|
|
46
|
+
* Returns an unsubscribe function.
|
|
47
|
+
*
|
|
48
|
+
* Three signals, cheapest first:
|
|
49
|
+
* - BroadcastChannel — instant, same-origin only.
|
|
50
|
+
* - visibility/focus/pageshow — the moment a backgrounded tab is looked at.
|
|
51
|
+
* - a ~1s cookie poll — the cross-subdomain path, and the backstop for a
|
|
52
|
+
* visible tab the user is staring at but not interacting with.
|
|
53
|
+
*/
|
|
54
|
+
declare function watchLogout(onLogout: () => void, options?: WatchLogoutOptions): () => void;
|
|
55
|
+
|
|
56
|
+
export { LOGOUT_COOKIE, type WatchLogoutOptions, broadcastLogout, clearLogoutSignal, isSessionRevoked, readLogoutCookie, watchLogout };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
declare const LOGOUT_COOKIE = "politogy_logout_at";
|
|
2
|
+
/**
|
|
3
|
+
* Server-side authority. Given the logout-cookie value and the moment this
|
|
4
|
+
* session was authenticated, is the session revoked?
|
|
5
|
+
*
|
|
6
|
+
* `authTime` is the OIDC `auth_time` (seconds) or an equivalent millisecond
|
|
7
|
+
* timestamp captured when the session was minted. Sessions with no recorded
|
|
8
|
+
* auth time are treated as revoked whenever a logout is on record: we cannot
|
|
9
|
+
* prove they postdate it, and failing closed is the only safe direction for a
|
|
10
|
+
* sign-out.
|
|
11
|
+
*/
|
|
12
|
+
declare function isSessionRevoked(logoutCookieValue: string | null | undefined, authTime: number | null | undefined): boolean;
|
|
13
|
+
/** Read the shared logout timestamp (browser only; null on server or unset). */
|
|
14
|
+
declare function readLogoutCookie(): number | null;
|
|
15
|
+
/**
|
|
16
|
+
* Record a sign-out for every tab on every Politogy subdomain. Call this
|
|
17
|
+
* BEFORE navigating to the app's sign-out route, so tabs that are already open
|
|
18
|
+
* see it immediately rather than after the round trip.
|
|
19
|
+
*
|
|
20
|
+
* Scoping mirrors the active-org and theme cookies: parent-domain on
|
|
21
|
+
* *.politogyvrm.com so all app subdomains read it, host-only anywhere else
|
|
22
|
+
* (localhost, *.pages.dev previews).
|
|
23
|
+
*/
|
|
24
|
+
declare function broadcastLogout(at?: number): void;
|
|
25
|
+
/**
|
|
26
|
+
* Clear the signal. Call after a successful LOGIN: the new session postdates
|
|
27
|
+
* the old sign-out, and leaving the cookie behind would make every future
|
|
28
|
+
* `isSessionRevoked` check depend on clock skew between the two.
|
|
29
|
+
*/
|
|
30
|
+
declare function clearLogoutSignal(): void;
|
|
31
|
+
type WatchLogoutOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* Sessions minted at or before this moment are revoked by a sign-out. Pass
|
|
34
|
+
* the session's auth time; when omitted, ANY logout signal observed after the
|
|
35
|
+
* watcher starts fires the callback (correct for an already-open tab, which
|
|
36
|
+
* by definition authenticated earlier).
|
|
37
|
+
*/
|
|
38
|
+
authTime?: number | null;
|
|
39
|
+
/** Poll interval override, for tests. */
|
|
40
|
+
intervalMs?: number;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Watch for a sign-out that happened in ANY other tab or window, on this or any
|
|
44
|
+
* sibling Politogy subdomain, and invoke `onLogout` once.
|
|
45
|
+
*
|
|
46
|
+
* Returns an unsubscribe function.
|
|
47
|
+
*
|
|
48
|
+
* Three signals, cheapest first:
|
|
49
|
+
* - BroadcastChannel — instant, same-origin only.
|
|
50
|
+
* - visibility/focus/pageshow — the moment a backgrounded tab is looked at.
|
|
51
|
+
* - a ~1s cookie poll — the cross-subdomain path, and the backstop for a
|
|
52
|
+
* visible tab the user is staring at but not interacting with.
|
|
53
|
+
*/
|
|
54
|
+
declare function watchLogout(onLogout: () => void, options?: WatchLogoutOptions): () => void;
|
|
55
|
+
|
|
56
|
+
export { LOGOUT_COOKIE, type WatchLogoutOptions, broadcastLogout, clearLogoutSignal, isSessionRevoked, readLogoutCookie, watchLogout };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"logout-signal.js"}
|
package/dist/native.cjs
CHANGED
|
@@ -2154,116 +2154,118 @@ function NavTopBar({
|
|
|
2154
2154
|
}
|
|
2155
2155
|
) })
|
|
2156
2156
|
] }),
|
|
2157
|
-
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute right-0 z-30 mt-2 w-72 rounded-xl border border-[color:var(--line)] bg-[color:var(--panel-elevated)] py-1 shadow-[var(--shadow-soft)]", children: [
|
|
2157
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute right-0 z-30 mt-2 flex max-h-[calc(100vh-5rem)] w-72 flex-col overflow-hidden rounded-xl border border-[color:var(--line)] bg-[color:var(--panel-elevated)] py-1 shadow-[var(--shadow-soft)]", children: [
|
|
2158
2158
|
/* @__PURE__ */ jsxRuntime.jsx(TopBarPanelArrow, {}),
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
/* @__PURE__ */ jsxRuntime.
|
|
2183
|
-
|
|
2184
|
-
/* @__PURE__ */ jsxRuntime.jsx("
|
|
2185
|
-
|
|
2186
|
-
"
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
/* @__PURE__ */ jsxRuntime.
|
|
2193
|
-
|
|
2194
|
-
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
2195
|
-
|
|
2159
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain", children: [
|
|
2160
|
+
userMenu?.myProfileHref ? /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2161
|
+
"a",
|
|
2162
|
+
{
|
|
2163
|
+
href: safeHref(userMenu.myProfileHref),
|
|
2164
|
+
className: "flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm font-medium text-[color:var(--foreground)] hover:bg-[color:var(--panel)]",
|
|
2165
|
+
children: [
|
|
2166
|
+
/* @__PURE__ */ jsxRuntime.jsx(ProfileIcon, {}),
|
|
2167
|
+
"My Profile"
|
|
2168
|
+
]
|
|
2169
|
+
}
|
|
2170
|
+
) : null,
|
|
2171
|
+
userMenu?.organizationSettingsHref ? /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2172
|
+
"a",
|
|
2173
|
+
{
|
|
2174
|
+
href: safeHref(userMenu.organizationSettingsHref),
|
|
2175
|
+
className: "flex w-full items-center gap-2.5 border-t border-[color:var(--line)] px-4 py-2.5 text-left text-sm font-medium text-[color:var(--foreground)] hover:bg-[color:var(--panel)]",
|
|
2176
|
+
children: [
|
|
2177
|
+
/* @__PURE__ */ jsxRuntime.jsx(BriefcaseIcon, {}),
|
|
2178
|
+
"Organization Settings"
|
|
2179
|
+
]
|
|
2180
|
+
}
|
|
2181
|
+
) : null,
|
|
2182
|
+
userMenu?.roles && userMenu.roles.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "border-t border-[color:var(--line)] px-4 py-3", children: [
|
|
2183
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Roles & Organizations" }),
|
|
2184
|
+
/* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex flex-col gap-1.5", children: userMenu.roles.map((r, i) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "flex flex-wrap items-center gap-1.5 text-sm", children: [
|
|
2185
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "rounded-md bg-[color:var(--panel)] px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-[color:var(--foreground)]", children: r.role }),
|
|
2186
|
+
r.orgName ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[color:var(--muted)]", children: [
|
|
2187
|
+
"in ",
|
|
2188
|
+
r.orgName
|
|
2189
|
+
] }) : null
|
|
2190
|
+
] }, `${r.role}-${i}`)) })
|
|
2191
|
+
] }) : null,
|
|
2192
|
+
userMenu?.accounts && userMenu.accounts.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "border-t border-[color:var(--line)] px-4 py-3", children: [
|
|
2193
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Accounts" }),
|
|
2194
|
+
/* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex flex-col gap-1", children: userMenu.accounts.map((a) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "flex items-center gap-2.5", children: [
|
|
2195
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
2196
|
+
"button",
|
|
2197
|
+
{
|
|
2198
|
+
type: "button",
|
|
2199
|
+
onClick: () => {
|
|
2200
|
+
if (!a.active) userMenu.onSwitchAccount?.(a.id);
|
|
2201
|
+
},
|
|
2202
|
+
className: "flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1 py-1 text-left hover:bg-[color:var(--panel)]",
|
|
2203
|
+
children: [
|
|
2204
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[color:var(--color-race-red)] text-[11px] font-bold text-white", children: a.avatarUrl ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: a.avatarUrl, alt: "", className: "h-full w-full object-cover" }) : (a.name ?? a.email ?? "?").slice(0, 2).toUpperCase() }),
|
|
2205
|
+
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "flex min-w-0 flex-col leading-tight", children: [
|
|
2206
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2207
|
+
"span",
|
|
2208
|
+
{
|
|
2209
|
+
className: [
|
|
2210
|
+
"truncate text-sm font-semibold",
|
|
2211
|
+
a.active ? "text-[color:var(--color-race-red)]" : "text-[color:var(--foreground)]"
|
|
2212
|
+
].join(" "),
|
|
2213
|
+
children: a.name ?? a.email ?? a.id.slice(0, 8)
|
|
2214
|
+
}
|
|
2215
|
+
),
|
|
2216
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-xs text-[color:var(--muted)]", children: a.active ? "Active" : a.email ?? "" })
|
|
2217
|
+
] })
|
|
2218
|
+
]
|
|
2219
|
+
}
|
|
2220
|
+
),
|
|
2221
|
+
!a.active && userMenu.onRemoveAccount ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
2222
|
+
"button",
|
|
2223
|
+
{
|
|
2224
|
+
type: "button",
|
|
2225
|
+
"aria-label": `Remove ${a.name ?? a.email ?? "account"}`,
|
|
2226
|
+
onClick: () => userMenu.onRemoveAccount?.(a.id),
|
|
2227
|
+
className: "shrink-0 rounded p-1 text-[color:var(--color-race-red)] hover:bg-[color:var(--panel)]",
|
|
2228
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(TrashIcon, {})
|
|
2229
|
+
}
|
|
2230
|
+
) : null
|
|
2231
|
+
] }, a.id)) }),
|
|
2232
|
+
userMenu.addAccountHref || userMenu.onAddAccount ? userMenu.addAccountHref ? /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2233
|
+
"a",
|
|
2196
2234
|
{
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
if (!a.active) userMenu.onSwitchAccount?.(a.id);
|
|
2200
|
-
},
|
|
2201
|
-
className: "flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1 py-1 text-left hover:bg-[color:var(--panel)]",
|
|
2235
|
+
href: safeHref(userMenu.addAccountHref),
|
|
2236
|
+
className: "mt-1.5 flex items-center gap-2.5 rounded-lg px-1 py-1.5 text-sm font-semibold text-[color:var(--color-race-red)] hover:bg-[color:var(--panel)]",
|
|
2202
2237
|
children: [
|
|
2203
|
-
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2204
|
-
|
|
2205
|
-
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2206
|
-
"span",
|
|
2207
|
-
{
|
|
2208
|
-
className: [
|
|
2209
|
-
"truncate text-sm font-semibold",
|
|
2210
|
-
a.active ? "text-[color:var(--color-race-red)]" : "text-[color:var(--foreground)]"
|
|
2211
|
-
].join(" "),
|
|
2212
|
-
children: a.name ?? a.email ?? a.id.slice(0, 8)
|
|
2213
|
-
}
|
|
2214
|
-
),
|
|
2215
|
-
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-xs text-[color:var(--muted)]", children: a.active ? "Active" : a.email ?? "" })
|
|
2216
|
-
] })
|
|
2238
|
+
/* @__PURE__ */ jsxRuntime.jsx(AddUserIcon, {}),
|
|
2239
|
+
"Add Account"
|
|
2217
2240
|
]
|
|
2218
2241
|
}
|
|
2219
|
-
)
|
|
2220
|
-
!a.active && userMenu.onRemoveAccount ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
2242
|
+
) : /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2221
2243
|
"button",
|
|
2222
2244
|
{
|
|
2223
2245
|
type: "button",
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2246
|
+
onClick: userMenu.onAddAccount,
|
|
2247
|
+
className: "mt-1.5 flex w-full items-center gap-2.5 rounded-lg px-1 py-1.5 text-left text-sm font-semibold text-[color:var(--color-race-red)] hover:bg-[color:var(--panel)]",
|
|
2248
|
+
children: [
|
|
2249
|
+
/* @__PURE__ */ jsxRuntime.jsx(AddUserIcon, {}),
|
|
2250
|
+
"Add Account"
|
|
2251
|
+
]
|
|
2228
2252
|
}
|
|
2229
2253
|
) : null
|
|
2230
|
-
] }
|
|
2231
|
-
userMenu
|
|
2232
|
-
|
|
2254
|
+
] }) : null,
|
|
2255
|
+
userMenu?.theme ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "border-t border-[color:var(--line)]", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
2256
|
+
NavThemeControl,
|
|
2233
2257
|
{
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
children: [
|
|
2237
|
-
/* @__PURE__ */ jsxRuntime.jsx(AddUserIcon, {}),
|
|
2238
|
-
"Add Account"
|
|
2239
|
-
]
|
|
2240
|
-
}
|
|
2241
|
-
) : /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2242
|
-
"button",
|
|
2243
|
-
{
|
|
2244
|
-
type: "button",
|
|
2245
|
-
onClick: userMenu.onAddAccount,
|
|
2246
|
-
className: "mt-1.5 flex w-full items-center gap-2.5 rounded-lg px-1 py-1.5 text-left text-sm font-semibold text-[color:var(--color-race-red)] hover:bg-[color:var(--panel)]",
|
|
2247
|
-
children: [
|
|
2248
|
-
/* @__PURE__ */ jsxRuntime.jsx(AddUserIcon, {}),
|
|
2249
|
-
"Add Account"
|
|
2250
|
-
]
|
|
2258
|
+
value: userMenu.theme.value,
|
|
2259
|
+
onChange: userMenu.theme.onChange
|
|
2251
2260
|
}
|
|
2252
|
-
) : null
|
|
2253
|
-
] })
|
|
2254
|
-
userMenu?.theme ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "border-t border-[color:var(--line)]", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
2255
|
-
NavThemeControl,
|
|
2256
|
-
{
|
|
2257
|
-
value: userMenu.theme.value,
|
|
2258
|
-
onChange: userMenu.theme.onChange
|
|
2259
|
-
}
|
|
2260
|
-
) }) : null,
|
|
2261
|
+
) }) : null
|
|
2262
|
+
] }),
|
|
2261
2263
|
onSignOut ? /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2262
2264
|
"button",
|
|
2263
2265
|
{
|
|
2264
2266
|
type: "button",
|
|
2265
2267
|
onClick: onSignOut,
|
|
2266
|
-
className: "flex w-full items-center gap-2.5 border-t border-[color:var(--line)] px-4 py-2.5 text-left text-sm font-medium text-[color:var(--foreground)] hover:bg-[color:var(--panel)] hover:text-[color:var(--color-race-red)]",
|
|
2268
|
+
className: "flex w-full shrink-0 items-center gap-2.5 border-t border-[color:var(--line)] px-4 py-2.5 text-left text-sm font-medium text-[color:var(--foreground)] hover:bg-[color:var(--panel)] hover:text-[color:var(--color-race-red)]",
|
|
2267
2269
|
children: [
|
|
2268
2270
|
/* @__PURE__ */ jsxRuntime.jsx(SignOutIcon, {}),
|
|
2269
2271
|
"Sign out"
|