@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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authagonal/login",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Default login UI for Authagonal — runtime-configurable via branding.json",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
+ }
@@ -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) => setProviders(res.providers ?? []))
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
- <Button type="submit" loading={loading} data-auth="submit-button">
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
- <Button type="submit" loading={loading}>
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 {