@authagonal/login 0.1.97

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 (73) hide show
  1. package/README.md +348 -0
  2. package/dist/App.d.ts +1 -0
  3. package/dist/api.d.ts +35 -0
  4. package/dist/branding.d.ts +22 -0
  5. package/dist/branding.json +8 -0
  6. package/dist/components/AuthLayout.d.ts +7 -0
  7. package/dist/components/ui/alert.d.ts +9 -0
  8. package/dist/components/ui/button.d.ts +11 -0
  9. package/dist/components/ui/card.d.ts +8 -0
  10. package/dist/components/ui/input.d.ts +3 -0
  11. package/dist/components/ui/label.d.ts +3 -0
  12. package/dist/components/ui/separator.d.ts +6 -0
  13. package/dist/favicon.svg +1 -0
  14. package/dist/hooks/useDarkMode.d.ts +6 -0
  15. package/dist/i18n/index.d.ts +2 -0
  16. package/dist/icons.svg +24 -0
  17. package/dist/index.css +3 -0
  18. package/dist/index.d.ts +23 -0
  19. package/dist/index.js +6332 -0
  20. package/dist/lib/utils.d.ts +2 -0
  21. package/dist/main.d.ts +2 -0
  22. package/dist/pages/ConsentPage.d.ts +1 -0
  23. package/dist/pages/DevicePage.d.ts +1 -0
  24. package/dist/pages/ForgotPasswordPage.d.ts +1 -0
  25. package/dist/pages/GrantsPage.d.ts +1 -0
  26. package/dist/pages/LoginPage.d.ts +1 -0
  27. package/dist/pages/MfaChallengePage.d.ts +1 -0
  28. package/dist/pages/MfaSetupPage.d.ts +1 -0
  29. package/dist/pages/RegisterPage.d.ts +1 -0
  30. package/dist/pages/ResetPasswordPage.d.ts +1 -0
  31. package/dist/types.d.ts +91 -0
  32. package/index.html +13 -0
  33. package/package.json +65 -0
  34. package/public/branding.json +8 -0
  35. package/public/favicon.svg +1 -0
  36. package/public/icons.svg +24 -0
  37. package/src/App.tsx +32 -0
  38. package/src/api.ts +156 -0
  39. package/src/branding.ts +55 -0
  40. package/src/components/AuthLayout.tsx +107 -0
  41. package/src/components/ui/alert.tsx +31 -0
  42. package/src/components/ui/button.tsx +51 -0
  43. package/src/components/ui/card.tsx +50 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/label.tsx +17 -0
  46. package/src/components/ui/separator.tsx +16 -0
  47. package/src/hooks/useDarkMode.ts +39 -0
  48. package/src/i18n/de.json +111 -0
  49. package/src/i18n/en.json +136 -0
  50. package/src/i18n/es.json +111 -0
  51. package/src/i18n/fr.json +111 -0
  52. package/src/i18n/index.ts +39 -0
  53. package/src/i18n/pt.json +111 -0
  54. package/src/i18n/tlh.json +111 -0
  55. package/src/i18n/vi.json +111 -0
  56. package/src/i18n/zh-Hans.json +111 -0
  57. package/src/index.ts +44 -0
  58. package/src/lib/utils.ts +6 -0
  59. package/src/main.tsx +19 -0
  60. package/src/pages/ConsentPage.tsx +144 -0
  61. package/src/pages/DevicePage.tsx +145 -0
  62. package/src/pages/ForgotPasswordPage.tsx +90 -0
  63. package/src/pages/GrantsPage.tsx +87 -0
  64. package/src/pages/LoginPage.tsx +423 -0
  65. package/src/pages/MfaChallengePage.tsx +246 -0
  66. package/src/pages/MfaSetupPage.tsx +366 -0
  67. package/src/pages/RegisterPage.tsx +161 -0
  68. package/src/pages/ResetPasswordPage.tsx +219 -0
  69. package/src/styles.css +33 -0
  70. package/src/types.ts +112 -0
  71. package/tsconfig.app.json +37 -0
  72. package/tsconfig.json +7 -0
  73. package/vite.config.ts +54 -0
