@authagonal/login 0.3.7 → 0.3.9
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/dist/api.d.ts +4 -4
- package/dist/components/Turnstile.d.ts +13 -0
- package/dist/index.js +865 -765
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
- package/src/api.ts +8 -8
- package/src/components/Turnstile.tsx +76 -0
- package/src/pages/ForgotPasswordPage.tsx +26 -4
- package/src/pages/LoginPage.tsx +24 -3
- package/src/pages/RegisterPage.tsx +29 -4
- package/src/pages/ResetPasswordPage.tsx +28 -3
- package/src/types.ts +2 -0
package/dist/types.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface ExternalProvider {
|
|
|
28
28
|
}
|
|
29
29
|
export interface ProvidersResponse {
|
|
30
30
|
providers: ExternalProvider[];
|
|
31
|
+
/** Cloudflare Turnstile site key when configured; absent = Turnstile disabled (render no widget). */
|
|
32
|
+
turnstileSiteKey?: string;
|
|
31
33
|
}
|
|
32
34
|
export interface PasswordPolicyRule {
|
|
33
35
|
rule: string;
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -45,18 +45,18 @@ function setupTokenHeaders(token?: string): Record<string, string> {
|
|
|
45
45
|
return token ? { 'X-MFA-Setup-Token': token } : {};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export function login(email: string, password: string, returnUrl?: string): Promise<MfaLoginResponse> {
|
|
48
|
+
export function login(email: string, password: string, returnUrl?: string, turnstileToken?: string): Promise<MfaLoginResponse> {
|
|
49
49
|
const url = returnUrl ? `/api/auth/login?returnUrl=${encodeURIComponent(returnUrl)}` : '/api/auth/login';
|
|
50
50
|
return api<MfaLoginResponse>(url, {
|
|
51
51
|
method: 'POST',
|
|
52
|
-
body: JSON.stringify({ email, password }),
|
|
52
|
+
body: JSON.stringify({ email, password, turnstileToken }),
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function register(email: string, password: string, firstName?: string, lastName?: string): Promise<RegisterResponse> {
|
|
56
|
+
export function register(email: string, password: string, firstName?: string, lastName?: string, turnstileToken?: string): Promise<RegisterResponse> {
|
|
57
57
|
return api<RegisterResponse>('/api/auth/register', {
|
|
58
58
|
method: 'POST',
|
|
59
|
-
body: JSON.stringify({ email, password, firstName, lastName }),
|
|
59
|
+
body: JSON.stringify({ email, password, firstName, lastName, turnstileToken }),
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -66,17 +66,17 @@ export function logout(): Promise<{ success: true }> {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export function forgotPassword(email: string): Promise<{ success: true }> {
|
|
69
|
+
export function forgotPassword(email: string, turnstileToken?: string): Promise<{ success: true }> {
|
|
70
70
|
return api<{ success: true }>('/api/auth/forgot-password', {
|
|
71
71
|
method: 'POST',
|
|
72
|
-
body: JSON.stringify({ email }),
|
|
72
|
+
body: JSON.stringify({ email, turnstileToken }),
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export function resetPassword(token: string, newPassword: string): Promise<{ success: true }> {
|
|
76
|
+
export function resetPassword(token: string, newPassword: string, turnstileToken?: string): Promise<{ success: true }> {
|
|
77
77
|
return api<{ success: true }>('/api/auth/reset-password', {
|
|
78
78
|
method: 'POST',
|
|
79
|
-
body: JSON.stringify({ token, newPassword }),
|
|
79
|
+
body: JSON.stringify({ token, newPassword, turnstileToken }),
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
// Cloudflare Turnstile ("I'm human"). Renders the managed widget and reports the
|
|
4
|
+
// token via onToken. Only mount this when a site key is configured (opt-in) — the
|
|
5
|
+
// server returns turnstileSiteKey on /api/auth/providers when Turnstile is enabled.
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
turnstile?: any;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
|
15
|
+
let scriptPromise: Promise<void> | null = null;
|
|
16
|
+
|
|
17
|
+
function loadTurnstileScript(): Promise<void> {
|
|
18
|
+
if (typeof window !== 'undefined' && window.turnstile) return Promise.resolve();
|
|
19
|
+
if (scriptPromise) return scriptPromise;
|
|
20
|
+
scriptPromise = new Promise<void>((resolve, reject) => {
|
|
21
|
+
const s = document.createElement('script');
|
|
22
|
+
s.src = SCRIPT_SRC;
|
|
23
|
+
s.async = true;
|
|
24
|
+
s.defer = true;
|
|
25
|
+
s.onload = () => resolve();
|
|
26
|
+
s.onerror = () => {
|
|
27
|
+
scriptPromise = null;
|
|
28
|
+
reject(new Error('Failed to load Cloudflare Turnstile'));
|
|
29
|
+
};
|
|
30
|
+
document.head.appendChild(s);
|
|
31
|
+
});
|
|
32
|
+
return scriptPromise;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface TurnstileProps {
|
|
36
|
+
siteKey: string;
|
|
37
|
+
/** Called with the token on success, or null when it expires / errors / is reset. */
|
|
38
|
+
onToken: (token: string | null) => void;
|
|
39
|
+
theme?: 'auto' | 'light' | 'dark';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function Turnstile({ siteKey, onToken, theme = 'auto' }: TurnstileProps) {
|
|
43
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
const widgetIdRef = useRef<string | null>(null);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
loadTurnstileScript()
|
|
49
|
+
.then(() => {
|
|
50
|
+
if (cancelled || !containerRef.current || !window.turnstile) return;
|
|
51
|
+
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
|
52
|
+
sitekey: siteKey,
|
|
53
|
+
theme,
|
|
54
|
+
callback: (token: string) => onToken(token),
|
|
55
|
+
'expired-callback': () => onToken(null),
|
|
56
|
+
'error-callback': () => onToken(null),
|
|
57
|
+
});
|
|
58
|
+
})
|
|
59
|
+
.catch(() => onToken(null));
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
if (widgetIdRef.current && window.turnstile) {
|
|
64
|
+
try {
|
|
65
|
+
window.turnstile.remove(widgetIdRef.current);
|
|
66
|
+
} catch {
|
|
67
|
+
/* widget already gone */
|
|
68
|
+
}
|
|
69
|
+
widgetIdRef.current = null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
// siteKey is stable for the page lifetime; re-render only if it changes.
|
|
73
|
+
}, [siteKey, theme, onToken]);
|
|
74
|
+
|
|
75
|
+
return <div ref={containerRef} className="flex justify-center" />;
|
|
76
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
2
|
import { useSearchParams, Link } from 'react-router-dom';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { forgotPassword } from '../api';
|
|
4
|
+
import { forgotPassword, getProviders } from '../api';
|
|
5
|
+
import { Turnstile } from '../components/Turnstile';
|
|
5
6
|
import { Button } from '@/components/ui/button';
|
|
6
7
|
import { Input } from '@/components/ui/input';
|
|
7
8
|
import { Label } from '@/components/ui/label';
|
|
@@ -17,6 +18,16 @@ export default function ForgotPasswordPage() {
|
|
|
17
18
|
const [loading, setLoading] = useState(false);
|
|
18
19
|
const [submitted, setSubmitted] = useState(false);
|
|
19
20
|
const [error, setError] = useState('');
|
|
21
|
+
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
|
|
22
|
+
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
|
23
|
+
const [turnstileKey, setTurnstileKey] = useState(0); // bump to re-mount the widget for a fresh challenge
|
|
24
|
+
|
|
25
|
+
// Surface the Turnstile site key (opt-in; empty when not configured for the tenant).
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
getProviders()
|
|
28
|
+
.then((res) => setTurnstileSiteKey(res.turnstileSiteKey))
|
|
29
|
+
.catch(() => {});
|
|
30
|
+
}, []);
|
|
20
31
|
|
|
21
32
|
const loginLink = returnUrl
|
|
22
33
|
? `/login?returnUrl=${encodeURIComponent(returnUrl)}`
|
|
@@ -28,11 +39,16 @@ export default function ForgotPasswordPage() {
|
|
|
28
39
|
setLoading(true);
|
|
29
40
|
|
|
30
41
|
try {
|
|
31
|
-
await forgotPassword(email);
|
|
42
|
+
await forgotPassword(email, turnstileToken || undefined);
|
|
32
43
|
setSubmitted(true);
|
|
33
44
|
} catch {
|
|
34
45
|
// The API always returns 200 for anti-enumeration, but handle errors just in case
|
|
35
46
|
setError(t('errorUnexpected'));
|
|
47
|
+
// Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
|
|
48
|
+
if (turnstileSiteKey) {
|
|
49
|
+
setTurnstileToken(null);
|
|
50
|
+
setTurnstileKey((k) => k + 1);
|
|
51
|
+
}
|
|
36
52
|
} finally {
|
|
37
53
|
setLoading(false);
|
|
38
54
|
}
|
|
@@ -75,7 +91,13 @@ export default function ForgotPasswordPage() {
|
|
|
75
91
|
/>
|
|
76
92
|
</div>
|
|
77
93
|
|
|
78
|
-
|
|
94
|
+
{turnstileSiteKey && (
|
|
95
|
+
<div className="mb-4">
|
|
96
|
+
<Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<Button type="submit" loading={loading} disabled={!!turnstileSiteKey && !turnstileToken}>
|
|
79
101
|
{loading ? t('sending') : t('sendResetLink')}
|
|
80
102
|
</Button>
|
|
81
103
|
|
package/src/pages/LoginPage.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
2
2
|
import { useSearchParams, Link, useNavigate } from 'react-router-dom';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { login, logout, ssoCheck, getProviders, getSession, ApiRequestError } from '../api';
|
|
5
|
+
import { Turnstile } from '../components/Turnstile';
|
|
5
6
|
import { useBranding } from '../branding';
|
|
6
7
|
import type { ExternalProvider } from '../types';
|
|
7
8
|
import { Button } from '@/components/ui/button';
|
|
@@ -45,6 +46,9 @@ export default function LoginPage() {
|
|
|
45
46
|
const [ssoChecked, setSsoChecked] = useState(false);
|
|
46
47
|
const [ssoChecking, setSsoChecking] = useState(false);
|
|
47
48
|
const [providers, setProviders] = useState<ExternalProvider[]>([]);
|
|
49
|
+
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
|
|
50
|
+
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
|
51
|
+
const [turnstileKey, setTurnstileKey] = useState(0); // bump to re-mount the widget for a fresh challenge
|
|
48
52
|
const [session, setSession] = useState<{ name: string; email: string } | null>(null);
|
|
49
53
|
const [mfaPrompt, setMfaPrompt] = useState<{ returnUrl: string; userId: string; clientId: string } | null>(null);
|
|
50
54
|
|
|
@@ -92,7 +96,10 @@ export default function LoginPage() {
|
|
|
92
96
|
// Fetch available external providers
|
|
93
97
|
useEffect(() => {
|
|
94
98
|
getProviders()
|
|
95
|
-
.then((res) =>
|
|
99
|
+
.then((res) => {
|
|
100
|
+
setProviders(res.providers ?? []);
|
|
101
|
+
setTurnstileSiteKey(res.turnstileSiteKey);
|
|
102
|
+
})
|
|
96
103
|
.catch(() => {});
|
|
97
104
|
}, []);
|
|
98
105
|
|
|
@@ -148,7 +155,7 @@ export default function LoginPage() {
|
|
|
148
155
|
setLoading(true);
|
|
149
156
|
|
|
150
157
|
try {
|
|
151
|
-
const result = await login(email, password, returnUrl || undefined);
|
|
158
|
+
const result = await login(email, password, returnUrl || undefined, turnstileToken || undefined);
|
|
152
159
|
|
|
153
160
|
if (result.mfaRequired && result.challengeId) {
|
|
154
161
|
// Redirect to MFA challenge page
|
|
@@ -216,12 +223,20 @@ export default function LoginPage() {
|
|
|
216
223
|
case 'password_required':
|
|
217
224
|
setError(t('errorPasswordRequired'));
|
|
218
225
|
break;
|
|
226
|
+
case 'captcha_failed':
|
|
227
|
+
setError(t('errorUnexpected'));
|
|
228
|
+
break;
|
|
219
229
|
default:
|
|
220
230
|
setError(err.message || t('errorUnexpected'));
|
|
221
231
|
}
|
|
222
232
|
} else {
|
|
223
233
|
setError(t('errorUnexpected'));
|
|
224
234
|
}
|
|
235
|
+
// Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
|
|
236
|
+
if (turnstileSiteKey) {
|
|
237
|
+
setTurnstileToken(null);
|
|
238
|
+
setTurnstileKey((k) => k + 1);
|
|
239
|
+
}
|
|
225
240
|
} finally {
|
|
226
241
|
setLoading(false);
|
|
227
242
|
}
|
|
@@ -393,7 +408,13 @@ export default function LoginPage() {
|
|
|
393
408
|
/>
|
|
394
409
|
</div>
|
|
395
410
|
|
|
396
|
-
|
|
411
|
+
{turnstileSiteKey && (
|
|
412
|
+
<div className="mb-4">
|
|
413
|
+
<Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
|
|
417
|
+
<Button type="submit" loading={loading} disabled={!!turnstileSiteKey && !turnstileToken} data-auth="submit-button">
|
|
397
418
|
{loading ? t('signingIn') : t('signIn')}
|
|
398
419
|
</Button>
|
|
399
420
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
2
|
import { useSearchParams, Link, useNavigate } from 'react-router-dom';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { register, getPasswordPolicy, ApiRequestError } from '../api';
|
|
4
|
+
import { register, getPasswordPolicy, getProviders, ApiRequestError } from '../api';
|
|
5
5
|
import type { PasswordPolicyRule } from '../types';
|
|
6
|
+
import { Turnstile } from '../components/Turnstile';
|
|
6
7
|
import { Button } from '@/components/ui/button';
|
|
7
8
|
import { Input } from '@/components/ui/input';
|
|
8
9
|
import { Label } from '@/components/ui/label';
|
|
@@ -23,6 +24,16 @@ export default function RegisterPage() {
|
|
|
23
24
|
const [loading, setLoading] = useState(false);
|
|
24
25
|
const [policyRules, setPolicyRules] = useState<PasswordPolicyRule[]>([]);
|
|
25
26
|
const [policyLoaded, setPolicyLoaded] = useState(false);
|
|
27
|
+
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
|
|
28
|
+
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
|
29
|
+
const [turnstileKey, setTurnstileKey] = useState(0);
|
|
30
|
+
|
|
31
|
+
// Turnstile site key is surfaced on /providers; absent = Turnstile disabled.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
getProviders()
|
|
34
|
+
.then((res) => setTurnstileSiteKey(res.turnstileSiteKey))
|
|
35
|
+
.catch(() => {});
|
|
36
|
+
}, []);
|
|
26
37
|
|
|
27
38
|
function loadPolicy() {
|
|
28
39
|
if (policyLoaded) return;
|
|
@@ -38,7 +49,7 @@ export default function RegisterPage() {
|
|
|
38
49
|
setLoading(true);
|
|
39
50
|
|
|
40
51
|
try {
|
|
41
|
-
await register(email, password, firstName || undefined, lastName || undefined);
|
|
52
|
+
await register(email, password, firstName || undefined, lastName || undefined, turnstileToken || undefined);
|
|
42
53
|
|
|
43
54
|
// Redirect to login with success message
|
|
44
55
|
const params = new URLSearchParams();
|
|
@@ -58,12 +69,20 @@ export default function RegisterPage() {
|
|
|
58
69
|
case 'email_and_password_required':
|
|
59
70
|
setError(t('errorEmailAndPasswordRequired'));
|
|
60
71
|
break;
|
|
72
|
+
case 'captcha_failed':
|
|
73
|
+
setError(t('errorRegistrationFailed'));
|
|
74
|
+
break;
|
|
61
75
|
default:
|
|
62
76
|
setError(err.message || t('errorRegistrationFailed'));
|
|
63
77
|
}
|
|
64
78
|
} else {
|
|
65
79
|
setError(t('errorRegistrationFailed'));
|
|
66
80
|
}
|
|
81
|
+
// Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
|
|
82
|
+
if (turnstileSiteKey) {
|
|
83
|
+
setTurnstileToken(null);
|
|
84
|
+
setTurnstileKey((k) => k + 1);
|
|
85
|
+
}
|
|
67
86
|
} finally {
|
|
68
87
|
setLoading(false);
|
|
69
88
|
}
|
|
@@ -145,7 +164,13 @@ export default function RegisterPage() {
|
|
|
145
164
|
</ul>
|
|
146
165
|
)}
|
|
147
166
|
|
|
148
|
-
|
|
167
|
+
{turnstileSiteKey && (
|
|
168
|
+
<div className="mb-4">
|
|
169
|
+
<Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
<Button type="submit" loading={loading} disabled={!!turnstileSiteKey && !turnstileToken}>
|
|
149
174
|
{loading ? t('registering') : t('registerButton')}
|
|
150
175
|
</Button>
|
|
151
176
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import { useSearchParams, Link } from 'react-router-dom';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { resetPassword, ApiRequestError } from '../api';
|
|
4
|
+
import { resetPassword, getProviders, ApiRequestError } from '../api';
|
|
5
|
+
import { Turnstile } from '../components/Turnstile';
|
|
5
6
|
import { Button } from '@/components/ui/button';
|
|
6
7
|
import { Input } from '@/components/ui/input';
|
|
7
8
|
import { Label } from '@/components/ui/label';
|
|
@@ -57,6 +58,9 @@ export default function ResetPasswordPage() {
|
|
|
57
58
|
const [success, setSuccess] = useState(false);
|
|
58
59
|
const [validationError, setValidationError] = useState('');
|
|
59
60
|
const [rules, setRules] = useState<PasswordRule[]>(defaultRules);
|
|
61
|
+
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>(undefined);
|
|
62
|
+
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
|
63
|
+
const [turnstileKey, setTurnstileKey] = useState(0); // bump to re-mount the widget for a fresh challenge
|
|
60
64
|
|
|
61
65
|
useEffect(() => {
|
|
62
66
|
fetch(`${API_URL}/api/auth/password-policy`)
|
|
@@ -65,6 +69,13 @@ export default function ResetPasswordPage() {
|
|
|
65
69
|
.catch(() => { /* use defaults */ });
|
|
66
70
|
}, []);
|
|
67
71
|
|
|
72
|
+
// Surface the Turnstile site key (opt-in; empty when not configured for the tenant).
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
getProviders()
|
|
75
|
+
.then((res) => setTurnstileSiteKey(res.turnstileSiteKey))
|
|
76
|
+
.catch(() => {});
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
68
79
|
function getRuleLabel(rule: PasswordRule): string {
|
|
69
80
|
switch (rule.rule) {
|
|
70
81
|
case 'minLength': return t('ruleMinLength', { count: rule.value ?? 8 });
|
|
@@ -102,7 +113,7 @@ export default function ResetPasswordPage() {
|
|
|
102
113
|
setLoading(true);
|
|
103
114
|
|
|
104
115
|
try {
|
|
105
|
-
await resetPassword(token, newPassword);
|
|
116
|
+
await resetPassword(token, newPassword, turnstileToken || undefined);
|
|
106
117
|
setSuccess(true);
|
|
107
118
|
} catch (err) {
|
|
108
119
|
if (err instanceof ApiRequestError) {
|
|
@@ -117,12 +128,20 @@ export default function ResetPasswordPage() {
|
|
|
117
128
|
case 'password_required':
|
|
118
129
|
setError(t('errorPasswordRequired'));
|
|
119
130
|
break;
|
|
131
|
+
case 'captcha_failed':
|
|
132
|
+
setError(t('errorUnexpected'));
|
|
133
|
+
break;
|
|
120
134
|
default:
|
|
121
135
|
setError(err.message || t('errorUnexpected'));
|
|
122
136
|
}
|
|
123
137
|
} else {
|
|
124
138
|
setError(t('errorUnexpected'));
|
|
125
139
|
}
|
|
140
|
+
// Turnstile tokens are single-use — reset so a retry gets a fresh challenge.
|
|
141
|
+
if (turnstileSiteKey) {
|
|
142
|
+
setTurnstileToken(null);
|
|
143
|
+
setTurnstileKey((k) => k + 1);
|
|
144
|
+
}
|
|
126
145
|
} finally {
|
|
127
146
|
setLoading(false);
|
|
128
147
|
}
|
|
@@ -204,7 +223,13 @@ export default function ResetPasswordPage() {
|
|
|
204
223
|
/>
|
|
205
224
|
</div>
|
|
206
225
|
|
|
207
|
-
|
|
226
|
+
{turnstileSiteKey && (
|
|
227
|
+
<div className="mb-4">
|
|
228
|
+
<Turnstile key={turnstileKey} siteKey={turnstileSiteKey} onToken={setTurnstileToken} />
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
<Button type="submit" loading={loading} disabled={!allRequirementsMet || (!!turnstileSiteKey && !turnstileToken)}>
|
|
208
233
|
{loading ? t('resetting') : t('resetPassword')}
|
|
209
234
|
</Button>
|
|
210
235
|
|
package/src/types.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface ExternalProvider {
|
|
|
33
33
|
|
|
34
34
|
export interface ProvidersResponse {
|
|
35
35
|
providers: ExternalProvider[];
|
|
36
|
+
/** Cloudflare Turnstile site key when configured; absent = Turnstile disabled (render no widget). */
|
|
37
|
+
turnstileSiteKey?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export interface PasswordPolicyRule {
|