@shellui/core 0.2.0 → 0.3.0-beta.1

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.
Files changed (111) hide show
  1. package/package.json +9 -4
  2. package/src/app.tsx +12 -9
  3. package/src/components/ui/badge.tsx +35 -0
  4. package/src/components/ui/dropdown-menu.tsx +94 -0
  5. package/src/components/ui/sidebar.tsx +1 -1
  6. package/src/constants/urls.ts +8 -0
  7. package/src/features/admin/AdminView.tsx +154 -0
  8. package/src/features/admin/components/AdminForbiddenAccess.tsx +10 -0
  9. package/src/features/auth/AuthProvider.tsx +464 -0
  10. package/src/features/auth/backends/index.ts +41 -0
  11. package/src/features/auth/backends/shellui.ts +278 -0
  12. package/src/features/auth/backends/supabase.ts +300 -0
  13. package/src/features/auth/backends/types.ts +30 -0
  14. package/src/features/auth/components/LoginButton.tsx +360 -0
  15. package/src/features/auth/components/LoginButtonIcons.tsx +48 -0
  16. package/src/features/auth/components/LoginView.tsx +721 -0
  17. package/src/features/auth/components/OAuthCallbackView.tsx +119 -0
  18. package/src/features/auth/hooks/useAuth.tsx +37 -0
  19. package/src/features/auth/types.ts +51 -0
  20. package/src/features/auth/utils/buildSessionFromParams.spec.ts +61 -0
  21. package/src/features/auth/utils/buildSessionFromParams.ts +79 -0
  22. package/src/features/auth/utils/clearStoredAuthSession.spec.ts +23 -0
  23. package/src/features/auth/utils/clearStoredAuthSession.ts +10 -0
  24. package/src/features/auth/utils/clientLoginContext.spec.ts +83 -0
  25. package/src/features/auth/utils/clientLoginContext.ts +89 -0
  26. package/src/features/auth/utils/decodeJwtPayload.spec.ts +17 -0
  27. package/src/features/auth/utils/decodeJwtPayload.ts +24 -0
  28. package/src/features/auth/utils/formatProviderLabel.spec.ts +19 -0
  29. package/src/features/auth/utils/formatProviderLabel.ts +11 -0
  30. package/src/features/auth/utils/getOAuthProviderCandidates.spec.ts +16 -0
  31. package/src/features/auth/utils/getOAuthProviderCandidates.ts +15 -0
  32. package/src/features/auth/utils/getPreferredBackendProvider.spec.ts +15 -0
  33. package/src/features/auth/utils/getPreferredBackendProvider.ts +10 -0
  34. package/src/features/auth/utils/getProviderVisual.spec.ts +30 -0
  35. package/src/features/auth/utils/getProviderVisual.ts +83 -0
  36. package/src/features/auth/utils/getUserFromSdkSettings.spec.ts +32 -0
  37. package/src/features/auth/utils/getUserFromSdkSettings.ts +13 -0
  38. package/src/features/auth/utils/index.ts +21 -0
  39. package/src/features/auth/utils/isLoginMethod.spec.ts +18 -0
  40. package/src/features/auth/utils/isLoginMethod.ts +5 -0
  41. package/src/features/auth/utils/isSessionExpired.spec.ts +23 -0
  42. package/src/features/auth/utils/isSessionExpired.ts +5 -0
  43. package/src/features/auth/utils/normalizeAuthSettings.spec.ts +60 -0
  44. package/src/features/auth/utils/normalizeAuthSettings.ts +71 -0
  45. package/src/features/auth/utils/normalizeNextPath.spec.ts +21 -0
  46. package/src/features/auth/utils/normalizeNextPath.ts +12 -0
  47. package/src/features/auth/utils/normalizeRedirectPath.spec.ts +12 -0
  48. package/src/features/auth/utils/normalizeRedirectPath.ts +3 -0
  49. package/src/features/auth/utils/persistAuthSession.spec.ts +35 -0
  50. package/src/features/auth/utils/persistAuthSession.ts +12 -0
  51. package/src/features/auth/utils/readStoredAuthSession.spec.ts +34 -0
  52. package/src/features/auth/utils/readStoredAuthSession.ts +14 -0
  53. package/src/features/auth/utils/toAuthSessionFromSettingsUser.spec.ts +76 -0
  54. package/src/features/auth/utils/toAuthSessionFromSettingsUser.ts +36 -0
  55. package/src/features/config/types.ts +55 -0
  56. package/src/features/layouts/AppLayout.tsx +8 -6
  57. package/src/features/layouts/appbar/AppBarLayout.tsx +42 -23
  58. package/src/features/layouts/fullscreen/FullscreenLayout.tsx +3 -2
  59. package/src/features/layouts/sidebar/SidebarInner.tsx +7 -5
  60. package/src/features/layouts/sidebar/SidebarLayout.tsx +16 -3
  61. package/src/features/layouts/utils.ts +54 -0
  62. package/src/features/layouts/windows/WindowsLayout.tsx +22 -4
  63. package/src/features/legal/LegalDocumentContent.tsx +102 -0
  64. package/src/features/legal/LegalDocumentView.tsx +42 -0
  65. package/src/features/legal/LegalDocumentsIndexView.tsx +51 -0
  66. package/src/features/legal/LegalDocumentsLinks.tsx +29 -0
  67. package/src/features/legal/legalDocuments.ts +62 -0
  68. package/src/features/settings/SettingsIcons.tsx +20 -0
  69. package/src/features/settings/SettingsProvider.tsx +347 -245
  70. package/src/features/settings/SettingsRoutes.tsx +8 -0
  71. package/src/features/settings/SettingsView.tsx +43 -8
  72. package/src/features/settings/components/Develop.tsx +2 -2
  73. package/src/features/settings/components/LegalDocumentsPanel.tsx +46 -0
  74. package/src/features/settings/components/UserIcon.tsx +20 -0
  75. package/src/features/settings/components/UserSettingsPanel.tsx +438 -0
  76. package/src/features/settings/components/createUserSettingsRoute.tsx +43 -0
  77. package/src/features/settings/utils/buildSettingsForPropagation.spec.ts +167 -0
  78. package/src/features/settings/utils/buildSettingsForPropagation.ts +61 -0
  79. package/src/features/settings/utils/flattenNavigationItems.spec.ts +17 -0
  80. package/src/features/settings/utils/flattenNavigationItems.ts +12 -0
  81. package/src/features/settings/utils/getAvailableThemesForSettings.spec.ts +15 -0
  82. package/src/features/settings/utils/getAvailableThemesForSettings.ts +16 -0
  83. package/src/features/settings/utils/getBrowserTimezone.spec.ts +11 -0
  84. package/src/features/settings/utils/getBrowserTimezone.ts +7 -0
  85. package/src/features/settings/utils/getPreferenceSnapshot.spec.ts +35 -0
  86. package/src/features/settings/utils/getPreferenceSnapshot.ts +10 -0
  87. package/src/features/settings/utils/getResolvedAppearanceForSettings.spec.ts +97 -0
  88. package/src/features/settings/utils/getResolvedAppearanceForSettings.ts +48 -0
  89. package/src/features/settings/utils/index.ts +12 -0
  90. package/src/features/settings/utils/isSameUser.spec.ts +35 -0
  91. package/src/features/settings/utils/isSameUser.ts +17 -0
  92. package/src/features/settings/utils/mergePreferencesIntoSettings.spec.ts +108 -0
  93. package/src/features/settings/utils/mergePreferencesIntoSettings.ts +47 -0
  94. package/src/features/settings/utils/resolveColorMode.spec.ts +29 -0
  95. package/src/features/settings/utils/resolveColorMode.ts +6 -0
  96. package/src/features/settings/utils/resolveLabel.spec.ts +17 -0
  97. package/src/features/settings/utils/resolveLabel.ts +7 -0
  98. package/src/features/settings/utils/toAbsoluteFontUrls.spec.ts +26 -0
  99. package/src/features/settings/utils/toAbsoluteFontUrls.ts +15 -0
  100. package/src/features/settings/utils/toSettingsUser.spec.ts +49 -0
  101. package/src/features/settings/utils/toSettingsUser.ts +15 -0
  102. package/src/i18n/translations/en/common.json +14 -0
  103. package/src/i18n/translations/en/settings.json +45 -0
  104. package/src/i18n/translations/fr/common.json +14 -0
  105. package/src/i18n/translations/fr/settings.json +45 -0
  106. package/src/index.css +37 -0
  107. package/src/index.ts +6 -0
  108. package/src/routes/components/NavigationItemRoute.tsx +32 -1
  109. package/src/routes/components/NotFoundView.tsx +13 -3
  110. package/src/routes/hooks/useNavigationItems.ts +19 -4
  111. package/src/routes/routes.tsx +87 -0