@@ -0,0 +1,366 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { mfaStatus, mfaTotpSetup, mfaTotpConfirm, mfaWebAuthnSetup, mfaWebAuthnConfirm, mfaRecoveryGenerate, mfaDeleteCredential, ApiRequestError } from '../api';
5
+ import type { MfaMethod } from '../types';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { Alert } from '@/components/ui/alert';
10
+ import { CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
11
+
12
+ function isSafeReturnUrl(url: string): boolean {
13
+ if (!url) return false;
14
+ try {
15
+ const parsed = new URL(url, window.location.origin);
16
+ return parsed.origin === window.location.origin && url.startsWith('/');
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ // Helper: Base64URL decode to Uint8Array
23
+ function base64UrlToBuffer(base64url: string): ArrayBuffer {
24
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
25
+ const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
26
+ const binary = atob(base64 + pad);
27
+ const bytes = new Uint8Array(binary.length);
28
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
29
+ return bytes.buffer as ArrayBuffer;
30
+ }
31
+
32
+ // Helper: ArrayBuffer to Base64URL
33
+ function bufferToBase64Url(buffer: ArrayBuffer): string {
34
+ const bytes = new Uint8Array(buffer);
35
+ let binary = '';
36
+ for (const byte of bytes) binary += String.fromCharCode(byte);
37
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
38
+ }
39
+
40
+ export default function MfaSetupPage() {
41
+ const { t } = useTranslation();
42
+ const [searchParams] = useSearchParams();
43
+ const mfaSetupToken = searchParams.get('setupToken') || undefined;
44
+ const returnUrl = searchParams.get('returnUrl') || '';
45
+ const backUrl = searchParams.get('backUrl') || '';
46
+ const [enabled, setEnabled] = useState(false);
47
+ const [methods, setMethods] = useState<MfaMethod[]>([]);
48
+ const [error, setError] = useState('');
49
+ const [loading, setLoading] = useState(true);
50
+
51
+ // TOTP setup state
52
+ const [totpSetup, setTotpSetup] = useState<{ setupToken: string; qrCodeDataUri: string; manualKey: string } | null>(null);
53
+ const [totpCode, setTotpCode] = useState('');
54
+ const [totpLoading, setTotpLoading] = useState(false);
55
+
56
+ // WebAuthn state
57
+ const [webAuthnLoading, setWebAuthnLoading] = useState(false);
58
+
59
+ // Recovery codes state
60
+ const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
61
+ const [recoveryLoading, setRecoveryLoading] = useState(false);
62
+
63
+ useEffect(() => {
64
+ loadStatus();
65
+ }, []);
66
+
67
+ async function loadStatus() {
68
+ try {
69
+ const status = await mfaStatus(mfaSetupToken);
70
+ setEnabled(status.enabled);
71
+ setMethods(status.methods);
72
+ } catch {
73
+ setError(t('errorUnexpected'));
74
+ } finally {
75
+ setLoading(false);
76
+ }
77
+ }
78
+
79
+ // When using a setup token, redirect after MFA is successfully set up
80
+ function handleSetupComplete() {
81
+ if (mfaSetupToken) {
82
+ // Server signed the cookie — redirect to the original destination
83
+ if (returnUrl && isSafeReturnUrl(returnUrl)) {
84
+ window.location.href = returnUrl;
85
+ } else {
86
+ window.location.href = '/';
87
+ }
88
+ }
89
+ }
90
+
91
+ async function handleTotpSetup() {
92
+ setError('');
93
+ setTotpLoading(true);
94
+ try {
95
+ const result = await mfaTotpSetup(mfaSetupToken);
96
+ setTotpSetup(result);
97
+ } catch (err) {
98
+ if (err instanceof ApiRequestError && err.error === 'totp_already_enrolled') {
99
+ setError(t('mfaTotpAlreadyEnrolled'));
100
+ } else {
101
+ setError(t('errorUnexpected'));
102
+ }
103
+ } finally {
104
+ setTotpLoading(false);
105
+ }
106
+ }
107
+
108
+ async function handleTotpConfirm() {
109
+ if (!totpSetup || !totpCode) return;
110
+ setError('');
111
+ setTotpLoading(true);
112
+ try {
113
+ await mfaTotpConfirm(totpSetup.setupToken, totpCode, mfaSetupToken);
114
+ setTotpSetup(null);
115
+ setTotpCode('');
116
+ handleSetupComplete();
117
+ await loadStatus();
118
+ } catch (err) {
119
+ if (err instanceof ApiRequestError && err.error === 'invalid_code') {
120
+ setError(t('mfaInvalidCode'));
121
+ } else {
122
+ setError(t('errorUnexpected'));
123
+ }
124
+ } finally {
125
+ setTotpLoading(false);
126
+ }
127
+ }
128
+
129
+ async function handleWebAuthnSetup() {
130
+ setError('');
131
+ setWebAuthnLoading(true);
132
+ try {
133
+ const { setupToken, options } = await mfaWebAuthnSetup(mfaSetupToken);
134
+
135
+ // Convert options for the browser API
136
+ const publicKeyOptions: PublicKeyCredentialCreationOptions = {
137
+ challenge: base64UrlToBuffer(options.challenge),
138
+ rp: options.rp,
139
+ user: {
140
+ ...options.user,
141
+ id: base64UrlToBuffer(options.user.id),
142
+ },
143
+ pubKeyCredParams: options.pubKeyCredParams,
144
+ timeout: options.timeout || 60000,
145
+ attestation: options.attestation || 'none',
146
+ authenticatorSelection: options.authenticatorSelection,
147
+ excludeCredentials: (options.excludeCredentials || []).map((c: { id: string; type: string; transports?: string[] }) => ({
148
+ id: base64UrlToBuffer(c.id),
149
+ type: c.type,
150
+ transports: c.transports,
151
+ })),
152
+ };
153
+
154
+ const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) as PublicKeyCredential;
155
+ if (!credential) {
156
+ setError(t('errorUnexpected'));
157
+ return;
158
+ }
159
+
160
+ const response = credential.response as AuthenticatorAttestationResponse;
161
+ const attestationJson = JSON.stringify({
162
+ id: credential.id,
163
+ rawId: bufferToBase64Url(credential.rawId),
164
+ type: credential.type,
165
+ response: {
166
+ attestationObject: bufferToBase64Url(response.attestationObject),
167
+ clientDataJSON: bufferToBase64Url(response.clientDataJSON),
168
+ },
169
+ });
170
+
171
+ await mfaWebAuthnConfirm(setupToken, attestationJson, mfaSetupToken);
172
+ handleSetupComplete();
173
+ await loadStatus();
174
+ } catch (err) {
175
+ if (err instanceof DOMException && err.name === 'NotAllowedError') {
176
+ setError(t('mfaWebAuthnCancelled'));
177
+ } else if (err instanceof ApiRequestError) {
178
+ setError(err.message || t('errorUnexpected'));
179
+ } else {
180
+ setError(t('errorUnexpected'));
181
+ }
182
+ } finally {
183
+ setWebAuthnLoading(false);
184
+ }
185
+ }
186
+
187
+ async function handleRecoveryGenerate() {
188
+ setError('');
189
+ setRecoveryLoading(true);
190
+ try {
191
+ const result = await mfaRecoveryGenerate(mfaSetupToken);
192
+ setRecoveryCodes(result.codes);
193
+ await loadStatus();
194
+ } catch (err) {
195
+ if (err instanceof ApiRequestError && err.error === 'primary_method_required') {
196
+ setError(t('mfaRecoveryRequiresPrimary'));
197
+ } else {
198
+ setError(t('errorUnexpected'));
199
+ }
200
+ } finally {
201
+ setRecoveryLoading(false);
202
+ }
203
+ }
204
+
205
+ async function handleDeleteCredential(credentialId: string) {
206
+ setError('');
207
+ try {
208
+ await mfaDeleteCredential(credentialId, mfaSetupToken);
209
+ await loadStatus();
210
+ } catch {
211
+ setError(t('errorUnexpected'));
212
+ }
213
+ }
214
+
215
+ if (loading) {
216
+ return <div className="text-center py-10 text-gray-500 dark:text-gray-400">{t('mfaLoading')}</div>;
217
+ }
218
+
219
+ const hasTotp = methods.some(m => m.type === 'totp');
220
+ const hasWebAuthn = methods.some(m => m.type === 'webauthn');
221
+ const hasRecovery = methods.some(m => m.type === 'recoverycode');
222
+ const hasPrimaryMethod = hasTotp || hasWebAuthn;
223
+ const supportsWebAuthn = typeof window !== 'undefined' && !!window.PublicKeyCredential;
224
+
225
+ return (
226
+ <div>
227
+ <CardTitle>{t('mfaSetupTitle')}</CardTitle>
228
+
229
+ {error && <Alert variant="error">{error}</Alert>}
230
+
231
+ <CardDescription className="mb-6">
232
+ {enabled ? t('mfaStatusEnabled') : t('mfaStatusDisabled')}
233
+ </CardDescription>
234
+
235
+ {/* Enrolled methods */}
236
+ {methods.filter(m => m.type !== 'recoverycode').length > 0 && (
237
+ <div className="mb-6">
238
+ <h3 className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100">{t('mfaEnrolledMethods')}</h3>
239
+ {methods.filter(m => m.type !== 'recoverycode').map(m => (
240
+ <div key={m.id} className="flex justify-between items-center p-3 border border-gray-200 dark:border-gray-800 rounded-lg mb-2">
241
+ <div>
242
+ <strong className="text-sm text-gray-900 dark:text-gray-100">{m.name}</strong>
243
+ <br />
244
+ <small className="text-gray-400 dark:text-gray-500 text-xs">{t('mfaAddedOn', { date: new Date(m.createdAt).toLocaleDateString() })}</small>
245
+ </div>
246
+ <Button
247
+ type="button"
248
+ variant="secondary"
249
+ size="sm"
250
+ className="w-auto"
251
+ onClick={() => handleDeleteCredential(m.id)}
252
+ >
253
+ {t('mfaRemove')}
254
+ </Button>
255
+ </div>
256
+ ))}
257
+ </div>
258
+ )}
259
+
260
+ {/* TOTP setup */}
261
+ {!hasTotp && !totpSetup && (
262
+ <Button className="mb-4" onClick={handleTotpSetup} disabled={totpLoading}>
263
+ {t('mfaSetupTotp')}
264
+ </Button>
265
+ )}
266
+
267
+ {totpSetup && (
268
+ <div className="mb-6">
269
+ <p className="text-center mb-4 text-sm text-gray-700 dark:text-gray-300">{t('mfaScanQrCode')}</p>
270
+ <div className="text-center mb-4">
271
+ <img src={totpSetup.qrCodeDataUri} alt="QR Code" className="w-[200px] h-[200px] mx-auto" style={{ imageRendering: 'pixelated' }} />
272
+ </div>
273
+ {totpSetup.manualKey && (
274
+ <div className="text-center mb-4">
275
+ <p className="text-[13px] text-gray-500 dark:text-gray-400 mb-1">{t('mfaManualKeyLabel')}</p>
276
+ <code
277
+ className="inline-block px-3 py-2 bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md text-sm tracking-widest select-all break-all cursor-pointer"
278
+ title={t('mfaCopyKey')}
279
+ onClick={() => navigator.clipboard?.writeText(totpSetup.manualKey)}
280
+ >
281
+ {totpSetup.manualKey}
282
+ </code>
283
+ </div>
284
+ )}
285
+ <div className="mb-4">
286
+ <Label htmlFor="totp-confirm">{t('mfaEnterCode')}</Label>
287
+ <Input
288
+ id="totp-confirm"
289
+ type="text"
290
+ value={totpCode}
291
+ onChange={(e) => setTotpCode(e.target.value)}
292
+ placeholder="000000"
293
+ autoComplete="one-time-code"
294
+ maxLength={6}
295
+ inputMode="numeric"
296
+ pattern="[0-9]{6}"
297
+ />
298
+ </div>
299
+ <Button onClick={handleTotpConfirm} disabled={totpLoading || totpCode.length !== 6}>
300
+ {t('mfaConfirm')}
301
+ </Button>
302
+ </div>
303
+ )}
304
+
305
+ {/* WebAuthn / Passkey setup */}
306
+ {supportsWebAuthn && !hasWebAuthn && (
307
+ <Button className="mb-4" onClick={handleWebAuthnSetup} disabled={webAuthnLoading}>
308
+ {webAuthnLoading ? t('mfaLoading') : t('mfaSetupPasskey')}
309
+ </Button>
310
+ )}
311
+
312
+ {/* Recovery codes */}
313
+ {hasPrimaryMethod && !hasRecovery && !recoveryCodes && (
314
+ <Button variant="secondary" className="mb-4" onClick={handleRecoveryGenerate} disabled={recoveryLoading}>
315
+ {t('mfaGenerateRecoveryCodes')}
316
+ </Button>
317
+ )}
318
+
319
+ {recoveryCodes && (
320
+ <div className="mb-6">
321
+ <h3 className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100">{t('mfaRecoveryCodesTitle')}</h3>
322
+ <p className="text-gray-500 dark:text-gray-400 mb-3 text-sm">{t('mfaRecoveryCodesWarning')}</p>
323
+ <div className="bg-gray-50 dark:bg-gray-800/60 text-gray-900 dark:text-gray-100 p-4 rounded-lg font-mono text-sm columns-2 gap-6">
324
+ {recoveryCodes.map((code, i) => (
325
+ <div key={i} className="mb-1">{code}</div>
326
+ ))}
327
+ </div>
328
+ <Button variant="secondary" className="mt-3" onClick={() => setRecoveryCodes(null)}>
329
+ {t('mfaDone')}
330
+ </Button>
331
+ </div>
332
+ )}
333
+
334
+ {hasRecovery && !recoveryCodes && (
335
+ <Button variant="secondary" className="mb-4" onClick={handleRecoveryGenerate} disabled={recoveryLoading}>
336
+ {t('mfaRegenerateRecoveryCodes')}
337
+ </Button>
338
+ )}
339
+
340
+ {/* Skip button — only when not in forced setup mode (user has cookie session) */}
341
+ {!mfaSetupToken && (
342
+ <CardFooter className="mt-4">
343
+ <button
344
+ type="button"
345
+ className="bg-transparent border-none cursor-pointer text-[13px] text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
346
+ onClick={() => {
347
+ const dest = returnUrl && isSafeReturnUrl(returnUrl) ? returnUrl : '/';
348
+ window.location.href = dest;
349
+ }}
350
+ >
351
+ {t('mfaSkipSetup')}
352
+ </button>
353
+ </CardFooter>
354
+ )}
355
+
356
+ {/* Back to app link — shown when navigating from an external app */}
357
+ {backUrl && (
358
+ <div className="mt-6 text-center pt-4 border-t border-gray-200 dark:border-gray-800">
359
+ <a href={backUrl} className="text-sm text-primary no-underline hover:underline">
360
+ &larr; {t('mfaBackToApp')}
361
+ </a>
362
+ </div>
363
+ )}
364
+ </div>
365
+ );
366
+ }
@@ -0,0 +1,161 @@
1
+ import { useState } from 'react';
2
+ import { useSearchParams, Link, useNavigate } from 'react-router-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { register, getPasswordPolicy, ApiRequestError } from '../api';
5
+ import type { PasswordPolicyRule } from '../types';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { Alert } from '@/components/ui/alert';
10
+ import { CardTitle, CardFooter } from '@/components/ui/card';
11
+
12
+ export default function RegisterPage() {
13
+ const { t } = useTranslation();
14
+ const navigate = useNavigate();
15
+ const [searchParams] = useSearchParams();
16
+ const returnUrl = searchParams.get('returnUrl') || '';
17
+
18
+ const [email, setEmail] = useState('');
19
+ const [password, setPassword] = useState('');
20
+ const [firstName, setFirstName] = useState('');
21
+ const [lastName, setLastName] = useState('');
22
+ const [error, setError] = useState('');
23
+ const [loading, setLoading] = useState(false);
24
+ const [policyRules, setPolicyRules] = useState<PasswordPolicyRule[]>([]);
25
+ const [policyLoaded, setPolicyLoaded] = useState(false);
26
+
27
+ function loadPolicy() {
28
+ if (policyLoaded) return;
29
+ getPasswordPolicy()
30
+ .then((res) => setPolicyRules(res.rules))
31
+ .catch(() => {})
32
+ .finally(() => setPolicyLoaded(true));
33
+ }
34
+
35
+ async function handleSubmit(e: React.FormEvent) {
36
+ e.preventDefault();
37
+ setError('');
38
+ setLoading(true);
39
+
40
+ try {
41
+ await register(email, password, firstName || undefined, lastName || undefined);
42
+
43
+ // Redirect to login with success message
44
+ const params = new URLSearchParams();
45
+ if (returnUrl) params.set('returnUrl', returnUrl);
46
+ params.set('login_hint', email);
47
+ params.set('message', 'registration_success');
48
+ navigate(`/login?${params.toString()}`);
49
+ } catch (err) {
50
+ if (err instanceof ApiRequestError) {
51
+ switch (err.error) {
52
+ case 'email_already_registered':
53
+ setError(t('errorEmailAlreadyRegistered'));
54
+ break;
55
+ case 'weak_password':
56
+ setError(err.message || t('errorWeakPassword'));
57
+ break;
58
+ case 'email_and_password_required':
59
+ setError(t('errorEmailAndPasswordRequired'));
60
+ break;
61
+ default:
62
+ setError(err.message || t('errorRegistrationFailed'));
63
+ }
64
+ } else {
65
+ setError(t('errorRegistrationFailed'));
66
+ }
67
+ } finally {
68
+ setLoading(false);
69
+ }
70
+ }
71
+
72
+ const loginLink = returnUrl
73
+ ? `/login?returnUrl=${encodeURIComponent(returnUrl)}`
74
+ : '/login';
75
+
76
+ return (
77
+ <div>
78
+ <CardTitle>{t('registerTitle')}</CardTitle>
79
+
80
+ {error && <Alert variant="error">{error}</Alert>}
81
+
82
+ <form onSubmit={handleSubmit}>
83
+ <div className="flex gap-3">
84
+ <div className="mb-4 flex-1">
85
+ <Label htmlFor="firstName">{t('firstName')}</Label>
86
+ <Input
87
+ id="firstName"
88
+ type="text"
89
+ value={firstName}
90
+ onChange={(e) => setFirstName(e.target.value)}
91
+ placeholder={t('firstNamePlaceholder')}
92
+ autoComplete="given-name"
93
+ maxLength={100}
94
+ />
95
+ </div>
96
+ <div className="mb-4 flex-1">
97
+ <Label htmlFor="lastName">{t('lastName')}</Label>
98
+ <Input
99
+ id="lastName"
100
+ type="text"
101
+ value={lastName}
102
+ onChange={(e) => setLastName(e.target.value)}
103
+ placeholder={t('lastNamePlaceholder')}
104
+ autoComplete="family-name"
105
+ maxLength={100}
106
+ />
107
+ </div>
108
+ </div>
109
+
110
+ <div className="mb-4">
111
+ <Label htmlFor="email">{t('email')}</Label>
112
+ <Input
113
+ id="email"
114
+ type="email"
115
+ value={email}
116
+ onChange={(e) => setEmail(e.target.value)}
117
+ placeholder={t('emailPlaceholder')}
118
+ autoComplete="email"
119
+ autoFocus
120
+ maxLength={256}
121
+ required
122
+ />
123
+ </div>
124
+
125
+ <div className="mb-4">
126
+ <Label htmlFor="password">{t('password')}</Label>
127
+ <Input
128
+ id="password"
129
+ type="password"
130
+ value={password}
131
+ onChange={(e) => setPassword(e.target.value)}
132
+ onFocus={loadPolicy}
133
+ placeholder={t('passwordPlaceholder')}
134
+ autoComplete="new-password"
135
+ maxLength={256}
136
+ required
137
+ />
138
+ </div>
139
+
140
+ {policyRules.length > 0 && (
141
+ <ul className="text-[13px] text-gray-500 dark:text-gray-400 mb-4 pl-5 list-disc">
142
+ {policyRules.map((rule) => (
143
+ <li key={rule.rule}>{rule.label}</li>
144
+ ))}
145
+ </ul>
146
+ )}
147
+
148
+ <Button type="submit" loading={loading}>
149
+ {loading ? t('registering') : t('registerButton')}
150
+ </Button>
151
+
152
+ <CardFooter>
153
+ <span className="text-sm text-gray-500 dark:text-gray-400">
154
+ {t('alreadyHaveAccount')}{' '}
155
+ <Link to={loginLink} className="text-sm font-medium text-primary hover:underline no-underline">{t('signIn')}</Link>
156
+ </span>
157
+ </CardFooter>
158
+ </form>
159
+ </div>
160
+ );
161
+ }