@authagonal/login 0.3.7 → 0.3.8
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 +2 -2
- package/dist/components/Turnstile.d.ts +13 -0
- package/dist/index.js +809 -738
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
- package/src/api.ts +4 -4
- package/src/components/Turnstile.tsx +76 -0
- package/src/pages/LoginPage.tsx +24 -3
- package/src/pages/RegisterPage.tsx +29 -4
- 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
|
|
|
@@ -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
|
+
}
|
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
|
|
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 {
|