@@ -4,225 +4,85 @@ import {
4
4
  shellui,
5
5
  type ShellUIMessage,
6
6
  type Settings,
7
- type SettingsNavigationItem,
8
7
  type Appearance,
9
- type SettingsAvailableTheme,
10
8
  } from '@shellui/sdk';
11
9
  import { SettingsContext } from './SettingsContext';
12
10
  import { useConfig } from '../config/useConfig';
13
- import { useTranslation } from 'react-i18next';
14
- import type { NavigationItem, NavigationGroup, ShellUIConfig } from '../config/types';
15
- import { getTheme, getAllThemes, registerTheme } from '../theme/themes';
11
+ import type { NavigationItem } from '../config/types';
12
+ import { useAuth } from '../auth/hooks/useAuth';
13
+ import { defaultTheme } from '../theme/themes';
14
+ import {
15
+ buildSettingsForPropagation,
16
+ getBrowserTimezone,
17
+ getPreferenceSnapshot,
18
+ isSameUser,
19
+ mergePreferencesIntoSettings,
20
+ toSettingsUser,
21
+ } from './utils';
16
22
 
17
23
  const logger = getLogger('shellcore');
18
24
 
19
- function flattenNavigationItems(
20
- navigation: (NavigationItem | NavigationGroup)[],
21
- ): NavigationItem[] {
22
- if (navigation.length === 0) return [];
23
- return navigation.flatMap((item) => {
24
- if ('title' in item && 'items' in item) return (item as NavigationGroup).items;
25
- return [item as NavigationItem];
26
- });
27
- }
28
-
29
- function resolveLabel(
30
- value: string | { en: string; fr: string; [key: string]: string },
31
- lang: string,
32
- ): string {
33
- if (typeof value === 'string') return value;
34
- return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
35
- }
36
-
37
- function resolveColorMode(colorScheme: 'light' | 'dark' | 'system'): 'light' | 'dark' {
38
- if (colorScheme === 'system' && typeof window !== 'undefined') {
39
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
25
+ const STORAGE_KEY = 'shellui:settings';
26
+ const AUTH_SESSION_STORAGE_KEY = 'shellui.auth.session';
27
+ const AUTH_LAST_USED_LOGIN_STORAGE_KEY = 'shellui.auth.last_used_login';
28
+
29
+ const toAbsoluteUrl = (url: string): URL | null => {
30
+ try {
31
+ return new URL(
32
+ url,
33
+ typeof window !== 'undefined' ? window.location.origin : 'http://localhost',
34
+ );
35
+ } catch {
36
+ return null;
40
37
  }
41
- return colorScheme === 'dark' ? 'dark' : 'light';
42
- }
38
+ };
43
39
 
44
- /** Convert font file URLs to absolute so iframes/modals on other ports or domains can load them. */
45
- function toAbsoluteFontUrls(urls: string[]): string[] {
46
- if (typeof window === 'undefined') return urls;
47
- const origin = window.location.origin;
48
- return urls.map((url) => {
49
- const trimmed = url.trim();
50
- if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
51
- return trimmed;
52
- }
53
- const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
54
- return `${origin}${path}`;
55
- });
56
- }
40
+ const normalizePath = (value: string): string => {
41
+ const trimmed = value.trim();
42
+ if (!trimmed) return '/';
43
+ const withoutTrailing = trimmed.replace(/\/+$/, '');
44
+ return withoutTrailing || '/';
45
+ };
57
46
 
