@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,6 +4,7 @@ import {
4
4
  SettingsIcon,
5
5
  CodeIcon,
6
6
  ShieldIcon,
7
+ FileTextIcon,
7
8
  PackageIcon,
8
9
  RefreshDoubleIcon,
9
10
  } from './SettingsIcons';
@@ -14,6 +15,7 @@ import { Advanced } from './components/Advanced';
14
15
  import { Develop } from './components/Develop';
15
16
  import { DataPrivacy } from './components/DataPrivacy';
16
17
  import { ServiceWorker } from './components/ServiceWorker';
18
+ import { LegalDocumentsPanel } from './components/LegalDocumentsPanel';
17
19
  import { isTauri } from '../../service-worker/register';
18
20
 
19
21
  export const createSettingsRoutes = (t: (key: string) => string) => [
@@ -47,6 +49,12 @@ export const createSettingsRoutes = (t: (key: string) => string) => [
47
49
  path: 'data-privacy',
48
50
  element: <DataPrivacy />,
49
51
  },
52
+ {
53
+ name: t('routes.legalDocuments'),
54
+ icon: FileTextIcon,
55
+ path: 'legal-documents',
56
+ element: <LegalDocumentsPanel />,
57
+ },
50
58
  {
51
59
  name: t('routes.develop'),
52
60
  icon: CodeIcon,
@@ -27,14 +27,18 @@ import { Button } from '../../components/ui/button';
27
27
  import { ChevronRightIcon, ChevronLeftIcon } from './SettingsIcons';
28
28
  import { flattenNavigationItems, resolveLocalizedString } from '../layouts/utils';
29
29
  import { ApplicationSettingsPanel } from './components/ApplicationSettingsPanel';
30
+ import { createUserSettingsRoute } from './components/createUserSettingsRoute';
30
31
  import type { NavigationItem } from '../config/types';
31
32
  import { cn } from '../../lib/utils';
33
+ import { useAuth } from '../auth/hooks/useAuth';
34
+ import { getLegalDocuments } from '../legal/legalDocuments';
32
35
 
33
36
  export const SettingsView = () => {
34
37
  const location = useLocation();
35
38
  const navigate = useNavigate();
36
39
  const { settings } = useSettings();
37
40
  const { config } = useConfig();
41
+ const { user, session, logout } = useAuth();
38
42
  const { t, i18n } = useTranslation('settings');
39
43
  // Re-check isTauri after mount and after a short delay so we catch late-injected __TAURI__ in dev
40
44
  const [isTauriEnv, setIsTauriEnv] = useState(() => isTauri());
@@ -73,6 +77,13 @@ export const SettingsView = () => {
73
77
  );
74
78
  }, [settings.developerFeatures.enabled, routesWithoutTauriSw]);
75
79
 
80
+ const settingsNavRoutes = useMemo(() => {
81
+ if (getLegalDocuments(config).length > 0) {
82
+ return filteredRoutes;
83
+ }
84
+ return filteredRoutes.filter((route) => route.path !== 'legal-documents');
85
+ }, [filteredRoutes, config]);
86
+
76
87
  // Application settings from navigation items with settings URL
77
88
  const applicationRoutes = useMemo(() => {
78
89
  const lang = i18n.language || 'en';
@@ -100,10 +111,29 @@ export const SettingsView = () => {
100
111
  });
101
112
  }, [config?.navigation, i18n.language]);
102
113
 
114
+ const userRoute = useMemo(
115
+ () =>
116
+ createUserSettingsRoute(user, logout, t, {
117
+ developerModeEnabled: settings.developerFeatures.enabled,
118
+ accessToken: session?.accessToken ?? null,
119
+ settingsAccessToken: settings.accessToken ?? null,
120
+ rawUserSettings: settings.user ?? null,
121
+ }),
122
+ [
123
+ user,
124
+ logout,
125
+ t,
126
+ settings.developerFeatures.enabled,
127
+ settings.accessToken,
128
+ settings.user,
129
+ session?.accessToken,
130
+ ],
131
+ );
132
+
103
133
  // All routes (core + applications) for selection and routing
