@djangocfg/layouts 2.1.56 → 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 +11 -3
- package/package.json +6 -5
- package/src/layouts/AuthLayout/AuthLayout.tsx +55 -47
- package/src/layouts/AuthLayout/{AuthHelp.tsx → components/AuthHelp.tsx} +3 -3
- package/src/layouts/AuthLayout/components/AuthSuccess.tsx +101 -0
- package/src/layouts/AuthLayout/{IdentifierForm.tsx → components/IdentifierForm.tsx} +15 -29
- package/src/layouts/AuthLayout/{OTPForm.tsx → components/OTPForm.tsx} +29 -34
- package/src/layouts/AuthLayout/components/TwoFactorForm.tsx +140 -0
- package/src/layouts/AuthLayout/components/TwoFactorSetup.tsx +286 -0
- package/src/layouts/AuthLayout/components/index.ts +7 -0
- package/src/layouts/AuthLayout/{OAuthCallback.tsx → components/oauth/OAuthCallback.tsx} +16 -15
- package/src/layouts/AuthLayout/components/oauth/OAuthProviders.tsx +56 -0
- package/src/layouts/AuthLayout/components/oauth/index.ts +2 -0
- package/src/layouts/AuthLayout/{AuthContext.tsx → context.tsx} +15 -15
- package/src/layouts/AuthLayout/index.ts +20 -18
- package/src/layouts/AuthLayout/types.ts +22 -66
- package/src/layouts/AuthLayout/OAuthProviders.tsx +0 -76
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('
|
|
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
|
|
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.
|
|
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.
|
|
96
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
97
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
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.
|
|
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)
|
|
5
|
-
* Supports
|
|
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 {
|
|
32
|
-
import {
|
|
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 {
|
|
16
|
+
import type { AuthLayoutProps } from './types';
|
|
37
17
|
|
|
38
|
-
export type AuthLayoutProps
|
|
18
|
+
export type { AuthLayoutProps };
|
|
39
19
|
|
|
40
|
-
export const AuthLayout: React.FC<
|
|
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
|
-
{
|
|
46
|
-
|
|
47
|
-
<
|
|
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 ${
|
|
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
|
-
</
|
|
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 } =
|
|
54
|
+
const { step, setStep } = useAuthFormContext();
|
|
74
55
|
|
|
75
|
-
|
|
76
|
-
|
|
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 {
|
|
6
|
+
import { useAuthFormContext } from '../context';
|
|
7
7
|
|
|
8
|
-
import type { AuthHelpProps } from '
|
|
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 } =
|
|
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 {
|
|
11
|
+
import { useAuthFormContext } from '../context';
|
|
12
12
|
import { AuthHelp } from './AuthHelp';
|
|
13
|
-
import { OAuthProviders } from './
|
|
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
|
-
} =
|
|
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
|
-
|
|
219
|
-
|
|
218
|
+
size="lg"
|
|
219
|
+
className="w-full"
|
|
220
|
+
disabled={!identifier || (hasAnyLinks && !acceptedTerms)}
|
|
221
|
+
loading={isLoading}
|
|
220
222
|
>
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
310
|
-
|
|
302
|
+
size="lg"
|
|
303
|
+
className="w-full"
|
|
304
|
+
disabled={!identifier || (hasAnyLinks && !acceptedTerms)}
|
|
305
|
+
loading={isLoading}
|
|
311
306
|
>
|
|
312
|
-
|
|
313
|
-
|
|
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 '
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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={
|
|
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
|
-
|
|
118
|
-
|
|
123
|
+
size="lg"
|
|
124
|
+
className="w-full"
|
|
125
|
+
disabled={otp.length < 6}
|
|
126
|
+
loading={isLoading}
|
|
119
127
|
>
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
138
|
+
className="flex-1"
|
|
140
139
|
>
|
|
141
|
-
<
|
|
142
|
-
|
|
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
|
|
149
|
+
className="flex-1"
|
|
153
150
|
>
|
|
154
|
-
<
|
|
155
|
-
|
|
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
|
+
};
|