@open-mercato/ui 0.6.7-develop.6773.1.f9b7fbae4f → 0.6.7-develop.6775.1.c2313bb8a3
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/perspectiveState.ts +44 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const PERSPECTIVE_COOKIE_PREFIX = "om_table_perspective";
|
|
2
|
+
const PERSPECTIVE_STORAGE_PREFIX = "om_table_perspective_snapshot";
|
|
3
|
+
function clearAllPerspectiveState() {
|
|
4
|
+
if (typeof window !== "undefined") {
|
|
5
|
+
try {
|
|
6
|
+
const storage = window.localStorage;
|
|
7
|
+
const staleKeys = [];
|
|
8
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
9
|
+
const key = storage.key(index);
|
|
10
|
+
if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key);
|
|
11
|
+
}
|
|
12
|
+
for (const key of staleKeys) storage.removeItem(key);
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (typeof document !== "undefined") {
|
|
17
|
+
try {
|
|
18
|
+
const cookies = document.cookie ? document.cookie.split(";") : [];
|
|
19
|
+
for (const cookie of cookies) {
|
|
20
|
+
const name = cookie.split("=")[0]?.trim();
|
|
21
|
+
if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {
|
|
22
|
+
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
PERSPECTIVE_COOKIE_PREFIX,
|
|
31
|
+
PERSPECTIVE_STORAGE_PREFIX,
|
|
32
|
+
clearAllPerspectiveState
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=perspectiveState.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/backend/perspectiveState.ts"],
|
|
4
|
+
"sourcesContent": ["export const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'\nexport const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'\n\n/**\n * Purge every browser-local DataTable perspective snapshot and active-view cookie\n * across all tables in this origin. Snapshots persist the unsaved/live table state\n * (column widths, order, visibility) keyed only by `tableId`, so they are shared by\n * every account that signs in on the same browser profile. Call this at the auth\n * identity boundary so one user's unsaved layout never carries over to a different\n * user \u2014 including a different tenant \u2014 reusing the same browser tab (#4185).\n *\n * Lives here rather than in DataTable so the backend shell can call it without\n * pulling the whole table implementation into the bundle of every backend page.\n * `@open-mercato/ui/backend/DataTable` re-exports it, which is the published\n * import path and must stay stable.\n */\nexport function clearAllPerspectiveState(): void {\n if (typeof window !== 'undefined') {\n try {\n const storage = window.localStorage\n const staleKeys: string[] = []\n for (let index = 0; index < storage.length; index += 1) {\n const key = storage.key(index)\n if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key)\n }\n for (const key of staleKeys) storage.removeItem(key)\n } catch {\n // private mode / quota errors are non-fatal\n }\n }\n if (typeof document !== 'undefined') {\n try {\n const cookies = document.cookie ? document.cookie.split(';') : []\n for (const cookie of cookies) {\n const name = cookie.split('=')[0]?.trim()\n if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {\n document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`\n }\n }\n } catch {\n // ignore \u2014 cookie access can throw in sandboxed contexts\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,4BAA4B;AAClC,MAAM,6BAA6B;AAenC,SAAS,2BAAiC;AAC/C,MAAI,OAAO,WAAW,aAAa;AACjC,QAAI;AACF,YAAM,UAAU,OAAO;AACvB,YAAM,YAAsB,CAAC;AAC7B,eAAS,QAAQ,GAAG,QAAQ,QAAQ,QAAQ,SAAS,GAAG;AACtD,cAAM,MAAM,QAAQ,IAAI,KAAK;AAC7B,YAAI,OAAO,IAAI,WAAW,GAAG,0BAA0B,GAAG,EAAG,WAAU,KAAK,GAAG;AAAA,MACjF;AACA,iBAAW,OAAO,UAAW,SAAQ,WAAW,GAAG;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AACA,MAAI,OAAO,aAAa,aAAa;AACnC,QAAI;AACF,YAAM,UAAU,SAAS,SAAS,SAAS,OAAO,MAAM,GAAG,IAAI,CAAC;AAChE,iBAAW,UAAU,SAAS;AAC5B,cAAM,OAAO,OAAO,MAAM,GAAG,EAAE,CAAC,GAAG,KAAK;AACxC,YAAI,QAAQ,KAAK,WAAW,GAAG,yBAAyB,GAAG,GAAG;AAC5D,mBAAS,SAAS,GAAG,IAAI;AAAA,QAC3B;AAAA,MACF;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6775.1.c2313bb8a3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
164
|
+
"@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
|
|
165
165
|
"@testing-library/dom": "^10.4.1",
|
|
166
166
|
"@testing-library/jest-dom": "^6.9.1",
|
|
167
167
|
"@testing-library/react": "^16.3.1",
|
|
@@ -1,9 +1,37 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { apiCall } from './utils/apiCall'
|
|
4
|
+
import { clearAllPerspectiveState } from './perspectiveState'
|
|
4
5
|
|
|
5
6
|
export const AUTH_IDENTITY_STORAGE_KEY = 'om:auth:identity'
|
|
6
7
|
export const AUTH_IDENTITY_BROADCAST_CHANNEL = 'om-auth-identity'
|
|
8
|
+
export const AUTH_IDENTITY_USER_STORAGE_KEY = 'om:auth:identity:user'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Purge browser-local DataTable perspective state whenever the backend renders for a
|
|
12
|
+
* different user than the one it last rendered for (#4185).
|
|
13
|
+
*
|
|
14
|
+
* The login form also purges on submit, but that only fires when its client handler
|
|
15
|
+
* runs: a form submitted before hydration, an SSO/magic-link return, or any other
|
|
16
|
+
* entry point into the backend bypasses it entirely and the previous account's unsaved
|
|
17
|
+
* column widths carry over. Anchoring the purge to the observed server identity covers
|
|
18
|
+
* every route into the backend instead of one form.
|
|
19
|
+
*
|
|
20
|
+
* Missing identity metadata is treated as a legacy browser state and purged once:
|
|
21
|
+
* snapshots may predate this marker and belong to a different account. After the
|
|
22
|
+
* marker is recorded, an account's own unsaved widths survive a plain reload.
|
|
23
|
+
*/
|
|
24
|
+
function purgePerspectiveStateOnIdentityChange(serverUserId: string | null): void {
|
|
25
|
+
if (typeof window === 'undefined' || !serverUserId) return
|
|
26
|
+
try {
|
|
27
|
+
const lastSeenUserId = window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)
|
|
28
|
+
if (lastSeenUserId === serverUserId) return
|
|
29
|
+
clearAllPerspectiveState()
|
|
30
|
+
window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, serverUserId)
|
|
31
|
+
} catch {
|
|
32
|
+
// private mode / quota errors are non-fatal
|
|
33
|
+
}
|
|
34
|
+
}
|
|
7
35
|
|
|
8
36
|
type FeatureCheckResponse = {
|
|
9
37
|
ok: boolean
|
|
@@ -24,6 +52,7 @@ export const __reload = {
|
|
|
24
52
|
export function AuthSessionGuard({ serverUserId }: AuthSessionGuardProps) {
|
|
25
53
|
React.useEffect(() => {
|
|
26
54
|
if (typeof window === 'undefined') return
|
|
55
|
+
purgePerspectiveStateOnIdentityChange(serverUserId)
|
|
27
56
|
let cancelled = false
|
|
28
57
|
let reloadScheduled = false
|
|
29
58
|
|
|
@@ -95,6 +95,11 @@ import {
|
|
|
95
95
|
} from '@dnd-kit/sortable'
|
|
96
96
|
import { CSS } from '@dnd-kit/utilities'
|
|
97
97
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
98
|
+
import { clearAllPerspectiveState, PERSPECTIVE_COOKIE_PREFIX, PERSPECTIVE_STORAGE_PREFIX } from './perspectiveState'
|
|
99
|
+
|
|
100
|
+
// Re-exported so `@open-mercato/ui/backend/DataTable` stays the published import
|
|
101
|
+
// path for the purge (BACKWARD_COMPATIBILITY: import paths are a contract surface).
|
|
102
|
+
export { clearAllPerspectiveState }
|
|
98
103
|
|
|
99
104
|
const logger = createLogger('ui').child({ component: 'DataTable' })
|
|
100
105
|
|
|
@@ -470,8 +475,6 @@ function resolveExportSections(config: DataTableExportConfig | null | undefined)
|
|
|
470
475
|
return sections
|
|
471
476
|
}
|
|
472
477
|
|
|
473
|
-
const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
|
|
474
|
-
const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
|
|
475
478
|
|
|
476
479
|
// Bounds for user-driven column resizing (#1835). Widths outside this range are
|
|
477
480
|
// clamped so a persisted/dragged value can never collapse a column to nothing or
|
|
@@ -552,43 +555,6 @@ export function writePerspectiveSnapshot(tableId: string, snapshot: PerspectiveS
|
|
|
552
555
|
writeVersionedPreference(key, PERSPECTIVE_SNAPSHOT_VERSION, snapshot)
|
|
553
556
|
}
|
|
554
557
|
|
|
555
|
-
/**
|
|
556
|
-
* Purge every browser-local DataTable perspective snapshot and active-view cookie
|
|
557
|
-
* across all tables in this origin. Snapshots persist the unsaved/live table state
|
|
558
|
-
* (column widths, order, visibility) keyed only by `tableId`, so they are shared by
|
|
559
|
-
* every account that signs in on the same browser profile. Call this at the auth
|
|
560
|
-
* identity boundary (login) so one user's unsaved layout never carries over to a
|
|
561
|
-
* different user — including a different tenant — reusing the same browser tab (#4185).
|
|
562
|
-
*/
|
|
563
|
-
export function clearAllPerspectiveState(): void {
|
|
564
|
-
if (typeof window !== 'undefined') {
|
|
565
|
-
try {
|
|
566
|
-
const storage = window.localStorage
|
|
567
|
-
const staleKeys: string[] = []
|
|
568
|
-
for (let index = 0; index < storage.length; index += 1) {
|
|
569
|
-
const key = storage.key(index)
|
|
570
|
-
if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key)
|
|
571
|
-
}
|
|
572
|
-
for (const key of staleKeys) storage.removeItem(key)
|
|
573
|
-
} catch {
|
|
574
|
-
// private mode / quota errors are non-fatal
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
if (typeof document !== 'undefined') {
|
|
578
|
-
try {
|
|
579
|
-
const cookies = document.cookie ? document.cookie.split(';') : []
|
|
580
|
-
for (const cookie of cookies) {
|
|
581
|
-
const name = cookie.split('=')[0]?.trim()
|
|
582
|
-
if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {
|
|
583
|
-
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
} catch {
|
|
587
|
-
// ignore — cookie access can throw in sandboxed contexts
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
558
|
export function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): PerspectiveSettings | null {
|
|
593
559
|
if (!source || typeof source !== 'object') return null
|
|
594
560
|
const forbidden = new Set(['__proto__', 'prototype', 'constructor'])
|
|
@@ -8,6 +8,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
|
8
8
|
import {
|
|
9
9
|
__reload,
|
|
10
10
|
AUTH_IDENTITY_STORAGE_KEY,
|
|
11
|
+
AUTH_IDENTITY_USER_STORAGE_KEY,
|
|
11
12
|
AuthSessionGuard,
|
|
12
13
|
notifyAuthIdentityChange,
|
|
13
14
|
} from '../AuthSessionGuard'
|
|
@@ -148,3 +149,54 @@ describe('notifyAuthIdentityChange', () => {
|
|
|
148
149
|
setItemSpy.mockRestore()
|
|
149
150
|
})
|
|
150
151
|
})
|
|
152
|
+
|
|
153
|
+
describe('perspective purge at the rendered identity boundary (#4185)', () => {
|
|
154
|
+
const snapshotKey = 'om_table_perspective_snapshot:customers-companies'
|
|
155
|
+
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
window.localStorage.clear()
|
|
158
|
+
apiCallMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true } } as never)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
window.localStorage.clear()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('purges legacy table state when no previous identity marker exists', () => {
|
|
166
|
+
window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
|
|
167
|
+
|
|
168
|
+
render(<AuthSessionGuard serverUserId="user-1" />)
|
|
169
|
+
|
|
170
|
+
expect(window.localStorage.getItem(snapshotKey)).toBeNull()
|
|
171
|
+
expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-1')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('keeps a returning user\'s unsaved widths across a reload', () => {
|
|
175
|
+
window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
|
|
176
|
+
window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
|
|
177
|
+
|
|
178
|
+
render(<AuthSessionGuard serverUserId="user-1" />)
|
|
179
|
+
|
|
180
|
+
expect(window.localStorage.getItem(snapshotKey)).toBe('{"columnWidths":{"name":240}}')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('purges perspective state when the backend renders for a different user', () => {
|
|
184
|
+
window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
|
|
185
|
+
window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
|
|
186
|
+
|
|
187
|
+
render(<AuthSessionGuard serverUserId="user-2" />)
|
|
188
|
+
|
|
189
|
+
expect(window.localStorage.getItem(snapshotKey)).toBeNull()
|
|
190
|
+
expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-2')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('does nothing when the shell renders without an authenticated user', () => {
|
|
194
|
+
window.localStorage.setItem(AUTH_IDENTITY_USER_STORAGE_KEY, 'user-1')
|
|
195
|
+
window.localStorage.setItem(snapshotKey, '{"columnWidths":{"name":240}}')
|
|
196
|
+
|
|
197
|
+
render(<AuthSessionGuard serverUserId={null} />)
|
|
198
|
+
|
|
199
|
+
expect(window.localStorage.getItem(snapshotKey)).toBe('{"columnWidths":{"name":240}}')
|
|
200
|
+
expect(window.localStorage.getItem(AUTH_IDENTITY_USER_STORAGE_KEY)).toBe('user-1')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
|
|
2
|
+
export const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Purge every browser-local DataTable perspective snapshot and active-view cookie
|
|
6
|
+
* across all tables in this origin. Snapshots persist the unsaved/live table state
|
|
7
|
+
* (column widths, order, visibility) keyed only by `tableId`, so they are shared by
|
|
8
|
+
* every account that signs in on the same browser profile. Call this at the auth
|
|
9
|
+
* identity boundary so one user's unsaved layout never carries over to a different
|
|
10
|
+
* user — including a different tenant — reusing the same browser tab (#4185).
|
|
11
|
+
*
|
|
12
|
+
* Lives here rather than in DataTable so the backend shell can call it without
|
|
13
|
+
* pulling the whole table implementation into the bundle of every backend page.
|
|
14
|
+
* `@open-mercato/ui/backend/DataTable` re-exports it, which is the published
|
|
15
|
+
* import path and must stay stable.
|
|
16
|
+
*/
|
|
17
|
+
export function clearAllPerspectiveState(): void {
|
|
18
|
+
if (typeof window !== 'undefined') {
|
|
19
|
+
try {
|
|
20
|
+
const storage = window.localStorage
|
|
21
|
+
const staleKeys: string[] = []
|
|
22
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
23
|
+
const key = storage.key(index)
|
|
24
|
+
if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key)
|
|
25
|
+
}
|
|
26
|
+
for (const key of staleKeys) storage.removeItem(key)
|
|
27
|
+
} catch {
|
|
28
|
+
// private mode / quota errors are non-fatal
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (typeof document !== 'undefined') {
|
|
32
|
+
try {
|
|
33
|
+
const cookies = document.cookie ? document.cookie.split(';') : []
|
|
34
|
+
for (const cookie of cookies) {
|
|
35
|
+
const name = cookie.split('=')[0]?.trim()
|
|
36
|
+
if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {
|
|
37
|
+
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore — cookie access can throw in sandboxed contexts
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|