58
- /**
59
- * Build the full appearance object for settings propagation so apps receive all theme
60
- * variable values and can style without knowing the theme name.
61
- */
62
- function getResolvedAppearanceForSettings(
63
- settings: Settings,
64
- config: ShellUIConfig | undefined,
65
- ): Appearance | undefined {
66
- if (typeof window === 'undefined') return undefined;
67
- config?.themes?.forEach(registerTheme);
68
- const themeName = settings.appearance?.name || config?.defaultTheme || 'default';
69
- const themeDef = getTheme(themeName) || getTheme('default');
70
- if (!themeDef) return undefined;
71
- const colorScheme = settings.appearance?.colorScheme ?? 'system';
72
- const mode = resolveColorMode(colorScheme);
73
- return {
74
- name: themeDef.name,
75
- displayName: themeDef.displayName,
76
- mode,
77
- colorScheme,
78
- colors: themeDef.colors,
79
- ...(themeDef.fontFamily !== undefined && { fontFamily: themeDef.fontFamily }),
80
- ...(themeDef.bodyFontFamily !== undefined && {
81
- bodyFontFamily: themeDef.bodyFontFamily,
82
- }),
83
- ...(themeDef.headingFontFamily !== undefined && {
84
- headingFontFamily: themeDef.headingFontFamily,
85
- }),
86
- ...(themeDef.letterSpacing !== undefined && {
87
- letterSpacing: themeDef.letterSpacing,
88
- }),
89
- ...(themeDef.textShadow !== undefined && { textShadow: themeDef.textShadow }),
90
- ...(themeDef.lineHeight !== undefined && { lineHeight: themeDef.lineHeight }),
91
- ...(themeDef.fontFiles !== undefined &&
92
- themeDef.fontFiles.length > 0 && {
93
- fontFiles: toAbsoluteFontUrls(themeDef.fontFiles),
94
- }),
95
- };
96
- }
47
+ const normalizeHashPath = (value: string): string => {
48
+ const hash = value.replace(/^#\/?/, '').replace(/\/+$/, '');
49
+ return hash;
50
+ };
97
51
 
98
- /**
99
- * Map registered themes to the slim shape sent to sub-apps (name, displayName, colors, optional typography for preview).
100
- */
101
- function getAvailableThemesForSettings(): SettingsAvailableTheme[] {
102
- return getAllThemes().map((theme) => ({
103
- name: theme.name,
104
- displayName: theme.displayName,
105
- colors: theme.colors,
106
- ...(theme.fontFamily !== undefined && { fontFamily: theme.fontFamily }),
107
- ...(theme.letterSpacing !== undefined && { letterSpacing: theme.letterSpacing }),
108
- ...(theme.textShadow !== undefined && { textShadow: theme.textShadow }),
109
- }));
110
- }
52
+ const isFrameForNavigationItem = (frameSrc: string, itemUrl: string): boolean => {
53
+ const frame = toAbsoluteUrl(frameSrc);
54
+ const item = toAbsoluteUrl(itemUrl);
55
+ if (!frame || !item) return false;
56
+ if (frame.origin !== item.origin) return false;
111
57
 
112
- /**
113
- * Build settings for propagation to iframes: inject navigation, full theme object,
114
- * and list of available themes so apps can render theme pickers.
115
- */
116
- function buildSettingsForPropagation(
117
- settings: Settings,
118
- config: ShellUIConfig | undefined,
119
- lang: string,
120
- ): Settings {
121
- const appearance = getResolvedAppearanceForSettings(settings, config);
122
- let result: Settings = {
123
- ...settings,
124
- appearance: appearance ?? settings.appearance,
125
- };
126
- // Inject available themes when we have a resolved appearance (themes are already registered above)
127
- if (result.appearance && typeof window !== 'undefined') {
128
- result = {
129
- ...result,
130
- appearance: {
131
- ...result.appearance,
132
- availableThemes: getAvailableThemesForSettings(),
133
- },
134
- };
58
+ const itemPathname = normalizePath(item.pathname);
59
+ const framePathname = normalizePath(frame.pathname);
60
+ if (framePathname !== itemPathname && !framePathname.startsWith(`${itemPathname}/`)) {
61
+ return false;
135
62
  }
136
- if (config?.navigation?.length) {
137
- const items: SettingsNavigationItem[] = flattenNavigationItems(config.navigation).map(
138
- (item) => ({
139
- path: item.path,
140
- url: item.url,
141
- label: resolveLabel(item.label, lang),
142
- }),
143
- );
144
- result = { ...result, navigation: { items } };
63
+
64
+ const itemHashPath = normalizeHashPath(item.hash);
65
+ if (!itemHashPath) {
66
+ return true;
145
67
  }
146
- return result;
147
- }
148
68
 
149
- const STORAGE_KEY = 'shellui:settings';
69
+ const frameHashPath = normalizeHashPath(frame.hash);
70
+ return frameHashPath === itemHashPath || frameHashPath.startsWith(`${itemHashPath}/`);
71
+ };
150
72
 
151
- // Get browser's timezone as default
152
- const getBrowserTimezone = (): string => {
153
- if (typeof window !== 'undefined' && Intl) {
154
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
155
- }
156
- return 'UTC';
73
+ const stripSensitiveUserFields = (settings: Settings): Settings => {
74
+ return {
75
+ ...settings,
76
+ accessToken: null,
77
+ };
157
78
  };
158
79
 
159
80
  const defaultAppearance: Appearance = {
160
- name: 'default',
161
- displayName: 'Default',
81
+ name: defaultTheme.name,
82
+ displayName: defaultTheme.displayName,
162
83
  mode: 'light',
163
84
  colorScheme: 'system',
164
- colors: {
165
- light: {
166
- background: '#ffffff',
167
- foreground: '#09090b',
168
- card: '#ffffff',
169
- cardForeground: '#09090b',
170
- popover: '#ffffff',
171
- popoverForeground: '#09090b',
172
- primary: '#18181b',
173
- primaryForeground: '#fafafa',
174
- secondary: '#f4f4f5',
175
- secondaryForeground: '#18181b',
176
- muted: '#f4f4f5',
177
- mutedForeground: '#71717a',
178
- accent: '#f4f4f5',
179
- accentForeground: '#18181b',
180
- destructive: '#ef4444',
181
- destructiveForeground: '#fafafa',
182
- border: '#e4e4e7',
183
- input: '#e4e4e7',
184
- ring: '#18181b',
185
- radius: '0.5rem',
186
- sidebarBackground: '#fafafa',
187
- sidebarForeground: '#09090b',
188
- sidebarPrimary: '#18181b',
189
- sidebarPrimaryForeground: '#fafafa',
190
- sidebarAccent: '#e4e4e7',
191
- sidebarAccentForeground: '#18181b',
192
- sidebarBorder: '#e4e4e7',
193
- sidebarRing: '#18181b',
194
- },
195
- dark: {
196
- background: '#09090b',
197
- foreground: '#fafafa',
198
- card: '#09090b',
199
- cardForeground: '#fafafa',
200
- popover: '#09090b',
201
- popoverForeground: '#fafafa',
202
- primary: '#fafafa',
203
- primaryForeground: '#18181b',
204
- secondary: '#27272a',
205
- secondaryForeground: '#fafafa',
206
- muted: '#27272a',
207
- mutedForeground: '#a1a1aa',
208
- accent: '#27272a',
209
- accentForeground: '#fafafa',
210
- destructive: '#7f1d1d',
211
- destructiveForeground: '#fafafa',
212
- border: '#27272a',
213
- input: '#27272a',
214
- ring: '#d4d4d8',
215
- radius: '0.5rem',
216
- sidebarBackground: '#09090b',
217
- sidebarForeground: '#fafafa',
218
- sidebarPrimary: '#fafafa',
219
- sidebarPrimaryForeground: '#18181b',
220
- sidebarAccent: '#27272a',
221
- sidebarAccentForeground: '#fafafa',
222
- sidebarBorder: '#27272a',
223
- sidebarRing: '#d4d4d8',
224
- },
225
- },
85
+ colors: defaultTheme.colors,
226
86
  };
227
87
 
228
88
  const defaultSettings: Settings = {
@@ -252,11 +112,15 @@ const defaultSettings: Settings = {
252
112
  serviceWorker: {
253
113
  enabled: true,
254
114
  },
115
+ user: null,
116
+ accessToken: null,
255
117
  };
256
118
 
257
119
  export function SettingsProvider({ children }: { children: ReactNode }) {
258
120
  const { config } = useConfig();
259
- const { i18n } = useTranslation();
121
+ const { user: authUser, session, syncUserPreferences, loadUserPreferences, logout } = useAuth();
122
+ const lastSyncedPreferencesRef = useRef<string | null>(null);
123
+ const loadingPreferencesRef = useRef(false);
260
124
  // Use a ref to always have current settings for message listeners (avoids closure issues)
261
125
  const settingsRef = useRef<Settings | null>(null);
262
126
  const [settings, setSettings] = useState<Settings>(() => {
@@ -318,6 +182,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
318
182
  // Migrate from legacy "caching" key if present
319
183
  enabled: parsed.serviceWorker?.enabled ?? parsed.caching?.enabled ?? true,
320
184
  },
185
+ user: parsed.user ?? null,
186
+ accessToken: null,
321
187
  };
322
188
  settingsRef.current = initialSettings;
323
189
  return initialSettings;
@@ -330,11 +196,190 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
330
196
  return defaultSettings;
331
197
  });
332
198
 
199
+ const navigationItems = useMemo<NavigationItem[]>(
200
+ () =>
201
+ config?.navigation?.flatMap((item) =>
202
+ 'title' in item && 'items' in item ? item.items : [item],
203
+ ) ?? [],
204
+ [config?.navigation],
205
+ );
206
+
207
+ const isTrustedFrameForAuthToken = useCallback(
208
+ (frameSrc: string): boolean => {
209
+ const adminUrl = config?.backend?.adminUrl?.trim();
210
+ if (adminUrl && isFrameForNavigationItem(frameSrc, adminUrl)) {
211
+ return true;
212
+ }
213
+ return navigationItems.some(
214
+ (item) => item.safeForAuthToken !== false && isFrameForNavigationItem(frameSrc, item.url),
215
+ );
216
+ },
217
+ [config?.backend?.adminUrl, navigationItems],
218
+ );
219
+
220
+ const propagateSettingsToIframes = useCallback(
221
+ (baseSettings: Settings) => {
222
+ const iframes = shellui.frameRegistry.getAllIframes();
223
+ if (iframes.length === 0) return;
224
+ const lang = baseSettings.language?.code || 'en';
225
+ for (const [uuid, iframe] of iframes) {
226
+ const frameSrc = iframe?.src ?? '';
227
+ const includeAuthAccessToken = isTrustedFrameForAuthToken(frameSrc);
228
+ const settingsToPropagate = buildSettingsForPropagation(baseSettings, config, lang, {
229
+ includeAuthAccessToken,
230
+ accessToken: session?.accessToken ?? null,
231
+ });
232
+ shellui.sendMessage({
233
+ type: 'SHELLUI_SETTINGS',
234
+ payload: { settings: settingsToPropagate },
235
+ to: [uuid],
236
+ });
237
+ }
238
+ },
239
+ [config, isTrustedFrameForAuthToken, session?.accessToken],
240
+ );
241
+
242
+ // When the shell rotates the JWT, `authUser` often does not change, so the user-sync effect
243
+ // above skips — still push `SHELLUI_SETTINGS` so trusted iframes get the new `accessToken`.
244
+ const accessTokenForChildren = session?.accessToken ?? null;
245
+ useEffect(() => {
246
+ if (typeof window === 'undefined' || window.parent !== window || !accessTokenForChildren) {
247
+ return;
248
+ }
249
+ propagateSettingsToIframes(settingsRef.current ?? defaultSettings);
250
+ }, [accessTokenForChildren, propagateSettingsToIframes]);
251
+
252
+ // Keep load/sync helpers stable across access-token rotation (refresh after preference sync).
253
+ const loadUserPreferencesRef = useRef(loadUserPreferences);
254
+ loadUserPreferencesRef.current = loadUserPreferences;
255
+ const syncUserPreferencesRef = useRef(syncUserPreferences);
256
+ syncUserPreferencesRef.current = syncUserPreferences;
257
+ const propagateSettingsToIframesRef = useRef(propagateSettingsToIframes);
258
+ propagateSettingsToIframesRef.current = propagateSettingsToIframes;
259
+
333
260
  // Keep ref in sync with state for message listeners
334
261
  useEffect(() => {
335
262
  settingsRef.current = settings;
336
263
  }, [settings]);
337
264
 
265
+ // Serialize so effect does not re-run on every new session object / rotated access_token.
266
+ const sessionUserPreferencesKey = JSON.stringify(session?.userPreferences ?? null);
267
+
268
+ useEffect(() => {
269
+ if (typeof window === 'undefined' || window.parent !== window || !session?.accessToken) {
270
+ return;
271
+ }
272
+
273
+ let cancelled = false;
274
+ loadingPreferencesRef.current = true;
275
+
276
+ const loadPreferences = async () => {
277
+ try {
278
+ const tokenPreferences = session?.userPreferences ?? null;
279
+ if (tokenPreferences) {
280
+ const currentSettings = settingsRef.current ?? defaultSettings;
281
+ const mergedSettings = mergePreferencesIntoSettings(currentSettings, tokenPreferences);
282
+ const signature = JSON.stringify(getPreferenceSnapshot(mergedSettings));
283
+ const prevSignature = JSON.stringify(getPreferenceSnapshot(currentSettings));
284
+ lastSyncedPreferencesRef.current = signature;
285
+ if (signature === prevSignature) {
286
+ logger.info('JWT app preferences match current settings; skipping state update', {
287
+ preferences: getPreferenceSnapshot(mergedSettings),
288
+ });
289
+ return;
290
+ }
291
+ settingsRef.current = mergedSettings;
292
+ setSettings(mergedSettings);
293
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(mergedSettings));
294
+ propagateSettingsToIframesRef.current(mergedSettings);
295
+ logger.info('Loaded app preferences from JWT metadata', {
296
+ preferences: getPreferenceSnapshot(mergedSettings),
297
+ });
298
+ return;
299
+ }
300
+
301
+ const preferences = await loadUserPreferencesRef.current();
302
+
303
+ if (cancelled) return;
304
+
305
+ if (!preferences) {
306
+ const currentSettings = settingsRef.current ?? defaultSettings;
307
+ const currentPreferences = getPreferenceSnapshot(currentSettings);
308
+ const signature = JSON.stringify(currentPreferences);
309
+ try {
310
+ await syncUserPreferencesRef.current(currentPreferences);
311
+ if (cancelled) return;
312
+ lastSyncedPreferencesRef.current = signature;
313
+ logger.info('No auth provider preferences found; seeded with current app preferences', {
314
+ preferences: currentPreferences,
315
+ });
316
+ } catch (error) {
317
+ if (!cancelled) {
318
+ logger.error('Failed to seed auth provider preferences from current app settings', {
319
+ error,
320
+ });
321
+ }
322
+ }
323
+ return;
324
+ }
325
+
326
+ const currentSettings = settingsRef.current ?? defaultSettings;
327
+ const mergedSettings = mergePreferencesIntoSettings(currentSettings, preferences);
328
+ const signature = JSON.stringify(getPreferenceSnapshot(mergedSettings));
329
+ const prevSignature = JSON.stringify(getPreferenceSnapshot(currentSettings));
330
+ lastSyncedPreferencesRef.current = signature;
331
+ if (signature === prevSignature) {
332
+ logger.info('Auth provider preferences match current settings; skipping state update', {
333
+ preferences: getPreferenceSnapshot(mergedSettings),
334
+ });
335
+ return;
336
+ }
337
+ settingsRef.current = mergedSettings;
338
+ setSettings(mergedSettings);
339
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(mergedSettings));
340
+
341
+ propagateSettingsToIframesRef.current(mergedSettings);
342
+
343
+ logger.info('Loaded app preferences from auth provider metadata', {
344
+ preferences: getPreferenceSnapshot(mergedSettings),
345
+ });
346
+ } catch (error) {
347
+ logger.error('Failed to load app preferences from auth provider metadata', { error });
348
+ } finally {
349
+ loadingPreferencesRef.current = false;
350
+ }
351
+ };
352
+
353
+ void loadPreferences();
354
+ return () => {
355
+ cancelled = true;
356
+ loadingPreferencesRef.current = false;
357
+ };
358
+ }, [session?.userId, sessionUserPreferencesKey]);
359
+
360
+ useEffect(() => {
361
+ if (typeof window === 'undefined' || window.parent !== window) {
362
+ return;
363
+ }
364
+
365
+ const currentSettings = settingsRef.current ?? defaultSettings;
366
+ const nextUser = toSettingsUser(authUser);
367
+ if (isSameUser(currentSettings.user, nextUser)) {
368
+ return;
369
+ }
370
+
371
+ const nextSettings = { ...currentSettings, user: nextUser };
372
+ settingsRef.current = nextSettings;
373
+ setSettings(nextSettings);
374
+
375
+ try {
376
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(nextSettings));
377
+ propagateSettingsToIframes(nextSettings);
378
+ } catch (error) {
379
+ logger.error('Failed to sync auth user into settings:', { error });
380
+ }
381
+ }, [authUser, config]);
382
+
338
383
  // Listen for settings updates from parent/other nodes
339
384
  useEffect(() => {
340
385
  if (typeof window === 'undefined') {
@@ -347,22 +392,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
347
392
  const payload = message.payload as { settings: Settings };
348
393
  const newSettings = payload.settings;
349
394
  if (newSettings) {
395
+ const shouldStripSensitiveFields = window.parent === window;
396
+ const nextSettings = shouldStripSensitiveFields
397
+ ? stripSensitiveUserFields(newSettings)
398
+ : newSettings;
350
399
  // Update localStorage with new settings value
351
- setSettings(newSettings);
400
+ settingsRef.current = nextSettings;
401
+ setSettings(nextSettings);
352
402
  if (window.parent === window) {
353
403
  try {
354
- localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
355
- // Confirm: root updated localStorage; re-inject navigation when propagating
356
- const settingsToPropagate = buildSettingsForPropagation(
357
- newSettings,
358
- config,
359
- i18n.language || 'en',
360
- );
404
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(nextSettings));
361
405
  logger.info('Root Parent received settings update', { message });
362
- shellui.propagateMessage({
363
- type: 'SHELLUI_SETTINGS',
364
- payload: { settings: settingsToPropagate },
365
- });
406
+ propagateSettingsToIframes(nextSettings);
366
407
  } catch (error) {
367
408
  logger.error('Failed to update settings from message:', { error });
368
409
  }
@@ -373,18 +414,35 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
373
414
 
374
415
  const cleanupSettingsRequested = shellui.addMessageListener(
375
416
  'SHELLUI_SETTINGS_REQUESTED',
376
- () => {
417
+ (message: ShellUIMessage) => {
377
418
  // Use ref to always get current settings (avoids stale closure)
378
419
  const currentSettings = settingsRef.current ?? defaultSettings;
379
- const settingsWithNav = buildSettingsForPropagation(
380
- currentSettings,
381
- config,
382
- i18n.language || 'en',
383
- );
384
- shellui.propagateMessage({
385
- type: 'SHELLUI_SETTINGS',
386
- payload: { settings: settingsWithNav },
387
- });
420
+ const requestingPath = (message.from ?? []).filter(Boolean);
421
+
422
+ if (requestingPath.length > 0) {
423
+ const [firstHopIframeUuid] = requestingPath;
424
+ const frame = shellui.frameRegistry
425
+ .getAllIframes()
426
+ .find(([uuid]) => uuid === firstHopIframeUuid)?.[1];
427
+ const lang = currentSettings.language?.code || 'en';
428
+ const includeAuthAccessToken = frame
429
+ ? isTrustedFrameForAuthToken(frame.src ?? '')
430
+ : false;
431
+ const settingsToPropagate = buildSettingsForPropagation(currentSettings, config, lang, {
432
+ includeAuthAccessToken,
433
+ accessToken: session?.accessToken ?? null,
434
+ });
435
+
436
+ // Route through the full parent -> child -> ... -> requester path so deep descendants
437
+ // receive settings even when they are nested more than one level under root.
438
+ shellui.sendMessage({
439
+ type: 'SHELLUI_SETTINGS',
440
+ payload: { settings: settingsToPropagate },
441
+ to: requestingPath,
442
+ });
443
+ return;
444
+ }
445
+ propagateSettingsToIframes(currentSettings);
388
446
  },
389
447
  );
390
448
 
@@ -395,7 +453,17 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
395
453
  const payload = message.payload as { settings: Settings };
396
454
  const newSettings = payload.settings;
397
455
  if (newSettings) {
398
- setSettings(newSettings);
456
+ const shouldStripSensitiveFields = window.parent === window;
457
+ const nextSettings = shouldStripSensitiveFields
458
+ ? stripSensitiveUserFields(newSettings)
459
+ : newSettings;
460
+ settingsRef.current = nextSettings;
461
+ setSettings(nextSettings);
462
+
463
+ // Forward settings down the iframe tree so deep descendants also update.
464
+ if (window.parent !== window) {
465
+ propagateSettingsToIframes(nextSettings);
466
+ }
399
467
  }
400
468
  },
401
469
  );
@@ -405,40 +473,72 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
405
473
  cleanupSettings();
406
474
  cleanupSettingsRequested();
407
475
  };
408
- }, [settings, config?.navigation, i18n.language]);
476
+ }, [config, isTrustedFrameForAuthToken, propagateSettingsToIframes, session?.accessToken]);
477
+
478
+ useEffect(() => {
479
+ if (
480
+ typeof window === 'undefined' ||
481
+ window.parent !== window ||
482
+ loadingPreferencesRef.current
483
+ ) {
484
+ return;
485
+ }
486
+
487
+ const preferences = getPreferenceSnapshot(settings);
488
+ const signature = JSON.stringify(preferences);
489
+ if (signature === lastSyncedPreferencesRef.current) {
490
+ return;
491
+ }
492
+
493
+ let cancelled = false;
494
+
495
+ const syncPreferences = async () => {
496
+ try {
497
+ await syncUserPreferencesRef.current(preferences);
498
+ if (cancelled) return;
499
+ lastSyncedPreferencesRef.current = signature;
500
+ logger.info('Synced app preferences to auth provider metadata', { preferences });
501
+ } catch (error) {
502
+ logger.error('Failed to sync app preferences to auth provider metadata', { error });
503
+ }
504
+ };
505
+
506
+ void syncPreferences();
507
+ return () => {
508
+ cancelled = true;
509
+ };
510
+ }, [settings]);
409
511
 
