@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.
Files changed (30) hide show
  1. package/package.json +12 -12
  2. package/src/layouts/AuthLayout/AuthLayout.tsx +25 -7
  3. package/src/layouts/AuthLayout/README.md +16 -12
  4. package/src/layouts/AuthLayout/components/index.ts +0 -2
  5. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +8 -9
  6. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +1 -1
  7. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  8. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +10 -2
  9. package/src/layouts/AuthLayout/components/steps/index.ts +0 -3
  10. package/src/layouts/AuthLayout/constants.ts +0 -4
  11. package/src/layouts/AuthLayout/index.ts +0 -3
  12. package/src/layouts/AuthLayout/styles/auth.css +0 -110
  13. package/src/layouts/AuthLayout/types.ts +5 -2
  14. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +2 -2
  15. package/src/layouts/_components/locale-switcher/LocaleSwitcherDropdown.tsx +2 -1
  16. package/src/layouts/_components/locale-switcher/LocaleSwitcherTrigger.tsx +4 -3
  17. package/src/layouts/_components/locale-switcher/iconFlag.ts +5 -0
  18. package/src/snippets/TwoFactor/TwoFactorChallengeDialog.tsx +188 -0
  19. package/src/snippets/TwoFactor/TwoFactorSetup.tsx +209 -0
  20. package/src/snippets/TwoFactor/index.ts +2 -0
  21. package/src/snippets/index.ts +1 -0
  22. package/src/testing/MockAuthFormProvider.tsx +7 -41
  23. package/src/testing/index.ts +4 -2
  24. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +0 -86
  25. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +0 -36
  26. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +0 -141
  27. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +0 -145
  28. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +0 -133
  29. package/src/layouts/AuthLayout/hooks/index.ts +0 -1
  30. 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);
@@ -0,0 +1,2 @@
1
+ export { TwoFactorChallengeDialog, type TwoFactorChallengeDialogProps } from './TwoFactorChallengeDialog';
2
+ export { TwoFactorSetup, type TwoFactorSetupProps } from './TwoFactorSetup';
@@ -6,6 +6,7 @@
6
6
 
7
7
  export * from './Breadcrumbs';
8
8
  export * from './AuthDialog';
9
+ export * from './TwoFactor';
9
10
  export * from './Analytics';
10
11
 
11
12
  // Devtools lives in @djangocfg/devtools — re-exported here for convenience
@@ -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,
@@ -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);