@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.
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 +167 -0
  78. package/src/features/settings/utils/buildSettingsForPropagation.ts +61 -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,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
+ };