410
512
  // ACTIONS
411
513
  const updateSettings = useCallback(
412
514
  (updates: Partial<Settings>) => {
413
- const newSettings = { ...settings, ...updates };
515
+ const nextSettings = { ...settings, ...updates };
414
516
 
415
517
  // Update localStorage and propagate to children if we're in the root window
416
518
  if (typeof window !== 'undefined' && window.parent === window) {
417
519
  try {
520
+ const newSettings = stripSensitiveUserFields(nextSettings);
418
521
  localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
522
+ settingsRef.current = newSettings;
419
523
  setSettings(newSettings);
420
524
  // Propagate to child iframes (sendMessageToParent does nothing in root)
421
- const settingsWithNav = buildSettingsForPropagation(
422
- newSettings,
423
- config,
424
- i18n.language || 'en',
425
- );
426
- shellui.propagateMessage({
427
- type: 'SHELLUI_SETTINGS',
428
- payload: { settings: settingsWithNav },
429
- });
525
+ propagateSettingsToIframes(newSettings);
430
526
  } catch (error) {
431
527
  logger.error('Failed to update settings in localStorage:', { error });
432
528
  }
433
529
  }
530
+ if (typeof window !== 'undefined' && window.parent !== window) {
531
+ settingsRef.current = nextSettings;
532
+ setSettings(nextSettings);
533
+ }
434
534
 
435
535
  // For child iframes, send to parent (parent will propagate to siblings)
436
536
  shellui.sendMessageToParent({
437
537
  type: 'SHELLUI_SETTINGS_UPDATED',
438
- payload: { settings: newSettings },
538
+ payload: { settings: stripSensitiveUserFields(nextSettings) },
439
539
  });
440
540
  },
441
- [settings, config?.navigation, i18n.language],
541
+ [settings, propagateSettingsToIframes],
442
542
  );
