@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.
- 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,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getPreferredBackendProvider } from './getPreferredBackendProvider';
|
|
3
|
+
|
|
4
|
+
describe('getPreferredBackendProvider', () => {
|
|
5
|
+
it('maps frontend aliases to backend provider ids', () => {
|
|
6
|
+
expect(getPreferredBackendProvider('x')).toBe('twitter');
|
|
7
|
+
expect(getPreferredBackendProvider('meta')).toBe('facebook');
|
|
8
|
+
expect(getPreferredBackendProvider('linkedin')).toBe('linkedin_oidc');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns normalized provider for regular providers', () => {
|
|
12
|
+
expect(getPreferredBackendProvider('github')).toBe('github');
|
|
13
|
+
expect(getPreferredBackendProvider('Google')).toBe('google');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps UI provider keys to the preferred backend provider identifier.
|
|
3
|
+
*/
|
|
4
|
+
export const getPreferredBackendProvider = (provider: string): string => {
|
|
5
|
+
const normalized = provider.toLowerCase();
|
|
6
|
+
if (normalized === 'x') return 'twitter';
|
|
7
|
+
if (normalized === 'meta') return 'facebook';
|
|
8
|
+
if (normalized === 'linkedin') return 'linkedin_oidc';
|
|
9
|
+
return normalized;
|
|
10
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getProviderVisual } from './getProviderVisual';
|
|
3
|
+
|
|
4
|
+
describe('getProviderVisual', () => {
|
|
5
|
+
it('returns branded icon metadata for known providers', () => {
|
|
6
|
+
const apple = getProviderVisual('apple');
|
|
7
|
+
expect(apple.Icon).toBeTypeOf('function');
|
|
8
|
+
expect(apple.iconClassName).toContain('dark:text-white');
|
|
9
|
+
expect(apple.badgeClassName).toContain('bg-muted');
|
|
10
|
+
|
|
11
|
+
const github = getProviderVisual('github');
|
|
12
|
+
expect(github.Icon).toBeTypeOf('function');
|
|
13
|
+
expect(github.iconClassName).toContain('text-[#24292F]');
|
|
14
|
+
|
|
15
|
+
const linkedIn = getProviderVisual('linkedin_oidc');
|
|
16
|
+
expect(linkedIn.Icon).toBeTypeOf('function');
|
|
17
|
+
expect(linkedIn.iconClassName).toContain('text-[#0A66C2]');
|
|
18
|
+
|
|
19
|
+
const ethereum = getProviderVisual('ethereum');
|
|
20
|
+
expect(ethereum.Icon).toBeTypeOf('function');
|
|
21
|
+
expect(ethereum.iconClassName).toContain('text-[#627EEA]');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns fallback visual for unknown providers', () => {
|
|
25
|
+
const fallback = getProviderVisual('custom');
|
|
26
|
+
expect(fallback.Icon).toBeTypeOf('function');
|
|
27
|
+
expect(fallback.iconClassName).toContain('text-primary');
|
|
28
|
+
expect(fallback.badgeClassName).toContain('bg-primary/10');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { IconType } from 'react-icons';
|
|
2
|
+
import {
|
|
3
|
+
FaApple,
|
|
4
|
+
FaFacebook,
|
|
5
|
+
FaGithub,
|
|
6
|
+
FaGoogle,
|
|
7
|
+
FaLinkedinIn,
|
|
8
|
+
FaMicrosoft,
|
|
9
|
+
FaQuestion,
|
|
10
|
+
FaEthereum,
|
|
11
|
+
FaXTwitter,
|
|
12
|
+
} from 'react-icons/fa6';
|
|
13
|
+
|
|
14
|
+
export type ProviderVisual = {
|
|
15
|
+
Icon: IconType;
|
|
16
|
+
iconClassName: string;
|
|
17
|
+
badgeClassName: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns lightweight visual metadata used to style provider buttons.
|
|
22
|
+
*/
|
|
23
|
+
export const getProviderVisual = (provider: string): ProviderVisual => {
|
|
24
|
+
switch (provider.toLowerCase()) {
|
|
25
|
+
case 'apple':
|
|
26
|
+
return {
|
|
27
|
+
Icon: FaApple,
|
|
28
|
+
iconClassName: 'text-black dark:text-white',
|
|
29
|
+
badgeClassName: 'bg-muted',
|
|
30
|
+
};
|
|
31
|
+
case 'google':
|
|
32
|
+
return {
|
|
33
|
+
Icon: FaGoogle,
|
|
34
|
+
iconClassName: 'text-[#DB4437] dark:text-[#F28B82]',
|
|
35
|
+
badgeClassName: 'bg-[#DB4437]/10 dark:bg-[#DB4437]/20',
|
|
36
|
+
};
|
|
37
|
+
case 'github':
|
|
38
|
+
return {
|
|
39
|
+
Icon: FaGithub,
|
|
40
|
+
iconClassName: 'text-[#24292F] dark:text-white',
|
|
41
|
+
badgeClassName: 'bg-muted',
|
|
42
|
+
};
|
|
43
|
+
case 'microsoft':
|
|
44
|
+
return {
|
|
45
|
+
Icon: FaMicrosoft,
|
|
46
|
+
iconClassName: 'text-[#0078D4] dark:text-[#5BB8FF]',
|
|
47
|
+
badgeClassName: 'bg-[#0078D4]/10 dark:bg-[#0078D4]/20',
|
|
48
|
+
};
|
|
49
|
+
case 'meta':
|
|
50
|
+
case 'facebook':
|
|
51
|
+
return {
|
|
52
|
+
Icon: FaFacebook,
|
|
53
|
+
iconClassName: 'text-[#1877F2] dark:text-[#60A5FA]',
|
|
54
|
+
badgeClassName: 'bg-[#1877F2]/10 dark:bg-[#1877F2]/20',
|
|
55
|
+
};
|
|
56
|
+
case 'x':
|
|
57
|
+
case 'twitter':
|
|
58
|
+
return {
|
|
59
|
+
Icon: FaXTwitter,
|
|
60
|
+
iconClassName: 'text-black dark:text-white',
|
|
61
|
+
badgeClassName: 'bg-muted',
|
|
62
|
+
};
|
|
63
|
+
case 'linkedin':
|
|
64
|
+
case 'linkedin_oidc':
|
|
65
|
+
return {
|
|
66
|
+
Icon: FaLinkedinIn,
|
|
67
|
+
iconClassName: 'text-[#0A66C2] dark:text-[#60A5FA]',
|
|
68
|
+
badgeClassName: 'bg-[#0A66C2]/10 dark:bg-[#0A66C2]/20',
|
|
69
|
+
};
|
|
70
|
+
case 'ethereum':
|
|
71
|
+
return {
|
|
72
|
+
Icon: FaEthereum,
|
|
73
|
+
iconClassName: 'text-[#627EEA] dark:text-[#A5B4FC]',
|
|
74
|
+
badgeClassName: 'bg-[#627EEA]/10 dark:bg-[#627EEA]/20',
|
|
75
|
+
};
|
|
76
|
+
default:
|
|
77
|
+
return {
|
|
78
|
+
Icon: FaQuestion,
|
|
79
|
+
iconClassName: 'text-primary',
|
|
80
|
+
badgeClassName: 'bg-primary/10 dark:bg-primary/20',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@shellui/sdk', () => ({
|
|
4
|
+
shellui: {
|
|
5
|
+
initialSettings: null,
|
|
6
|
+
},
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { shellui } from '@shellui/sdk';
|
|
10
|
+
import { getAccessTokenFromSdkSettings, getUserFromSdkSettings } from './getUserFromSdkSettings';
|
|
11
|
+
|
|
12
|
+
describe('getUserFromSdkSettings', () => {
|
|
13
|
+
it('returns null when sdk has no user', () => {
|
|
14
|
+
(shellui as { initialSettings: unknown }).initialSettings = null;
|
|
15
|
+
expect(getUserFromSdkSettings()).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns user from sdk initial settings', () => {
|
|
19
|
+
(shellui as { initialSettings: unknown }).initialSettings = {
|
|
20
|
+
user: {
|
|
21
|
+
id: 'u1',
|
|
22
|
+
email: 'u1@example.com',
|
|
23
|
+
},
|
|
24
|
+
accessToken: 'jwt.from.initial.settings',
|
|
25
|
+
};
|
|
26
|
+
expect(getUserFromSdkSettings()).toEqual({
|
|
27
|
+
id: 'u1',
|
|
28
|
+
email: 'u1@example.com',
|
|
29
|
+
});
|
|
30
|
+
expect(getAccessTokenFromSdkSettings()).toBe('jwt.from.initial.settings');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { shellui, type Settings, type SettingsUser } from '@shellui/sdk';
|
|
2
|
+
|
|
3
|
+
// Retrieves the current user from ShellUI initial settings when available.
|
|
4
|
+
export const getUserFromSdkSettings = (): SettingsUser | null => {
|
|
5
|
+
const initialSettings = shellui.initialSettings as Settings | null;
|
|
6
|
+
return initialSettings?.user ?? null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Retrieves the current access token from ShellUI initial settings when available.
|
|
10
|
+
export const getAccessTokenFromSdkSettings = (): string | null => {
|
|
11
|
+
const initialSettings = shellui.initialSettings as Settings | null;
|
|
12
|
+
return initialSettings?.accessToken ?? null;
|
|
13
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
getShellUILoginClientTimezone,
|
|
3
|
+
getShellUILoginCompanyId,
|
|
4
|
+
getShellUILoginDeviceId,
|
|
5
|
+
} from './clientLoginContext';
|
|
6
|
+
export { buildSessionFromParams } from './buildSessionFromParams';
|
|
7
|
+
export { clearStoredAuthSession } from './clearStoredAuthSession';
|
|
8
|
+
export { decodeJwtPayload } from './decodeJwtPayload';
|
|
9
|
+
export { formatProviderLabel } from './formatProviderLabel';
|
|
10
|
+
export { getAccessTokenFromSdkSettings, getUserFromSdkSettings } from './getUserFromSdkSettings';
|
|
11
|
+
export { getOAuthProviderCandidates } from './getOAuthProviderCandidates';
|
|
12
|
+
export { getPreferredBackendProvider } from './getPreferredBackendProvider';
|
|
13
|
+
export { getProviderVisual } from './getProviderVisual';
|
|
14
|
+
export { isLoginMethod } from './isLoginMethod';
|
|
15
|
+
export { isSessionExpired } from './isSessionExpired';
|
|
16
|
+
export { normalizeAuthSettings } from './normalizeAuthSettings';
|
|
17
|
+
export { normalizeNextPath } from './normalizeNextPath';
|
|
18
|
+
export { normalizeRedirectPath } from './normalizeRedirectPath';
|
|
19
|
+
export { persistAuthSession } from './persistAuthSession';
|
|
20
|
+
export { readStoredAuthSession } from './readStoredAuthSession';
|
|
21
|
+
export { toAuthSessionFromSettingsUser } from './toAuthSessionFromSettingsUser';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isLoginMethod } from './isLoginMethod';
|
|
3
|
+
|
|
4
|
+
describe('isLoginMethod', () => {
|
|
5
|
+
it('returns true for supported auth methods', () => {
|
|
6
|
+
expect(isLoginMethod('password')).toBe(true);
|
|
7
|
+
expect(isLoginMethod('oauth')).toBe(true);
|
|
8
|
+
expect(isLoginMethod('magic_link')).toBe(true);
|
|
9
|
+
expect(isLoginMethod('web3')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns false for unsupported values', () => {
|
|
13
|
+
expect(isLoginMethod('sms')).toBe(false);
|
|
14
|
+
expect(isLoginMethod(null)).toBe(false);
|
|
15
|
+
expect(isLoginMethod(undefined)).toBe(false);
|
|
16
|
+
expect(isLoginMethod(123)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { LoginMethod } from '../types';
|
|
2
|
+
|
|
3
|
+
// Checks whether a value is a supported auth login method.
|
|
4
|
+
export const isLoginMethod = (value: unknown): value is LoginMethod =>
|
|
5
|
+
value === 'password' || value === 'oauth' || value === 'magic_link' || value === 'web3';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { isSessionExpired } from './isSessionExpired';
|
|
3
|
+
import type { AuthSession } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('isSessionExpired', () => {
|
|
6
|
+
it('returns false when session expires well in the future', () => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
9
|
+
const now = Math.floor(Date.now() / 1000);
|
|
10
|
+
const session = { expiresAt: now + 120 } as AuthSession;
|
|
11
|
+
expect(isSessionExpired(session)).toBe(false);
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns true when session is already expired or in grace window', () => {
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
18
|
+
const now = Math.floor(Date.now() / 1000);
|
|
19
|
+
const nearExpirySession = { expiresAt: now + 20 } as AuthSession;
|
|
20
|
+
expect(isSessionExpired(nearExpirySession)).toBe(true);
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeAuthSettings } from './normalizeAuthSettings';
|
|
3
|
+
|
|
4
|
+
describe('normalizeAuthSettings', () => {
|
|
5
|
+
it('normalizes settings payload to supported methods and oauth providers', () => {
|
|
6
|
+
const payload = {
|
|
7
|
+
methods: ['password', 'oauth', 'unknown'],
|
|
8
|
+
external: {
|
|
9
|
+
github: true,
|
|
10
|
+
google: true,
|
|
11
|
+
email: true,
|
|
12
|
+
},
|
|
13
|
+
enable_magic_link: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const result = normalizeAuthSettings(payload);
|
|
17
|
+
expect(result.methods).toEqual(expect.arrayContaining(['password', 'oauth', 'magic_link']));
|
|
18
|
+
expect(result.oauthProviders).toEqual(expect.arrayContaining(['github', 'google']));
|
|
19
|
+
expect(result.oauthProviders).not.toContain('email');
|
|
20
|
+
expect(result.oauthClients).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('supports array payloads and handles invalid values', () => {
|
|
24
|
+
expect(normalizeAuthSettings([])).toEqual({
|
|
25
|
+
methods: [],
|
|
26
|
+
oauthProviders: [],
|
|
27
|
+
oauthClients: [],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = normalizeAuthSettings([{ loginMethod: 'both', oauth_provider: 'GitLab' }]);
|
|
31
|
+
expect(result.methods).toEqual(expect.arrayContaining(['password', 'oauth']));
|
|
32
|
+
expect(result.oauthProviders).toEqual(['gitlab']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('detects web3-enabled providers in settings payload', () => {
|
|
36
|
+
const payload = {
|
|
37
|
+
external: {
|
|
38
|
+
ethereum: true,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const result = normalizeAuthSettings(payload);
|
|
42
|
+
expect(result.methods).toEqual(expect.arrayContaining(['web3']));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('parses oauthClients and unions provider list', () => {
|
|
46
|
+
const payload = {
|
|
47
|
+
methods: ['oauth'],
|
|
48
|
+
oauthClients: [
|
|
49
|
+
{ id: 12, provider: 'GitHub', label: 'Production' },
|
|
50
|
+
{ id: 14, provider: 'google', label: 'Staging' },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
const result = normalizeAuthSettings(payload);
|
|
54
|
+
expect(result.oauthClients).toEqual([
|
|
55
|
+
{ id: 12, provider: 'github', label: 'Production' },
|
|
56
|
+
{ id: 14, provider: 'google', label: 'Staging' },
|
|
57
|
+
]);
|
|
58
|
+
expect(result.oauthProviders).toEqual(expect.arrayContaining(['github', 'google']));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AuthSettings, LoginMethod } from '../types';
|
|
2
|
+
import { isLoginMethod } from './isLoginMethod';
|
|
3
|
+
|
|
4
|
+
const NON_OAUTH_EXTERNAL_PROVIDERS = new Set(['email', 'phone', 'sms']);
|
|
5
|
+
const WEB3_EXTERNAL_PROVIDERS = new Set(['ethereum', 'solana', 'web3_ethereum', 'web3_solana']);
|
|
6
|
+
|
|
7
|
+
// Normalizes auth settings payloads into supported login methods and providers.
|
|
8
|
+
export const normalizeAuthSettings = (payload: unknown): AuthSettings => {
|
|
9
|
+
const record = Array.isArray(payload) ? payload[0] : payload;
|
|
10
|
+
if (!record || typeof record !== 'object') {
|
|
11
|
+
return { methods: [], oauthProviders: [], oauthClients: [] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const obj = record as Record<string, unknown>;
|
|
15
|
+
const methodsFromArray = Array.isArray(obj.methods) ? obj.methods.filter(isLoginMethod) : [];
|
|
16
|
+
const methods = new Set<LoginMethod>(methodsFromArray);
|
|
17
|
+
const oauthProvidersSet = new Set<string>();
|
|
18
|
+
const oauthClients: AuthSettings['oauthClients'] = [];
|
|
19
|
+
|
|
20
|
+
if (isLoginMethod(obj.loginMethod)) methods.add(obj.loginMethod);
|
|
21
|
+
if (obj.loginMethod === 'both') {
|
|
22
|
+
methods.add('password');
|
|
23
|
+
methods.add('oauth');
|
|
24
|
+
}
|
|
25
|
+
if (obj.enable_password === true) methods.add('password');
|
|
26
|
+
if (obj.enable_oauth === true) methods.add('oauth');
|
|
27
|
+
if (obj.enable_magic_link === true) methods.add('magic_link');
|
|
28
|
+
if (obj.enable_web3 === true) methods.add('web3');
|
|
29
|
+
|
|
30
|
+
if (obj.external && typeof obj.external === 'object') {
|
|
31
|
+
const external = obj.external as Record<string, unknown>;
|
|
32
|
+
const enabledProviders = Object.entries(external)
|
|
33
|
+
.filter(([, enabled]) => enabled === true)
|
|
34
|
+
.map(([provider]) => provider);
|
|
35
|
+
if (enabledProviders.length > 0) methods.add('oauth');
|
|
36
|
+
enabledProviders
|
|
37
|
+
.filter((provider) => !NON_OAUTH_EXTERNAL_PROVIDERS.has(provider.toLowerCase()))
|
|
38
|
+
.forEach((provider) => oauthProvidersSet.add(provider.toLowerCase()));
|
|
39
|
+
if (enabledProviders.some((provider) => WEB3_EXTERNAL_PROVIDERS.has(provider.toLowerCase()))) {
|
|
40
|
+
methods.add('web3');
|
|
41
|
+
}
|
|
42
|
+
if (external.email === true || obj.disable_signup === false) methods.add('magic_link');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(obj.oauthProviders)) {
|
|
46
|
+
obj.oauthProviders
|
|
47
|
+
.filter((provider): provider is string => typeof provider === 'string')
|
|
48
|
+
.forEach((provider) => oauthProvidersSet.add(provider.toLowerCase()));
|
|
49
|
+
}
|
|
50
|
+
if (typeof obj.oauth_provider === 'string') {
|
|
51
|
+
oauthProvidersSet.add(obj.oauth_provider.toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(obj.oauthClients)) {
|
|
54
|
+
obj.oauthClients.forEach((row) => {
|
|
55
|
+
if (!row || typeof row !== 'object') return;
|
|
56
|
+
const item = row as Record<string, unknown>;
|
|
57
|
+
const id = Number(item.id);
|
|
58
|
+
const provider = typeof item.provider === 'string' ? item.provider.toLowerCase().trim() : '';
|
|
59
|
+
const label = typeof item.label === 'string' ? item.label.trim() : '';
|
|
60
|
+
if (!Number.isInteger(id) || id <= 0 || !provider || !label) return;
|
|
61
|
+
oauthClients.push({ id, provider, label });
|
|
62
|
+
oauthProvidersSet.add(provider);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
methods: Array.from(methods),
|
|
68
|
+
oauthProviders: Array.from(oauthProvidersSet),
|
|
69
|
+
oauthClients,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import urls from '../../../constants/urls';
|
|
3
|
+
import { normalizeNextPath } from './normalizeNextPath';
|
|
4
|
+
|
|
5
|
+
describe('normalizeNextPath', () => {
|
|
6
|
+
it('keeps valid absolute paths', () => {
|
|
7
|
+
expect(normalizeNextPath('/')).toBe('/');
|
|
8
|
+
expect(normalizeNextPath('/dashboard')).toBe('/dashboard');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('rejects invalid or unsafe next paths', () => {
|
|
12
|
+
expect(normalizeNextPath(null)).toBeNull();
|
|
13
|
+
expect(normalizeNextPath('dashboard')).toBeNull();
|
|
14
|
+
expect(normalizeNextPath('//evil.example.com')).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects login route loops', () => {
|
|
18
|
+
expect(normalizeNextPath(urls.login)).toBeNull();
|
|
19
|
+
expect(normalizeNextPath(`${urls.login}?next=%2Fhome`)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import urls from '../../../constants/urls';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitizes "next" redirect paths to prevent invalid or unsafe redirects.
|
|
5
|
+
*/
|
|
6
|
+
export const normalizeNextPath = (value: string | null): string | null => {
|
|
7
|
+
if (!value) return null;
|
|
8
|
+
if (!value.startsWith('/')) return null;
|
|
9
|
+
if (value.startsWith('//')) return null;
|
|
10
|
+
if (value === urls.login || value.startsWith(`${urls.login}?`)) return null;
|
|
11
|
+
return value;
|
|
12
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeRedirectPath } from './normalizeRedirectPath';
|
|
3
|
+
|
|
4
|
+
describe('normalizeRedirectPath', () => {
|
|
5
|
+
it('keeps absolute paths unchanged', () => {
|
|
6
|
+
expect(normalizeRedirectPath('/login')).toBe('/login');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('prefixes relative paths with slash', () => {
|
|
10
|
+
expect(normalizeRedirectPath('login')).toBe('/login');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { persistAuthSession } from './persistAuthSession';
|
|
3
|
+
import type { AuthSession } from '../types';
|
|
4
|
+
|
|
5
|
+
const createStorageMock = () => {
|
|
6
|
+
const data = new Map<string, string>();
|
|
7
|
+
return {
|
|
8
|
+
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
|
9
|
+
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
|
10
|
+
removeItem: vi.fn((key: string) => data.delete(key)),
|
|
11
|
+
clear: vi.fn(() => data.clear()),
|
|
12
|
+
key: vi.fn(() => null),
|
|
13
|
+
get length() {
|
|
14
|
+
return data.size;
|
|
15
|
+
},
|
|
16
|
+
} as unknown as Storage;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('persistAuthSession', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
value: createStorageMock(),
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('stores session under auth storage key', () => {
|
|
28
|
+
const session = { userId: '1' } as AuthSession;
|
|
29
|
+
persistAuthSession(session);
|
|
30
|
+
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
31
|
+
'shellui.auth.session',
|
|
32
|
+
JSON.stringify(session),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AuthSession } from '../types';
|
|
2
|
+
|
|
3
|
+
const AUTH_SESSION_STORAGE_KEY = 'shellui.auth.session';
|
|
4
|
+
|
|
5
|
+
// Persists the current auth session to local storage for browser reload recovery.
|
|
6
|
+
export const persistAuthSession = (session: AuthSession) => {
|
|
7
|
+
try {
|
|
8
|
+
localStorage.setItem(AUTH_SESSION_STORAGE_KEY, JSON.stringify(session));
|
|
9
|
+
} catch {
|
|
10
|
+
// Ignore storage errors (e.g. private mode); user can still continue in-memory.
|
|
11
|
+
}
|
|
12
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { readStoredAuthSession } from './readStoredAuthSession';
|
|
3
|
+
|
|
4
|
+
const createStorageMock = (value: string | null) =>
|
|
5
|
+
({
|
|
6
|
+
getItem: vi.fn(() => value),
|
|
7
|
+
setItem: vi.fn(),
|
|
8
|
+
removeItem: vi.fn(),
|
|
9
|
+
clear: vi.fn(),
|
|
10
|
+
key: vi.fn(() => null),
|
|
11
|
+
length: 0,
|
|
12
|
+
}) as unknown as Storage;
|
|
13
|
+
|
|
14
|
+
describe('readStoredAuthSession', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
value: createStorageMock(null),
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null when there is no stored session', () => {
|
|
23
|
+
expect(readStoredAuthSession()).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('parses and returns stored session data', () => {
|
|
27
|
+
const stored = JSON.stringify({ userId: 'u1', expiresAt: 123 });
|
|
28
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
29
|
+
configurable: true,
|
|
30
|
+
value: createStorageMock(stored),
|
|
31
|
+
});
|
|
32
|
+
expect(readStoredAuthSession()).toEqual({ userId: 'u1', expiresAt: 123 });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthSession } from '../types';
|
|
2
|
+
|
|
3
|
+
const AUTH_SESSION_STORAGE_KEY = 'shellui.auth.session';
|
|
4
|
+
|
|
5
|
+
// Reads a previously persisted auth session from local storage.
|
|
6
|
+
export const readStoredAuthSession = (): AuthSession | null => {
|
|
7
|
+
try {
|
|
8
|
+
const raw = localStorage.getItem(AUTH_SESSION_STORAGE_KEY);
|
|
9
|
+
if (!raw) return null;
|
|
10
|
+
return JSON.parse(raw) as AuthSession;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { toAuthSessionFromSettingsUser } from './toAuthSessionFromSettingsUser';
|
|
3
|
+
import type { SettingsUser } from '@shellui/sdk';
|
|
4
|
+
|
|
5
|
+
const toBase64Url = (value: string): string => Buffer.from(value, 'utf8').toString('base64url');
|
|
6
|
+
|
|
7
|
+
const shelluiJwtWithGroups = () => {
|
|
8
|
+
const payload = {
|
|
9
|
+
user_metadata: {
|
|
10
|
+
is_staff: true,
|
|
11
|
+
is_company_owner: true,
|
|
12
|
+
groups: ['team-a', 'team-b'],
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
return `header.${toBase64Url(JSON.stringify(payload))}.signature`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('toAuthSessionFromSettingsUser', () => {
|
|
19
|
+
it('maps settings user fields into an auth session', () => {
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
22
|
+
|
|
23
|
+
const settingsUser = {
|
|
24
|
+
id: 'user-1',
|
|
25
|
+
email: 'user@example.com',
|
|
26
|
+
name: 'Demo User',
|
|
27
|
+
profilePicture: 'https://example.com/avatar.png',
|
|
28
|
+
authProvider: 'google',
|
|
29
|
+
} as SettingsUser;
|
|
30
|
+
|
|
31
|
+
const session = toAuthSessionFromSettingsUser(settingsUser, 'jwt.example.token');
|
|
32
|
+
expect(session.userId).toBe('user-1');
|
|
33
|
+
expect(session.userEmail).toBe('user@example.com');
|
|
34
|
+
expect(session.userName).toBe('Demo User');
|
|
35
|
+
expect(session.userAvatarUrl).toBe('https://example.com/avatar.png');
|
|
36
|
+
expect(session.userIsStaff).toBe(false);
|
|
37
|
+
expect(session.userIsCompanyOwner).toBe(false);
|
|
38
|
+
expect(session.userGroups).toEqual([]);
|
|
39
|
+
expect(session.provider).toBe('google');
|
|
40
|
+
expect(session.tokenType).toBe('bearer');
|
|
41
|
+
expect(session.accessToken).toBe('jwt.example.token');
|
|
42
|
+
expect(session.refreshToken).toBe('');
|
|
43
|
+
expect(session.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
|
|
44
|
+
|
|
45
|
+
vi.useRealTimers();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reads groups and staff from ShellUI JWT user_metadata when present', () => {
|
|
49
|
+
const settingsUser = {
|
|
50
|
+
id: 'user-1',
|
|
51
|
+
email: 'user@example.com',
|
|
52
|
+
name: 'Demo User',
|
|
53
|
+
profilePicture: null,
|
|
54
|
+
authProvider: 'github',
|
|
55
|
+
} as SettingsUser;
|
|
56
|
+
|
|
57
|
+
const session = toAuthSessionFromSettingsUser(settingsUser, shelluiJwtWithGroups());
|
|
58
|
+
expect(session.userIsStaff).toBe(true);
|
|
59
|
+
expect(session.userIsCompanyOwner).toBe(true);
|
|
60
|
+
expect(session.userGroups).toEqual(['team-a', 'team-b']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('prefers explicit settings user groups over JWT when both are set', () => {
|
|
64
|
+
const settingsUser = {
|
|
65
|
+
id: 'user-1',
|
|
66
|
+
email: 'user@example.com',
|
|
67
|
+
name: 'Demo User',
|
|
68
|
+
profilePicture: null,
|
|
69
|
+
authProvider: 'github',
|
|
70
|
+
groups: ['from-settings', 'alpha'],
|
|
71
|
+
} as SettingsUser;
|
|
72
|
+
|
|
73
|
+
const session = toAuthSessionFromSettingsUser(settingsUser, shelluiJwtWithGroups());
|
|
74
|
+
expect(session.userGroups).toEqual(['alpha', 'from-settings']);
|
|
75
|
+
});
|
|
76
|
+
});
|