104
134
  const allRoutes = useMemo(
105
- () => [...filteredRoutes, ...applicationRoutes],
106
- [filteredRoutes, applicationRoutes],
135
+ () => [...userRoute, ...settingsNavRoutes, ...applicationRoutes],
136
+ [userRoute, settingsNavRoutes, applicationRoutes],
107
137
  );
108
138
 
109
139
  // Group routes by category
@@ -115,21 +145,26 @@ export const SettingsView = () => {
115
145
  : []),
116
146
  {
117
147
  title: t('categories.preferences'),
118
- routes: filteredRoutes.filter((route) =>
119
- ['appearance', 'language-and-region', 'data-privacy'].includes(route.path),
120
- ),
148
+ routes: [
149
+ ...settingsNavRoutes.filter((route) =>
150
+ ['appearance', 'language-and-region', 'data-privacy'].includes(route.path),
151
+ ),
152
+ ...userRoute,
153
+ ],
121
154
  },
122
155
  {
123
156
  title: t('categories.system'),
124
- routes: filteredRoutes.filter((route) => ['update-app', 'advanced'].includes(route.path)),
157
+ routes: settingsNavRoutes.filter((route) =>
158
+ ['update-app', 'advanced', 'legal-documents'].includes(route.path),
159
+ ),
125
160
  },
126
161
  {
127
162
  title: t('categories.developer'),
128
- routes: filteredRoutes.filter((route) => developerOnlyPaths.includes(route.path)),
163
+ routes: settingsNavRoutes.filter((route) => developerOnlyPaths.includes(route.path)),
129
164
  },
130
165
  ];
131
166
  return groups.filter((group) => group.routes.length > 0);
132
- }, [filteredRoutes, applicationRoutes, t]);
167
+ }, [settingsNavRoutes, applicationRoutes, userRoute, t]);
133
168
 
134
169
  // Find matching nav item by checking if URL contains or ends with the item path
