@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.
@@ -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.6773.1.f9b7fbae4f",
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.6773.1.f9b7fbae4f",
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.6773.1.f9b7fbae4f",
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
+ }