@djangocfg/layouts 2.1.57 → 2.1.58

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/README.md CHANGED
@@ -678,7 +678,7 @@ export default function DashboardPage() {
678
678
  }
679
679
  ```
680
680
 
681
- ### Auth Page (OTP Authentication)
681
+ ### Auth Page (OTP + 2FA Authentication)
682
682
 
683
683
  ```tsx
684
684
  // app/auth/page.tsx
@@ -695,9 +695,10 @@ export default function AuthPage() {
695
695
  privacyUrl="/privacy"
696
696
  enablePhoneAuth={false}
697
697
  enableGithubAuth={true}
698
+ logoUrl="/logo.svg"
698
699
  redirectUrl="/dashboard"
699
700
  onOTPSuccess={() => {
700
- console.log('OTP authentication successful');
701
+ console.log('Authentication successful');
701
702
  }}
702
703
  onOAuthSuccess={(user, isNewUser, provider) => {
703
704
  console.log('OAuth success:', { user, isNewUser, provider });
@@ -714,17 +715,24 @@ export default function AuthPage() {
714
715
  }
715
716
  ```
716
717
 
718
+ **Authentication Flow:**
719
+ 1. **Identifier** → Enter email/phone or click GitHub OAuth
720
+ 2. **OTP** → Enter 6-digit verification code
721
+ 3. **2FA** → Enter TOTP code (if 2FA enabled for user)
722
+ 4. **Success** → Show logo animation, then redirect
723
+
717
724
  **AuthLayout Props:**
718
725
  | Prop | Type | Description |
719
726
  |------|------|-------------|
720
727
  | `sourceUrl` | `string` | Application URL for OTP emails |
721
728
  | `redirectUrl` | `string` | URL to redirect after successful auth (default: `/dashboard`) |
729
+ | `logoUrl` | `string` | Logo URL for success screen (SVG recommended) |
722
730
  | `enablePhoneAuth` | `boolean` | Enable phone number authentication |
723
731
  | `enableGithubAuth` | `boolean` | Enable GitHub OAuth |
724
732
  | `termsUrl` | `string` | Terms of service URL (shows checkbox if provided) |
725
733
  | `privacyUrl` | `string` | Privacy policy URL |
726
734
  | `supportUrl` | `string` | Support page URL |
727
- | `onOTPSuccess` | `() => void` | Callback after successful OTP verification |
735
+ | `onOTPSuccess` | `() => void` | Callback after successful authentication |
728
736
  | `onOAuthSuccess` | `(user, isNewUser, provider) => void` | Callback after successful OAuth |
729
737
  | `onError` | `(message: string) => void` | Error callback |
730
738
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.57",
3
+ "version": "2.1.58",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -92,9 +92,9 @@
92
92
  "check": "tsc --noEmit"
93
93
  },
94
94
  "peerDependencies": {
95
- "@djangocfg/api": "^2.1.57",
96
- "@djangocfg/centrifugo": "^2.1.57",
97
- "@djangocfg/ui-nextjs": "^2.1.57",
95
+ "@djangocfg/api": "^2.1.58",
96
+ "@djangocfg/centrifugo": "^2.1.58",
97
+ "@djangocfg/ui-nextjs": "^2.1.58",
98
98
  "@hookform/resolvers": "^5.2.0",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
@@ -112,11 +112,12 @@
112
112
  },
113
113
  "dependencies": {
114
114
  "nextjs-toploader": "^3.9.17",
115
+ "qrcode.react": "^4.2.0",
115
116
  "react-ga4": "^2.1.0",
116
117
  "uuid": "^11.1.0"
117
118
  },
118
119
  "devDependencies": {
119
- "@djangocfg/typescript-config": "^2.1.57",
120
+ "@djangocfg/typescript-config": "^2.1.58",
120
121
  "@types/node": "^24.7.2",
121
122
  "@types/react": "^19.1.0",
122
123
  "@types/react-dom": "^19.1.0",
@@ -1,26 +1,8 @@
1
1
  /**
2
2
  * Auth Layout
3
3
  *
4
- * Layout for authentication pages with OTP (email/phone) and OAuth (GitHub) support.
5
- * Supports two-step authentication flow: identifier input → OTP verification
6
- * Also handles OAuth callbacks automatically when enableGithubAuth is true.
7
- *
8
- * @example
9
- * ```tsx
10
- * import { AuthLayout } from '@djangocfg/layouts';
11
- *
12
- * <AuthLayout
13
- * sourceUrl="https://example.com"
14
- * supportUrl="https://example.com/support"
15
- * termsUrl="https://example.com/terms"
16
- * privacyUrl="https://example.com/privacy"
17
- * enablePhoneAuth={false}
18
- * enableGithubAuth={true}
19
- * redirectUrl="/dashboard"
20
- * >
21
- * {/* Optional custom content above forms *\/}
22
- * </AuthLayout>
23
- * ```
4
+ * Layout for authentication pages with OTP (email/phone), OAuth (GitHub), and 2FA support.
5
+ * Supports multi-step authentication flow: identifier → OTP → 2FA (if enabled)
24
6
  */
25
7
 
26
8
  'use client';
@@ -28,51 +10,77 @@
28
10
  import React from 'react';
29
11
 
30
12
  import { Suspense } from '../../components';
31
- import { AuthProvider, useAuthContext } from './AuthContext';
32
- import { IdentifierForm } from './IdentifierForm';
33
- import { OAuthCallback } from './OAuthCallback';
34
- import { OTPForm } from './OTPForm';
13
+ import { AuthSuccess, IdentifierForm, OAuthCallback, OTPForm, TwoFactorForm, TwoFactorSetup } from './components';
14
+ import { AuthFormProvider, useAuthFormContext } from './context';
35
15
 
36
- import type { AuthProps } from './types';
16
+ import type { AuthLayoutProps } from './types';
37
17
 
38
- export type AuthLayoutProps = AuthProps;
18
+ export type { AuthLayoutProps };
39
19
 
40
- export const AuthLayout: React.FC<AuthProps> = (props) => {
41
- const { enableGithubAuth, redirectUrl = '/dashboard', onOAuthSuccess, onError } = props;
20
+ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
21
+ const { enableGithubAuth, redirectUrl = '/dashboard', onOAuthSuccess, onError, className } = props;
42
22
 
43
23
  return (
44
24
  <Suspense>
45
- {/* Handle OAuth callback when GitHub auth is enabled */}
46
- {enableGithubAuth && (
47
- <Suspense fallback={null}>
48
- <OAuthCallback
49
- redirectUrl={redirectUrl}
50
- onSuccess={onOAuthSuccess ? (user, isNewUser) => onOAuthSuccess(user, isNewUser, 'github') : undefined}
51
- onError={onError}
52
- />
53
- </Suspense>
54
- )}
25
+ <AuthFormProvider {...props}>
26
+ {/* Full-screen success overlay */}
27
+ <AuthSuccessOverlay />
55
28
 
56
- <AuthProvider {...props}>
57
29
  <div
58
- className={`min-h-screen flex flex-col items-center justify-center bg-background py-6 px-4 sm:py-12 sm:px-6 lg:px-8 ${props.className || ''}`}
30
+ className={`min-h-screen flex flex-col items-center justify-center bg-background py-6 px-4 sm:py-12 sm:px-6 lg:px-8 ${className || ''}`}
59
31
  >
32
+ {/* Handle OAuth callback when GitHub auth is enabled */}
33
+ {enableGithubAuth && (
34
+ <Suspense fallback={null}>
35
+ <OAuthCallback
36
+ redirectUrl={redirectUrl}
37
+ onSuccess={onOAuthSuccess ? (user, isNewUser) => onOAuthSuccess(user, isNewUser, 'github') : undefined}
38
+ onError={onError}
39
+ />
40
+ </Suspense>
41
+ )}
42
+
60
43
  <div className="w-full sm:max-w-md space-y-8">
61
44
  {props.children}
62
-
63
45
  <AuthContent />
64
46
  </div>
65
47
  </div>
66
- </AuthProvider>
48
+ </AuthFormProvider>
67
49
  </Suspense>
68
50
  );
69
51
  };
70
52
 
71
- // Separate component to use the context
72
53
  const AuthContent: React.FC = () => {
73
- const { step } = useAuthContext();
54
+ const { step, setStep } = useAuthFormContext();
74
55
 
75
- return (
76
- <div>{step === 'identifier' ? <IdentifierForm /> : <OTPForm />}</div>
77
- );
56
+ switch (step) {
57
+ case 'identifier':
58
+ return <IdentifierForm />;
59
+ case 'otp':
60
+ return <OTPForm />;
61
+ case '2fa':
62
+ return <TwoFactorForm />;
63
+ case '2fa-setup':
64
+ return (
65
+ <TwoFactorSetup
66
+ onComplete={() => setStep('success')}
67
+ onSkip={() => setStep('success')}
68
+ />
69
+ );
70
+ case 'success':
71
+ // Success is rendered as full-screen overlay, return null here
72
+ return null;
73
+ default:
74
+ return <IdentifierForm />;
75
+ }
76
+ };
77
+
78
+ const AuthSuccessOverlay: React.FC = () => {
79
+ const { step } = useAuthFormContext();
80
+
81
+ if (step !== 'success') {
82
+ return null;
83
+ }
84
+
85
+ return <AuthSuccess />;
78
86
  };
@@ -3,15 +3,15 @@ import React from 'react';
3
3
 
4
4
  import { Button } from '@djangocfg/ui-nextjs/components';
5
5
 
6
- import { useAuthContext } from './AuthContext';
6
+ import { useAuthFormContext } from '../context';
7
7
 
8
- import type { AuthHelpProps } from './types';
8
+ import type { AuthHelpProps } from '../types';
9
9
 
10
10
  export const AuthHelp: React.FC<AuthHelpProps> = ({
11
11
  className = '',
12
12
  variant = 'default',
13
13
  }) => {
14
- const { supportUrl, channel } = useAuthContext();
14
+ const { supportUrl, channel } = useAuthFormContext();
15
15
 
16
16
  const getChannelIcon = () => {
17
17
  return channel === 'phone' ? (
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Auth Success Component
3
+ *
4
+ * Full-screen success layout shown after successful authentication.
5
+ * Displays a centered logo with a subtle animation, then redirects.
6
+ */
7
+
8
+ 'use client';
9
+
10
+ import React, { useEffect, useState } from 'react';
11
+
12
+ import { useCfgRouter } from '@djangocfg/ui-nextjs/hooks';
13
+
14
+ import { useAuthFormContext } from '../context';
15
+
16
+ export interface AuthSuccessProps {
17
+ className?: string;
18
+ /** Delay before redirect in ms (default: 1500) */
19
+ redirectDelay?: number;
20
+ }
21
+
22
+ export const AuthSuccess: React.FC<AuthSuccessProps> = ({ className, redirectDelay = 1500 }) => {
23
+ const { logoUrl, redirectUrl } = useAuthFormContext();
24
+ const router = useCfgRouter();
25
+ const [isVisible, setIsVisible] = useState(false);
26
+
27
+ useEffect(() => {
28
+ // Trigger animation after mount
29
+ const animTimer = setTimeout(() => setIsVisible(true), 50);
30
+
31
+ // Redirect after delay
32
+ const redirectTimer = setTimeout(() => {
33
+ const finalUrl = redirectUrl || '/dashboard';
34
+ router.hardPush(finalUrl);
35
+ }, redirectDelay);
36
+
37
+ return () => {
38
+ clearTimeout(animTimer);
39
+ clearTimeout(redirectTimer);
40
+ };
41
+ }, [redirectUrl, redirectDelay, router]);
42
+
43
+ if (!logoUrl) {
44
+ // Fallback: simple checkmark if no logo provided
45
+ return (
46
+ <div className={`fixed inset-0 flex items-center justify-center bg-background z-50 ${className || ''}`}>
47
+ <div
48
+ className={`transition-all duration-700 ease-out ${
49
+ isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
50
+ }`}
51
+ >
52
+ <div className="w-24 h-24 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
53
+ <svg
54
+ className="w-12 h-12 text-green-600 dark:text-green-400"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ viewBox="0 0 24 24"
58
+ >
59
+ <path
60
+ strokeLinecap="round"
61
+ strokeLinejoin="round"
62
+ strokeWidth={2}
63
+ d="M5 13l4 4L19 7"
64
+ />
65
+ </svg>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div className={`fixed inset-0 flex items-center justify-center bg-background z-50 ${className || ''}`}>
74
+ <div
75
+ className={`transition-all duration-700 ease-out ${
76
+ isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-90'
77
+ }`}
78
+ >
79
+ {/* Logo container with max size and animation */}
80
+ <div className="relative">
81
+ {/* Subtle glow effect */}
82
+ <div
83
+ className={`absolute inset-0 blur-3xl transition-opacity duration-1000 ${
84
+ isVisible ? 'opacity-20' : 'opacity-0'
85
+ }`}
86
+ style={{
87
+ background: 'radial-gradient(circle, currentColor 0%, transparent 70%)',
88
+ }}
89
+ />
90
+
91
+ {/* Logo image */}
92
+ <img
93
+ src={logoUrl}
94
+ alt="Success"
95
+ className="relative w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 object-contain"
96
+ />
97
+ </div>
98
+ </div>
99
+ </div>
100
+ );
101
+ };
@@ -8,9 +8,9 @@ import {
8
8
  PhoneInput, Tabs, TabsContent, TabsList, TabsTrigger
9
9
  } from '@djangocfg/ui-nextjs/components';
10
10
 
11
- import { useAuthContext } from './AuthContext';
11
+ import { useAuthFormContext } from '../context';
12
12
  import { AuthHelp } from './AuthHelp';
13
- import { OAuthProviders } from './OAuthProviders';
13
+ import { OAuthProviders } from './oauth';
14
14
 
15
15
  export const IdentifierForm: React.FC = () => {
16
16
  const {
@@ -28,7 +28,7 @@ export const IdentifierForm: React.FC = () => {
28
28
  detectChannelFromIdentifier,
29
29
  validateIdentifier,
30
30
  error,
31
- } = useAuthContext();
31
+ } = useAuthFormContext();
32
32
 
33
33
  const [localChannel, setLocalChannel] = useState<'email' | 'phone'>(channel);
34
34
 
@@ -215,20 +215,13 @@ export const IdentifierForm: React.FC = () => {
215
215
  {/* Submit Button */}
216
216
  <Button
217
217
  type="submit"
218
- className="w-full h-11 text-base font-medium"
219
- disabled={isLoading || !identifier || (hasAnyLinks && !acceptedTerms)}
218
+ size="lg"
219
+ className="w-full"
220
+ disabled={!identifier || (hasAnyLinks && !acceptedTerms)}
221
+ loading={isLoading}
220
222
  >
221
- {isLoading ? (
222
- <div className="flex items-center gap-2">
223
- <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
224
- Sending code...
225
- </div>
226
- ) : (
227
- <div className="flex items-center gap-2">
228
- <Send className="w-4 h-4" />
229
- Send verification code
230
- </div>
231
- )}
223
+ <Send className="w-4 h-4" />
224
+ Send verification code
232
225
  </Button>
233
226
  </form>
234
227
  </Tabs>
@@ -306,20 +299,13 @@ export const IdentifierForm: React.FC = () => {
306
299
  {/* Submit Button */}
307
300
  <Button
308
301
  type="submit"
309
- className="w-full h-11 text-base font-medium"
310
- disabled={isLoading || !identifier || (hasAnyLinks && !acceptedTerms)}
302
+ size="lg"
303
+ className="w-full"
304
+ disabled={!identifier || (hasAnyLinks && !acceptedTerms)}
305
+ loading={isLoading}
311
306
  >
312
- {isLoading ? (
313
- <div className="flex items-center gap-2">
314
- <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
315
- Sending code...
316
- </div>
317
- ) : (
318
- <div className="flex items-center gap-2">
319
- <Send className="w-4 h-4" />
320
- Send verification code
321
- </div>
322
- )}
307
+ <Send className="w-4 h-4" />
308
+ Send verification code
323
309
  </Button>
324
310
  </form>
325
311
  )}
@@ -7,8 +7,8 @@ import {
7
7
  Button, Card, CardContent, CardDescription, CardHeader, CardTitle, OTPInput
8
8
  } from '@djangocfg/ui-nextjs/components';
9
9
 
10
- import { config } from '../../utils';
11
- import { useAuthContext } from './AuthContext';
10
+ import { config } from '../../../utils';
11
+ import { useAuthFormContext } from '../context';
12
12
  import { AuthHelp } from './AuthHelp';
13
13
 
14
14
  export const OTPForm: React.FC = () => {
@@ -23,15 +23,17 @@ export const OTPForm: React.FC = () => {
23
23
  handleOTPSubmit,
24
24
  handleResendOTP,
25
25
  handleBackToIdentifier,
26
- } = useAuthContext();
26
+ isAutoSubmittingFromUrl,
27
+ } = useAuthFormContext();
27
28
 
28
29
  // Ref to track if auto-submit is in progress to prevent duplicate submissions
29
30
  const isAutoSubmittingRef = useRef(false);
30
31
 
31
32
  // Handle auto-submit when OTP is complete (after paste or last digit entry)
33
+ // Note: useAutoAuth already handles auto-submit from URL, this handles manual input/paste
32
34
  const handleOTPComplete = useCallback((completedValue: string) => {
33
- // Prevent duplicate submissions
34
- if (isAutoSubmittingRef.current || isLoading) return;
35
+ // Prevent duplicate submissions - check local ref, isLoading, and URL auto-submit ref
36
+ if (isAutoSubmittingRef.current || isLoading || isAutoSubmittingFromUrl.current) return;
35
37
 
36
38
  if (completedValue.length === 6) {
37
39
  isAutoSubmittingRef.current = true;
@@ -41,13 +43,17 @@ export const OTPForm: React.FC = () => {
41
43
  preventDefault: () => {},
42
44
  } as React.FormEvent;
43
45
 
44
- // Small delay to ensure state is updated
45
- setTimeout(() => {
46
- handleOTPSubmit(fakeEvent);
47
- isAutoSubmittingRef.current = false;
46
+ // Small delay to ensure state is updated, then submit
47
+ // Reset ref after submit completes (isLoading will handle preventing re-submits)
48
+ setTimeout(async () => {
49
+ try {
50
+ await handleOTPSubmit(fakeEvent);
51
+ } finally {
52
+ isAutoSubmittingRef.current = false;
53
+ }
48
54
  }, 100);
49
55
  }
50
- }, [handleOTPSubmit, isLoading]);
56
+ }, [handleOTPSubmit, isLoading, isAutoSubmittingFromUrl]);
51
57
 
52
58
  const getChannelIcon = () => {
53
59
  return channel === 'phone' ? (
@@ -98,7 +104,7 @@ export const OTPForm: React.FC = () => {
98
104
  onComplete={handleOTPComplete}
99
105
  disabled={isLoading}
100
106
  autoFocus={true}
101
- autoSubmit={true}
107
+ autoSubmit={false}
102
108
  size="lg"
103
109
  />
104
110
  </div>
@@ -114,20 +120,13 @@ export const OTPForm: React.FC = () => {
114
120
  <div className="space-y-4">
115
121
  <Button
116
122
  type="submit"
117
- className="w-full h-11 text-base font-medium"
118
- disabled={isLoading || otp.length < 6}
123
+ size="lg"
124
+ className="w-full"
125
+ disabled={otp.length < 6}
126
+ loading={isLoading}
119
127
  >
120
- {isLoading ? (
121
- <div className="flex items-center gap-2">
122
- <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
123
- Verifying...
124
- </div>
125
- ) : (
126
- <div className="flex items-center gap-2">
127
- <ShieldCheck className="w-5 h-5" />
128
- Verify Code
129
- </div>
130
- )}
128
+ <ShieldCheck className="w-5 h-5" />
129
+ Verify Code
131
130
  </Button>
132
131
 
133
132
  <div className="flex gap-3">
@@ -136,12 +135,10 @@ export const OTPForm: React.FC = () => {
136
135
  variant="outline"
137
136
  onClick={handleBackToIdentifier}
138
137
  disabled={isLoading}
139
- className="flex-1 h-10"
138
+ className="flex-1"
140
139
  >
141
- <div className="flex items-center gap-2">
142
- <ArrowLeft className="w-4 h-4" />
143
- Back
144
- </div>
140
+ <ArrowLeft className="w-4 h-4" />
141
+ Back
145
142
  </Button>
146
143
 
147
144
  <Button
@@ -149,12 +146,10 @@ export const OTPForm: React.FC = () => {
149
146
  variant="outline"
150
147
  onClick={handleResendOTP}
151
148
  disabled={isLoading}
152
- className="flex-1 h-10"
149
+ className="flex-1"
153
150
  >
154
- <div className="flex items-center gap-2">
155
- <RotateCw className="w-4 h-4" />
156
- Resend
157
- </div>
151
+ <RotateCw className="w-4 h-4" />
152
+ Resend
158
153
  </Button>
159
154
  </div>
160
155
  </div>
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ import { KeyRound, Loader2, ShieldCheck } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import {
7
+ Alert,
8
+ AlertDescription,
9
+ Button,
10
+ Card,
11
+ CardContent,
12
+ CardDescription,
13
+ CardFooter,
14
+ CardHeader,
15
+ CardTitle,
16
+ OTPInput,
17
+ Input,
18
+ } from '@djangocfg/ui-nextjs/components';
19
+
20
+ import { useAuthFormContext } from '../context';
21
+
22
+ /**
23
+ * Two-Factor Authentication Form
24
+ *
25
+ * Displays TOTP code input or backup code input based on user selection.
26
+ * Used after OTP/OAuth verification when user has 2FA enabled.
27
+ */
28
+ export const TwoFactorForm: React.FC = () => {
29
+ const {
30
+ twoFactorCode,
31
+ useBackupCode,
32
+ error,
33
+ is2FALoading,
34
+ twoFactorWarning,
35
+ setTwoFactorCode,
36
+ handle2FASubmit,
37
+ handleUseBackupCode,
38
+ handleUseTOTP,
39
+ } = useAuthFormContext();
40
+
41
+ return (
42
+ <Card className="w-full">
43
+ <CardHeader className="space-y-1 text-center">
44
+ <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-2">
45
+ <ShieldCheck className="w-6 h-6 text-primary" />
46
+ </div>
47
+ <CardTitle className="text-2xl">Two-Factor Authentication</CardTitle>
48
+ <CardDescription>
49
+ {useBackupCode
50
+ ? 'Enter one of your backup recovery codes'
51
+ : 'Enter the 6-digit code from your authenticator app'}
52
+ </CardDescription>
53
+ </CardHeader>
54
+
55
+ <form onSubmit={handle2FASubmit}>
56
+ <CardContent className="space-y-4">
57
+ {/* Error Alert */}
58
+ {error && (
59
+ <Alert variant="destructive">
60
+ <AlertDescription>{error}</AlertDescription>
61
+ </Alert>
62
+ )}
63
+
64
+ {/* Warning Alert (e.g., low backup codes) */}
65
+ {twoFactorWarning && (
66
+ <Alert>
67
+ <AlertDescription>{twoFactorWarning}</AlertDescription>
68
+ </Alert>
69
+ )}
70
+
71
+ {/* TOTP Code Input */}
72
+ {!useBackupCode && (
73
+ <div className="flex justify-center">
74
+ <OTPInput
75
+ length={6}
76
+ validationMode="numeric"
77
+ pasteBehavior="clean"
78
+ value={twoFactorCode}
79
+ onChange={setTwoFactorCode}
80
+ disabled={is2FALoading}
81
+ autoFocus={true}
82
+ size="lg"
83
+ />
84
+ </div>
85
+ )}
86
+
87
+ {/* Backup Code Input */}
88
+ {useBackupCode && (
89
+ <div className="space-y-2">
90
+ <Input
91
+ type="text"
92
+ placeholder="Enter backup code"
93
+ value={twoFactorCode}
94
+ onChange={(e) => setTwoFactorCode(e.target.value.toUpperCase())}
95
+ disabled={is2FALoading}
96
+ className="text-center font-mono text-lg tracking-widest"
97
+ maxLength={12}
98
+ autoComplete="off"
99
+ />
100
+ <p className="text-xs text-muted-foreground text-center">
101
+ Backup codes are 8 characters, letters and numbers
102
+ </p>
103
+ </div>
104
+ )}
105
+ </CardContent>
106
+
107
+ <CardFooter className="flex flex-col space-y-3">
108
+ <Button
109
+ type="submit"
110
+ className="w-full"
111
+ disabled={is2FALoading || (!useBackupCode && twoFactorCode.length !== 6)}
112
+ >
113
+ {is2FALoading ? (
114
+ <>
115
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
116
+ Verifying...
117
+ </>
118
+ ) : (
119
+ 'Verify'
120
+ )}
121
+ </Button>
122
+
123
+ {/* Toggle between TOTP and Backup Code */}
124
+ <Button
125
+ type="button"
126
+ variant="ghost"
127
+ className="w-full text-sm"
128
+ onClick={useBackupCode ? handleUseTOTP : handleUseBackupCode}
129
+ disabled={is2FALoading}
130
+ >
131
+ <KeyRound className="mr-2 h-4 w-4" />
132
+ {useBackupCode
133
+ ? 'Use authenticator app instead'
134
+ : "Can't access your authenticator? Use a backup code"}
135
+ </Button>
136
+ </CardFooter>
137
+ </form>
138
+ </Card>
139
+ );
140
+ };