@shellui/core 0.2.0 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +167 -0
- package/src/features/settings/utils/buildSettingsForPropagation.ts +61 -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,721 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
3
|
+
import { shellui } from '@shellui/sdk';
|
|
4
|
+
import { Button } from '../../../components/ui/button';
|
|
5
|
+
import urls from '../../../constants/urls';
|
|
6
|
+
import { cn } from '../../../lib/utils';
|
|
7
|
+
import { useConfig } from '../../config/useConfig';
|
|
8
|
+
import { LegalDocumentsLinks } from '../../legal/LegalDocumentsLinks';
|
|
9
|
+
import { useAuth } from '../hooks/useAuth';
|
|
10
|
+
import type { AuthSettings, LoginMethod } from '../types';
|
|
11
|
+
import {
|
|
12
|
+
formatProviderLabel,
|
|
13
|
+
getOAuthProviderCandidates,
|
|
14
|
+
getPreferredBackendProvider,
|
|
15
|
+
getProviderVisual,
|
|
16
|
+
isLoginMethod,
|
|
17
|
+
normalizeNextPath,
|
|
18
|
+
} from '../utils';
|
|
19
|
+
|
|
20
|
+
const LAST_USED_LOGIN_STORAGE_KEY = 'shellui.auth.last_used_login';
|
|
21
|
+
|
|
22
|
+
const SHELLUI_OAUTH_ERROR_PARAM = 'shellui_oauth_error';
|
|
23
|
+
const SHELLUI_OAUTH_ERROR_CODE_PARAM = 'shellui_oauth_error_code';
|
|
24
|
+
|
|
25
|
+
type LastUsedLogin =
|
|
26
|
+
| { method: 'oauth'; provider: string }
|
|
27
|
+
| { method: 'magic_link' }
|
|
28
|
+
| { method: 'web3'; chain: 'ethereum' };
|
|
29
|
+
|
|
30
|
+
const readLastUsedLoginFromStorage = (): LastUsedLogin | null => {
|
|
31
|
+
if (typeof window === 'undefined') return null;
|
|
32
|
+
try {
|
|
33
|
+
const raw = localStorage.getItem(LAST_USED_LOGIN_STORAGE_KEY);
|
|
34
|
+
if (!raw) return null;
|
|
35
|
+
const parsed = JSON.parse(raw) as { method?: string; provider?: string; chain?: string };
|
|
36
|
+
if (parsed.method === 'magic_link') {
|
|
37
|
+
return { method: 'magic_link' };
|
|
38
|
+
}
|
|
39
|
+
if (parsed.method === 'web3') {
|
|
40
|
+
return { method: 'web3', chain: 'ethereum' };
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
parsed.method === 'oauth' &&
|
|
44
|
+
typeof parsed.provider === 'string' &&
|
|
45
|
+
parsed.provider.trim()
|
|
46
|
+
) {
|
|
47
|
+
return { method: 'oauth', provider: parsed.provider.toLowerCase() };
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Ignore malformed data and fallback to default ordering.
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ChevronDownIcon = ({ className }: { className?: string }) => (
|
|
56
|
+
<svg
|
|
57
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
58
|
+
width="24"
|
|
59
|
+
height="24"
|
|
60
|
+
viewBox="0 0 24 24"
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke="currentColor"
|
|
63
|
+
strokeWidth="2"
|
|
64
|
+
strokeLinecap="round"
|
|
65
|
+
strokeLinejoin="round"
|
|
66
|
+
className={className}
|
|
67
|
+
aria-hidden
|
|
68
|
+
>
|
|
69
|
+
<path d="m6 9 6 6 6-6" />
|
|
70
|
+
</svg>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
export const LoginView = () => {
|
|
74
|
+
const navigate = useNavigate();
|
|
75
|
+
const location = useLocation();
|
|
76
|
+
const { config } = useConfig();
|
|
77
|
+
const {
|
|
78
|
+
isAuthenticated,
|
|
79
|
+
isLoading: authLoading,
|
|
80
|
+
error: authError,
|
|
81
|
+
authEvent,
|
|
82
|
+
clearAuthEvent,
|
|
83
|
+
startOAuth,
|
|
84
|
+
startWeb3Ethereum,
|
|
85
|
+
getAuthSettings,
|
|
86
|
+
sendMagicLink,
|
|
87
|
+
} = useAuth();
|
|
88
|
+
const configuredSettings = useMemo<AuthSettings>(() => {
|
|
89
|
+
const configuredMethods = Array.isArray(config.backend?.login?.methods)
|
|
90
|
+
? config.backend.login.methods.filter(isLoginMethod)
|
|
91
|
+
: [];
|
|
92
|
+
const configuredProvidersRaw = Array.isArray(config.backend?.login?.oauthProviders)
|
|
93
|
+
? config.backend.login.oauthProviders
|
|
94
|
+
.filter(
|
|
95
|
+
(provider): provider is string =>
|
|
96
|
+
typeof provider === 'string' && provider.trim() !== '',
|
|
97
|
+
)
|
|
98
|
+
.map((provider) => provider.toLowerCase())
|
|
99
|
+
: [];
|
|
100
|
+
const configuredProviders = Array.from(new Set(configuredProvidersRaw));
|
|
101
|
+
return {
|
|
102
|
+
methods: configuredMethods,
|
|
103
|
+
oauthProviders: configuredProviders,
|
|
104
|
+
oauthClients: [],
|
|
105
|
+
};
|
|
106
|
+
}, [config.backend?.login?.methods, config.backend?.login?.oauthProviders]);
|
|
107
|
+
const [oauthLoadingProvider, setOauthLoadingProvider] = useState<string | null>(null);
|
|
108
|
+
const [web3Loading, setWeb3Loading] = useState(false);
|
|
109
|
+
const [magicLinkEmail, setMagicLinkEmail] = useState('');
|
|
110
|
+
const [magicLinkLoading, setMagicLinkLoading] = useState(false);
|
|
111
|
+
const [magicLinkMessage, setMagicLinkMessage] = useState<string | null>(null);
|
|
112
|
+
const [magicLinkError, setMagicLinkError] = useState<string | null>(null);
|
|
113
|
+
const [methodError, setMethodError] = useState<string | null>(null);
|
|
114
|
+
const [oauthBounceError, setOauthBounceError] = useState<string | null>(null);
|
|
115
|
+
const [lastUsedLogin, setLastUsedLogin] = useState<LastUsedLogin | null>(
|
|
116
|
+
readLastUsedLoginFromStorage,
|
|
117
|
+
);
|
|
118
|
+
const [showAlternativeMethods, setShowAlternativeMethods] = useState(false);
|
|
119
|
+
const nextPath = useMemo(() => {
|
|
120
|
+
const params = new URLSearchParams(location.search);
|
|
121
|
+
return normalizeNextPath(params.get('next')) ?? '/';
|
|
122
|
+
}, [location.search]);
|
|
123
|
+
const loginPathWithNext = useMemo(() => {
|
|
124
|
+
return `${urls.login}?next=${encodeURIComponent(nextPath)}`;
|
|
125
|
+
}, [nextPath]);
|
|
126
|
+
const oauthCallbackPathWithNext = useMemo(() => {
|
|
127
|
+
return `${urls.loginCallback}?next=${encodeURIComponent(nextPath)}`;
|
|
128
|
+
}, [nextPath]);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const params = new URLSearchParams(location.search);
|
|
132
|
+
const rawErr = params.get(SHELLUI_OAUTH_ERROR_PARAM);
|
|
133
|
+
if (!rawErr) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const code = params.get(SHELLUI_OAUTH_ERROR_CODE_PARAM);
|
|
137
|
+
params.delete(SHELLUI_OAUTH_ERROR_PARAM);
|
|
138
|
+
params.delete(SHELLUI_OAUTH_ERROR_CODE_PARAM);
|
|
139
|
+
const qs = params.toString();
|
|
140
|
+
const path = `${location.pathname}${qs ? `?${qs}` : ''}${location.hash}`;
|
|
141
|
+
navigate(path, { replace: true });
|
|
142
|
+
setOauthLoadingProvider(null);
|
|
143
|
+
const baseMsg = rawErr.trim() || 'Sign-in could not continue.';
|
|
144
|
+
const hint =
|
|
145
|
+
code === 'redirect_not_allowed'
|
|
146
|
+
? ` Add this origin in shellui-auth (Django admin or shellui-admin → Company → Login redirect URLs), for example: ${typeof window !== 'undefined' ? `${window.location.origin}/login` : '/login'}.`
|
|
147
|
+
: '';
|
|
148
|
+
setOauthBounceError(`${baseMsg}${hint}`);
|
|
149
|
+
}, [location.hash, location.pathname, location.search, navigate]);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (authEvent === 'oauth_callback' && isAuthenticated) {
|
|
153
|
+
clearAuthEvent();
|
|
154
|
+
navigate(nextPath, { replace: true });
|
|
155
|
+
}
|
|
156
|
+
}, [authEvent, clearAuthEvent, isAuthenticated, navigate, nextPath]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!authLoading && isAuthenticated) {
|
|
160
|
+
navigate(nextPath, { replace: true });
|
|
161
|
+
}
|
|
162
|
+
}, [authLoading, isAuthenticated, navigate, nextPath]);
|
|
163
|
+
|
|
164
|
+
const supportsOAuth = configuredSettings.methods.includes('oauth');
|
|
165
|
+
const supportsMagicLink = configuredSettings.methods.includes('magic_link');
|
|
166
|
+
const supportsWeb3 = configuredSettings.methods.includes('web3');
|
|
167
|
+
const settingsLoadError = oauthBounceError ?? authError ?? methodError;
|
|
168
|
+
const isActionPending = Boolean(oauthLoadingProvider) || web3Loading || magicLinkLoading;
|
|
169
|
+
const allOAuthProviders = useMemo(
|
|
170
|
+
() =>
|
|
171
|
+
configuredSettings.oauthProviders.length > 0 ? configuredSettings.oauthProviders : ['github'],
|
|
172
|
+
[configuredSettings.oauthProviders],
|
|
173
|
+
);
|
|
174
|
+
const lastUsedOauthProvider = useMemo(() => {
|
|
175
|
+
if (!supportsOAuth || !lastUsedLogin || lastUsedLogin.method !== 'oauth') return null;
|
|
176
|
+
const provider = lastUsedLogin.provider.toLowerCase();
|
|
177
|
+
return allOAuthProviders.includes(provider) ? provider : null;
|
|
178
|
+
}, [allOAuthProviders, lastUsedLogin, supportsOAuth]);
|
|
179
|
+
const featuredMethod =
|
|
180
|
+
supportsMagicLink && lastUsedLogin?.method === 'magic_link'
|
|
181
|
+
? 'magic_link'
|
|
182
|
+
: supportsWeb3 && lastUsedLogin?.method === 'web3'
|
|
183
|
+
? 'web3'
|
|
184
|
+
: supportsOAuth && lastUsedOauthProvider
|
|
185
|
+
? 'oauth'
|
|
186
|
+
: null;
|
|
187
|
+
const isIframeView = typeof window !== 'undefined' && window.parent !== window;
|
|
188
|
+
const featuredOAuthProvider = featuredMethod === 'oauth' ? lastUsedOauthProvider : null;
|
|
189
|
+
const otherOAuthProviders = useMemo(() => {
|
|
190
|
+
if (!supportsOAuth) return [];
|
|
191
|
+
if (!featuredOAuthProvider) return allOAuthProviders;
|
|
192
|
+
return allOAuthProviders.filter((provider) => provider !== featuredOAuthProvider);
|
|
193
|
+
}, [allOAuthProviders, featuredOAuthProvider, supportsOAuth]);
|
|
194
|
+
const hasAlternativeMethods = useMemo(() => {
|
|
195
|
+
if (featuredMethod === 'oauth') {
|
|
196
|
+
return otherOAuthProviders.length > 0 || supportsMagicLink || supportsWeb3;
|
|
197
|
+
}
|
|
198
|
+
if (featuredMethod === 'web3') {
|
|
199
|
+
return supportsOAuth || supportsMagicLink;
|
|
200
|
+
}
|
|
201
|
+
if (featuredMethod === 'magic_link') {
|
|
202
|
+
return (supportsOAuth && otherOAuthProviders.length > 0) || supportsWeb3;
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}, [featuredMethod, otherOAuthProviders.length, supportsMagicLink, supportsOAuth, supportsWeb3]);
|
|
206
|
+
const showAlternatives = featuredMethod === null || showAlternativeMethods;
|
|
207
|
+
const alternativesAreCollapsible = featuredMethod !== null && hasAlternativeMethods;
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (featuredMethod !== null) {
|
|
210
|
+
setShowAlternativeMethods(false);
|
|
211
|
+
}
|
|
212
|
+
}, [featuredMethod]);
|
|
213
|
+
const signInDescription = useMemo(() => {
|
|
214
|
+
if ((supportsOAuth || supportsWeb3) && supportsMagicLink) {
|
|
215
|
+
return 'Continue with your identity provider or request a secure sign-in link by email.';
|
|
216
|
+
}
|
|
217
|
+
if (supportsOAuth || supportsWeb3) {
|
|
218
|
+
return 'Continue with your configured identity provider.';
|
|
219
|
+
}
|
|
220
|
+
if (supportsMagicLink) {
|
|
221
|
+
return 'Enter your email to receive a secure sign-in link.';
|
|
222
|
+
}
|
|
223
|
+
return 'No sign-in method is currently available.';
|
|
224
|
+
}, [supportsMagicLink, supportsOAuth, supportsWeb3]);
|
|
225
|
+
|
|
226
|
+
const verifyMethodSupport = useCallback(
|
|
227
|
+
async (
|
|
228
|
+
method: LoginMethod,
|
|
229
|
+
provider?: string,
|
|
230
|
+
): Promise<{ isSupported: boolean; backendProvider?: string; oauthClientId?: number }> => {
|
|
231
|
+
try {
|
|
232
|
+
const backendSettings = await getAuthSettings();
|
|
233
|
+
if (!backendSettings.methods.includes(method)) {
|
|
234
|
+
if (method === 'web3' && supportsWeb3) {
|
|
235
|
+
// Some Supabase settings payloads do not explicitly advertise web3 methods.
|
|
236
|
+
return { isSupported: true };
|
|
237
|
+
}
|
|
238
|
+
const humanMethod = method === 'magic_link' ? 'magic link' : method;
|
|
239
|
+
setMethodError(`This backend does not support ${humanMethod} login.`);
|
|
240
|
+
return { isSupported: false };
|
|
241
|
+
}
|
|
242
|
+
if (method === 'oauth' && provider && backendSettings.oauthProviders.length > 0) {
|
|
243
|
+
const requestedCandidates = getOAuthProviderCandidates(provider);
|
|
244
|
+
const availableProviders = backendSettings.oauthProviders.map((p) => p.toLowerCase());
|
|
245
|
+
const matchedProvider = requestedCandidates.find((candidate) =>
|
|
246
|
+
availableProviders.includes(candidate),
|
|
247
|
+
);
|
|
248
|
+
if (!matchedProvider) {
|
|
249
|
+
setMethodError(
|
|
250
|
+
`This backend does not support OAuth with ${formatProviderLabel(provider)}.`,
|
|
251
|
+
);
|
|
252
|
+
return { isSupported: false };
|
|
253
|
+
}
|
|
254
|
+
const matchedClient = backendSettings.oauthClients.find(
|
|
255
|
+
(row) => row.provider === matchedProvider,
|
|
256
|
+
);
|
|
257
|
+
return {
|
|
258
|
+
isSupported: true,
|
|
259
|
+
backendProvider: matchedProvider,
|
|
260
|
+
oauthClientId: matchedClient?.id,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (method === 'oauth' && provider) {
|
|
264
|
+
const backendProvider = getPreferredBackendProvider(provider);
|
|
265
|
+
const client = backendSettings.oauthClients.find(
|
|
266
|
+
(row) => row.provider === backendProvider,
|
|
267
|
+
);
|
|
268
|
+
return {
|
|
269
|
+
isSupported: true,
|
|
270
|
+
backendProvider,
|
|
271
|
+
oauthClientId: client?.id,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return { isSupported: true };
|
|
275
|
+
} catch (err) {
|
|
276
|
+
setMethodError(
|
|
277
|
+
err instanceof Error ? err.message : 'Could not verify backend login capabilities.',
|
|
278
|
+
);
|
|
279
|
+
return { isSupported: false };
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
[getAuthSettings, supportsWeb3],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const handleOAuthLogin = async (provider: string) => {
|
|
286
|
+
setMethodError(null);
|
|
287
|
+
setOauthBounceError(null);
|
|
288
|
+
setMagicLinkError(null);
|
|
289
|
+
setMagicLinkMessage(null);
|
|
290
|
+
setOauthLoadingProvider(provider);
|
|
291
|
+
const support = await verifyMethodSupport('oauth', provider);
|
|
292
|
+
if (!support.isSupported) {
|
|
293
|
+
setOauthLoadingProvider(null);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const backendProvider = support.backendProvider ?? getPreferredBackendProvider(provider);
|
|
297
|
+
const rememberedLogin: LastUsedLogin = { method: 'oauth', provider: provider.toLowerCase() };
|
|
298
|
+
setLastUsedLogin(rememberedLogin);
|
|
299
|
+
if (typeof window !== 'undefined') {
|
|
300
|
+
localStorage.setItem(LAST_USED_LOGIN_STORAGE_KEY, JSON.stringify(rememberedLogin));
|
|
301
|
+
}
|
|
302
|
+
if (typeof window !== 'undefined' && window.parent !== window) {
|
|
303
|
+
shellui.login({
|
|
304
|
+
method: 'oauth',
|
|
305
|
+
provider: backendProvider,
|
|
306
|
+
redirectPath: oauthCallbackPathWithNext,
|
|
307
|
+
oauthClientId: support.oauthClientId,
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const started = startOAuth(backendProvider, oauthCallbackPathWithNext, support.oauthClientId);
|
|
312
|
+
if (!started) {
|
|
313
|
+
setOauthLoadingProvider(null);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const handleWeb3Login = async () => {
|
|
318
|
+
setMethodError(null);
|
|
319
|
+
setMagicLinkError(null);
|
|
320
|
+
setMagicLinkMessage(null);
|
|
321
|
+
setWeb3Loading(true);
|
|
322
|
+
const support = await verifyMethodSupport('web3');
|
|
323
|
+
if (!support.isSupported) {
|
|
324
|
+
setWeb3Loading(false);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const rememberedLogin: LastUsedLogin = { method: 'web3', chain: 'ethereum' };
|
|
329
|
+
setLastUsedLogin(rememberedLogin);
|
|
330
|
+
if (typeof window !== 'undefined') {
|
|
331
|
+
localStorage.setItem(LAST_USED_LOGIN_STORAGE_KEY, JSON.stringify(rememberedLogin));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (typeof window !== 'undefined' && window.parent !== window) {
|
|
335
|
+
shellui.login({
|
|
336
|
+
method: 'web3',
|
|
337
|
+
chain: 'ethereum',
|
|
338
|
+
redirectPath: loginPathWithNext,
|
|
339
|
+
});
|
|
340
|
+
setWeb3Loading(false);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const started = await startWeb3Ethereum();
|
|
344
|
+
if (!started) {
|
|
345
|
+
setMethodError('Unable to complete Ethereum wallet login.');
|
|
346
|
+
}
|
|
347
|
+
setWeb3Loading(false);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const handleMagicLinkLogin = async (event: FormEvent<HTMLFormElement>) => {
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
const email = magicLinkEmail.trim();
|
|
353
|
+
if (!email) {
|
|
354
|
+
setMagicLinkError('Please enter your email address.');
|
|
355
|
+
setMagicLinkMessage(null);
|
|
356
|
+
setMethodError(null);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
setMagicLinkLoading(true);
|
|
360
|
+
setMagicLinkError(null);
|
|
361
|
+
setMagicLinkMessage(null);
|
|
362
|
+
setMethodError(null);
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const support = await verifyMethodSupport('magic_link');
|
|
366
|
+
if (!support.isSupported) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const rememberedLogin: LastUsedLogin = { method: 'magic_link' };
|
|
370
|
+
setLastUsedLogin(rememberedLogin);
|
|
371
|
+
if (typeof window !== 'undefined') {
|
|
372
|
+
localStorage.setItem(LAST_USED_LOGIN_STORAGE_KEY, JSON.stringify(rememberedLogin));
|
|
373
|
+
}
|
|
374
|
+
await sendMagicLink(email, loginPathWithNext);
|
|
375
|
+
setMagicLinkMessage('Check your inbox for a magic login link.');
|
|
376
|
+
} catch (err) {
|
|
377
|
+
setMagicLinkError(err instanceof Error ? err.message : 'Could not send magic link.');
|
|
378
|
+
} finally {
|
|
379
|
+
setMagicLinkLoading(false);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
if (isAuthenticated) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<main className="flex min-h-full w-full bg-background">
|
|
389
|
+
{!isIframeView && (
|
|
390
|
+
<section
|
|
391
|
+
className="hidden w-1/2 bg-muted/40 md:block"
|
|
392
|
+
aria-hidden
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
|
|
396
|
+
<section
|
|
397
|
+
className={cn('flex min-h-full w-full flex-col px-6 py-10', !isIframeView && 'md:w-1/2')}
|
|
398
|
+
>
|
|
399
|
+
<div className="flex w-full flex-1 items-center justify-center">
|
|
400
|
+
<div
|
|
401
|
+
className={cn(
|
|
402
|
+
'w-full max-w-xl space-y-6 animate-in fade-in-0 slide-in-from-bottom-4 duration-500',
|
|
403
|
+
isIframeView ? 'max-w-2xl' : '',
|
|
404
|
+
)}
|
|
405
|
+
>
|
|
406
|
+
<div className="space-y-2">
|
|
407
|
+
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
|
|
408
|
+
Welcome back
|
|
409
|
+
</h1>
|
|
410
|
+
<p className="text-sm text-muted-foreground">{signInDescription}</p>
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
{settingsLoadError && (
|
|
414
|
+
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
415
|
+
{settingsLoadError}
|
|
416
|
+
</p>
|
|
417
|
+
)}
|
|
418
|
+
|
|
419
|
+
<div className="space-y-4">
|
|
420
|
+
{featuredMethod === 'oauth' && featuredOAuthProvider && (
|
|
421
|
+
<section className="space-y-2 rounded-2xl bg-muted/30 p-4">
|
|
422
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
423
|
+
Last used
|
|
424
|
+
</p>
|
|
425
|
+
{(() => {
|
|
426
|
+
const visual = getProviderVisual(featuredOAuthProvider);
|
|
427
|
+
const label = formatProviderLabel(featuredOAuthProvider);
|
|
428
|
+
return (
|
|
429
|
+
<Button
|
|
430
|
+
type="button"
|
|
431
|
+
variant="outline"
|
|
432
|
+
className="h-12 w-full justify-start px-3 text-base"
|
|
433
|
+
onClick={() => void handleOAuthLogin(featuredOAuthProvider)}
|
|
434
|
+
disabled={isActionPending}
|
|
435
|
+
>
|
|
436
|
+
<span
|
|
437
|
+
className="inline-flex h-6 w-6 shrink-0 items-center justify-center"
|
|
438
|
+
aria-hidden
|
|
439
|
+
>
|
|
440
|
+
<visual.Icon className={cn('h-3 w-3', visual.iconClassName)} />
|
|
441
|
+
</span>
|
|
442
|
+
<span className="truncate">
|
|
443
|
+
{oauthLoadingProvider === featuredOAuthProvider
|
|
444
|
+
? `Redirecting to ${label}...`
|
|
445
|
+
: `Continue with ${label}`}
|
|
446
|
+
</span>
|
|
447
|
+
</Button>
|
|
448
|
+
);
|
|
449
|
+
})()}
|
|
450
|
+
</section>
|
|
451
|
+
)}
|
|
452
|
+
|
|
453
|
+
{featuredMethod === 'magic_link' && supportsMagicLink && (
|
|
454
|
+
<section className="space-y-2 rounded-2xl bg-muted/30 p-4">
|
|
455
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
456
|
+
Last used
|
|
457
|
+
</p>
|
|
458
|
+
<form
|
|
459
|
+
className="space-y-2"
|
|
460
|
+
onSubmit={(event) => void handleMagicLinkLogin(event)}
|
|
461
|
+
>
|
|
462
|
+
<input
|
|
463
|
+
id="magic-link-email"
|
|
464
|
+
type="email"
|
|
465
|
+
value={magicLinkEmail}
|
|
466
|
+
onChange={(event) => setMagicLinkEmail(event.target.value)}
|
|
467
|
+
placeholder="name@company.com"
|
|
468
|
+
autoComplete="email"
|
|
469
|
+
required
|
|
470
|
+
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
471
|
+
/>
|
|
472
|
+
<Button
|
|
473
|
+
type="submit"
|
|
474
|
+
variant="secondary"
|
|
475
|
+
className="w-full"
|
|
476
|
+
disabled={isActionPending}
|
|
477
|
+
>
|
|
478
|
+
{magicLinkLoading ? 'Sending magic link...' : 'Send magic link'}
|
|
479
|
+
</Button>
|
|
480
|
+
{magicLinkMessage && (
|
|
481
|
+
<p className="text-sm text-muted-foreground">{magicLinkMessage}</p>
|
|
482
|
+
)}
|
|
483
|
+
{magicLinkError && <p className="text-sm text-destructive">{magicLinkError}</p>}
|
|
484
|
+
</form>
|
|
485
|
+
</section>
|
|
486
|
+
)}
|
|
487
|
+
|
|
488
|
+
{featuredMethod === 'web3' && supportsWeb3 && (
|
|
489
|
+
<section className="space-y-2 rounded-2xl bg-muted/30 p-4">
|
|
490
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
491
|
+
Last used
|
|
492
|
+
</p>
|
|
493
|
+
{(() => {
|
|
494
|
+
const visual = getProviderVisual('ethereum');
|
|
495
|
+
return (
|
|
496
|
+
<Button
|
|
497
|
+
type="button"
|
|
498
|
+
variant="outline"
|
|
499
|
+
className="h-12 w-full justify-start px-3 text-base"
|
|
500
|
+
onClick={() => void handleWeb3Login()}
|
|
501
|
+
disabled={isActionPending}
|
|
502
|
+
>
|
|
503
|
+
<span
|
|
504
|
+
className="inline-flex h-6 w-6 shrink-0 items-center justify-center"
|
|
505
|
+
aria-hidden
|
|
506
|
+
>
|
|
507
|
+
<visual.Icon className={cn('h-3 w-3', visual.iconClassName)} />
|
|
508
|
+
</span>
|
|
509
|
+
<span className="truncate">
|
|
510
|
+
{web3Loading ? 'Connecting wallet...' : 'Continue with Ethereum wallet'}
|
|
511
|
+
</span>
|
|
512
|
+
</Button>
|
|
513
|
+
);
|
|
514
|
+
})()}
|
|
515
|
+
</section>
|
|
516
|
+
)}
|
|
517
|
+
|
|
518
|
+
{featuredMethod !== null && hasAlternativeMethods && (
|
|
519
|
+
<div className="pt-1">
|
|
520
|
+
<Button
|
|
521
|
+
type="button"
|
|
522
|
+
variant="ghost"
|
|
523
|
+
size="sm"
|
|
524
|
+
aria-expanded={showAlternativeMethods}
|
|
525
|
+
className="h-8 w-auto justify-start gap-2 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
526
|
+
onClick={() => setShowAlternativeMethods((prev) => !prev)}
|
|
527
|
+
>
|
|
528
|
+
<ChevronDownIcon
|
|
529
|
+
className={cn(
|
|
530
|
+
'h-3.5 w-3.5 transition-transform duration-300',
|
|
531
|
+
showAlternativeMethods ? 'rotate-180' : 'rotate-0',
|
|
532
|
+
)}
|
|
533
|
+
/>
|
|
534
|
+
{showAlternativeMethods ? 'Hide other methods' : 'Use another method'}
|
|
535
|
+
</Button>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
|
|
539
|
+
<div
|
|
540
|
+
className={cn(
|
|
541
|
+
'grid transition-[grid-template-rows,opacity] duration-500 ease-out',
|
|
542
|
+
alternativesAreCollapsible
|
|
543
|
+
? showAlternatives
|
|
544
|
+
? 'grid-rows-[1fr] opacity-100'
|
|
545
|
+
: 'grid-rows-[0fr] opacity-0'
|
|
546
|
+
: 'grid-rows-[1fr] opacity-100',
|
|
547
|
+
)}
|
|
548
|
+
>
|
|
549
|
+
<div className="min-h-0 overflow-hidden">
|
|
550
|
+
<div
|
|
551
|
+
className={cn(
|
|
552
|
+
'space-y-4 transition-transform duration-500 ease-out',
|
|
553
|
+
alternativesAreCollapsible && !showAlternatives
|
|
554
|
+
? '-translate-y-2'
|
|
555
|
+
: 'translate-y-0',
|
|
556
|
+
)}
|
|
557
|
+
>
|
|
558
|
+
{featuredMethod === 'oauth' &&
|
|
559
|
+
(otherOAuthProviders.length > 0 || supportsMagicLink || supportsWeb3) && (
|
|
560
|
+
<div className="relative py-1">
|
|
561
|
+
<div className="border-t border-border" />
|
|
562
|
+
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
|
|
563
|
+
or
|
|
564
|
+
</span>
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
{featuredMethod === 'magic_link' && (supportsOAuth || supportsWeb3) && (
|
|
569
|
+
<div className="relative py-1">
|
|
570
|
+
<div className="border-t border-border" />
|
|
571
|
+
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
|
|
572
|
+
or
|
|
573
|
+
</span>
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
|
|
577
|
+
{featuredMethod === 'web3' && (supportsOAuth || supportsMagicLink) && (
|
|
578
|
+
<div className="relative py-1">
|
|
579
|
+
<div className="border-t border-border" />
|
|
580
|
+
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
|
|
581
|
+
or
|
|
582
|
+
</span>
|
|
583
|
+
</div>
|
|
584
|
+
)}
|
|
585
|
+
|
|
586
|
+
{supportsWeb3 && featuredMethod !== 'web3' && (
|
|
587
|
+
<section className="space-y-2">
|
|
588
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
589
|
+
Wallet login
|
|
590
|
+
</p>
|
|
591
|
+
{(() => {
|
|
592
|
+
const visual = getProviderVisual('ethereum');
|
|
593
|
+
return (
|
|
594
|
+
<Button
|
|
595
|
+
type="button"
|
|
596
|
+
variant="outline"
|
|
597
|
+
className="h-11 w-full justify-start px-3"
|
|
598
|
+
onClick={() => void handleWeb3Login()}
|
|
599
|
+
disabled={isActionPending}
|
|
600
|
+
>
|
|
601
|
+
<span
|
|
602
|
+
className="inline-flex h-6 w-6 shrink-0 items-center justify-center"
|
|
603
|
+
aria-hidden
|
|
604
|
+
>
|
|
605
|
+
<visual.Icon className={cn('h-3 w-3', visual.iconClassName)} />
|
|
606
|
+
</span>
|
|
607
|
+
<span className="truncate">
|
|
608
|
+
{web3Loading
|
|
609
|
+
? 'Connecting wallet...'
|
|
610
|
+
: 'Continue with Ethereum wallet'}
|
|
611
|
+
</span>
|
|
612
|
+
</Button>
|
|
613
|
+
);
|
|
614
|
+
})()}
|
|
615
|
+
</section>
|
|
616
|
+
)}
|
|
617
|
+
|
|
618
|
+
{supportsOAuth && otherOAuthProviders.length > 0 && (
|
|
619
|
+
<section className="space-y-2">
|
|
620
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
621
|
+
{featuredMethod === 'oauth' ? 'Other social logins' : 'Social login'}
|
|
622
|
+
</p>
|
|
623
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
624
|
+
{otherOAuthProviders.map((provider) => {
|
|
625
|
+
const visual = getProviderVisual(provider);
|
|
626
|
+
const label = formatProviderLabel(provider);
|
|
627
|
+
return (
|
|
628
|
+
<Button
|
|
629
|
+
key={provider}
|
|
630
|
+
type="button"
|
|
631
|
+
variant="outline"
|
|
632
|
+
className="h-11 w-full justify-start px-3"
|
|
633
|
+
onClick={() => void handleOAuthLogin(provider)}
|
|
634
|
+
disabled={isActionPending}
|
|
635
|
+
>
|
|
636
|
+
<span
|
|
637
|
+
className="inline-flex h-6 w-6 shrink-0 items-center justify-center"
|
|
638
|
+
aria-hidden
|
|
639
|
+
>
|
|
640
|
+
<visual.Icon className={cn('h-3 w-3', visual.iconClassName)} />
|
|
641
|
+
</span>
|
|
642
|
+
<span className="truncate">
|
|
643
|
+
{oauthLoadingProvider === provider
|
|
644
|
+
? `Redirecting to ${label}...`
|
|
645
|
+
: `Continue with ${label}`}
|
|
646
|
+
</span>
|
|
647
|
+
</Button>
|
|
648
|
+
);
|
|
649
|
+
})}
|
|
650
|
+
</div>
|
|
651
|
+
</section>
|
|
652
|
+
)}
|
|
653
|
+
|
|
654
|
+
{(supportsOAuth || supportsWeb3) &&
|
|
655
|
+
supportsMagicLink &&
|
|
656
|
+
featuredMethod !== 'magic_link' && (
|
|
657
|
+
<div className="relative py-1">
|
|
658
|
+
<div className="border-t border-border" />
|
|
659
|
+
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
|
|
660
|
+
or
|
|
661
|
+
</span>
|
|
662
|
+
</div>
|
|
663
|
+
)}
|
|
664
|
+
|
|
665
|
+
{supportsMagicLink && featuredMethod !== 'magic_link' && (
|
|
666
|
+
<section className="space-y-2">
|
|
667
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
668
|
+
Email magic link
|
|
669
|
+
</p>
|
|
670
|
+
<form
|
|
671
|
+
className="space-y-2"
|
|
672
|
+
onSubmit={(event) => void handleMagicLinkLogin(event)}
|
|
673
|
+
>
|
|
674
|
+
<input
|
|
675
|
+
id="magic-link-email"
|
|
676
|
+
type="email"
|
|
677
|
+
value={magicLinkEmail}
|
|
678
|
+
onChange={(event) => setMagicLinkEmail(event.target.value)}
|
|
679
|
+
placeholder="name@company.com"
|
|
680
|
+
autoComplete="email"
|
|
681
|
+
required
|
|
682
|
+
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
683
|
+
/>
|
|
684
|
+
<Button
|
|
685
|
+
type="submit"
|
|
686
|
+
variant="secondary"
|
|
687
|
+
className="w-full"
|
|
688
|
+
disabled={isActionPending}
|
|
689
|
+
>
|
|
690
|
+
{magicLinkLoading ? 'Sending magic link...' : 'Send magic link'}
|
|
691
|
+
</Button>
|
|
692
|
+
{magicLinkMessage && (
|
|
693
|
+
<p className="text-sm text-muted-foreground">{magicLinkMessage}</p>
|
|
694
|
+
)}
|
|
695
|
+
{magicLinkError && (
|
|
696
|
+
<p className="text-sm text-destructive">{magicLinkError}</p>
|
|
697
|
+
)}
|
|
698
|
+
</form>
|
|
699
|
+
</section>
|
|
700
|
+
)}
|
|
701
|
+
</div>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
{!supportsOAuth && !supportsMagicLink && !supportsWeb3 && (
|
|
706
|
+
<p className="text-sm text-muted-foreground">
|
|
707
|
+
No login method is currently enabled in backend auth settings.
|
|
708
|
+
</p>
|
|
709
|
+
)}
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
{!isIframeView && (
|
|
714
|
+
<div className="w-full max-w-xl border-t border-border pt-4">
|
|
715
|
+
<LegalDocumentsLinks />
|
|
716
|
+
</div>
|
|
717
|
+
)}
|
|
718
|
+
</section>
|
|
719
|
+
</main>
|
|
720
|
+
);
|
|
721
|
+
};
|