@open-mercato/ui 0.6.7-develop.6771.1.5afd5982e1 → 0.6.7-develop.6774.1.d836fe3135
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/AuthSessionGuard.js +14 -0
- package/dist/backend/AuthSessionGuard.js.map +2 -2
- package/dist/backend/DataTable.js +1 -28
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/perspectiveState.js +34 -0
- package/dist/backend/perspectiveState.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AuthSessionGuard.tsx +29 -0
- package/src/backend/DataTable.tsx +5 -39
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
- package/src/backend/perspectiveState.ts +44 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { apiCall } from "./utils/apiCall.js";
|
|
4
|
+
import { clearAllPerspectiveState } from "./perspectiveState.js";
|
|
4
5
|
const AUTH_IDENTITY_STORAGE_KEY = "om:auth:identity";
|
|
5
6
|
const AUTH_IDENTITY_BROADCAST_CHANNEL = "om-auth-identity";
|
|
7
|
+
const AUTH_IDENTITY_USER_STORAGE_KEY = "om:auth:identity:user";
|
|
8
|
+
function purgePerspectiveStateOnIdentityChange(serverUserId) {
|
|
9
|
+
if (typeof window === "undefined" || !serverUserId) return;
|
|
10
|
+
try {
|
|
11
|
+
const lastSeenUserId = window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY);
|
|
12
|
+
if (lastSeenUserId === serverUserId) return;
|
|
13
|
+
clearAllPerspectiveState();
|
|
14
|
+
window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, serverUserId);
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
}
|
|
6
18
|
const __reload = {
|
|
7
19
|
fn: () => {
|
|
8
20
|
if (typeof window !== "undefined") window.location.reload();
|
|
@@ -11,6 +23,7 @@ const __reload = {
|
|
|
11
23
|
function AuthSessionGuard({ serverUserId }) {
|
|
12
24
|
React.useEffect(() => {
|
|
13
25
|
if (typeof window === "undefined") return;
|
|
26
|
+
purgePerspectiveStateOnIdentityChange(serverUserId);
|
|
14
27
|
let cancelled = false;
|
|
15
28
|
let reloadScheduled = false;
|
|
16
29
|
const triggerReload = () => {
|
|
@@ -92,6 +105,7 @@ function notifyAuthIdentityChange() {
|
|
|
92
105
|
export {
|
|
93
106
|
AUTH_IDENTITY_BROADCAST_CHANNEL,
|
|
94
107
|
AUTH_IDENTITY_STORAGE_KEY,
|
|
108
|
+
AUTH_IDENTITY_USER_STORAGE_KEY,
|
|
95
109
|
AuthSessionGuard,
|
|
96
110
|
__reload,
|
|
97
111
|
notifyAuthIdentityChange
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/backend/AuthSessionGuard.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { apiCall } from './utils/apiCall'\n\nexport const AUTH_IDENTITY_STORAGE_KEY = 'om:auth:identity'\nexport const AUTH_IDENTITY_BROADCAST_CHANNEL = 'om-auth-identity'\n\ntype FeatureCheckResponse = {\n ok: boolean\n granted?: string[]\n userId?: string\n}\n\nexport type AuthSessionGuardProps = {\n serverUserId: string | null\n}\n\nexport const __reload = {\n fn: (): void => {\n if (typeof window !== 'undefined') window.location.reload()\n },\n}\n\nexport function AuthSessionGuard({ serverUserId }: AuthSessionGuardProps) {\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n let cancelled = false\n let reloadScheduled = false\n\n const triggerReload = () => {\n if (reloadScheduled) return\n reloadScheduled = true\n __reload.fn()\n }\n\n const checkIdentity = async () => {\n if (cancelled || reloadScheduled) return\n try {\n const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: [] }),\n cache: 'no-store',\n })\n if (cancelled || reloadScheduled) return\n if (res.status === 401) {\n if (serverUserId) triggerReload()\n return\n }\n if (!res.ok) return\n const currentUserId = typeof res.result?.userId === 'string' ? res.result.userId : null\n if (!currentUserId) return\n if (currentUserId !== serverUserId) triggerReload()\n } catch {\n // network errors are ignored \u2014 next focus/storage event retries\n }\n }\n\n const onVisibilityOrFocus = () => {\n if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return\n void checkIdentity()\n }\n\n const onStorage = (event: StorageEvent) => {\n if (event.key !== AUTH_IDENTITY_STORAGE_KEY) return\n void checkIdentity()\n }\n\n let broadcastChannel: BroadcastChannel | null = null\n if (typeof BroadcastChannel !== 'undefined') {\n try {\n broadcastChannel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)\n broadcastChannel.onmessage = () => { void checkIdentity() }\n } catch {\n broadcastChannel = null\n }\n }\n\n window.addEventListener('focus', onVisibilityOrFocus)\n document.addEventListener('visibilitychange', onVisibilityOrFocus)\n window.addEventListener('storage', onStorage)\n\n return () => {\n cancelled = true\n window.removeEventListener('focus', onVisibilityOrFocus)\n document.removeEventListener('visibilitychange', onVisibilityOrFocus)\n window.removeEventListener('storage', onStorage)\n if (broadcastChannel) {\n broadcastChannel.onmessage = null\n broadcastChannel.close()\n }\n }\n }, [serverUserId])\n\n return null\n}\n\nexport function notifyAuthIdentityChange(): void {\n if (typeof window === 'undefined') return\n try {\n if (typeof BroadcastChannel !== 'undefined') {\n const channel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)\n channel.postMessage('changed')\n channel.close()\n }\n } catch {\n // ignore \u2014 fall back to storage event below\n }\n try {\n window.localStorage.setItem(AUTH_IDENTITY_STORAGE_KEY, String(Date.now()))\n } catch {\n // private mode / quota errors are non-fatal\n }\n}\n"],
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AACvB,SAAS,eAAe;
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { apiCall } from './utils/apiCall'\nimport { clearAllPerspectiveState } from './perspectiveState'\n\nexport const AUTH_IDENTITY_STORAGE_KEY = 'om:auth:identity'\nexport const AUTH_IDENTITY_BROADCAST_CHANNEL = 'om-auth-identity'\nexport const AUTH_IDENTITY_USER_STORAGE_KEY = 'om:auth:identity:user'\n\n/**\n * Purge browser-local DataTable perspective state whenever the backend renders for a\n * different user than the one it last rendered for (#4185).\n *\n * The login form also purges on submit, but that only fires when its client handler\n * runs: a form submitted before hydration, an SSO/magic-link return, or any other\n * entry point into the backend bypasses it entirely and the previous account's unsaved\n * column widths carry over. Anchoring the purge to the observed server identity covers\n * every route into the backend instead of one form.\n *\n * Missing identity metadata is treated as a legacy browser state and purged once:\n * snapshots may predate this marker and belong to a different account. After the\n * marker is recorded, an account's own unsaved widths survive a plain reload.\n */\nfunction purgePerspectiveStateOnIdentityChange(serverUserId: string | null): void {\n if (typeof window === 'undefined' || !serverUserId) return\n try {\n const lastSeenUserId = window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)\n if (lastSeenUserId === serverUserId) return\n clearAllPerspectiveState()\n window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, serverUserId)\n } catch {\n // private mode / quota errors are non-fatal\n }\n}\n\ntype FeatureCheckResponse = {\n ok: boolean\n granted?: string[]\n userId?: string\n}\n\nexport type AuthSessionGuardProps = {\n serverUserId: string | null\n}\n\nexport const __reload = {\n fn: (): void => {\n if (typeof window !== 'undefined') window.location.reload()\n },\n}\n\nexport function AuthSessionGuard({ serverUserId }: AuthSessionGuardProps) {\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n purgePerspectiveStateOnIdentityChange(serverUserId)\n let cancelled = false\n let reloadScheduled = false\n\n const triggerReload = () => {\n if (reloadScheduled) return\n reloadScheduled = true\n __reload.fn()\n }\n\n const checkIdentity = async () => {\n if (cancelled || reloadScheduled) return\n try {\n const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: [] }),\n cache: 'no-store',\n })\n if (cancelled || reloadScheduled) return\n if (res.status === 401) {\n if (serverUserId) triggerReload()\n return\n }\n if (!res.ok) return\n const currentUserId = typeof res.result?.userId === 'string' ? res.result.userId : null\n if (!currentUserId) return\n if (currentUserId !== serverUserId) triggerReload()\n } catch {\n // network errors are ignored \u2014 next focus/storage event retries\n }\n }\n\n const onVisibilityOrFocus = () => {\n if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return\n void checkIdentity()\n }\n\n const onStorage = (event: StorageEvent) => {\n if (event.key !== AUTH_IDENTITY_STORAGE_KEY) return\n void checkIdentity()\n }\n\n let broadcastChannel: BroadcastChannel | null = null\n if (typeof BroadcastChannel !== 'undefined') {\n try {\n broadcastChannel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)\n broadcastChannel.onmessage = () => { void checkIdentity() }\n } catch {\n broadcastChannel = null\n }\n }\n\n window.addEventListener('focus', onVisibilityOrFocus)\n document.addEventListener('visibilitychange', onVisibilityOrFocus)\n window.addEventListener('storage', onStorage)\n\n return () => {\n cancelled = true\n window.removeEventListener('focus', onVisibilityOrFocus)\n document.removeEventListener('visibilitychange', onVisibilityOrFocus)\n window.removeEventListener('storage', onStorage)\n if (broadcastChannel) {\n broadcastChannel.onmessage = null\n broadcastChannel.close()\n }\n }\n }, [serverUserId])\n\n return null\n}\n\nexport function notifyAuthIdentityChange(): void {\n if (typeof window === 'undefined') return\n try {\n if (typeof BroadcastChannel !== 'undefined') {\n const channel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)\n channel.postMessage('changed')\n channel.close()\n }\n } catch {\n // ignore \u2014 fall back to storage event below\n }\n try {\n window.localStorage.setItem(AUTH_IDENTITY_STORAGE_KEY, String(Date.now()))\n } catch {\n // private mode / quota errors are non-fatal\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from "@dnd-kit/sortable";
|
|
76
76
|
import { CSS } from "@dnd-kit/utilities";
|
|
77
77
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
78
|
+
import { clearAllPerspectiveState, PERSPECTIVE_COOKIE_PREFIX, PERSPECTIVE_STORAGE_PREFIX } from "./perspectiveState.js";
|
|
78
79
|
const logger = createLogger("ui").child({ component: "DataTable" });
|
|
79
80
|
let refreshScheduled = false;
|
|
80
81
|
function scheduleRouterRefresh(router) {
|
|
@@ -200,8 +201,6 @@ function resolveExportSections(config) {
|
|
|
200
201
|
addSection("full", config.full, "Full data export");
|
|
201
202
|
return sections;
|
|
202
203
|
}
|
|
203
|
-
const PERSPECTIVE_COOKIE_PREFIX = "om_table_perspective";
|
|
204
|
-
const PERSPECTIVE_STORAGE_PREFIX = "om_table_perspective_snapshot";
|
|
205
204
|
const COLUMN_MIN_WIDTH = 60;
|
|
206
205
|
const COLUMN_MAX_WIDTH = 900;
|
|
207
206
|
function formatDurationLabel(durationMs) {
|
|
@@ -256,32 +255,6 @@ function writePerspectiveSnapshot(tableId, snapshot) {
|
|
|
256
255
|
}
|
|
257
256
|
writeVersionedPreference(key, PERSPECTIVE_SNAPSHOT_VERSION, snapshot);
|
|
258
257
|
}
|
|
259
|
-
function clearAllPerspectiveState() {
|
|
260
|
-
if (typeof window !== "undefined") {
|
|
261
|
-
try {
|
|
262
|
-
const storage = window.localStorage;
|
|
263
|
-
const staleKeys = [];
|
|
264
|
-
for (let index = 0; index < storage.length; index += 1) {
|
|
265
|
-
const key = storage.key(index);
|
|
266
|
-
if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key);
|
|
267
|
-
}
|
|
268
|
-
for (const key of staleKeys) storage.removeItem(key);
|
|
269
|
-
} catch {
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
if (typeof document !== "undefined") {
|
|
273
|
-
try {
|
|
274
|
-
const cookies = document.cookie ? document.cookie.split(";") : [];
|
|
275
|
-
for (const cookie of cookies) {
|
|
276
|
-
const name = cookie.split("=")[0]?.trim();
|
|
277
|
-
if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {
|
|
278
|
-
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
} catch {
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
258
|
function sanitizePerspectiveSettings(source) {
|
|
286
259
|
if (!source || typeof source !== "object") return null;
|
|
287
260
|
const forbidden = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|