@shellui/core 0.2.0-beta.5 → 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.
- 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 +143 -0
- package/src/features/settings/utils/buildSettingsForPropagation.ts +55 -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
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
}, [
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
28
|
-
const mobileNav = filterNavigationByViewport(
|
|
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
|
-
}, [
|
|
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/
|
|
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(
|
|
527
|
+
const { start, end } = splitNavigationByPosition(authAwareNavigation);
|
|
517
528
|
return {
|
|
518
529
|
startNavItems: flattenNavigationItems(start),
|
|
519
530
|
endNavItems: end,
|
|
520
|
-
navigationItems: flattenNavigationItems(
|
|
531
|
+
navigationItems: flattenNavigationItems(authAwareNavigation),
|
|
521
532
|
};
|
|
522
|
-
}, [
|
|
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
|
+
};
|