@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.
- package/package.json +9 -4
- package/src/app.tsx +12 -9
- package/src/components/ui/badge.tsx +35 -0
- package/src/components/ui/dropdown-menu.tsx +94 -0
- package/src/components/ui/sidebar.tsx +1 -1
- package/src/constants/urls.ts +8 -0
- package/src/features/admin/AdminView.tsx +154 -0
- package/src/features/admin/components/AdminForbiddenAccess.tsx +10 -0
- package/src/features/auth/AuthProvider.tsx +464 -0
- package/src/features/auth/backends/index.ts +41 -0
- package/src/features/auth/backends/shellui.ts +278 -0
- package/src/features/auth/backends/supabase.ts +300 -0
- package/src/features/auth/backends/types.ts +30 -0
- package/src/features/auth/components/LoginButton.tsx +360 -0
- package/src/features/auth/components/LoginButtonIcons.tsx +48 -0
- package/src/features/auth/components/LoginView.tsx +721 -0
- package/src/features/auth/components/OAuthCallbackView.tsx +119 -0
- package/src/features/auth/hooks/useAuth.tsx +37 -0
- package/src/features/auth/types.ts +51 -0
- package/src/features/auth/utils/buildSessionFromParams.spec.ts +61 -0
- package/src/features/auth/utils/buildSessionFromParams.ts +79 -0
- package/src/features/auth/utils/clearStoredAuthSession.spec.ts +23 -0
- package/src/features/auth/utils/clearStoredAuthSession.ts +10 -0
- package/src/features/auth/utils/clientLoginContext.spec.ts +83 -0
- package/src/features/auth/utils/clientLoginContext.ts +89 -0
- package/src/features/auth/utils/decodeJwtPayload.spec.ts +17 -0
- package/src/features/auth/utils/decodeJwtPayload.ts +24 -0
- package/src/features/auth/utils/formatProviderLabel.spec.ts +19 -0
- package/src/features/auth/utils/formatProviderLabel.ts +11 -0
- package/src/features/auth/utils/getOAuthProviderCandidates.spec.ts +16 -0
- package/src/features/auth/utils/getOAuthProviderCandidates.ts +15 -0
- package/src/features/auth/utils/getPreferredBackendProvider.spec.ts +15 -0
- package/src/features/auth/utils/getPreferredBackendProvider.ts +10 -0
- package/src/features/auth/utils/getProviderVisual.spec.ts +30 -0
- package/src/features/auth/utils/getProviderVisual.ts +83 -0
- package/src/features/auth/utils/getUserFromSdkSettings.spec.ts +32 -0
- package/src/features/auth/utils/getUserFromSdkSettings.ts +13 -0
- package/src/features/auth/utils/index.ts +21 -0
- package/src/features/auth/utils/isLoginMethod.spec.ts +18 -0
- package/src/features/auth/utils/isLoginMethod.ts +5 -0
- package/src/features/auth/utils/isSessionExpired.spec.ts +23 -0
- package/src/features/auth/utils/isSessionExpired.ts +5 -0
- package/src/features/auth/utils/normalizeAuthSettings.spec.ts +60 -0
- package/src/features/auth/utils/normalizeAuthSettings.ts +71 -0
- package/src/features/auth/utils/normalizeNextPath.spec.ts +21 -0
- package/src/features/auth/utils/normalizeNextPath.ts +12 -0
- package/src/features/auth/utils/normalizeRedirectPath.spec.ts +12 -0
- package/src/features/auth/utils/normalizeRedirectPath.ts +3 -0
- package/src/features/auth/utils/persistAuthSession.spec.ts +35 -0
- package/src/features/auth/utils/persistAuthSession.ts +12 -0
- package/src/features/auth/utils/readStoredAuthSession.spec.ts +34 -0
- package/src/features/auth/utils/readStoredAuthSession.ts +14 -0
- package/src/features/auth/utils/toAuthSessionFromSettingsUser.spec.ts +76 -0
- package/src/features/auth/utils/toAuthSessionFromSettingsUser.ts +36 -0
- package/src/features/config/types.ts +55 -0
- package/src/features/layouts/AppLayout.tsx +8 -6
- package/src/features/layouts/appbar/AppBarLayout.tsx +42 -23
- package/src/features/layouts/fullscreen/FullscreenLayout.tsx +3 -2
- package/src/features/layouts/sidebar/SidebarInner.tsx +7 -5
- package/src/features/layouts/sidebar/SidebarLayout.tsx +16 -3
- package/src/features/layouts/utils.ts +54 -0
- package/src/features/layouts/windows/WindowsLayout.tsx +22 -4
- package/src/features/legal/LegalDocumentContent.tsx +102 -0
- package/src/features/legal/LegalDocumentView.tsx +42 -0
- package/src/features/legal/LegalDocumentsIndexView.tsx +51 -0
- package/src/features/legal/LegalDocumentsLinks.tsx +29 -0
- package/src/features/legal/legalDocuments.ts +62 -0
- package/src/features/settings/SettingsIcons.tsx +20 -0
- package/src/features/settings/SettingsProvider.tsx +347 -245
- package/src/features/settings/SettingsRoutes.tsx +8 -0
- package/src/features/settings/SettingsView.tsx +43 -8
- package/src/features/settings/components/Develop.tsx +2 -2
- package/src/features/settings/components/LegalDocumentsPanel.tsx +46 -0
- package/src/features/settings/components/UserIcon.tsx +20 -0
- package/src/features/settings/components/UserSettingsPanel.tsx +438 -0
- package/src/features/settings/components/createUserSettingsRoute.tsx +43 -0
- package/src/features/settings/utils/buildSettingsForPropagation.spec.ts +167 -0
- package/src/features/settings/utils/buildSettingsForPropagation.ts +61 -0
- package/src/features/settings/utils/flattenNavigationItems.spec.ts +17 -0
- package/src/features/settings/utils/flattenNavigationItems.ts +12 -0
- package/src/features/settings/utils/getAvailableThemesForSettings.spec.ts +15 -0
- package/src/features/settings/utils/getAvailableThemesForSettings.ts +16 -0
- package/src/features/settings/utils/getBrowserTimezone.spec.ts +11 -0
- package/src/features/settings/utils/getBrowserTimezone.ts +7 -0
- package/src/features/settings/utils/getPreferenceSnapshot.spec.ts +35 -0
- package/src/features/settings/utils/getPreferenceSnapshot.ts +10 -0
- package/src/features/settings/utils/getResolvedAppearanceForSettings.spec.ts +97 -0
- package/src/features/settings/utils/getResolvedAppearanceForSettings.ts +48 -0
- package/src/features/settings/utils/index.ts +12 -0
- package/src/features/settings/utils/isSameUser.spec.ts +35 -0
- package/src/features/settings/utils/isSameUser.ts +17 -0
- package/src/features/settings/utils/mergePreferencesIntoSettings.spec.ts +108 -0
- package/src/features/settings/utils/mergePreferencesIntoSettings.ts +47 -0
- package/src/features/settings/utils/resolveColorMode.spec.ts +29 -0
- package/src/features/settings/utils/resolveColorMode.ts +6 -0
- package/src/features/settings/utils/resolveLabel.spec.ts +17 -0
- package/src/features/settings/utils/resolveLabel.ts +7 -0
- package/src/features/settings/utils/toAbsoluteFontUrls.spec.ts +26 -0
- package/src/features/settings/utils/toAbsoluteFontUrls.ts +15 -0
- package/src/features/settings/utils/toSettingsUser.spec.ts +49 -0
- package/src/features/settings/utils/toSettingsUser.ts +15 -0
- package/src/i18n/translations/en/common.json +14 -0
- package/src/i18n/translations/en/settings.json +45 -0
- package/src/i18n/translations/fr/common.json +14 -0
- package/src/i18n/translations/fr/settings.json +45 -0
- package/src/index.css +37 -0
- package/src/index.ts +6 -0
- package/src/routes/components/NavigationItemRoute.tsx +32 -1
- package/src/routes/components/NotFoundView.tsx +13 -3
- package/src/routes/hooks/useNavigationItems.ts +19 -4
- 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 {
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
42
|
-
}
|
|
38
|
+
};
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
const
|
|
48
|
-
return
|
|
49
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
69
|
+
const frameHashPath = normalizeHashPath(frame.hash);
|
|
70
|
+
return frameHashPath === itemHashPath || frameHashPath.startsWith(`${itemHashPath}/`);
|
|
71
|
+
};
|
|
150
72
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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:
|
|
161
|
-
displayName:
|
|
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 {
|
|
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
|
-
|
|
400
|
+
settingsRef.current = nextSettings;
|
|
401
|
+
setSettings(nextSettings);
|
|
352
402
|
if (window.parent === window) {
|
|
353
403
|
try {
|
|
354
|
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(
|
|
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
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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
|
-
|
|
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:
|
|
538
|
+
payload: { settings: stripSensitiveUserFields(nextSettings) },
|
|
439
539
|
});
|
|
440
540
|
},
|
|
441
|
-
[settings,
|
|
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
|
-
|
|
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
|
-
}, [
|
|
605
|
+
}, [logout, propagateSettingsToIframes]);
|
|
504
606
|
|
|
505
607
|
const value = useMemo(
|
|
506
608
|
() => ({
|