135
170
  const getSelectedItemFromUrl = useCallback(() => {
@@ -126,9 +126,9 @@ export const Develop = () => {
126
126
  }}
127
127
  >
128
128
  <option value="">{t('develop.navigation.placeholder')}</option>
129
- {navItems.map((item) => (
129
+ {navItems.map((item, index) => (
130
130
  <option
131
- key={item.path || 'root'}
131
+ key={`${item.path || 'root'}-${item.url}-${item.openIn || 'default'}-${index}`}
132
132
  value={getNavPathPrefix(item)}
133
133
  >
134
134
  {resolveLocalizedString(item.label, currentLanguage) || item.path}
@@ -0,0 +1,46 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { Button } from '../../../components/ui/button';
3
+ import { useConfig } from '../../config/useConfig';
4
+ import { getLegalDocuments } from '../../legal/legalDocuments';
5
+ import { LegalDocumentContent } from '../../legal/LegalDocumentContent';
6
+
7
+ export const LegalDocumentsPanel = () => {
8
+ const { config } = useConfig();
9
+ const legalDocuments = useMemo(() => getLegalDocuments(config), [config]);
10
+ const [selectedDocumentPath, setSelectedDocumentPath] = useState<string | null>(
11
+ legalDocuments[0]?.path ?? null,
12
+ );
13
+ const selectedDocument =
14
+ legalDocuments.find((item) => item.path === selectedDocumentPath) ?? legalDocuments[0] ?? null;
15
+
16
+ if (legalDocuments.length === 0) {
17
+ return (
18
+ <div className="rounded-lg border bg-card p-4 space-y-1">
19
+ <h3
20
+ className="text-sm font-medium leading-none"
21
+ style={{ fontFamily: 'var(--heading-font-family, inherit)' }}
22
+ >
23
+ Legal documents
24
+ </h3>
25
+ <p className="text-sm text-muted-foreground">No legal documents are configured.</p>
26
+ </div>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <section className="space-y-4">
32
+ <div className="flex flex-wrap gap-2">
33
+ {legalDocuments.map((document) => (
34
+ <Button
35
+ key={document.path}
36
+ variant={selectedDocument?.path === document.path ? 'default' : 'outline'}
37
+ onClick={() => setSelectedDocumentPath(document.path)}
38
+ >
39
+ {document.title}
40
+ </Button>
41
+ ))}
42
+ </div>
43
+ {selectedDocument && <LegalDocumentContent document={selectedDocument} />}
44
+ </section>
45
+ );
46
+ };
@@ -0,0 +1,20 @@
1
+ export const UserIcon = () => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ width="16"
5
+ height="16"
6
+ viewBox="0 0 24 24"
7
+ fill="none"
8
+ stroke="currentColor"
9
+ strokeWidth="2"
10
+ strokeLinecap="round"
11
+ strokeLinejoin="round"
12
+ >
13
+ <path d="M20 21a8 8 0 1 0-16 0" />
14
+ <circle
15
+ cx="12"
16
+ cy="7"
17
+ r="4"
18
+ />
19
+ </svg>
20
+ );
@@ -0,0 +1,438 @@
1
+ import { useCallback, useMemo, useState, type ReactNode } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { shellui, type Settings } from '@shellui/sdk';
4
+ import type { AuthUser } from '../../auth/hooks/useAuth';
5
+ import { decodeJwtPayload } from '../../auth/utils/decodeJwtPayload';
6
+ import { Badge } from '../../../components/ui/badge';
7
+ import { Button } from '../../../components/ui/button';
8
+ import {
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipProvider,
12
+ TooltipTrigger,
13
+ } from '../../../components/ui/tooltip';
14
+ import { useSettings } from '../hooks/useSettings';
15
+
16
+ const JWT_TIMESTAMP_LINE = /^(?<before>\s*"(?:exp|iat)"\s*:\s*)(?<sec>\d+)(?<after>,?\s*)$/;
17
+
18
+ const formatJwtUnixTooltip = (unixSeconds: number, lang: string, timezone: string): string => {
19
+ const date = new Date(unixSeconds * 1000);
20
+ let absolute: string;
21
+ try {
22
+ absolute = new Intl.DateTimeFormat(lang, {
23
+ timeZone: timezone,
24
+ weekday: 'long',
25
+ year: 'numeric',
26
+ month: 'long',
27
+ day: 'numeric',
28
+ hour: '2-digit',
29
+ minute: '2-digit',
30
+ second: '2-digit',
31
+ timeZoneName: 'short',
32
+ }).format(date);
33
+ } catch {
34
+ try {
35
+ absolute = date.toLocaleString(lang, { timeZone: timezone });
36
+ } catch {
37
+ absolute = date.toLocaleString(lang);
38
+ }
39
+ }
40
+
41
+ const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' });
42
+ const elapsed = date.getTime() - Date.now();
43
+ const divisions: [Intl.RelativeTimeFormatUnit, number][] = [
44
+ ['year', 31536000000],
45
+ ['month', 2629800000],
46
+ ['week', 604800000],
47
+ ['day', 86400000],
48
+ ['hour', 3600000],
49
+ ['minute', 60000],
50
+ ['second', 1000],
51
+ ];
52
+
53
+ for (const [unit, ms] of divisions) {
54
+ if (Math.abs(elapsed) >= ms || unit === 'second') {
55
+ const relative = rtf.format(Math.round(elapsed / ms), unit);
56
+ return `${relative}\n${absolute}\n(${timezone})`;
57
+ }
58
+ }
59
+
60
+ return `${absolute}\n(${timezone})`;
61
+ };
62
+
63
+ const JwtTimestampInfo = ({
64
+ unixSeconds,
65
+ lang,
66
+ timezone,
67
+ ariaLabel,
68
+ }: {
69
+ unixSeconds: number;
70
+ lang: string;
71
+ timezone: string;
72
+ ariaLabel: string;
73
+ }) => {
74
+ const tooltip = formatJwtUnixTooltip(unixSeconds, lang, timezone);
75
+ return (
76
+ <Tooltip delayDuration={200}>
77
+ <TooltipTrigger asChild>
78
+ <button
79
+ type="button"
80
+ className="-mb-0.5 ml-1 inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
81
+ aria-label={ariaLabel}
82
+ >
83
+ <svg
84
+ xmlns="http://www.w3.org/2000/svg"
85
+ width="12"
86
+ height="12"
87
+ viewBox="0 0 24 24"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ strokeWidth="2"
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ aria-hidden
94
+ >
95
+ <circle
96
+ cx="12"
97
+ cy="12"
98
+ r="10"
99
+ />
100
+ <path d="M12 16v-4" />
101
+ <path d="M12 8h.01" />
102
+ </svg>
103
+ </button>
104
+ </TooltipTrigger>
105
+ <TooltipContent
106
+ side="top"
107
+ align="center"
108
+ className="max-w-xs whitespace-pre-line text-left"
109
+ >
110
+ {tooltip}
111
+ </TooltipContent>
112
+ </Tooltip>
113
+ );
114
+ };
115
+
116
+ const JwtPayloadPre = ({
117
+ jsonText,
118
+ lang,
119
+ timezone,
120
+ timestampAriaLabel,
121
+ }: {
122
+ jsonText: string;
123
+ lang: string;
124
+ timezone: string;
125
+ timestampAriaLabel: string;
126
+ }) => {
127
+ const lines = jsonText.split('\n');
128
+ return (
129
+ <pre className="mt-1 max-h-48 overflow-auto rounded bg-muted p-2 text-xs text-foreground">
130
+ {lines.map((line, index) => {
131
+ const match = line.match(JWT_TIMESTAMP_LINE);
132
+ if (!match?.groups) {
133
+ return (
134
+ <span
135
+ key={index}
136
+ className="block"
137
+ >
138
+ {line || '\u00A0'}
139
+ </span>
140
+ );
141
+ }
142
+ const { before, sec, after } = match.groups;
143
+ const unix = Number(sec);
144
+ return (
145
+ <span
146
+ key={index}
147
+ className="block"
148
+ >
149
+ {before}
150
+ {sec}
151
+ {after}
152
+ <JwtTimestampInfo
153
+ unixSeconds={unix}
154
+ lang={lang}
155
+ timezone={timezone}
156
+ ariaLabel={timestampAriaLabel}
157
+ />
158
+ </span>
159
+ );
160
+ })}
161
+ </pre>
162
+ );
163
+ };
164
+
165
+ const formatLoginMethod = (
166
+ authProvider: string | null,
167
+ t: (key: string, options?: { [key: string]: string }) => string,
168
+ ) => {
169
+ if (!authProvider) return t('userAccount.loginMethods.unknown');
170
+ const normalized = authProvider.toLowerCase();
171
+ if (normalized === 'email') return t('userAccount.loginMethods.magicLinkEmail');
172
+ if (normalized === 'github') return t('userAccount.loginMethods.github');
173
+ return authProvider.charAt(0).toUpperCase() + authProvider.slice(1);
174
+ };
175
+
176
+ export const UserSettingsPanel = ({
177
+ user,
178
+ onLogout,
179
+ developerModeEnabled,
180
+ accessToken,
181
+ settingsAccessToken,
182
+ rawUserSettings,
183
+ }: {
184
+ user: AuthUser;
185
+ onLogout: () => Promise<void>;
186
+ developerModeEnabled: boolean;
187
+ accessToken: string | null;
188
+ settingsAccessToken: string | null;
189
+ rawUserSettings: Settings['user'];
190
+ }) => {
191
+ const { t, i18n } = useTranslation('settings');
192
+ const { settings } = useSettings();
193
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
194
+ const decodedJwtPayload = useMemo(
195
+ () => (accessToken ? decodeJwtPayload(accessToken) : null),
196
+ [accessToken],
197
+ );
198
+
199
+ const lang = settings.language?.code || i18n.language || 'en';
200
+ const timezone =
201
+ settings.region?.timezone ||
202
+ (typeof Intl !== 'undefined' && Intl.DateTimeFormat().resolvedOptions().timeZone) ||
203
+ 'UTC';
204
+
205
+ const rawUserSettingsJson = useMemo(
206
+ () => JSON.stringify(rawUserSettings ?? null, null, 2),
207
+ [rawUserSettings],
208
+ );
209
+ const jwtPayloadJson = useMemo(
210
+ () =>
211
+ accessToken
212
+ ? JSON.stringify(
213
+ decodedJwtPayload ?? t('userAccount.developerDiagnostics.unableToDecodeJwtPayload'),
214
+ null,
215
+ 2,
216
+ )
217
+ : t('userAccount.developerDiagnostics.noAccessTokenAvailable'),
218
+ [accessToken, decodedJwtPayload, t],
219
+ );
220
+ const settingsAccessTokenText =
221
+ settingsAccessToken || t('userAccount.developerDiagnostics.notSharedForApp');
222
+ const handleLogout = useCallback(async () => {
223
+ setIsLoggingOut(true);
224
+ try {
225
+ shellui.sendMessageToParent({
226
+ type: 'SHELLUI_LOGOUT',
227
+ payload: {},
228
+ });
229
+ await onLogout();
230
+ } finally {
231
+ setIsLoggingOut(false);
232
+ }
233
+ }, [onLogout]);
234
+ const handleCopyDeveloperDiagnostics = useCallback(
235
+ async (label: string, value: string) => {
236
+ if (!navigator?.clipboard?.writeText) {
237
+ shellui.toast({
238
+ title: t('userAccount.clipboard.copyFailedTitle'),
239
+ description: t('userAccount.clipboard.clipboardApiUnavailable'),
240
+ type: 'error',
241
+ });
242
+ return;
243
+ }
244
+
245
+ try {
246
+ await navigator.clipboard.writeText(value);
247
+ shellui.toast({
248
+ title: t('userAccount.clipboard.copiedTitle'),
249
+ description: t('userAccount.clipboard.copiedDescription', { label }),
250
+ type: 'success',
251
+ });
252
+ } catch {
253
+ shellui.toast({
254
+ title: t('userAccount.clipboard.copyFailedTitle'),
255
+ description: t('userAccount.clipboard.unableToCopyDiagnostics'),
256
+ type: 'error',
257
+ });
258
+ }
259
+ },
260
+ [t],
261
+ );
262
+
263
+ const developerDiagnostics: ReactNode = developerModeEnabled ? (
264
+ <TooltipProvider delayDuration={200}>
265
+ <div className="rounded-lg bg-muted/40 p-3">
266
+ <h3
267
+ className="text-sm font-semibold text-foreground"
268
+ style={{ fontFamily: 'var(--heading-font-family, inherit)' }}
269
+ >
270
+ {t('userAccount.developerDiagnostics.title')}
271
+ </h3>
272
+ <div className="mt-3 space-y-3 text-sm">
273
+ <div>
274
+ <div className="flex items-center justify-between gap-2">
275
+ <p className="text-muted-foreground">
276
+ {t('userAccount.developerDiagnostics.jwtPayload')}
277
+ </p>
278
+ <Button
279
+ type="button"
280
+ variant="outline"
281
+ size="sm"
282
+ className="h-6 px-2 text-xs"
283
+ onClick={() =>
284
+ void handleCopyDeveloperDiagnostics(
285
+ t('userAccount.developerDiagnostics.jwtPayload'),
286
+ jwtPayloadJson,
287
+ )
288
+ }
289
+ >
290
+ {t('userAccount.developerDiagnostics.copy')}
291
+ </Button>
292
+ </div>
293
+ {accessToken && decodedJwtPayload ? (
294
+ <JwtPayloadPre
295
+ jsonText={jwtPayloadJson}
296
+ lang={lang}
297
+ timezone={timezone}
298
+ timestampAriaLabel={t('userAccount.developerDiagnostics.jwtTimestampTooltipAria')}
299
+ />
300
+ ) : (
301
+ <pre className="mt-1 max-h-48 overflow-auto rounded bg-muted p-2 text-xs text-foreground">
302
+ {jwtPayloadJson}
303
+ </pre>
304
+ )}
305
+ </div>
306
+ <div>
307
+ <div className="flex items-center justify-between gap-2">
308
+ <p className="text-muted-foreground">
309
+ {t('userAccount.developerDiagnostics.sharedSettingsAccessToken')}
310
+ </p>
311
+ <Button
312
+ type="button"
313
+ variant="outline"
314
+ size="sm"
315
+ className="h-6 px-2 text-xs"
316
+ onClick={() =>
317
+ void handleCopyDeveloperDiagnostics(
318
+ t('userAccount.developerDiagnostics.sharedSettingsAccessToken'),
319
+ settingsAccessTokenText,
320
+ )
321
+ }
322
+ >
323
+ {t('userAccount.developerDiagnostics.copy')}
324
+ </Button>
325
+ </div>
326
+ <pre className="mt-1 max-h-48 overflow-auto rounded bg-muted p-2 text-xs text-foreground">
327
+ {settingsAccessTokenText}
328
+ </pre>
329
+ </div>
330
+ <div>
331
+ <div className="flex items-center justify-between gap-2">
332
+ <p className="text-muted-foreground">
333
+ {t('userAccount.developerDiagnostics.rawUserSettings')}
334
+ </p>
335
+ <Button
336
+ type="button"
337
+ variant="outline"
338
+ size="sm"
339
+ className="h-6 px-2 text-xs"
340
+ onClick={() =>
341
+ void handleCopyDeveloperDiagnostics(
342
+ t('userAccount.developerDiagnostics.rawUserSettings'),
343
+ rawUserSettingsJson,
344
+ )
345
+ }
346
+ >
347
+ {t('userAccount.developerDiagnostics.copy')}
348
+ </Button>
349
+ </div>
350
+ <pre className="mt-1 max-h-48 overflow-auto rounded bg-muted p-2 text-xs text-foreground">
351
+ {rawUserSettingsJson}
352
+ </pre>
353
+ </div>
354
+ </div>
355
+ </div>
356
+ </TooltipProvider>
357
+ ) : null;
358
+
359
+ return (
360
+ <section className="max-w-xl space-y-5">
361
+ <div className="flex items-center gap-3">
362
+ {user.profilePicture ? (
363
+ <img
364
+ src={user.profilePicture}
365
+ alt={
366
+ user.name
367
+ ? t('userAccount.profile.avatarAltWithName', { name: user.name })
368
+ : t('userAccount.profile.avatarAltDefault')
369
+ }
370
+ className="h-12 w-12 rounded-full border border-border object-cover"
371
+ referrerPolicy="no-referrer"
372
+ />
373
+ ) : (
374
+ <div className="flex h-12 w-12 items-center justify-center rounded-full border border-border bg-muted text-xs text-muted-foreground">
375
+ {user.name?.charAt(0).toUpperCase() ||
376
+ user.email?.charAt(0).toUpperCase() ||
377
+ t('userAccount.profile.placeholderInitial')}
378
+ </div>
379
+ )}
380
+ <div className="min-w-0">
381
+ <p className="truncate text-base font-medium text-foreground">
382
+ {user.name || t('userAccount.profile.unknownUser')}
383
+ </p>
384
+ <p className="truncate text-sm text-muted-foreground">
385
+ {user.email || t('userAccount.profile.noEmail')}
386
+ </p>
387
+ </div>
388
+ </div>
389
+
390
+ <dl className="space-y-3 text-sm">
391
+ <div>
392
+ <dt className="text-muted-foreground">{t('userAccount.fields.name')}</dt>
393
+ <dd className="mt-0.5 text-foreground">{user.name || '-'}</dd>
394
+ </div>
395
+ <div>
396
+ <dt className="text-muted-foreground">{t('userAccount.fields.email')}</dt>
397
+ <dd className="mt-0.5 text-foreground">{user.email || '-'}</dd>
398
+ </div>
399
+ <div>
400
+ <dt className="text-muted-foreground">{t('userAccount.fields.loginMethod')}</dt>
401
+ <dd className="mt-0.5 text-foreground">{formatLoginMethod(user.authProvider, t)}</dd>
402
+ </div>
403
+ <div>
404
+ <dt className="text-muted-foreground">{t('userAccount.fields.groups')}</dt>
405
+ <dd className="mt-1.5">
406
+ {user.groups.length ? (
407
+ <div className="flex flex-wrap gap-1.5">
408
+ {user.groups.map((name) => (
409
+ <Badge
410
+ key={name}
411
+ variant="secondary"
412
+ className="font-normal"
413
+ >
414
+ {name}
415
+ </Badge>
416
+ ))}
417
+ </div>
418
+ ) : (
419
+ <span className="text-foreground">{t('userAccount.fields.noGroups')}</span>
420
+ )}
421
+ </dd>
422
+ </div>
423
+ </dl>
424
+
425
+ {developerDiagnostics}
426
+
427
+ <Button
428
+ type="button"
429
+ variant="secondary"
430
+ className="mt-4 w-full sm:w-auto"
431
+ onClick={() => void handleLogout()}
432
+ disabled={isLoggingOut}
433
+ >
434
+ {isLoggingOut ? t('userAccount.actions.loggingOut') : t('userAccount.actions.logout')}
435
+ </Button>
436
+ </section>
437
+ );
438
+ };
@@ -0,0 +1,43 @@
1
+ import type { ReactElement } from 'react';
2
+ import type { Settings } from '@shellui/sdk';
3
+ import type { AuthUser } from '../../auth/hooks/useAuth';
4
+ import { UserIcon } from './UserIcon';
5
+ import { UserSettingsPanel } from './UserSettingsPanel';
6
+
7
+ type SettingsRouteItem = {
8
+ name: string;
9
+ icon?: () => ReactElement;
10
+ path: string;
11
+ element: ReactElement;
12
+ };
13
+
14
+ export const createUserSettingsRoute = (
15
+ user: AuthUser | null,
16
+ logout: () => Promise<void>,
17
+ t: (key: string, options?: { defaultValue?: string }) => string,
18
+ options: {
19
+ developerModeEnabled: boolean;
20
+ accessToken: string | null;
21
+ settingsAccessToken: string | null;
22
+ rawUserSettings: Settings['user'];
23
+ },
24
+ ): SettingsRouteItem[] =>
25
+ user
26
+ ? [
27
+ {
28
+ name: t('routes.userAccount', { defaultValue: 'user account' }),
29
+ icon: UserIcon,
30
+ path: 'user',
31
+ element: (
32
+ <UserSettingsPanel
33
+ user={user}
34
+ onLogout={logout}
35
+ developerModeEnabled={options.developerModeEnabled}
36
+ accessToken={options.accessToken}
37
+ settingsAccessToken={options.settingsAccessToken}
38
+ rawUserSettings={options.rawUserSettings}
39
+ />
40
+ ),
41
+ },
42
+ ]
43
+ : [];