@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,119 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useLocation, useNavigate } from 'react-router';
3
+ import urls from '../../../constants/urls';
4
+ import { normalizeNextPath } from '../utils';
5
+ import { useAuth } from '../hooks/useAuth';
6
+
7
+ const toPositiveInt = (raw: string | null): number | undefined => {
8
+ if (!raw) return undefined;
9
+ const n = Number(raw);
10
+ if (!Number.isFinite(n) || n <= 0) return undefined;
11
+ return Math.trunc(n);
12
+ };
13
+
14
+ export const OAuthCallbackView = () => {
15
+ const location = useLocation();
16
+ const navigate = useNavigate();
17
+ const { completeOAuthCallback, isAuthenticated } = useAuth();
18
+ const [localError, setLocalError] = useState<string | null>(null);
19
+ const [isWorking, setIsWorking] = useState(true);
20
+ const hasStartedRef = useRef(false);
21
+ const nextPath = useMemo(() => {
22
+ const params = new URLSearchParams(location.search);
23
+ return normalizeNextPath(params.get('next')) ?? '/';
24
+ }, [location.search]);
25
+
26
+ useEffect(() => {
27
+ if (!isAuthenticated) return;
28
+ navigate(nextPath, { replace: true });
29
+ }, [isAuthenticated, navigate, nextPath]);
30
+
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+ if (hasStartedRef.current || isAuthenticated) {
34
+ return () => {
35
+ cancelled = true;
36
+ };
37
+ }
38
+ hasStartedRef.current = true;
39
+ const run = async () => {
40
+ const params = new URLSearchParams(location.search);
41
+ const oauthError = params.get('error');
42
+ if (oauthError) {
43
+ if (!cancelled) {
44
+ setLocalError(oauthError);
45
+ setIsWorking(false);
46
+ }
47
+ return;
48
+ }
49
+ const code = params.get('code');
50
+ const provider = params.get('provider');
51
+ if (!code || !provider) {
52
+ if (!cancelled) {
53
+ setLocalError('Missing OAuth callback parameters.');
54
+ setIsWorking(false);
55
+ }
56
+ return;
57
+ }
58
+ const redirectUri = `${window.location.origin}${location.pathname}${location.search}`;
59
+ const ok = await completeOAuthCallback({
60
+ provider,
61
+ code,
62
+ redirectUri,
63
+ oauthClientId: toPositiveInt(params.get('company_oauth_client_id')),
64
+ });
65
+ if (cancelled) return;
66
+ if (!ok) {
67
+ setIsWorking(false);
68
+ return;
69
+ }
70
+ navigate(nextPath, { replace: true });
71
+ };
72
+ void run();
73
+ return () => {
74
+ cancelled = true;
75
+ };
76
+ }, [
77
+ completeOAuthCallback,
78
+ isAuthenticated,
79
+ location.pathname,
80
+ location.search,
81
+ navigate,
82
+ nextPath,
83
+ ]);
84
+
85
+ const displayError = localError;
86
+ if (displayError) {
87
+ return (
88
+ <main className="flex min-h-full items-center justify-center px-6 py-10">
89
+ <div className="w-full max-w-lg space-y-3">
90
+ <h1 className="text-xl font-semibold tracking-tight text-foreground">
91
+ OAuth sign-in failed
92
+ </h1>
93
+ <p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
94
+ {displayError}
95
+ </p>
96
+ <button
97
+ type="button"
98
+ className="text-sm text-primary underline-offset-4 hover:underline"
99
+ onClick={() =>
100
+ navigate(`${urls.login}?next=${encodeURIComponent(nextPath)}`, { replace: true })
101
+ }
102
+ >
103
+ Return to login
104
+ </button>
105
+ </div>
106
+ </main>
107
+ );
108
+ }
109
+
110
+ if (isWorking) {
111
+ return (
112
+ <main className="flex min-h-full items-center justify-center px-6 py-10">
113
+ <p className="text-sm text-muted-foreground">Completing sign-in...</p>
114
+ </main>
115
+ );
116
+ }
117
+
118
+ return null;
119
+ };
@@ -0,0 +1,37 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { AuthEvent, AuthSession, AuthSettings, AuthUser, UserPreferences } from '../types';
3
+
4
+ export type { AuthSession, AuthUser } from '../types';
5
+
6
+ export interface AuthContextValue {
7
+ session: AuthSession | null;
8
+ user: AuthUser | null;
9
+ isAuthenticated: boolean;
10
+ isLoading: boolean;
11
+ error: string | null;
12
+ authEvent: AuthEvent;
13
+ clearAuthEvent: () => void;
14
+ completeOAuthCallback: (params: {
15
+ provider: string;
16
+ code: string;
17
+ redirectUri: string;
18
+ oauthClientId?: number;
19
+ }) => Promise<boolean>;
20
+ startOAuth: (provider: string, redirectPath?: string, oauthClientId?: number) => boolean;
21
+ startWeb3Ethereum: () => Promise<boolean>;
22
+ getAuthSettings: () => Promise<AuthSettings>;
23
+ sendMagicLink: (email: string, redirectPath?: string) => Promise<void>;
24
+ syncUserPreferences: (preferences: UserPreferences) => Promise<void>;
25
+ loadUserPreferences: () => Promise<UserPreferences | null>;
26
+ logout: () => Promise<void>;
27
+ }
28
+
29
+ export const AuthContext = createContext<AuthContextValue | null>(null);
30
+
31
+ export const useAuth = () => {
32
+ const context = useContext(AuthContext);
33
+ if (!context) {
34
+ throw new Error('useAuth must be used within an AuthProvider.');
35
+ }
36
+ return context;
37
+ };
@@ -0,0 +1,51 @@
1
+ export interface AuthSession {
2
+ accessToken: string;
3
+ refreshToken: string;
4
+ tokenType: string;
5
+ expiresAt: number;
6
+ provider: string | null;
7
+ userId: string | null;
8
+ userEmail: string | null;
9
+ userName: string | null;
10
+ userAvatarUrl: string | null;
11
+ userIsStaff: boolean;
12
+ /** True when JWT `user_metadata.is_company_owner` is set for the active company (ShellUI auth). */
13
+ userIsCompanyOwner: boolean;
14
+ /** Sorted unique group names from JWT `user_metadata.groups` (ShellUI auth). */
15
+ userGroups: string[];
16
+ userPreferences?: UserPreferences | null;
17
+ }
18
+
19
+ export interface AuthUser {
20
+ id: string | null;
21
+ email: string | null;
22
+ name: string | null;
23
+ profilePicture: string | null;
24
+ isStaff: boolean;
25
+ /** Company owner for the JWT tenant (`user_metadata.is_company_owner`, ShellUI auth). */
26
+ isCompanyOwner: boolean;
27
+ authProvider: string | null;
28
+ /** Group names from the access token; empty if none or not a ShellUI JWT. */
29
+ groups: string[];
30
+ }
31
+
32
+ export type AuthEvent = 'oauth_callback' | null;
33
+
34
+ export type LoginMethod = 'password' | 'oauth' | 'magic_link' | 'web3';
35
+
36
+ export interface AuthSettings {
37
+ methods: LoginMethod[];
38
+ oauthProviders: string[];
39
+ oauthClients: Array<{
40
+ id: number;
41
+ provider: string;
42
+ label: string;
43
+ }>;
44
+ }
45
+
46
+ export type UserPreferences = {
47
+ themeName?: string;
48
+ language?: string;
49
+ region?: string;
50
+ colorScheme?: 'light' | 'dark' | 'system';
51
+ };
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildSessionFromParams } from './buildSessionFromParams';
3
+
4
+ const toBase64Url = (value: string): string => Buffer.from(value, 'utf8').toString('base64url');
5
+
6
+ const createToken = () => {
7
+ const payload = {
8
+ sub: 'user-id',
9
+ email: 'user@example.com',
10
+ app_metadata: { provider: 'github' },
11
+ user_metadata: {
12
+ name: 'Jane',
13
+ avatar_url: 'https://example.com/avatar.png',
14
+ is_staff: true,
15
+ is_company_owner: true,
16
+ groups: ['editors', 'admin'],
17
+ shelluiPreferences: {
18
+ themeName: 'default',
19
+ language: 'fr',
20
+ region: 'Europe/Paris',
21
+ colorScheme: 'dark',
22
+ },
23
+ },
24
+ };
25
+ return `header.${toBase64Url(JSON.stringify(payload))}.signature`;
26
+ };
27
+
28
+ describe('buildSessionFromParams', () => {
29
+ it('returns session data when required params exist', () => {
30
+ const params = new URLSearchParams({
31
+ access_token: createToken(),
32
+ refresh_token: 'refresh-token',
33
+ expires_in: '3600',
34
+ token_type: 'bearer',
35
+ });
36
+
37
+ const session = buildSessionFromParams(params, 1_000);
38
+ expect(session).not.toBeNull();
39
+ expect(session?.refreshToken).toBe('refresh-token');
40
+ expect(session?.provider).toBe('github');
41
+ expect(session?.userId).toBe('user-id');
42
+ expect(session?.userEmail).toBe('user@example.com');
43
+ expect(session?.userName).toBe('Jane');
44
+ expect(session?.userAvatarUrl).toBe('https://example.com/avatar.png');
45
+ expect(session?.userIsStaff).toBe(true);
46
+ expect(session?.userIsCompanyOwner).toBe(true);
47
+ expect(session?.userPreferences).toEqual({
48
+ themeName: 'default',
49
+ language: 'fr',
50
+ region: 'Europe/Paris',
51
+ colorScheme: 'dark',
52
+ });
53
+ expect(session?.userGroups).toEqual(['admin', 'editors']);
54
+ expect(session?.expiresAt).toBe(4_600);
55
+ });
56
+
57
+ it('returns null when access or refresh token is missing', () => {
58
+ const params = new URLSearchParams({ access_token: 'a' });
59
+ expect(buildSessionFromParams(params, 1_000)).toBeNull();
60
+ });
61
+ });
@@ -0,0 +1,79 @@
1
+ import type { AuthSession, UserPreferences } from '../types';
2
+ import { decodeJwtPayload, normalizeJwtUserGroups } from './decodeJwtPayload';
3
+
4
+ // Builds an AuthSession from callback or refresh URL parameters.
5
+ export const buildSessionFromParams = (
6
+ params: URLSearchParams,
7
+ nowSeconds: number,
8
+ ): AuthSession | null => {
9
+ const accessToken = params.get('access_token');
10
+ const refreshToken = params.get('refresh_token');
11
+ if (!accessToken || !refreshToken) return null;
12
+
13
+ const expiresAtFromParam = Number(params.get('expires_at'));
14
+ const expiresInFromParam = Number(params.get('expires_in'));
15
+ const expiresAt =
16
+ Number.isFinite(expiresAtFromParam) && expiresAtFromParam > 0
17
+ ? expiresAtFromParam
18
+ : Number.isFinite(expiresInFromParam) && expiresInFromParam > 0
19
+ ? nowSeconds + expiresInFromParam
20
+ : nowSeconds + 3600;
21
+
22
+ const tokenType = params.get('token_type') ?? 'bearer';
23
+ const payload = decodeJwtPayload(accessToken);
24
+ const appMetadata =
25
+ payload?.app_metadata && typeof payload.app_metadata === 'object'
26
+ ? (payload.app_metadata as Record<string, unknown>)
27
+ : null;
28
+ const userMetadata =
29
+ payload?.user_metadata && typeof payload.user_metadata === 'object'
30
+ ? (payload.user_metadata as Record<string, unknown>)
31
+ : null;
32
+ const rawPreferences =
33
+ userMetadata?.shelluiPreferences && typeof userMetadata.shelluiPreferences === 'object'
34
+ ? (userMetadata.shelluiPreferences as Record<string, unknown>)
35
+ : null;
36
+ const preferences: UserPreferences | null = rawPreferences
37
+ ? {
38
+ ...(typeof rawPreferences.themeName === 'string'
39
+ ? { themeName: rawPreferences.themeName }
40
+ : {}),
41
+ ...(rawPreferences.language === 'en' || rawPreferences.language === 'fr'
42
+ ? { language: rawPreferences.language as 'en' | 'fr' }
43
+ : {}),
44
+ ...(typeof rawPreferences.region === 'string' ? { region: rawPreferences.region } : {}),
45
+ ...(rawPreferences.colorScheme === 'light' ||
46
+ rawPreferences.colorScheme === 'dark' ||
47
+ rawPreferences.colorScheme === 'system'
48
+ ? { colorScheme: rawPreferences.colorScheme as 'light' | 'dark' | 'system' }
49
+ : {}),
50
+ }
51
+ : null;
52
+ const userId =
53
+ typeof payload?.sub === 'string'
54
+ ? payload.sub
55
+ : typeof payload?.user_id === 'string'
56
+ ? payload.user_id
57
+ : null;
58
+
59
+ return {
60
+ accessToken,
61
+ refreshToken,
62
+ tokenType,
63
+ expiresAt,
64
+ provider: typeof appMetadata?.provider === 'string' ? appMetadata.provider : null,
65
+ userId,
66
+ userEmail: typeof payload?.email === 'string' ? payload.email : null,
67
+ userName:
68
+ typeof userMetadata?.full_name === 'string'
69
+ ? userMetadata.full_name
70
+ : typeof userMetadata?.name === 'string'
71
+ ? userMetadata.name
72
+ : null,
73
+ userAvatarUrl: typeof userMetadata?.avatar_url === 'string' ? userMetadata.avatar_url : null,
74
+ userIsStaff: userMetadata?.is_staff === true,
75
+ userIsCompanyOwner: userMetadata?.is_company_owner === true,
76
+ userPreferences: preferences,
77
+ userGroups: normalizeJwtUserGroups(userMetadata?.groups),
78
+ };
79
+ };
@@ -0,0 +1,23 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { clearStoredAuthSession } from './clearStoredAuthSession';
3
+
4
+ describe('clearStoredAuthSession', () => {
5
+ beforeEach(() => {
6
+ Object.defineProperty(globalThis, 'localStorage', {
7
+ configurable: true,
8
+ value: {
9
+ getItem: vi.fn(),
10
+ setItem: vi.fn(),
11
+ removeItem: vi.fn(),
12
+ clear: vi.fn(),
13
+ key: vi.fn(() => null),
14
+ length: 0,
15
+ } as unknown as Storage,
16
+ });
17
+ });
18
+
19
+ it('removes auth session from storage', () => {
20
+ clearStoredAuthSession();
21
+ expect(localStorage.removeItem).toHaveBeenCalledWith('shellui.auth.session');
22
+ });
23
+ });
@@ -0,0 +1,10 @@
1
+ const AUTH_SESSION_STORAGE_KEY = 'shellui.auth.session';
2
+
3
+ // Removes the persisted auth session from local storage.
4
+ export const clearStoredAuthSession = () => {
5
+ try {
6
+ localStorage.removeItem(AUTH_SESSION_STORAGE_KEY);
7
+ } catch {
8
+ // Ignore storage errors.
9
+ }
10
+ };
@@ -0,0 +1,83 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getShellUILoginClientTimezone, getShellUILoginDeviceId } from './clientLoginContext';
3
+
4
+ const createStorageMock = (store: Record<string, string>) =>
5
+ ({
6
+ getItem: vi.fn((k: string) => store[k] ?? null),
7
+ setItem: vi.fn((k: string, v: string) => {
8
+ store[k] = v;
9
+ }),
10
+ removeItem: vi.fn((k: string) => {
11
+ delete store[k];
12
+ }),
13
+ clear: vi.fn(() => {
14
+ for (const k of Object.keys(store)) delete store[k];
15
+ }),
16
+ key: vi.fn(() => null),
17
+ length: 0,
18
+ }) as unknown as Storage;
19
+
20
+ describe('getShellUILoginClientTimezone', () => {
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ it('returns empty string on invalid or unknown timezone', () => {
27
+ vi.stubGlobal('window', {});
28
+ vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(
29
+ () =>
30
+ ({
31
+ resolvedOptions: () => ({ timeZone: 'not a/valid zone!' }),
32
+ }) as Intl.DateTimeFormat,
33
+ );
34
+ expect(getShellUILoginClientTimezone()).toBe('');
35
+ });
36
+
37
+ it('returns normalized IANA id when valid', () => {
38
+ vi.stubGlobal('window', {});
39
+ vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(
40
+ () =>
41
+ ({
42
+ resolvedOptions: () => ({ timeZone: 'Europe/Paris' }),
43
+ }) as Intl.DateTimeFormat,
44
+ );
45
+ expect(getShellUILoginClientTimezone()).toBe('Europe/Paris');
46
+ });
47
+ });
48
+
49
+ describe('getShellUILoginDeviceId', () => {
50
+ const originalCrypto = globalThis.crypto;
51
+
52
+ beforeEach(() => {
53
+ Object.defineProperty(globalThis, 'crypto', {
54
+ configurable: true,
55
+ value: { randomUUID: () => '11111111-2222-3333-4444-555555555555' },
56
+ });
57
+ });
58
+
59
+ afterEach(() => {
60
+ Object.defineProperty(globalThis, 'crypto', {
61
+ configurable: true,
62
+ value: originalCrypto,
63
+ });
64
+ vi.unstubAllGlobals();
65
+ });
66
+
67
+ it('persists and returns a new id when missing', () => {
68
+ const store: Record<string, string> = {};
69
+ vi.stubGlobal('window', {});
70
+ vi.stubGlobal('localStorage', createStorageMock(store));
71
+ expect(getShellUILoginDeviceId()).toBe('11111111-2222-3333-4444-555555555555');
72
+ expect(store.shellui_auth_client_device_id).toBe('11111111-2222-3333-4444-555555555555');
73
+ });
74
+
75
+ it('reuses stored id when present', () => {
76
+ const store: Record<string, string> = {
77
+ shellui_auth_client_device_id: 'existing-id',
78
+ };
79
+ vi.stubGlobal('window', {});
80
+ vi.stubGlobal('localStorage', createStorageMock(store));
81
+ expect(getShellUILoginDeviceId()).toBe('existing-id');
82
+ });
83
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Client hints for shellui-auth login audit (`/api/v1/authorize` query params).
3
+ * Must stay aligned with shellui-auth `normalize_client_timezone` / device id limits.
4
+ */
5
+
6
+ const DEVICE_ID_STORAGE_KEY = 'shellui_auth_client_device_id';
7
+ const COMPANY_ID_STORAGE_KEY = 'shellui_auth_company_id';
8
+
9
+ const IANA_TZ_RE = /^[A-Za-z0-9_+/\-]{1,64}$/;
10
+
11
+ /**
12
+ * IANA timezone from the browser (coarse hint only), or '' when unavailable/invalid.
13
+ */
14
+ export function getShellUILoginClientTimezone(): string {
15
+ if (typeof window === 'undefined') {
16
+ return '';
17
+ }
18
+ try {
19
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
20
+ if (!tz || typeof tz !== 'string') {
21
+ return '';
22
+ }
23
+ const s = tz.trim();
24
+ if (!s || s.length > 64 || !IANA_TZ_RE.test(s)) {
25
+ return '';
26
+ }
27
+ return s;
28
+ } catch {
29
+ return '';
30
+ }
31
+ }
32
+
33
+ function newDeviceId(): string {
34
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
35
+ return crypto.randomUUID();
36
+ }
37
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 18)}`;
38
+ }
39
+
40
+ /**
41
+ * Stable first-party id (UUID) in localStorage, max 128 chars, or '' when unavailable.
42
+ */
43
+ export function getShellUILoginDeviceId(): string {
44
+ if (typeof window === 'undefined') {
45
+ return '';
46
+ }
47
+ try {
48
+ const existing = localStorage.getItem(DEVICE_ID_STORAGE_KEY);
49
+ if (existing) {
50
+ const trimmed = existing.trim();
51
+ if (trimmed && trimmed.length <= 128) {
52
+ return trimmed;
53
+ }
54
+ }
55
+ let created = newDeviceId();
56
+ if (created.length > 128) {
57
+ created = created.slice(0, 128);
58
+ }
59
+ localStorage.setItem(DEVICE_ID_STORAGE_KEY, created);
60
+ return created;
61
+ } catch {
62
+ return '';
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Preferred company id for tenant-scoped auth calls.
68
+ * Uses localStorage value first; if missing, persists and returns the configured fallback.
69
+ */
70
+ export function getShellUILoginCompanyId(fallbackCompanyId?: string | number): string {
71
+ const fallback = `${fallbackCompanyId ?? ''}`.trim();
72
+ const validFallback = fallback && /^\d+$/.test(fallback) ? fallback : '';
73
+ if (typeof window === 'undefined') {
74
+ return validFallback;
75
+ }
76
+ try {
77
+ const stored = localStorage.getItem(COMPANY_ID_STORAGE_KEY)?.trim() ?? '';
78
+ if (stored && /^\d+$/.test(stored)) {
79
+ return stored;
80
+ }
81
+ if (validFallback) {
82
+ localStorage.setItem(COMPANY_ID_STORAGE_KEY, validFallback);
83
+ return validFallback;
84
+ }
85
+ } catch {
86
+ return validFallback;
87
+ }
88
+ return '';
89
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { decodeJwtPayload } from './decodeJwtPayload';
3
+
4
+ const toBase64Url = (value: string): string => Buffer.from(value, 'utf8').toString('base64url');
5
+
6
+ describe('decodeJwtPayload', () => {
7
+ it('decodes payload from a valid JWT token', () => {
8
+ const payload = { sub: 'user-1', email: 'demo@example.com' };
9
+ const token = `header.${toBase64Url(JSON.stringify(payload))}.signature`;
10
+ expect(decodeJwtPayload(token)).toEqual(payload);
11
+ });
12
+
13
+ it('returns null for invalid tokens', () => {
14
+ expect(decodeJwtPayload('invalid-token')).toBeNull();
15
+ expect(decodeJwtPayload('header.badpayload.signature')).toBeNull();
16
+ });
17
+ });
@@ -0,0 +1,24 @@
1
+ // Decodes a JWT payload section into a plain object.
2
+ export const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
3
+ try {
4
+ const payloadPart = token.split('.')[1];
5
+ if (!payloadPart) return null;
6
+ const base64 = payloadPart.replace(/-/g, '+').replace(/_/g, '/');
7
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
8
+ const binary = atob(padded);
9
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
10
+ const json = new TextDecoder('utf-8').decode(bytes);
11
+ return JSON.parse(json) as Record<string, unknown>;
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+
17
+ /** Normalizes `user_metadata.groups` from a ShellUI auth JWT to sorted unique names. */
18
+ export const normalizeJwtUserGroups = (raw: unknown): string[] => {
19
+ if (!Array.isArray(raw)) return [];
20
+ const names = raw
21
+ .filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
22
+ .map((n) => n.trim());
23
+ return [...new Set(names)].sort((a, b) => a.localeCompare(b));
24
+ };
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatProviderLabel } from './formatProviderLabel';
3
+
4
+ describe('formatProviderLabel', () => {
5
+ it('formats known provider aliases', () => {
6
+ expect(formatProviderLabel('github')).toBe('GitHub');
7
+ expect(formatProviderLabel('twitter')).toBe('X');
8
+ expect(formatProviderLabel('x')).toBe('X');
9
+ expect(formatProviderLabel('facebook')).toBe('Meta');
10
+ expect(formatProviderLabel('meta')).toBe('Meta');
11
+ expect(formatProviderLabel('linkedin')).toBe('LinkedIn');
12
+ expect(formatProviderLabel('linkedin_oidc')).toBe('LinkedIn');
13
+ });
14
+
15
+ it('capitalizes unknown providers', () => {
16
+ expect(formatProviderLabel('google')).toBe('Google');
17
+ expect(formatProviderLabel('apple')).toBe('Apple');
18
+ });
19
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Converts an OAuth provider key into a user-facing display label.
3
+ */
4
+ export const formatProviderLabel = (provider: string): string => {
5
+ const key = provider.toLowerCase();
6
+ if (key === 'github') return 'GitHub';
7
+ if (key === 'x' || key === 'twitter') return 'X';
8
+ if (key === 'meta' || key === 'facebook') return 'Meta';
9
+ if (key === 'linkedin' || key === 'linkedin_oidc') return 'LinkedIn';
10
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
11
+ };
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getOAuthProviderCandidates } from './getOAuthProviderCandidates';
3
+
4
+ describe('getOAuthProviderCandidates', () => {
5
+ it('returns aliases for supported provider families', () => {
6
+ expect(getOAuthProviderCandidates('x')).toEqual(['x', 'twitter']);
7
+ expect(getOAuthProviderCandidates('twitter')).toEqual(['twitter', 'x']);
8
+ expect(getOAuthProviderCandidates('meta')).toEqual(['meta', 'facebook']);
9
+ expect(getOAuthProviderCandidates('linkedin')).toEqual(['linkedin', 'linkedin_oidc']);
10
+ });
11
+
12
+ it('returns a normalized single candidate for unknown providers', () => {
13
+ expect(getOAuthProviderCandidates('GitHub')).toEqual(['github']);
14
+ expect(getOAuthProviderCandidates('google')).toEqual(['google']);
15
+ });
16
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Expands a provider key into canonical aliases accepted by different backends.
3
+ */
4
+ export const getOAuthProviderCandidates = (provider: string): string[] => {
5
+ const normalized = provider.toLowerCase();
6
+ const aliases: Record<string, string[]> = {
7
+ x: ['x', 'twitter'],
8
+ twitter: ['twitter', 'x'],
9
+ meta: ['meta', 'facebook'],
10
+ facebook: ['facebook', 'meta'],
11
+ linkedin: ['linkedin', 'linkedin_oidc'],
12
+ linkedin_oidc: ['linkedin_oidc', 'linkedin'],
13
+ };
14
+ return aliases[normalized] ?? [normalized];
15
+ };