443
543
 
444
544
  const updateSetting = useCallback(
@@ -458,8 +558,13 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
458
558
  // Clear all localStorage data
459
559
  if (typeof window !== 'undefined') {
460
560
  try {
561
+ // Force logout so in-memory auth state is also reset.
562
+ void logout();
563
+
461
564
  // Clear settings
462
565
  localStorage.removeItem(STORAGE_KEY);
566
+ localStorage.removeItem(AUTH_SESSION_STORAGE_KEY);
567
+ localStorage.removeItem(AUTH_LAST_USED_LOGIN_STORAGE_KEY);
463
568
 
464
569
  // Clear all other localStorage items that start with shellui:
465
570
  const keysToRemove: string[] = [];
@@ -473,20 +578,13 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
473
578
 
474
579
  // Reset settings to defaults
475
580
  const newSettings = defaultSettings;
581
+ settingsRef.current = newSettings;
476
582
  setSettings(newSettings);
477
583
 
478
584
  // If we're in the root window, update localStorage with defaults
479
585
  if (window.parent === window) {
480
586
  localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
481
- const settingsToPropagate = buildSettingsForPropagation(
482
- newSettings,
483
- config,
484
- i18n.language || 'en',
485
- );
486
- shellui.propagateMessage({
487
- type: 'SHELLUI_SETTINGS',
488
- payload: { settings: settingsToPropagate },
489
- });
587
+ propagateSettingsToIframes(newSettings);
490
588
  }
491
589
 
492
590
  // Notify parent about reset
@@ -494,13 +592,17 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
494
592
  type: 'SHELLUI_SETTINGS_UPDATED',
495
593
  payload: { settings: newSettings },
496
594
  });
595
+ shellui.sendMessageToParent({
596
+ type: 'SHELLUI_LOGOUT',
597
+ payload: {},
598
+ });
497
599
 
498
600
  logger.info('All app data has been reset');
499
601
  } catch (error) {
500
602
  logger.error('Failed to reset all data:', { error });
501
603
  }
502
604
  }
503
- }, [config?.navigation, i18n.language]);
605
+ }, [logout, propagateSettingsToIframes]);
504
606
 
505
607
  const value = useMemo(
506
608
  () => ({