@shellui/core 0.2.0 → 0.3.0-beta.0

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 +143 -0
  78. package/src/features/settings/utils/buildSettingsForPropagation.ts +55 -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
@@ -0,0 +1,36 @@
1
+ import type { SettingsUser } from '@shellui/sdk';
2
+ import type { AuthSession } from '../types';
3
+ import { decodeJwtPayload, normalizeJwtUserGroups } from './decodeJwtPayload';
4
+
5
+ // Maps an SDK settings user object to the internal auth session shape.
6
+ export const toAuthSessionFromSettingsUser = (
7
+ settingsUser: SettingsUser,
8
+ accessToken: string | null = null,
9
+ ): AuthSession => {
10
+ const payload = accessToken ? decodeJwtPayload(accessToken) : null;
11
+ const userMetadata =
12
+ payload?.user_metadata && typeof payload.user_metadata === 'object'
13
+ ? (payload.user_metadata as Record<string, unknown>)
14
+ : null;
15
+ const fromJwt = userMetadata ? normalizeJwtUserGroups(userMetadata.groups) : [];
16
+ const fromSettings = Array.isArray(settingsUser.groups)
17
+ ? normalizeJwtUserGroups(settingsUser.groups)
18
+ : [];
19
+
20
+ return {
21
+ accessToken: accessToken ?? '',
22
+ refreshToken: '',
23
+ tokenType: 'bearer',
24
+ // Long-lived synthetic expiry; parent shell controls real auth.
25
+ expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
26
+ provider: settingsUser.authProvider,
27
+ userId: settingsUser.id,
28
+ userEmail: settingsUser.email,
29
+ userName: settingsUser.name,
30
+ userAvatarUrl: settingsUser.profilePicture,
31
+ userIsStaff: userMetadata?.is_staff === true,
32
+ userIsCompanyOwner: userMetadata?.is_company_owner === true,
33
+ userGroups: fromSettings.length ? fromSettings : fromJwt,
34
+ userPreferences: null,
35
+ };
36
+ };
@@ -20,6 +20,12 @@ export interface NavigationItem {
20
20
  icon?: string; // Path to SVG icon file (e.g., '/icons/book-open.svg')
21
21
  /** When true, hide this item from the sidebar and 404 page; route remains valid and item still appears in Develop settings. */
22
22
  hidden?: boolean;
23
+ /** When true, hide this item from navigation when the user is not authenticated. */
24
+ hideWhenLoggedOut?: boolean;
25
+ /** When true, navigating to this route requires authentication and redirects to login with a next URL. */
26
+ requiresAuth?: boolean;
27
+ /** When true, this item is available only when Settings > Advanced > Developer features is enabled. */
28
+ requiresDevMode?: boolean;
23
29
  /** When true, hide this item on mobile (bottom nav). Has no effect if hidden is true. */
24
30
  hiddenOnMobile?: boolean;
25
31
  /** When true, hide this item on desktop (sidebar). Has no effect if hidden is true. */
@@ -34,6 +40,12 @@ export interface NavigationItem {
34
40
  position?: 'start' | 'end';
35
41
  /** URL to display as a settings panel in Settings > Applications. When set, the nav item appears in the Applications group. */
36
42
  settings?: string;
43
+ /**
44
+ * Trust control for auth token sharing to iframe apps.
45
+ * - undefined/true: trusted (default), token can be shared
46
+ * - false: untrusted, token is never shared
47
+ */
48
+ safeForAuthToken?: boolean;
37
49
  }
38
50
 
39
51
  export interface NavigationGroup {
@@ -170,9 +182,48 @@ export interface CookieConsentConfig {
170
182
  cookies: CookieDefinition[];
171
183
  }
172
184
 
185
+ export interface LegalDocumentsConfig {
186
+ privacyPolicy?: string;
187
+ termsOfService?: string;
188
+ legalNotice?: string;
189
+ dataProcessingAgreement?: string;
190
+ }
191
+
173
192
  /** When set to 'tauri', disables service worker and hides its settings (Tauri uses a different caching system). */
174
193
  export type RuntimeType = 'browser' | 'tauri';
175
194
 
195
+ /** Supported backend providers for auth/API communication. */
196
+ export type BackendType = 'shellui' | 'supabase';
197
+
198
+ /** Supported auth login methods that can be declared in config. */
199
+ export type BackendLoginMethod = 'password' | 'oauth' | 'magic_link' | 'web3';
200
+
201
+ /** Optional login capabilities declared by app config for immediate UI rendering. */
202
+ export interface BackendLoginConfig {
203
+ /** Enabled login methods shown by the Login view. */
204
+ methods?: BackendLoginMethod[];
205
+ /** OAuth providers used when oauth is enabled (e.g. ["github"]). */
206
+ oauthProviders?: string[];
207
+ }
208
+
209
+ /** Backend API configuration. */
210
+ export interface BackendConfig {
211
+ /** Backend provider type. */
212
+ type: BackendType;
213
+ /** Base URL used to access backend APIs. */
214
+ url: string;
215
+ /** Admin route pathname (e.g. "/admin"). Used as Shell route path for embedded admin panel. */
216
+ adminPathname?: string;
217
+ /** Admin content URL loaded in the admin route view (e.g. "https://example.com/admin"). */
218
+ adminUrl?: string;
219
+ /** Optional Supabase publishable key (public key). */
220
+ publishableKey?: string;
221
+ /** Optional login capabilities used by frontend for immediate rendering. */
222
+ login?: BackendLoginConfig;
223
+ /** Optional tenant id used for multi-tenant shellui-auth calls. */
224
+ companyId?: string | number;
225
+ }
226
+
176
227
  export interface ShellUIConfig {
177
228
  port?: number;
178
229
  title?: string;
@@ -196,6 +247,10 @@ export interface ShellUIConfig {
196
247
  defaultTheme?: string; // Default theme name to use
197
248
  /** Sentry error reporting. Load from env (e.g. SENTRY_DSN). Only active in production builds. */
198
249
  sentry?: SentryConfig;
250
+ /** Backend communication config. Defaults to undefined (no backend integration). */
251
+ backend?: BackendConfig;
199
252
  /** Cookie consent: list of cookies by category; accepted ids are stored in settings. */
200
253
  cookieConsent?: CookieConsentConfig;
254
+ /** Legal documents content rendered as markdown. */
255
+ legalDocuments?: LegalDocumentsConfig;
201
256
  }
@@ -25,7 +25,8 @@ interface AppLayoutProps {
25
25
  title?: string;
26
26
  appIcon?: string;
27
27
  logo?: string;
28
- navigation: (NavigationItem | NavigationGroup)[];
28
+ navigation?: (NavigationItem | NavigationGroup)[];
29
+ children?: React.ReactNode;
29
30
  }
30
31
 
31
32
  /** Renders the layout based on settings.layout (override) or config.layout: 'sidebar' (default), 'fullscreen', or 'windows'. Lazy-loads only the active layout. */
@@ -35,6 +36,7 @@ export function AppLayout({
35
36
  appIcon,
36
37
  logo,
37
38
  navigation,
39
+ children,
38
40
  }: AppLayoutProps) {
39
41
  const { settings } = useSettings();
40
42
  const effectiveLayout: LayoutType = settings.layout ?? layout;
@@ -45,16 +47,16 @@ export function AppLayout({
45
47
 
46
48
  if (effectiveLayout === 'fullscreen') {
47
49
  LayoutComponent = FullscreenLayout;
48
- layoutProps = { title, navigation };
50
+ layoutProps = { title, navigation: navigation || [], children };
49
51
  } else if (effectiveLayout === 'windows') {
50
52
  LayoutComponent = WindowsLayout;
51
- layoutProps = { title, appIcon, logo, navigation };
53
+ layoutProps = { title, appIcon, logo, navigation: navigation || [] };
52
54
  } else if (effectiveLayout === 'app-bar') {
53
55
  LayoutComponent = AppBarLayout;
54
- layoutProps = { title, appIcon, logo, navigation };
56
+ layoutProps = { title, appIcon, logo, navigation: navigation || [] };
55
57
  } else {
56
58
  LayoutComponent = SidebarLayout;
57
- layoutProps = { title, appIcon, logo, navigation };
59
+ layoutProps = { title, appIcon, logo, navigation: navigation || [] };
58
60
  }
59
61
  return (
60
62
  <ModalProvider>
@@ -62,7 +64,7 @@ export function AppLayout({
62
64
  <SonnerProvider>
63
65
  <OverlayShell>
64
66
  <Suspense fallback={<LayoutFallback />}>
65
- <LayoutComponent {...layoutProps} />
67
+ {children ? children : <LayoutComponent {...layoutProps} />}
66
68
  </Suspense>
67
69
  </OverlayShell>
68
70
  </SonnerProvider>
@@ -4,10 +4,12 @@ import { useTranslation } from 'react-i18next';
4
4
  import { shellui } from '@shellui/sdk';
5
5
  import type { NavigationItem, NavigationGroup } from '../../config/types';
6
6
  import {
7
+ filterNavigationForAuthState,
7
8
  filterNavigationByViewport,
8
9
  flattenNavigationItems,
9
10
  getActivePathPrefix,
10
11
  getNavPathPrefix,
12
+ hasLoginNavigationItem,
11
13
  resolveLocalizedString as resolveNavLabel,
12
14
  splitNavigationByPosition,
13
15
  withHomepageWhenNoRoot,
@@ -15,6 +17,9 @@ import {
15
17
  import { Select } from '../../../components/ui/select';
16
18
  import { AppBarTooltip, TooltipProvider } from '../../../components/ui/tooltip';
17
19
  import { cn } from '../../../lib/utils';
20
+ import { LoginButton } from '../../auth/components/LoginButton';
21
+ import { useAuth } from '../../auth/hooks/useAuth';
22
+ import { useSettings } from '../../settings/hooks/useSettings';
18
23
 
19
24
  const TOP_BAR_MAX_HEIGHT = 42;
20
25
 
@@ -142,22 +147,30 @@ function TopBarEndItem({
142
147
 
143
148
  export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
144
149
  const { i18n } = useTranslation();
150
+ const { isAuthenticated } = useAuth();
151
+ const { settings } = useSettings();
145
152
  const location = useLocation();
146
153
  const navigate = useNavigate();
147
154
  const currentLanguage = i18n.language || 'en';
155
+ const hasCustomLoginNav = useMemo(() => hasLoginNavigationItem(navigation), [navigation]);
156
+ const authAwareNavigation = useMemo(
157
+ () =>
158
+ filterNavigationForAuthState(navigation, isAuthenticated, settings.developerFeatures.enabled),
159
+ [navigation, isAuthenticated, settings.developerFeatures.enabled],
160
+ );
148
161
 
149
162
  const { endNavItems, navigationItems, displayStartItems, activePathPrefix } = useMemo(() => {
150
- const desktopNav = filterNavigationByViewport(navigation, 'desktop');
163
+ const desktopNav = filterNavigationByViewport(authAwareNavigation, 'desktop');
151
164
  const { start, end } = splitNavigationByPosition(desktopNav);
152
165
  const startItems = flattenNavigationItems(start).filter((i) => !i.hidden);
153
- const flat = flattenNavigationItems(navigation);
166
+ const flat = flattenNavigationItems(authAwareNavigation);
154
167
  return {
155
168
  endNavItems: flattenNavigationItems(end).filter((i) => !i.hidden),
156
169
  navigationItems: flat,
157
170
  displayStartItems: withHomepageWhenNoRoot(startItems),
158
171
  activePathPrefix: getActivePathPrefix(location.pathname, flat),
159
172
  };
160
- }, [navigation, location.pathname]);
173
+ }, [authAwareNavigation, location.pathname]);
161
174
 
162
175
  useEffect(() => {
163
176
  if (!title) return;
@@ -220,9 +233,9 @@ export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
220
233
  }
221
234
  }}
222
235
  >
223
- {displayStartItems.map((item) => (
236
+ {displayStartItems.map((item, index) => (
224
237
  <option
225
- key={item.path || 'root'}
238
+ key={`${item.path || 'root'}-${item.url}-${item.openIn || 'default'}-${index}`}
226
239
  value={getNavPathPrefix(item)}
227
240
  >
228
241
  {resolveNavLabel(item.label, currentLanguage) || item.path || 'Home'}
@@ -233,24 +246,30 @@ export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
233
246
 
234
247
  <div className="flex-1 min-w-0" />
235
248
 
236
- {/* End links: icon-only or first letter + tooltip */}
237
- {endNavItems.length > 0 && (
238
- <TooltipProvider
239
- delayDuration={200}
240
- skipDelayDuration={0}
241
- >
242
- <div className="flex items-center gap-0.5 shrink-0">
243
- {endNavItems.map((item) => (
244
- <TopBarEndItem
245
- key={item.path}
246
- item={item}
247
- label={resolveNavLabel(item.label, currentLanguage) || item.path || ''}
248
- activePathPrefix={activePathPrefix}
249
- />
250
- ))}
251
- </div>
252
- </TooltipProvider>
253
- )}
249
+ <div className="flex items-center gap-1 shrink-0">
250
+ {/* End links: icon-only or first letter + tooltip */}
251
+ {endNavItems.length > 0 && (
252
+ <TooltipProvider
253
+ delayDuration={200}
254
+ skipDelayDuration={0}
255
+ >
256
+ <div className="flex items-center gap-0.5">
257
+ {endNavItems.map((item) => (
258
+ <TopBarEndItem
259
+ key={item.path}
260
+ item={item}
261
+ label={resolveNavLabel(item.label, currentLanguage) || item.path || ''}
262
+ activePathPrefix={activePathPrefix}
263
+ />
264
+ ))}
265
+ </div>
266
+ </TooltipProvider>
267
+ )}
268
+ <LoginButton
269
+ variant="appbar"
270
+ hideWhenLoggedOut={hasCustomLoginNav}
271
+ />
272
+ </div>
254
273
  </header>
255
274
 
256
275
  <main className="flex-1 flex flex-col overflow-auto min-h-0">
@@ -7,6 +7,7 @@ import { flattenNavigationItems } from '../utils';
7
7
  interface FullscreenLayoutProps {
8
8
  title?: string;
9
9
  navigation: (NavigationItem | NavigationGroup)[];
10
+ children?: React.ReactNode;
10
11
  }
11
12
 
12
13
  function resolveLocalizedLabel(
@@ -18,7 +19,7 @@ function resolveLocalizedLabel(
18
19
  }
19
20
 
20
21
  /** Full-width layout with no sidebar or navigation; only content area. Modal, drawer and providers are still active. */
21
- export function FullscreenLayout({ title, navigation }: FullscreenLayoutProps) {
22
+ export function FullscreenLayout({ title, navigation, children }: FullscreenLayoutProps) {
22
23
  const location = useLocation();
23
24
  const { i18n } = useTranslation();
24
25
  const currentLanguage = i18n.language || 'en';
@@ -43,7 +44,7 @@ export function FullscreenLayout({ title, navigation }: FullscreenLayoutProps) {
43
44
 
44
45
  return (
45
46
  <main className="flex flex-col w-full h-screen overflow-hidden bg-background">
46
- <Outlet />
47
+ {children || <Outlet />}
47
48
  </main>
48
49
  );
49
50
  }
@@ -2,6 +2,7 @@ import { Link } from 'react-router';
2
2
  import type { NavigationItem, NavigationGroup } from '../../config/types';
3
3
  import { SidebarHeader, SidebarContent, SidebarFooter } from '../../../components/ui/sidebar';
4
4
  import { NavigationContent } from './NavigationContent';
5
+ import { LoginButton } from '../../auth/components/LoginButton';
5
6
 
6
7
  /** Reusable sidebar inner: header, main nav, footer. Used in desktop Sidebar and mobile Drawer. */
7
8
  export function SidebarInner({
@@ -9,11 +10,13 @@ export function SidebarInner({
9
10
  logo,
10
11
  startNav,
11
12
  endItems,
13
+ showAuthButton,
12
14
  }: {
13
15
  title?: string;
14
16
  logo?: string;
15
17
  startNav: (NavigationItem | NavigationGroup)[];
16
18
  endItems: (NavigationItem | NavigationGroup)[];
19
+ showAuthButton: boolean;
17
20
  }) {
18
21
  return (
19
22
  <>
@@ -38,11 +41,10 @@ export function SidebarInner({
38
41
  <SidebarContent className="gap-1">
39
42
  <NavigationContent navigation={startNav} />
40
43
  </SidebarContent>
41
- {endItems.length > 0 && (
42
- <SidebarFooter>
43
- <NavigationContent navigation={endItems} />
44
- </SidebarFooter>
45
- )}
44
+ <SidebarFooter>
45
+ {endItems.length > 0 && <NavigationContent navigation={endItems} />}
46
+ {showAuthButton && <LoginButton variant="sidebar" />}
47
+ </SidebarFooter>
46
48
  </>
47
49
  );
48
50
  }
@@ -4,9 +4,11 @@ import { useTranslation } from 'react-i18next';
4
4
  import { Sidebar } from '../../../components/ui/sidebar';
5
5
  import { cn } from '../../../lib/utils';
6
6
  import {
7
+ filterNavigationForAuthState,
7
8
  filterNavigationByViewport,
8
9
  filterNavigationForSidebar,
9
10
  flattenNavigationItems,
11
+ hasLoginNavigationItem,
10
12
  resolveLocalizedString as resolveLocalizedLabel,
11
13
  splitNavigationByPosition,
12
14
  } from '../utils';
@@ -14,18 +16,28 @@ import { SidebarInner } from './SidebarInner';
14
16
  import { MobileBottomNav } from './MobileBottomNav';
15
17
  import type { SidebarLayoutProps } from './types';
16
18
  import { useNavigationItems } from '../../../routes/hooks/useNavigationItems';
19
+ import { useAuth } from '../../auth/hooks/useAuth';
20
+ import { useSettings } from '../../settings/hooks/useSettings';
17
21
 
18
22
  const SidebarLayoutContent = ({ title, logo, navigation }: SidebarLayoutProps) => {
19
23
  const { i18n } = useTranslation();
24
+ const { isAuthenticated } = useAuth();
25
+ const { settings } = useSettings();
20
26
  const { navigationItem, rootItem } = useNavigationItems();
21
27
 
22
28
  const currentLanguage = useMemo(() => {
23
29
  return i18n.language || 'en';
24
30
  }, [i18n]);
25
31
 
32
+ const hasCustomLoginNav = useMemo(() => hasLoginNavigationItem(navigation), [navigation]);
33
+ const authAwareNavigation = useMemo(
34
+ () =>
35
+ filterNavigationForAuthState(navigation, isAuthenticated, settings.developerFeatures.enabled),
36
+ [navigation, isAuthenticated, settings.developerFeatures.enabled],
37
+ );
26
38
  const { startNav, endItems, mobileNavItems } = useMemo(() => {
27
- const desktopNav = filterNavigationByViewport(navigation, 'desktop');
28
- const mobileNav = filterNavigationByViewport(navigation, 'mobile');
39
+ const desktopNav = filterNavigationByViewport(authAwareNavigation, 'desktop');
40
+ const mobileNav = filterNavigationByViewport(authAwareNavigation, 'mobile');
29
41
  const { start, end } = splitNavigationByPosition(desktopNav);
30
42
  const mobileFlat = flattenNavigationItems(mobileNav);
31
43
 
@@ -34,7 +46,7 @@ const SidebarLayoutContent = ({ title, logo, navigation }: SidebarLayoutProps) =
34
46
  endItems: end,
35
47
  mobileNavItems: mobileFlat,
36
48
  };
37
- }, [navigation]);
49
+ }, [authAwareNavigation]);
38
50
 
39
51
  useEffect(() => {
40
52
  if (!title) return;
@@ -55,6 +67,7 @@ const SidebarLayoutContent = ({ title, logo, navigation }: SidebarLayoutProps) =
55
67
  logo={logo}
56
68
  startNav={startNav}
57
69
  endItems={endItems}
70
+ showAuthButton={!hasCustomLoginNav || isAuthenticated}
58
71
  />
59
72
  </Sidebar>
60
73
 
@@ -1,4 +1,5 @@
1
1
  import type { NavigationItem, NavigationGroup, LocalizedString } from '../config/types';
2
+ import urls from '../../constants/urls';
2
3
 
3
4
  /** Path prefix for a nav item: "/" for root (path '' or '/'), otherwise "/{path}". */
4
5
  export function getNavPathPrefix(item: NavigationItem): string {
@@ -133,6 +134,59 @@ export function withHomepageWhenNoRoot(items: NavigationItem[]): NavigationItem[
133
134
  return [HOMEPAGE_NAV_ITEM, ...items];
134
135
  }
135
136
 
137
+ const normalizePathForComparison = (value: string): string => {
138
+ const trimmed = value.trim();
139
+ if (!trimmed) return '';
140
+ try {
141
+ const parsed = new URL(trimmed, 'http://localhost');
142
+ return parsed.pathname.replace(/\/+$/, '') || '/';
143
+ } catch {
144
+ return trimmed.replace(/\/+$/, '') || '/';
145
+ }
146
+ };
147
+
148
+ /** True when a nav item URL points to the built-in login route. */
149
+ export function isLoginNavigationUrl(url: string): boolean {
150
+ return normalizePathForComparison(url) === urls.login;
151
+ }
152
+
153
+ /** Whether the config defines at least one navigation item targeting the login route. */
154
+ export function hasLoginNavigationItem(navigation: (NavigationItem | NavigationGroup)[]): boolean {
155
+ return flattenNavigationItems(navigation).some((item) => isLoginNavigationUrl(item.url));
156
+ }
157
+
158
+ /**
159
+ * Hide login navigation entries only when authenticated, so custom login entries
160
+ * can still be used while signed out (e.g. open login in modal/drawer).
161
+ */
162
+ export function filterNavigationForAuthState(
163
+ navigation: (NavigationItem | NavigationGroup)[],
164
+ isAuthenticated: boolean,
165
+ isDevModeEnabled = false,
166
+ ): (NavigationItem | NavigationGroup)[] {
167
+ if (navigation.length === 0) return navigation;
168
+ return navigation
169
+ .map((item) => {
170
+ if ('title' in item && 'items' in item) {
171
+ const group = item as NavigationGroup;
172
+ const visibleItems = group.items.filter((navItem) => {
173
+ if (navItem.hideWhenLoggedOut && !isAuthenticated) return false;
174
+ if (navItem.requiresDevMode && !isDevModeEnabled) return false;
175
+ if (isAuthenticated && isLoginNavigationUrl(navItem.url)) return false;
176
+ return true;
177
+ });
178
+ if (visibleItems.length === 0) return null;
179
+ return { ...group, items: visibleItems };
180
+ }
181
+ const navItem = item as NavigationItem;
182
+ if (navItem.hideWhenLoggedOut && !isAuthenticated) return null;
183
+ if (navItem.requiresDevMode && !isDevModeEnabled) return null;
184
+ if (isAuthenticated && isLoginNavigationUrl(navItem.url)) return null;
185
+ return item;
186
+ })
187
+ .filter((item): item is NavigationItem | NavigationGroup => item !== null);
188
+ }
189
+
136
190
  /** Split navigation by position: start (main content) and end (footer). */
137
191
  export function splitNavigationByPosition(navigation: (NavigationItem | NavigationGroup)[]): {
138
192
  start: (NavigationItem | NavigationGroup)[];
@@ -11,16 +11,20 @@ import { useTranslation } from 'react-i18next';
11
11
  import { shellui } from '@shellui/sdk';
12
12
  import type { NavigationItem, NavigationGroup } from '../../config/types';
13
13
  import {
14
+ filterNavigationForAuthState,
14
15
  flattenNavigationItems,
15
16
  getActivePathPrefix,
16
17
  getNavPathPrefix,
18
+ hasLoginNavigationItem,
17
19
  resolveLocalizedString as resolveNavLabel,
18
20
  splitNavigationByPosition,
19
21
  } from '../utils';
20
- import { useSettings } from '../../settings/SettingsContext';
22
+ import { useSettings } from '../../settings/hooks/useSettings';
21
23
  import { ContentView } from '../../../components/ContentView';
22
24
  import { cn } from '../../../lib/utils';
23
25
  import { Z_INDEX } from '../../../lib/z-index';
26
+ import { LoginButton } from '../../auth/components/LoginButton';
27
+ import { useAuth } from '../../auth/hooks/useAuth';
24
28
 
25
29
  interface WindowsLayoutProps {
26
30
  title?: string;
@@ -509,17 +513,24 @@ export function WindowsLayout({
509
513
  }: WindowsLayoutProps) {
510
514
  const location = useLocation();
511
515
  const { i18n } = useTranslation();
516
+ const { isAuthenticated } = useAuth();
512
517
  const { settings } = useSettings();
513
518
  const currentLanguage = i18n.language || 'en';
519
+ const hasCustomLoginNav = useMemo(() => hasLoginNavigationItem(navigation), [navigation]);
520
+ const authAwareNavigation = useMemo(
521
+ () =>
522
+ filterNavigationForAuthState(navigation, isAuthenticated, settings.developerFeatures.enabled),
523
+ [navigation, isAuthenticated, settings.developerFeatures.enabled],
524
+ );
514
525
  const timeZone = settings.region?.timezone ?? getBrowserTimezone();
515
526
  const { startNavItems, endNavItems, navigationItems } = useMemo(() => {
516
- const { start, end } = splitNavigationByPosition(navigation);
527
+ const { start, end } = splitNavigationByPosition(authAwareNavigation);
517
528
  return {
518
529
  startNavItems: flattenNavigationItems(start),
519
530
  endNavItems: end,
520
- navigationItems: flattenNavigationItems(navigation),
531
+ navigationItems: flattenNavigationItems(authAwareNavigation),
521
532
  };
522
- }, [navigation]);
533
+ }, [authAwareNavigation]);
523
534
 
524
535
  const [windows, setWindows] = useState<WindowState[]>([]);
525
536
  /** Id of the window that is on top (first plan). Clicking a window or its taskbar button sets this. */
@@ -836,6 +847,13 @@ export function WindowsLayout({
836
847
  </div>
837
848
  )}
838
849
 
850
+ <div className="flex items-center shrink-0 border-l border-sidebar-border pl-2 ml-1">
851
+ <LoginButton
852
+ variant="windows"
853
+ hideWhenLoggedOut={hasCustomLoginNav}
854
+ />
855
+ </div>
856
+
839
857
  {/* Date and time (extreme bottom right, OS-style); uses region timezone from settings */}
840
858
  <div
841
859
  className="flex flex-col items-end justify-center shrink-0 px-3 py-1 text-sidebar-foreground border-l border-sidebar-border ml-1 min-w-0"
@@ -0,0 +1,102 @@
1
+ import type { LegalDocumentDescriptor } from './legalDocuments';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import { Link } from 'react-router';
4
+ import {
5
+ Breadcrumb,
6
+ BreadcrumbItem,
7
+ BreadcrumbLink,
8
+ BreadcrumbList,
9
+ BreadcrumbPage,
10
+ BreadcrumbSeparator,
11
+ } from '../../components/ui/breadcrumb';
12
+ import urls from '../../constants/urls';
13
+
14
+ type LegalDocumentContentProps = {
15
+ document: LegalDocumentDescriptor;
16
+ };
17
+
18
+ export const LegalDocumentContent = ({ document }: LegalDocumentContentProps) => {
19
+ const isIframeView = typeof window !== 'undefined' && window.parent !== window;
20
+
21
+ return (
22
+ <article className="space-y-5">
23
+ {!isIframeView && (
24
+ <Breadcrumb>
25
+ <BreadcrumbList>
26
+ <BreadcrumbItem>
27
+ <BreadcrumbLink asChild>
28
+ <Link to={urls.legalDocuments}>Legal documents</Link>
29
+ </BreadcrumbLink>
30
+ </BreadcrumbItem>
31
+ <BreadcrumbSeparator />
32
+ <BreadcrumbItem>
33
+ <BreadcrumbPage>{document.title}</BreadcrumbPage>
34
+ </BreadcrumbItem>
35
+ </BreadcrumbList>
36
+ </Breadcrumb>
37
+ )}
38
+ <div className="max-w-none space-y-4 text-sm leading-7 text-card-foreground">
39
+ <ReactMarkdown
40
+ components={{
41
+ h1: ({ children }) => (
42
+ <h1
43
+ className="text-3xl font-semibold tracking-tight text-card-foreground"
44
+ style={{ fontFamily: 'var(--heading-font-family, inherit)' }}
45
+ >
46
+ {children}
47
+ </h1>
48
+ ),
49
+ h2: ({ children }) => (
50
+ <h2
51
+ className="mt-8 border-b pb-2 text-2xl font-semibold tracking-tight text-card-foreground first:mt-0"
52
+ style={{ fontFamily: 'var(--heading-font-family, inherit)' }}
53
+ >
54
+ {children}
55
+ </h2>
56
+ ),
57
+ h3: ({ children }) => (
58
+ <h3
59
+ className="mt-6 text-xl font-semibold tracking-tight text-card-foreground"
60
+ style={{ fontFamily: 'var(--heading-font-family, inherit)' }}
61
+ >
62
+ {children}
63
+ </h3>
64
+ ),
65
+ p: ({ children }) => (
66
+ <p className="text-sm leading-7 text-card-foreground">{children}</p>
67
+ ),
68
+ ul: ({ children }) => <ul className="my-4 ml-6 list-disc space-y-2">{children}</ul>,
69
+ ol: ({ children }) => <ol className="my-4 ml-6 list-decimal space-y-2">{children}</ol>,
70
+ li: ({ children }) => (
71
+ <li className="text-sm leading-7 text-card-foreground">{children}</li>
72
+ ),
73
+ strong: ({ children }) => (
74
+ <strong className="font-semibold text-card-foreground">{children}</strong>
75
+ ),
76
+ a: ({ href, children }) => (
77
+ <a
78
+ href={href}
79
+ target="_blank"
80
+ rel="noreferrer"
81
+ className="font-medium text-primary underline underline-offset-4"
82
+ >
83
+ {children}
84
+ </a>
85
+ ),
86
+ blockquote: ({ children }) => (
87
+ <blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground">
88
+ {children}
89
+ </blockquote>
90
+ ),
91
+ code: ({ children }) => (
92
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{children}</code>
93
+ ),
94
+ hr: () => <hr className="my-6 border-border" />,
95
+ }}
96
+ >
97
+ {document.content}
98
+ </ReactMarkdown>
99
+ </div>
100
+ </article>
101
+ );
102
+ };