@djangocfg/layouts 2.1.109 → 2.1.111
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 +16 -14
- package/src/components/errors/ErrorBoundary.tsx +12 -6
- package/src/components/errors/ErrorLayout.tsx +19 -9
- package/src/components/errors/errorConfig.ts +28 -22
- package/src/layouts/AuthLayout/AuthLayout.tsx +92 -20
- package/src/layouts/AuthLayout/components/index.ts +11 -7
- package/src/layouts/AuthLayout/components/oauth/index.ts +0 -1
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +35 -0
- package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +56 -0
- package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +22 -0
- package/src/layouts/AuthLayout/components/shared/AuthError.tsx +26 -0
- package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +47 -0
- package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +53 -0
- package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +41 -0
- package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +42 -0
- package/src/layouts/AuthLayout/components/shared/ChannelToggle.tsx +48 -0
- package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +57 -0
- package/src/layouts/AuthLayout/components/shared/index.ts +21 -0
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +171 -0
- package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +114 -0
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +70 -0
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +24 -0
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +125 -0
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +91 -0
- package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +92 -0
- package/src/layouts/AuthLayout/components/steps/index.ts +6 -0
- package/src/layouts/AuthLayout/constants.ts +24 -0
- package/src/layouts/AuthLayout/content.ts +78 -0
- package/src/layouts/AuthLayout/hooks/index.ts +1 -0
- package/src/layouts/AuthLayout/hooks/useCopyToClipboard.ts +37 -0
- package/src/layouts/AuthLayout/index.ts +9 -5
- package/src/layouts/AuthLayout/styles/auth.css +578 -0
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +130 -58
- package/src/layouts/ProfileLayout/components/TwoFactorSection.tsx +2 -2
- package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +12 -4
- package/src/layouts/PublicLayout/components/PublicNavigation.tsx +6 -2
- package/src/layouts/_components/UserMenu.tsx +14 -6
- package/src/snippets/AuthDialog/AuthDialog.tsx +15 -6
- package/src/snippets/Breadcrumbs.tsx +19 -8
- package/src/snippets/McpChat/components/ChatPanel.tsx +16 -6
- package/src/snippets/McpChat/components/ChatSidebar.tsx +20 -8
- package/src/snippets/PWAInstall/components/A2HSHint.tsx +23 -10
- package/src/snippets/PWAInstall/components/DesktopGuide.tsx +44 -32
- package/src/snippets/PWAInstall/components/IOSGuideDrawer.tsx +34 -25
- package/src/snippets/PWAInstall/components/IOSGuideModal.tsx +34 -25
- package/src/snippets/PushNotifications/components/PushPrompt.tsx +16 -6
- package/src/layouts/AuthLayout/components/AuthHelp.tsx +0 -114
- package/src/layouts/AuthLayout/components/AuthSuccess.tsx +0 -101
- package/src/layouts/AuthLayout/components/IdentifierForm.tsx +0 -322
- package/src/layouts/AuthLayout/components/OTPForm.tsx +0 -174
- package/src/layouts/AuthLayout/components/TwoFactorForm.tsx +0 -140
- package/src/layouts/AuthLayout/components/TwoFactorSetup.tsx +0 -286
- package/src/layouts/AuthLayout/components/oauth/OAuthProviders.tsx +0 -56
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface AuthFooterProps {
|
|
6
|
+
termsUrl?: string;
|
|
7
|
+
privacyUrl?: string;
|
|
8
|
+
supportUrl?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* AuthFooter - Minimal link row for terms, privacy, and support
|
|
14
|
+
*/
|
|
15
|
+
export const AuthFooter: React.FC<AuthFooterProps> = ({
|
|
16
|
+
termsUrl,
|
|
17
|
+
privacyUrl,
|
|
18
|
+
supportUrl,
|
|
19
|
+
className = '',
|
|
20
|
+
}) => {
|
|
21
|
+
const links = [
|
|
22
|
+
termsUrl && { href: termsUrl, label: 'Terms' },
|
|
23
|
+
privacyUrl && { href: privacyUrl, label: 'Privacy' },
|
|
24
|
+
supportUrl && { href: supportUrl, label: 'Help' },
|
|
25
|
+
].filter(Boolean) as { href: string; label: string }[];
|
|
26
|
+
|
|
27
|
+
if (links.length === 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={`auth-footer ${className}`}>
|
|
33
|
+
{links.map((link, index) => (
|
|
34
|
+
<React.Fragment key={link.href}>
|
|
35
|
+
{index > 0 && <span className="auth-footer-dot">·</span>}
|
|
36
|
+
<a
|
|
37
|
+
href={link.href}
|
|
38
|
+
target="_blank"
|
|
39
|
+
rel="noopener noreferrer"
|
|
40
|
+
>
|
|
41
|
+
{link.label}
|
|
42
|
+
</a>
|
|
43
|
+
</React.Fragment>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface AuthHeaderProps {
|
|
6
|
+
logo?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
identifier?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AuthHeader - Apple-style header with logo, title, and subtitle
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Optional logo with scale animation
|
|
18
|
+
* - Large, bold title
|
|
19
|
+
* - Muted subtitle
|
|
20
|
+
* - Optional highlighted identifier
|
|
21
|
+
*/
|
|
22
|
+
export const AuthHeader: React.FC<AuthHeaderProps> = ({
|
|
23
|
+
logo,
|
|
24
|
+
title,
|
|
25
|
+
subtitle,
|
|
26
|
+
identifier,
|
|
27
|
+
className = '',
|
|
28
|
+
}) => {
|
|
29
|
+
return (
|
|
30
|
+
<div className={`auth-header ${className}`}>
|
|
31
|
+
{logo && (
|
|
32
|
+
<img
|
|
33
|
+
src={logo}
|
|
34
|
+
alt=""
|
|
35
|
+
className="auth-logo"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
<h1 className="auth-title">{title}</h1>
|
|
40
|
+
{subtitle && (
|
|
41
|
+
<p className="auth-subtitle">
|
|
42
|
+
{subtitle}
|
|
43
|
+
{identifier && (
|
|
44
|
+
<>
|
|
45
|
+
<br />
|
|
46
|
+
<span className="auth-identifier">{identifier}</span>
|
|
47
|
+
</>
|
|
48
|
+
)}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface AuthLinkProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
href?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AuthLink - Text link styled button/anchor
|
|
12
|
+
*/
|
|
13
|
+
export const AuthLink: React.FC<AuthLinkProps> = ({
|
|
14
|
+
children,
|
|
15
|
+
href,
|
|
16
|
+
className = '',
|
|
17
|
+
...props
|
|
18
|
+
}) => {
|
|
19
|
+
if (href) {
|
|
20
|
+
return (
|
|
21
|
+
<a
|
|
22
|
+
href={href}
|
|
23
|
+
className={`auth-link ${className}`}
|
|
24
|
+
target="_blank"
|
|
25
|
+
rel="noopener noreferrer"
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</a>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
className={`auth-link ${className}`}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</button>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { OTPInput } from '@djangocfg/ui-core/components';
|
|
6
|
+
|
|
7
|
+
import { AUTH } from '../../constants';
|
|
8
|
+
|
|
9
|
+
export interface AuthOTPInputProps {
|
|
10
|
+
value: string;
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
onComplete?: (value: string) => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
autoFocus?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* AuthOTPInput - Reusable OTP input with consistent styling
|
|
19
|
+
*
|
|
20
|
+
* Used in: OTPStep, TwoFactorStep, SetupStep
|
|
21
|
+
*/
|
|
22
|
+
export const AuthOTPInput: React.FC<AuthOTPInputProps> = ({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
onComplete,
|
|
26
|
+
disabled = false,
|
|
27
|
+
autoFocus = true,
|
|
28
|
+
}) => (
|
|
29
|
+
<div className="auth-otp-container auth-otp-wrapper">
|
|
30
|
+
<OTPInput
|
|
31
|
+
length={AUTH.OTP_LENGTH}
|
|
32
|
+
validationMode="numeric"
|
|
33
|
+
pasteBehavior="clean"
|
|
34
|
+
value={value}
|
|
35
|
+
onChange={onChange}
|
|
36
|
+
onComplete={onComplete}
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
autoFocus={autoFocus}
|
|
39
|
+
size="lg"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Mail, Phone } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import type { AuthChannel } from '../../types';
|
|
7
|
+
|
|
8
|
+
export interface ChannelToggleProps {
|
|
9
|
+
channel: AuthChannel;
|
|
10
|
+
onChange: (channel: AuthChannel) => void;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ChannelToggle - Apple-style segmented control for email/phone
|
|
17
|
+
*/
|
|
18
|
+
export const ChannelToggle: React.FC<ChannelToggleProps> = ({
|
|
19
|
+
channel,
|
|
20
|
+
onChange,
|
|
21
|
+
disabled = false,
|
|
22
|
+
className = '',
|
|
23
|
+
}) => {
|
|
24
|
+
return (
|
|
25
|
+
<div className={`auth-channel-toggle ${className}`}>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
className="auth-channel-option"
|
|
29
|
+
data-active={channel === 'email'}
|
|
30
|
+
onClick={() => onChange('email')}
|
|
31
|
+
disabled={disabled}
|
|
32
|
+
>
|
|
33
|
+
<Mail />
|
|
34
|
+
Email
|
|
35
|
+
</button>
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
className="auth-channel-option"
|
|
39
|
+
data-active={channel === 'phone'}
|
|
40
|
+
onClick={() => onChange('phone')}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
>
|
|
43
|
+
<Phone />
|
|
44
|
+
Phone
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { Checkbox } from '@djangocfg/ui-core/components';
|
|
6
|
+
|
|
7
|
+
export interface TermsCheckboxProps {
|
|
8
|
+
checked: boolean;
|
|
9
|
+
onChange: (checked: boolean) => void;
|
|
10
|
+
termsUrl?: string;
|
|
11
|
+
privacyUrl?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TermsCheckbox - Compact terms acceptance checkbox
|
|
18
|
+
*/
|
|
19
|
+
export const TermsCheckbox: React.FC<TermsCheckboxProps> = ({
|
|
20
|
+
checked,
|
|
21
|
+
onChange,
|
|
22
|
+
termsUrl,
|
|
23
|
+
privacyUrl,
|
|
24
|
+
disabled = false,
|
|
25
|
+
className = '',
|
|
26
|
+
}) => {
|
|
27
|
+
// Don't render if no links provided
|
|
28
|
+
if (!termsUrl && !privacyUrl) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className={`auth-terms ${className}`}>
|
|
34
|
+
<Checkbox
|
|
35
|
+
id="auth-terms"
|
|
36
|
+
checked={checked}
|
|
37
|
+
onCheckedChange={onChange}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
className="auth-terms-checkbox"
|
|
40
|
+
/>
|
|
41
|
+
<label htmlFor="auth-terms">
|
|
42
|
+
I agree to the{' '}
|
|
43
|
+
{termsUrl && (
|
|
44
|
+
<a href={termsUrl} target="_blank" rel="noopener noreferrer">
|
|
45
|
+
Terms
|
|
46
|
+
</a>
|
|
47
|
+
)}
|
|
48
|
+
{termsUrl && privacyUrl && ' and '}
|
|
49
|
+
{privacyUrl && (
|
|
50
|
+
<a href={privacyUrl} target="_blank" rel="noopener noreferrer">
|
|
51
|
+
Privacy Policy
|
|
52
|
+
</a>
|
|
53
|
+
)}
|
|
54
|
+
</label>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { AuthContainer } from './AuthContainer';
|
|
2
|
+
export { AuthHeader } from './AuthHeader';
|
|
3
|
+
export { AuthFooter } from './AuthFooter';
|
|
4
|
+
export { AuthDivider } from './AuthDivider';
|
|
5
|
+
export { AuthError } from './AuthError';
|
|
6
|
+
export { AuthButton } from './AuthButton';
|
|
7
|
+
export { AuthLink } from './AuthLink';
|
|
8
|
+
export { AuthOTPInput } from './AuthOTPInput';
|
|
9
|
+
export { ChannelToggle } from './ChannelToggle';
|
|
10
|
+
export { TermsCheckbox } from './TermsCheckbox';
|
|
11
|
+
|
|
12
|
+
export type { AuthContainerProps } from './AuthContainer';
|
|
13
|
+
export type { AuthHeaderProps } from './AuthHeader';
|
|
14
|
+
export type { AuthFooterProps } from './AuthFooter';
|
|
15
|
+
export type { AuthDividerProps } from './AuthDivider';
|
|
16
|
+
export type { AuthErrorProps } from './AuthError';
|
|
17
|
+
export type { AuthButtonProps } from './AuthButton';
|
|
18
|
+
export type { AuthLinkProps } from './AuthLink';
|
|
19
|
+
export type { AuthOTPInputProps } from './AuthOTPInput';
|
|
20
|
+
export type { ChannelToggleProps } from './ChannelToggle';
|
|
21
|
+
export type { TermsCheckboxProps } from './TermsCheckbox';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Github } from 'lucide-react';
|
|
4
|
+
import React, { useEffect } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useGithubAuth } from '@djangocfg/api/auth';
|
|
7
|
+
import { Input, PhoneInput } from '@djangocfg/ui-core/components';
|
|
8
|
+
|
|
9
|
+
import { AUTH_CONTENT } from '../../content';
|
|
10
|
+
import { useAuthFormContext } from '../../context';
|
|
11
|
+
import {
|
|
12
|
+
AuthButton,
|
|
13
|
+
AuthContainer,
|
|
14
|
+
AuthDivider,
|
|
15
|
+
AuthError,
|
|
16
|
+
AuthFooter,
|
|
17
|
+
AuthHeader,
|
|
18
|
+
ChannelToggle,
|
|
19
|
+
TermsCheckbox,
|
|
20
|
+
} from '../shared';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* IdentifierStep - Apple-style email/phone input step
|
|
24
|
+
*
|
|
25
|
+
* Clean, minimal design with:
|
|
26
|
+
* - Optional logo
|
|
27
|
+
* - Channel toggle (email/phone)
|
|
28
|
+
* - Single input field
|
|
29
|
+
* - Terms checkbox
|
|
30
|
+
* - OAuth options
|
|
31
|
+
*/
|
|
32
|
+
export const IdentifierStep: React.FC = () => {
|
|
33
|
+
const {
|
|
34
|
+
identifier,
|
|
35
|
+
channel,
|
|
36
|
+
isLoading,
|
|
37
|
+
acceptedTerms,
|
|
38
|
+
error,
|
|
39
|
+
logoUrl,
|
|
40
|
+
termsUrl,
|
|
41
|
+
privacyUrl,
|
|
42
|
+
supportUrl,
|
|
43
|
+
enablePhoneAuth,
|
|
44
|
+
enableGithubAuth,
|
|
45
|
+
sourceUrl,
|
|
46
|
+
setIdentifier,
|
|
47
|
+
setChannel,
|
|
48
|
+
setAcceptedTerms,
|
|
49
|
+
setError,
|
|
50
|
+
handleIdentifierSubmit,
|
|
51
|
+
detectChannelFromIdentifier,
|
|
52
|
+
validateIdentifier,
|
|
53
|
+
} = useAuthFormContext();
|
|
54
|
+
|
|
55
|
+
// GitHub OAuth
|
|
56
|
+
const { isLoading: isGithubLoading, startGithubAuth } = useGithubAuth({
|
|
57
|
+
sourceUrl,
|
|
58
|
+
onError: setError,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Force email if phone disabled
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!enablePhoneAuth && channel === 'phone') {
|
|
64
|
+
setChannel('email');
|
|
65
|
+
if (identifier && detectChannelFromIdentifier(identifier) === 'phone') {
|
|
66
|
+
setIdentifier('');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, [enablePhoneAuth, channel, identifier, setChannel, setIdentifier, detectChannelFromIdentifier]);
|
|
70
|
+
|
|
71
|
+
// Handle identifier change with auto-detection
|
|
72
|
+
const handleChange = (value: string) => {
|
|
73
|
+
setIdentifier(value);
|
|
74
|
+
const detected = detectChannelFromIdentifier(value);
|
|
75
|
+
if (detected && detected !== channel) {
|
|
76
|
+
if (detected === 'phone' && !enablePhoneAuth) return;
|
|
77
|
+
setChannel(detected);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Handle channel switch
|
|
82
|
+
const handleChannelChange = (newChannel: 'email' | 'phone') => {
|
|
83
|
+
if (newChannel === 'phone' && !enablePhoneAuth) return;
|
|
84
|
+
setChannel(newChannel);
|
|
85
|
+
if (identifier && !validateIdentifier(identifier, newChannel)) {
|
|
86
|
+
setIdentifier('');
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const content = AUTH_CONTENT.identifier;
|
|
91
|
+
const subtitle = channel === 'phone' ? content.subtitle.phone : content.subtitle.email;
|
|
92
|
+
const hasTerms = Boolean(termsUrl || privacyUrl);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<AuthContainer step="identifier">
|
|
96
|
+
<AuthHeader logo={logoUrl} title={content.title} subtitle={subtitle} />
|
|
97
|
+
|
|
98
|
+
<form onSubmit={handleIdentifierSubmit} className="auth-form-group">
|
|
99
|
+
{enablePhoneAuth && (
|
|
100
|
+
<ChannelToggle
|
|
101
|
+
channel={channel}
|
|
102
|
+
onChange={handleChannelChange}
|
|
103
|
+
disabled={isLoading}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{channel === 'phone' ? (
|
|
108
|
+
<div className="auth-phone-input">
|
|
109
|
+
<PhoneInput
|
|
110
|
+
value={identifier}
|
|
111
|
+
onChange={(value) => handleChange(value || '')}
|
|
112
|
+
disabled={isLoading}
|
|
113
|
+
placeholder={content.placeholder.phone}
|
|
114
|
+
defaultCountry="US"
|
|
115
|
+
className="auth-input"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
) : (
|
|
119
|
+
<Input
|
|
120
|
+
type="email"
|
|
121
|
+
value={identifier}
|
|
122
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
123
|
+
placeholder={content.placeholder.email}
|
|
124
|
+
disabled={isLoading}
|
|
125
|
+
required
|
|
126
|
+
autoFocus
|
|
127
|
+
className="auth-input"
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<TermsCheckbox
|
|
132
|
+
checked={acceptedTerms}
|
|
133
|
+
onChange={setAcceptedTerms}
|
|
134
|
+
termsUrl={termsUrl}
|
|
135
|
+
privacyUrl={privacyUrl}
|
|
136
|
+
disabled={isLoading}
|
|
137
|
+
/>
|
|
138
|
+
|
|
139
|
+
<AuthError message={error} />
|
|
140
|
+
|
|
141
|
+
<AuthButton
|
|
142
|
+
loading={isLoading}
|
|
143
|
+
disabled={!identifier || (hasTerms && !acceptedTerms)}
|
|
144
|
+
>
|
|
145
|
+
{content.button}
|
|
146
|
+
</AuthButton>
|
|
147
|
+
</form>
|
|
148
|
+
|
|
149
|
+
{enableGithubAuth && (
|
|
150
|
+
<>
|
|
151
|
+
<AuthDivider />
|
|
152
|
+
<AuthButton
|
|
153
|
+
type="button"
|
|
154
|
+
variant="secondary"
|
|
155
|
+
loading={isGithubLoading}
|
|
156
|
+
onClick={startGithubAuth}
|
|
157
|
+
>
|
|
158
|
+
<Github className="w-5 h-5" />
|
|
159
|
+
{content.oauth.github}
|
|
160
|
+
</AuthButton>
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<AuthFooter
|
|
165
|
+
termsUrl={termsUrl}
|
|
166
|
+
privacyUrl={privacyUrl}
|
|
167
|
+
supportUrl={supportUrl}
|
|
168
|
+
/>
|
|
169
|
+
</AuthContainer>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { config } from '../../../../utils';
|
|
6
|
+
import { AUTH } from '../../constants';
|
|
7
|
+
import { AUTH_CONTENT } from '../../content';
|
|
8
|
+
import { useAuthFormContext } from '../../context';
|
|
9
|
+
import {
|
|
10
|
+
AuthButton,
|
|
11
|
+
AuthContainer,
|
|
12
|
+
AuthError,
|
|
13
|
+
AuthHeader,
|
|
14
|
+
AuthLink,
|
|
15
|
+
AuthOTPInput,
|
|
16
|
+
} from '../shared';
|
|
17
|
+
|
|
18
|
+
type SubmitState = 'idle' | 'submitting';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OTPStep - Apple-style OTP verification
|
|
22
|
+
*
|
|
23
|
+
* Minimal design focused on the OTP input:
|
|
24
|
+
* - Clear title
|
|
25
|
+
* - Masked identifier for privacy
|
|
26
|
+
* - Large OTP input
|
|
27
|
+
* - Text links for actions
|
|
28
|
+
*/
|
|
29
|
+
export const OTPStep: React.FC = () => {
|
|
30
|
+
const {
|
|
31
|
+
identifier,
|
|
32
|
+
channel,
|
|
33
|
+
otp,
|
|
34
|
+
isLoading,
|
|
35
|
+
error,
|
|
36
|
+
setOtp,
|
|
37
|
+
handleOTPSubmit,
|
|
38
|
+
handleResendOTP,
|
|
39
|
+
handleBackToIdentifier,
|
|
40
|
+
isAutoSubmittingFromUrl,
|
|
41
|
+
} = useAuthFormContext();
|
|
42
|
+
|
|
43
|
+
const [submitState, setSubmitState] = useState<SubmitState>('idle');
|
|
44
|
+
|
|
45
|
+
// Mask identifier for privacy
|
|
46
|
+
const maskedIdentifier = React.useMemo(() => {
|
|
47
|
+
if (channel === 'phone') {
|
|
48
|
+
return identifier.slice(-4).padStart(identifier.length, '*');
|
|
49
|
+
}
|
|
50
|
+
const [local, domain] = identifier.split('@');
|
|
51
|
+
if (!local || !domain) return identifier;
|
|
52
|
+
return `${local[0]}${'*'.repeat(Math.min(local.length - 1, 5))}@${domain}`;
|
|
53
|
+
}, [identifier, channel]);
|
|
54
|
+
|
|
55
|
+
// Auto-submit on complete (state machine approach)
|
|
56
|
+
const handleOTPComplete = useCallback(
|
|
57
|
+
async (completedValue: string) => {
|
|
58
|
+
if (submitState !== 'idle' || isLoading || isAutoSubmittingFromUrl.current) return;
|
|
59
|
+
if (completedValue.length !== AUTH.OTP_LENGTH) return;
|
|
60
|
+
|
|
61
|
+
setSubmitState('submitting');
|
|
62
|
+
const fakeEvent = { preventDefault: () => {} } as React.FormEvent;
|
|
63
|
+
|
|
64
|
+
setTimeout(async () => {
|
|
65
|
+
try {
|
|
66
|
+
await handleOTPSubmit(fakeEvent);
|
|
67
|
+
} finally {
|
|
68
|
+
setSubmitState('idle');
|
|
69
|
+
}
|
|
70
|
+
}, AUTH.AUTO_SUBMIT_DELAY);
|
|
71
|
+
},
|
|
72
|
+
[handleOTPSubmit, isLoading, isAutoSubmittingFromUrl, submitState]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const content = AUTH_CONTENT.otp;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<AuthContainer step="otp">
|
|
79
|
+
<AuthHeader
|
|
80
|
+
title={content.title}
|
|
81
|
+
subtitle={content.subtitle}
|
|
82
|
+
identifier={maskedIdentifier}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
<form onSubmit={handleOTPSubmit} className="auth-form-group">
|
|
86
|
+
<AuthOTPInput
|
|
87
|
+
value={otp}
|
|
88
|
+
onChange={setOtp}
|
|
89
|
+
onComplete={handleOTPComplete}
|
|
90
|
+
disabled={isLoading}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
{config.isDevelopment && (
|
|
94
|
+
<div className="auth-dev-notice">{AUTH_CONTENT.dev.anyCodeWorks}</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
<AuthError message={error} />
|
|
98
|
+
|
|
99
|
+
<AuthButton loading={isLoading} disabled={otp.length < AUTH.OTP_LENGTH}>
|
|
100
|
+
{content.button}
|
|
101
|
+
</AuthButton>
|
|
102
|
+
|
|
103
|
+
<div className="auth-actions">
|
|
104
|
+
<AuthLink onClick={handleResendOTP} disabled={isLoading}>
|
|
105
|
+
{content.resend}
|
|
106
|
+
</AuthLink>
|
|
107
|
+
<AuthLink onClick={handleBackToIdentifier} disabled={isLoading}>
|
|
108
|
+
{content.changeIdentifier(channel)}
|
|
109
|
+
</AuthLink>
|
|
110
|
+
</div>
|
|
111
|
+
</form>
|
|
112
|
+
</AuthContainer>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, Copy } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { AUTH_CONTENT } from '../../../content';
|
|
7
|
+
import { useCopyToClipboard } from '../../../hooks';
|
|
8
|
+
import { AuthButton, AuthContainer, AuthHeader } from '../../shared';
|
|
9
|
+
|
|
10
|
+
interface SetupCompleteProps {
|
|
11
|
+
backupCodes: string[];
|
|
12
|
+
backupCodesWarning?: string;
|
|
13
|
+
onDone: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SetupComplete - Backup codes display after 2FA setup
|
|
18
|
+
*/
|
|
19
|
+
export const SetupComplete: React.FC<SetupCompleteProps> = ({
|
|
20
|
+
backupCodes,
|
|
21
|
+
backupCodesWarning,
|
|
22
|
+
onDone,
|
|
23
|
+
}) => {
|
|
24
|
+
const { copied, copy } = useCopyToClipboard();
|
|
25
|
+
const content = AUTH_CONTENT.setup.complete;
|
|
26
|
+
|
|
27
|
+
const handleCopyAll = () => {
|
|
28
|
+
copy(backupCodes.join('\n'));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<AuthContainer step="2fa-setup">
|
|
33
|
+
<AuthHeader title={content.title} subtitle={content.subtitle} />
|
|
34
|
+
|
|
35
|
+
<div className="auth-form-group">
|
|
36
|
+
{backupCodesWarning && (
|
|
37
|
+
<div className="auth-dev-notice">{backupCodesWarning}</div>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
<div className="auth-backup-codes">
|
|
41
|
+
{backupCodes.map((code, index) => (
|
|
42
|
+
<div key={index} className="auth-backup-code">
|
|
43
|
+
{code}
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<p className="auth-instruction">{content.instruction}</p>
|
|
49
|
+
|
|
50
|
+
<AuthButton type="button" variant="secondary" onClick={handleCopyAll}>
|
|
51
|
+
{copied ? (
|
|
52
|
+
<>
|
|
53
|
+
<Check className="w-4 h-4" />
|
|
54
|
+
{content.copied}
|
|
55
|
+
</>
|
|
56
|
+
) : (
|
|
57
|
+
<>
|
|
58
|
+
<Copy className="w-4 h-4" />
|
|
59
|
+
{content.copyAll}
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
</AuthButton>
|
|
63
|
+
|
|
64
|
+
<AuthButton type="button" onClick={onDone}>
|
|
65
|
+
{content.done}
|
|
66
|
+
</AuthButton>
|
|
67
|
+
</div>
|
|
68
|
+
</AuthContainer>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { AUTH_CONTENT } from '../../../content';
|
|
6
|
+
import { AuthButton, AuthContainer, AuthHeader } from '../../shared';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SetupLoading - Loading state for 2FA setup
|
|
10
|
+
*/
|
|
11
|
+
export const SetupLoading: React.FC = () => {
|
|
12
|
+
const content = AUTH_CONTENT.setup.loading;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<AuthContainer step="2fa-setup">
|
|
16
|
+
<AuthHeader title={content.title} />
|
|
17
|
+
<div className="auth-form-group">
|
|
18
|
+
<AuthButton loading disabled>
|
|
19
|
+
{content.button}
|
|
20
|
+
</AuthButton>
|
|
21
|
+
</div>
|
|
22
|
+
</AuthContainer>
|
|
23
|
+
);
|
|
24
|
+
};
|