@djangocfg/layouts 2.1.580 → 2.1.581
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/package.json +12 -12
- package/src/layouts/AuthLayout/AuthLayout.tsx +25 -7
- package/src/layouts/AuthLayout/README.md +16 -12
- package/src/layouts/AuthLayout/components/index.ts +0 -2
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +8 -9
- package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +10 -2
- package/src/layouts/AuthLayout/components/steps/index.ts +0 -3
- package/src/layouts/AuthLayout/constants.ts +0 -4
- package/src/layouts/AuthLayout/index.ts +0 -3
- package/src/layouts/AuthLayout/styles/auth.css +0 -110
- package/src/layouts/AuthLayout/types.ts +5 -2
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +2 -2
- package/src/layouts/_components/locale-switcher/LocaleSwitcherDropdown.tsx +2 -1
- package/src/layouts/_components/locale-switcher/LocaleSwitcherTrigger.tsx +4 -3
- package/src/layouts/_components/locale-switcher/iconFlag.ts +5 -0
- package/src/snippets/TwoFactor/TwoFactorChallengeDialog.tsx +188 -0
- package/src/snippets/TwoFactor/TwoFactorSetup.tsx +209 -0
- package/src/snippets/TwoFactor/index.ts +2 -0
- package/src/snippets/index.ts +1 -0
- package/src/testing/MockAuthFormProvider.tsx +7 -41
- package/src/testing/index.ts +4 -2
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +0 -86
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +0 -36
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +0 -141
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +0 -145
- package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +0 -133
- package/src/layouts/AuthLayout/hooks/index.ts +0 -1
- package/src/layouts/AuthLayout/hooks/useCopyToClipboard.ts +0 -51
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { memo, useCallback, useMemo, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AUTH_CONSTANTS,
|
|
7
|
+
endTwoFactorChallenge,
|
|
8
|
+
useTwoFactorChallenge,
|
|
9
|
+
useTwoFactorVerify,
|
|
10
|
+
} from '@djangocfg/api/auth';
|
|
11
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
12
|
+
import {
|
|
13
|
+
Button,
|
|
14
|
+
Dialog,
|
|
15
|
+
DialogContent,
|
|
16
|
+
DialogDescription,
|
|
17
|
+
DialogHeader,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
Input,
|
|
20
|
+
OTPInput,
|
|
21
|
+
} from '@djangocfg/ui-core/components';
|
|
22
|
+
|
|
23
|
+
export interface TwoFactorChallengeDialogProps {
|
|
24
|
+
/** Runs once the code is accepted and the session is stored. Without it the dialog navigates itself. */
|
|
25
|
+
onVerified?: () => void;
|
|
26
|
+
/** Runs when the user abandons the challenge. */
|
|
27
|
+
onCancel?: () => void;
|
|
28
|
+
/** Where the dialog navigates after success when `onVerified` is absent. */
|
|
29
|
+
redirectUrl?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Second-factor prompt over whatever page started the sign-in.
|
|
34
|
+
*
|
|
35
|
+
* Opens when a sign-in step calls `beginTwoFactorChallenge` and owns everything
|
|
36
|
+
* from there: code entry, backup-code fallback, lockout hints. The sign-in form
|
|
37
|
+
* does not know it exists.
|
|
38
|
+
*/
|
|
39
|
+
function TwoFactorChallengeDialogRaw(props: TwoFactorChallengeDialogProps) {
|
|
40
|
+
const challenge = useTwoFactorChallenge();
|
|
41
|
+
const { onCancel } = props;
|
|
42
|
+
|
|
43
|
+
const handleOpenChange = useCallback(
|
|
44
|
+
(open: boolean) => {
|
|
45
|
+
if (open) return;
|
|
46
|
+
endTwoFactorChallenge();
|
|
47
|
+
onCancel?.();
|
|
48
|
+
},
|
|
49
|
+
[onCancel],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Dialog open={challenge !== null} onOpenChange={handleOpenChange}>
|
|
54
|
+
{/* A stray click must not discard a verified password or email code. */}
|
|
55
|
+
<DialogContent className="max-w-sm" onInteractOutside={(e) => e.preventDefault()}>
|
|
56
|
+
{challenge && <Challenge key={challenge.sessionId} sessionId={challenge.sessionId} {...props} />}
|
|
57
|
+
</DialogContent>
|
|
58
|
+
</Dialog>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Challenge({
|
|
63
|
+
sessionId,
|
|
64
|
+
onVerified,
|
|
65
|
+
redirectUrl,
|
|
66
|
+
}: TwoFactorChallengeDialogProps & { sessionId: string }) {
|
|
67
|
+
const t = useAppT();
|
|
68
|
+
const [code, setCode] = useState('');
|
|
69
|
+
const [useBackupCode, setUseBackupCode] = useState(false);
|
|
70
|
+
|
|
71
|
+
const verify = useTwoFactorVerify({
|
|
72
|
+
onSuccess: () => {
|
|
73
|
+
endTwoFactorChallenge();
|
|
74
|
+
onVerified?.();
|
|
75
|
+
},
|
|
76
|
+
redirectUrl,
|
|
77
|
+
skipRedirect: Boolean(onVerified),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const content = useMemo(
|
|
81
|
+
() => ({
|
|
82
|
+
title: t('layouts.auth.twoFactor.title'),
|
|
83
|
+
subtitle: {
|
|
84
|
+
totp: t('layouts.auth.twoFactor.subtitle.totp'),
|
|
85
|
+
backup: t('layouts.auth.twoFactor.subtitle.backup'),
|
|
86
|
+
},
|
|
87
|
+
placeholder: t('layouts.auth.twoFactor.placeholder.backup'),
|
|
88
|
+
button: t('layouts.auth.twoFactor.button'),
|
|
89
|
+
toggle: {
|
|
90
|
+
toBackup: t('layouts.auth.twoFactor.toggle.toBackup'),
|
|
91
|
+
toTotp: t('layouts.auth.twoFactor.toggle.toTotp'),
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
[t],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const { isLoading, error, warning, attemptsRemaining, verifyTOTP, verifyBackupCode, clearError } = verify;
|
|
98
|
+
|
|
99
|
+
const switchMode = useCallback(() => {
|
|
100
|
+
setUseBackupCode((prev) => !prev);
|
|
101
|
+
setCode('');
|
|
102
|
+
clearError();
|
|
103
|
+
}, [clearError]);
|
|
104
|
+
|
|
105
|
+
const handleTotpComplete = useCallback(
|
|
106
|
+
(value: string) => {
|
|
107
|
+
if (!isLoading) void verifyTOTP(sessionId, value);
|
|
108
|
+
},
|
|
109
|
+
[isLoading, sessionId, verifyTOTP],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const handleSubmit = useCallback(
|
|
113
|
+
(e: React.FormEvent) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
if (isLoading) return;
|
|
116
|
+
void (useBackupCode ? verifyBackupCode(sessionId, code) : verifyTOTP(sessionId, code));
|
|
117
|
+
},
|
|
118
|
+
[code, isLoading, sessionId, useBackupCode, verifyBackupCode, verifyTOTP],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const canSubmit = useBackupCode ? code.length > 0 : code.length === AUTH_CONSTANTS.TOTP_LENGTH;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<DialogHeader>
|
|
126
|
+
<DialogTitle>{content.title}</DialogTitle>
|
|
127
|
+
<DialogDescription>
|
|
128
|
+
{useBackupCode ? content.subtitle.backup : content.subtitle.totp}
|
|
129
|
+
</DialogDescription>
|
|
130
|
+
</DialogHeader>
|
|
131
|
+
|
|
132
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
133
|
+
{useBackupCode ? (
|
|
134
|
+
<Input
|
|
135
|
+
value={code}
|
|
136
|
+
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
137
|
+
placeholder={content.placeholder}
|
|
138
|
+
disabled={isLoading}
|
|
139
|
+
maxLength={AUTH_CONSTANTS.BACKUP_CODE_MAX_LENGTH}
|
|
140
|
+
autoComplete="off"
|
|
141
|
+
autoFocus
|
|
142
|
+
/>
|
|
143
|
+
) : (
|
|
144
|
+
<div className="flex justify-center">
|
|
145
|
+
<OTPInput
|
|
146
|
+
length={AUTH_CONSTANTS.TOTP_LENGTH}
|
|
147
|
+
validationMode="numeric"
|
|
148
|
+
pasteBehavior="clean"
|
|
149
|
+
value={code}
|
|
150
|
+
onChange={setCode}
|
|
151
|
+
onComplete={handleTotpComplete}
|
|
152
|
+
disabled={isLoading}
|
|
153
|
+
autoFocus
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{warning && <p className="text-sm text-muted-foreground">{warning}</p>}
|
|
159
|
+
{error && (
|
|
160
|
+
<p role="alert" className="text-sm text-destructive">
|
|
161
|
+
{error}
|
|
162
|
+
</p>
|
|
163
|
+
)}
|
|
164
|
+
{attemptsRemaining !== null && attemptsRemaining <= 3 && (
|
|
165
|
+
<p className="text-sm text-muted-foreground">
|
|
166
|
+
{attemptsRemaining} attempt{attemptsRemaining === 1 ? '' : 's'} remaining
|
|
167
|
+
</p>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
<Button type="submit" className="w-full" disabled={!canSubmit || isLoading}>
|
|
171
|
+
{content.button}
|
|
172
|
+
</Button>
|
|
173
|
+
|
|
174
|
+
<Button
|
|
175
|
+
type="button"
|
|
176
|
+
variant="link"
|
|
177
|
+
className="w-full"
|
|
178
|
+
onClick={switchMode}
|
|
179
|
+
disabled={isLoading}
|
|
180
|
+
>
|
|
181
|
+
{useBackupCode ? content.toggle.toTotp : content.toggle.toBackup}
|
|
182
|
+
</Button>
|
|
183
|
+
</form>
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const TwoFactorChallengeDialog = memo(TwoFactorChallengeDialogRaw);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, Copy } from 'lucide-react';
|
|
4
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
5
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { AUTH_CONSTANTS, useTwoFactorSetup } from '@djangocfg/api/auth';
|
|
8
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
9
|
+
import {
|
|
10
|
+
Alert,
|
|
11
|
+
AlertDescription,
|
|
12
|
+
Button,
|
|
13
|
+
Collapsible,
|
|
14
|
+
CollapsibleContent,
|
|
15
|
+
CollapsibleTrigger,
|
|
16
|
+
OTPInput,
|
|
17
|
+
Preloader,
|
|
18
|
+
} from '@djangocfg/ui-core/components';
|
|
19
|
+
|
|
20
|
+
const QR_SIZE = 160;
|
|
21
|
+
const COPIED_MS = 2000;
|
|
22
|
+
|
|
23
|
+
export interface TwoFactorSetupProps {
|
|
24
|
+
/** Runs when the user dismisses the backup codes — never earlier, or the codes are lost. */
|
|
25
|
+
onComplete?: () => void;
|
|
26
|
+
/** Shows a "skip" link on the QR step. */
|
|
27
|
+
onSkip?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function useCopy() {
|
|
31
|
+
const [copied, setCopied] = useState(false);
|
|
32
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => () => {
|
|
35
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const copy = useCallback(async (text: string) => {
|
|
39
|
+
try {
|
|
40
|
+
await navigator.clipboard.writeText(text);
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setCopied(true);
|
|
45
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
46
|
+
timerRef.current = setTimeout(() => setCopied(false), COPIED_MS);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
return { copied, copy };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Enable 2FA: QR provisioning, confirmation code, then one-time backup codes.
|
|
54
|
+
* Self-contained — no sign-in context — so any settings surface can mount it.
|
|
55
|
+
*/
|
|
56
|
+
function TwoFactorSetupRaw({ onComplete, onSkip }: TwoFactorSetupProps) {
|
|
57
|
+
const t = useAppT();
|
|
58
|
+
const { copied, copy } = useCopy();
|
|
59
|
+
const [confirmCode, setConfirmCode] = useState('');
|
|
60
|
+
|
|
61
|
+
const { isLoading, error, setupData, backupCodes, backupCodesWarning, setupStep, startSetup, confirmSetup } =
|
|
62
|
+
useTwoFactorSetup({ onError: () => {} });
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (setupStep === 'idle') void startSetup();
|
|
66
|
+
}, [setupStep, startSetup]);
|
|
67
|
+
|
|
68
|
+
const content = useMemo(
|
|
69
|
+
() => ({
|
|
70
|
+
loading: t('layouts.auth.setup.loading.button'),
|
|
71
|
+
qr: {
|
|
72
|
+
title: t('layouts.auth.setup.qrCode.title'),
|
|
73
|
+
subtitle: t('layouts.auth.setup.qrCode.subtitle'),
|
|
74
|
+
manualEntry: t('layouts.auth.setup.qrCode.manualEntry'),
|
|
75
|
+
confirmPrompt: t('layouts.auth.setup.qrCode.confirmPrompt'),
|
|
76
|
+
button: t('layouts.auth.setup.qrCode.button'),
|
|
77
|
+
skip: t('layouts.auth.setup.qrCode.skip'),
|
|
78
|
+
},
|
|
79
|
+
done: {
|
|
80
|
+
title: t('layouts.auth.setup.complete.title'),
|
|
81
|
+
subtitle: t('layouts.auth.setup.complete.subtitle'),
|
|
82
|
+
instruction: t('layouts.auth.setup.complete.instruction'),
|
|
83
|
+
copyAll: t('layouts.auth.setup.complete.copyAll'),
|
|
84
|
+
copied: t('layouts.auth.setup.complete.copied'),
|
|
85
|
+
button: t('layouts.auth.setup.complete.done'),
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
[t],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const handleConfirm = useCallback(
|
|
92
|
+
(e: React.FormEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
void confirmSetup(confirmCode);
|
|
95
|
+
},
|
|
96
|
+
[confirmCode, confirmSetup],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (setupStep === 'complete' && backupCodes) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="w-full max-w-sm space-y-4">
|
|
102
|
+
<Header title={content.done.title} subtitle={content.done.subtitle} />
|
|
103
|
+
{backupCodesWarning && (
|
|
104
|
+
<Alert>
|
|
105
|
+
<AlertDescription>{backupCodesWarning}</AlertDescription>
|
|
106
|
+
</Alert>
|
|
107
|
+
)}
|
|
108
|
+
<div className="grid grid-cols-2 gap-1.5 rounded-lg border border-dashed border-border bg-muted/40 p-3.5">
|
|
109
|
+
{backupCodes.map((code) => (
|
|
110
|
+
<code key={code} className="p-1.5 text-center font-mono text-[13px] tracking-wide">
|
|
111
|
+
{code}
|
|
112
|
+
</code>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
<p className="text-center text-sm text-muted-foreground">{content.done.instruction}</p>
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="outline"
|
|
119
|
+
className="w-full"
|
|
120
|
+
onClick={() => void copy(backupCodes.join('\n'))}
|
|
121
|
+
>
|
|
122
|
+
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
|
123
|
+
{copied ? content.done.copied : content.done.copyAll}
|
|
124
|
+
</Button>
|
|
125
|
+
<Button type="button" className="w-full" onClick={() => onComplete?.()}>
|
|
126
|
+
{content.done.button}
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!setupData) {
|
|
133
|
+
return <Preloader variant="inline" text={content.loading} className="py-4" />;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<form onSubmit={handleConfirm} className="w-full max-w-sm space-y-4">
|
|
138
|
+
<Header title={content.qr.title} subtitle={content.qr.subtitle} />
|
|
139
|
+
|
|
140
|
+
<div className="flex justify-center">
|
|
141
|
+
{/* White plate: authenticator apps need dark-on-light contrast in both themes. */}
|
|
142
|
+
<div className="rounded-lg bg-white p-3.5 shadow-sm">
|
|
143
|
+
<QRCodeSVG value={setupData.provisioningUri} size={QR_SIZE} level="M" marginSize={0} />
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<Collapsible className="flex flex-col items-center">
|
|
148
|
+
<CollapsibleTrigger asChild>
|
|
149
|
+
<Button type="button" variant="link" size="sm">
|
|
150
|
+
{content.qr.manualEntry}
|
|
151
|
+
</Button>
|
|
152
|
+
</CollapsibleTrigger>
|
|
153
|
+
<CollapsibleContent className="flex flex-col items-center gap-2 p-3">
|
|
154
|
+
<code className="break-all rounded bg-muted px-3.5 py-2 text-center font-mono text-[13px] tracking-wider">
|
|
155
|
+
{setupData.secret}
|
|
156
|
+
</code>
|
|
157
|
+
<Button type="button" variant="link" size="sm" onClick={() => void copy(setupData.secret)}>
|
|
158
|
+
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
|
159
|
+
{copied ? content.done.copied : 'Copy'}
|
|
160
|
+
</Button>
|
|
161
|
+
</CollapsibleContent>
|
|
162
|
+
</Collapsible>
|
|
163
|
+
|
|
164
|
+
<p className="text-center text-sm text-muted-foreground">{content.qr.confirmPrompt}</p>
|
|
165
|
+
|
|
166
|
+
<div className="flex justify-center">
|
|
167
|
+
<OTPInput
|
|
168
|
+
length={AUTH_CONSTANTS.TOTP_LENGTH}
|
|
169
|
+
validationMode="numeric"
|
|
170
|
+
pasteBehavior="clean"
|
|
171
|
+
value={confirmCode}
|
|
172
|
+
onChange={setConfirmCode}
|
|
173
|
+
disabled={isLoading}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{error && (
|
|
178
|
+
<p role="alert" className="text-center text-sm text-destructive">
|
|
179
|
+
{error}
|
|
180
|
+
</p>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
<Button
|
|
184
|
+
type="submit"
|
|
185
|
+
className="w-full"
|
|
186
|
+
disabled={isLoading || confirmCode.length !== AUTH_CONSTANTS.TOTP_LENGTH}
|
|
187
|
+
>
|
|
188
|
+
{content.qr.button}
|
|
189
|
+
</Button>
|
|
190
|
+
|
|
191
|
+
{onSkip && (
|
|
192
|
+
<Button type="button" variant="link" className="w-full" onClick={onSkip} disabled={isLoading}>
|
|
193
|
+
{content.qr.skip}
|
|
194
|
+
</Button>
|
|
195
|
+
)}
|
|
196
|
+
</form>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function Header({ title, subtitle }: { title: string; subtitle: string }) {
|
|
201
|
+
return (
|
|
202
|
+
<div className="space-y-1 text-center">
|
|
203
|
+
<h3 className="text-base font-semibold">{title}</h3>
|
|
204
|
+
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const TwoFactorSetup = memo(TwoFactorSetupRaw);
|
package/src/snippets/index.ts
CHANGED
|
@@ -24,15 +24,9 @@ export interface MockAuthFormProviderProps extends Partial<AuthLayoutConfig> {
|
|
|
24
24
|
isLoading?: boolean;
|
|
25
25
|
acceptedTerms?: boolean;
|
|
26
26
|
error?: string;
|
|
27
|
-
twoFactorSessionId?: string | null;
|
|
28
|
-
shouldPrompt2FA?: boolean;
|
|
29
|
-
twoFactorCode?: string;
|
|
30
|
-
useBackupCode?: boolean;
|
|
31
27
|
rateLimitSeconds?: number;
|
|
28
|
+
resendSeconds?: number;
|
|
32
29
|
webmail?: WebmailLink | null;
|
|
33
|
-
is2FALoading?: boolean;
|
|
34
|
-
twoFactorWarning?: string | null;
|
|
35
|
-
twoFactorAttemptsRemaining?: number | null;
|
|
36
30
|
}
|
|
37
31
|
|
|
38
32
|
function formatRateLimit(seconds: number): string {
|
|
@@ -65,15 +59,9 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
65
59
|
isLoading: initialLoading = false,
|
|
66
60
|
acceptedTerms: initialAcceptedTerms = false,
|
|
67
61
|
error: initialError = '',
|
|
68
|
-
twoFactorSessionId: initialTwoFactorSessionId = null,
|
|
69
|
-
shouldPrompt2FA: initialShouldPrompt2FA = false,
|
|
70
|
-
twoFactorCode: initialTwoFactorCode = '',
|
|
71
|
-
useBackupCode: initialUseBackupCode = false,
|
|
72
62
|
rateLimitSeconds: initialRateLimitSeconds = 0,
|
|
63
|
+
resendSeconds: initialResendSeconds = 0,
|
|
73
64
|
webmail: initialWebmail = null,
|
|
74
|
-
is2FALoading = false,
|
|
75
|
-
twoFactorWarning = null,
|
|
76
|
-
twoFactorAttemptsRemaining = null,
|
|
77
65
|
}) => {
|
|
78
66
|
const [step, setStep] = useState<AuthStep>(initialStep);
|
|
79
67
|
const [identifier, setIdentifier] = useState(initialIdentifier);
|
|
@@ -84,11 +72,8 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
84
72
|
const [rememberMe, setRememberMe] = useState(false);
|
|
85
73
|
const [marketingConsent, setMarketingConsent] = useState<boolean | null>(null);
|
|
86
74
|
const [error, setError] = useState(initialError);
|
|
87
|
-
const [twoFactorSessionId, setTwoFactorSessionId] = useState<string | null>(initialTwoFactorSessionId);
|
|
88
|
-
const [shouldPrompt2FA, setShouldPrompt2FA] = useState(initialShouldPrompt2FA);
|
|
89
|
-
const [twoFactorCode, setTwoFactorCode] = useState(initialTwoFactorCode);
|
|
90
|
-
const [useBackupCode, setUseBackupCode] = useState(initialUseBackupCode);
|
|
91
75
|
const [rateLimitSeconds, setRateLimitSeconds] = useState(initialRateLimitSeconds);
|
|
76
|
+
const [resendSeconds, setResendSeconds] = useState(initialResendSeconds);
|
|
92
77
|
const [webmail, setWebmail] = useState<WebmailLink | null>(initialWebmail);
|
|
93
78
|
|
|
94
79
|
const isAutoSubmittingFromUrl = useRef(false);
|
|
@@ -109,13 +94,11 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
109
94
|
marketingConsent,
|
|
110
95
|
step,
|
|
111
96
|
error,
|
|
112
|
-
twoFactorSessionId,
|
|
113
|
-
shouldPrompt2FA,
|
|
114
|
-
twoFactorCode,
|
|
115
|
-
useBackupCode,
|
|
116
97
|
rateLimitSeconds,
|
|
117
98
|
isRateLimited: rateLimitSeconds > 0,
|
|
118
99
|
rateLimitLabel: formatRateLimit(rateLimitSeconds),
|
|
100
|
+
resendSeconds,
|
|
101
|
+
resendLabel: formatRateLimit(resendSeconds),
|
|
119
102
|
webmail,
|
|
120
103
|
|
|
121
104
|
// State handlers
|
|
@@ -129,11 +112,8 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
129
112
|
clearError: () => setError(''),
|
|
130
113
|
setStep,
|
|
131
114
|
setIsLoading,
|
|
132
|
-
setTwoFactorSessionId,
|
|
133
|
-
setShouldPrompt2FA,
|
|
134
|
-
setTwoFactorCode,
|
|
135
|
-
setUseBackupCode,
|
|
136
115
|
startRateLimitCountdown: (seconds: number) => setRateLimitSeconds(seconds),
|
|
116
|
+
startResendCountdown: (seconds: number) => setResendSeconds(seconds),
|
|
137
117
|
setWebmail,
|
|
138
118
|
|
|
139
119
|
// Submit handlers — all no-op (preventDefault only) so the visible
|
|
@@ -145,9 +125,6 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
145
125
|
handleResendOTP: async () => {},
|
|
146
126
|
handleBackToIdentifier: () => setStep('identifier'),
|
|
147
127
|
forceOTPStep: () => setStep('otp'),
|
|
148
|
-
handle2FASubmit: async (e) => stop(e),
|
|
149
|
-
handleUseBackupCode: () => setUseBackupCode(true),
|
|
150
|
-
handleUseTOTP: () => setUseBackupCode(false),
|
|
151
128
|
|
|
152
129
|
// Validation — accept anything that looks email-ish or 10+ digits.
|
|
153
130
|
validateIdentifier: (id: string) =>
|
|
@@ -156,11 +133,6 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
156
133
|
// Auto-submit ref
|
|
157
134
|
isAutoSubmittingFromUrl,
|
|
158
135
|
|
|
159
|
-
// 2FA state (read-only here; tests/stories override via props)
|
|
160
|
-
is2FALoading,
|
|
161
|
-
twoFactorWarning,
|
|
162
|
-
twoFactorAttemptsRemaining,
|
|
163
|
-
|
|
164
136
|
// Layout config
|
|
165
137
|
sourceUrl,
|
|
166
138
|
supportUrl,
|
|
@@ -180,15 +152,9 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
|
|
|
180
152
|
marketingConsent,
|
|
181
153
|
step,
|
|
182
154
|
error,
|
|
183
|
-
twoFactorSessionId,
|
|
184
|
-
shouldPrompt2FA,
|
|
185
|
-
twoFactorCode,
|
|
186
|
-
useBackupCode,
|
|
187
155
|
rateLimitSeconds,
|
|
156
|
+
resendSeconds,
|
|
188
157
|
webmail,
|
|
189
|
-
is2FALoading,
|
|
190
|
-
twoFactorWarning,
|
|
191
|
-
twoFactorAttemptsRemaining,
|
|
192
158
|
sourceUrl,
|
|
193
159
|
supportUrl,
|
|
194
160
|
termsUrl,
|
package/src/testing/index.ts
CHANGED
|
@@ -31,6 +31,8 @@ export { AuthBrandPanel } from '../layouts/AuthLayout/components/shared';
|
|
|
31
31
|
export {
|
|
32
32
|
IdentifierStep,
|
|
33
33
|
OTPStep,
|
|
34
|
-
TwoFactorStep,
|
|
35
|
-
SetupStep,
|
|
36
34
|
} from '../layouts/AuthLayout/components/steps';
|
|
35
|
+
|
|
36
|
+
// The 2FA challenge is its own surface; stories open it by beginning a challenge.
|
|
37
|
+
export { TwoFactorChallengeDialog, TwoFactorSetup } from '../snippets/TwoFactor';
|
|
38
|
+
export { beginTwoFactorChallenge, endTwoFactorChallenge } from '@djangocfg/api/auth';
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Check, Copy } from 'lucide-react';
|
|
4
|
-
import React, { memo, useMemo } from 'react';
|
|
5
|
-
|
|
6
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
-
|
|
8
|
-
import { useCopyToClipboard } from '../../../hooks';
|
|
9
|
-
import { AuthButton, AuthContainer, AuthHeader } from '../../shared';
|
|
10
|
-
|
|
11
|
-
interface SetupCompleteProps {
|
|
12
|
-
backupCodes: string[];
|
|
13
|
-
backupCodesWarning?: string | null;
|
|
14
|
-
onDone: () => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* SetupComplete - Backup codes display after 2FA setup.
|
|
19
|
-
*
|
|
20
|
-
* Memoised: re-renders only when `backupCodes` array reference,
|
|
21
|
-
* `backupCodesWarning`, or `onDone` change. The array content is
|
|
22
|
-
* compared by reference — the parent should not recreate the array
|
|
23
|
-
* on every render.
|
|
24
|
-
*/
|
|
25
|
-
function SetupCompleteRaw({
|
|
26
|
-
backupCodes,
|
|
27
|
-
backupCodesWarning,
|
|
28
|
-
onDone,
|
|
29
|
-
}: SetupCompleteProps) {
|
|
30
|
-
const { copied, copy } = useCopyToClipboard();
|
|
31
|
-
const t = useAppT();
|
|
32
|
-
const content = useMemo(() => ({
|
|
33
|
-
title: t('layouts.auth.setup.complete.title'),
|
|
34
|
-
subtitle: t('layouts.auth.setup.complete.subtitle'),
|
|
35
|
-
instruction: t('layouts.auth.setup.complete.instruction'),
|
|
36
|
-
copyAll: t('layouts.auth.setup.complete.copyAll'),
|
|
37
|
-
copied: t('layouts.auth.setup.complete.copied'),
|
|
38
|
-
done: t('layouts.auth.setup.complete.done'),
|
|
39
|
-
}), [t]);
|
|
40
|
-
|
|
41
|
-
const handleCopyAll = () => {
|
|
42
|
-
copy(backupCodes.join('\n'));
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<AuthContainer step="2fa-setup">
|
|
47
|
-
<AuthHeader title={content.title} subtitle={content.subtitle} />
|
|
48
|
-
|
|
49
|
-
<div className="auth-form-group">
|
|
50
|
-
{backupCodesWarning && (
|
|
51
|
-
<div className="auth-dev-notice">{backupCodesWarning}</div>
|
|
52
|
-
)}
|
|
53
|
-
|
|
54
|
-
<div className="auth-backup-codes">
|
|
55
|
-
{backupCodes.map((code, index) => (
|
|
56
|
-
<div key={index} className="auth-backup-code">
|
|
57
|
-
{code}
|
|
58
|
-
</div>
|
|
59
|
-
))}
|
|
60
|
-
</div>
|
|
61
|
-
|
|
62
|
-
<p className="auth-instruction">{content.instruction}</p>
|
|
63
|
-
|
|
64
|
-
<AuthButton type="button" variant="secondary" onClick={handleCopyAll}>
|
|
65
|
-
{copied ? (
|
|
66
|
-
<>
|
|
67
|
-
<Check className="w-4 h-4" />
|
|
68
|
-
{content.copied}
|
|
69
|
-
</>
|
|
70
|
-
) : (
|
|
71
|
-
<>
|
|
72
|
-
<Copy className="w-4 h-4" />
|
|
73
|
-
{content.copyAll}
|
|
74
|
-
</>
|
|
75
|
-
)}
|
|
76
|
-
</AuthButton>
|
|
77
|
-
|
|
78
|
-
<AuthButton type="button" onClick={onDone}>
|
|
79
|
-
{content.done}
|
|
80
|
-
</AuthButton>
|
|
81
|
-
</div>
|
|
82
|
-
</AuthContainer>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const SetupComplete = memo(SetupCompleteRaw);
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { memo, useMemo } from 'react';
|
|
4
|
-
|
|
5
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
6
|
-
import { Preloader } from '@djangocfg/ui-core/components';
|
|
7
|
-
|
|
8
|
-
import { AuthContainer, AuthHeader } from '../../shared';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* SetupLoading - Loading state for 2FA setup.
|
|
12
|
-
*
|
|
13
|
-
* Uses the ui-core Preloader (text-primary spinner) so the "Setting up…"
|
|
14
|
-
* indicator stays clearly visible instead of the near-invisible chip spinner.
|
|
15
|
-
*
|
|
16
|
-
* Memoised: no props; pure translation-driven component. Prevents
|
|
17
|
-
* re-renders when parent orchestrators re-render for unrelated reasons.
|
|
18
|
-
*/
|
|
19
|
-
function SetupLoadingRaw() {
|
|
20
|
-
const t = useAppT();
|
|
21
|
-
const content = useMemo(() => ({
|
|
22
|
-
title: t('layouts.auth.setup.loading.title'),
|
|
23
|
-
button: t('layouts.auth.setup.loading.button'),
|
|
24
|
-
}), [t]);
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<AuthContainer step="2fa-setup">
|
|
28
|
-
<AuthHeader title={content.title} />
|
|
29
|
-
<div className="auth-form-group">
|
|
30
|
-
<Preloader variant="inline" text={content.button} className="py-4" />
|
|
31
|
-
</div>
|
|
32
|
-
</AuthContainer>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const SetupLoading = memo(SetupLoadingRaw);
|