@djangocfg/layouts 2.1.580 → 2.1.581
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -12
- package/src/layouts/AuthLayout/AuthLayout.tsx +25 -7
- package/src/layouts/AuthLayout/README.md +16 -12
- package/src/layouts/AuthLayout/components/index.ts +0 -2
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +8 -9
- package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
- package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +10 -2
- package/src/layouts/AuthLayout/components/steps/index.ts +0 -3
- package/src/layouts/AuthLayout/constants.ts +0 -4
- package/src/layouts/AuthLayout/index.ts +0 -3
- package/src/layouts/AuthLayout/styles/auth.css +0 -110
- package/src/layouts/AuthLayout/types.ts +5 -2
- package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +2 -2
- package/src/layouts/_components/locale-switcher/LocaleSwitcherDropdown.tsx +2 -1
- package/src/layouts/_components/locale-switcher/LocaleSwitcherTrigger.tsx +4 -3
- package/src/layouts/_components/locale-switcher/iconFlag.ts +5 -0
- package/src/snippets/TwoFactor/TwoFactorChallengeDialog.tsx +188 -0
- package/src/snippets/TwoFactor/TwoFactorSetup.tsx +209 -0
- package/src/snippets/TwoFactor/index.ts +2 -0
- package/src/snippets/index.ts +1 -0
- package/src/testing/MockAuthFormProvider.tsx +7 -41
- package/src/testing/index.ts +4 -2
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +0 -86
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +0 -36
- package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +0 -141
- package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +0 -145
- package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +0 -133
- package/src/layouts/AuthLayout/hooks/index.ts +0 -1
- package/src/layouts/AuthLayout/hooks/useCopyToClipboard.ts +0 -51
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Check, Copy } from 'lucide-react';
|
|
4
|
-
import React, { memo, useMemo, useState } from 'react';
|
|
5
|
-
import { QRCodeSVG } from 'qrcode.react';
|
|
6
|
-
|
|
7
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
-
import {
|
|
9
|
-
Collapsible,
|
|
10
|
-
CollapsibleContent,
|
|
11
|
-
CollapsibleTrigger,
|
|
12
|
-
} from '@djangocfg/ui-core/components';
|
|
13
|
-
|
|
14
|
-
import { AUTH } from '../../../constants';
|
|
15
|
-
import { useCopyToClipboard } from '../../../hooks';
|
|
16
|
-
import {
|
|
17
|
-
AuthButton,
|
|
18
|
-
AuthContainer,
|
|
19
|
-
AuthError,
|
|
20
|
-
AuthHeader,
|
|
21
|
-
AuthLink,
|
|
22
|
-
AuthOTPInput,
|
|
23
|
-
} from '../../shared';
|
|
24
|
-
|
|
25
|
-
interface SetupQRCodeProps {
|
|
26
|
-
provisioningUri: string;
|
|
27
|
-
secret: string;
|
|
28
|
-
isLoading: boolean;
|
|
29
|
-
error?: string | null;
|
|
30
|
-
onConfirm: (code: string) => Promise<unknown>;
|
|
31
|
-
onSkip?: () => void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* SetupQRCode - QR code scanning step for 2FA setup.
|
|
36
|
-
*
|
|
37
|
-
* Memoised: re-renders only when `provisioningUri`, `secret`, `isLoading`,
|
|
38
|
-
* `error`, `onConfirm` or `onSkip` change. `onConfirm` and `onSkip` are
|
|
39
|
-
* compared by reference — callers should stabilise them with useCallback.
|
|
40
|
-
*/
|
|
41
|
-
function SetupQRCodeRaw({
|
|
42
|
-
provisioningUri,
|
|
43
|
-
secret,
|
|
44
|
-
isLoading,
|
|
45
|
-
error,
|
|
46
|
-
onConfirm,
|
|
47
|
-
onSkip,
|
|
48
|
-
}: SetupQRCodeProps) {
|
|
49
|
-
const [confirmCode, setConfirmCode] = useState('');
|
|
50
|
-
const { copied, copy } = useCopyToClipboard();
|
|
51
|
-
const t = useAppT();
|
|
52
|
-
const content = useMemo(() => ({
|
|
53
|
-
title: t('layouts.auth.setup.qrCode.title'),
|
|
54
|
-
subtitle: t('layouts.auth.setup.qrCode.subtitle'),
|
|
55
|
-
manualEntry: t('layouts.auth.setup.qrCode.manualEntry'),
|
|
56
|
-
confirmPrompt: t('layouts.auth.setup.qrCode.confirmPrompt'),
|
|
57
|
-
button: t('layouts.auth.setup.qrCode.button'),
|
|
58
|
-
skip: t('layouts.auth.setup.qrCode.skip'),
|
|
59
|
-
copied: t('layouts.auth.setup.complete.copied'),
|
|
60
|
-
copy: 'Copy',
|
|
61
|
-
}), [t]);
|
|
62
|
-
|
|
63
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
64
|
-
e.preventDefault();
|
|
65
|
-
await onConfirm(confirmCode);
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const handleCopySecret = () => {
|
|
69
|
-
copy(secret);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<AuthContainer step="2fa-setup">
|
|
74
|
-
<AuthHeader title={content.title} subtitle={content.subtitle} />
|
|
75
|
-
|
|
76
|
-
<form onSubmit={handleSubmit} className="auth-form-group">
|
|
77
|
-
<div className="auth-qr-container">
|
|
78
|
-
<div className="auth-qr">
|
|
79
|
-
<QRCodeSVG
|
|
80
|
-
value={provisioningUri}
|
|
81
|
-
size={AUTH.QR_CODE_SIZE}
|
|
82
|
-
level="M"
|
|
83
|
-
marginSize={0}
|
|
84
|
-
/>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
87
|
-
|
|
88
|
-
<Collapsible className="auth-collapsible-centered">
|
|
89
|
-
<CollapsibleTrigger asChild>
|
|
90
|
-
<AuthLink>{content.manualEntry}</AuthLink>
|
|
91
|
-
</CollapsibleTrigger>
|
|
92
|
-
<CollapsibleContent>
|
|
93
|
-
<div className="auth-secret-container">
|
|
94
|
-
<code className="auth-secret">{secret}</code>
|
|
95
|
-
<AuthLink onClick={handleCopySecret}>
|
|
96
|
-
{copied ? (
|
|
97
|
-
<>
|
|
98
|
-
<Check className="w-4 h-4" />
|
|
99
|
-
{content.copied}
|
|
100
|
-
</>
|
|
101
|
-
) : (
|
|
102
|
-
<>
|
|
103
|
-
<Copy className="w-4 h-4" />
|
|
104
|
-
{content.copy}
|
|
105
|
-
</>
|
|
106
|
-
)}
|
|
107
|
-
</AuthLink>
|
|
108
|
-
</div>
|
|
109
|
-
</CollapsibleContent>
|
|
110
|
-
</Collapsible>
|
|
111
|
-
|
|
112
|
-
<p className="auth-instruction">{content.confirmPrompt}</p>
|
|
113
|
-
|
|
114
|
-
<AuthOTPInput
|
|
115
|
-
value={confirmCode}
|
|
116
|
-
onChange={setConfirmCode}
|
|
117
|
-
disabled={isLoading}
|
|
118
|
-
/>
|
|
119
|
-
|
|
120
|
-
<AuthError message={error} />
|
|
121
|
-
|
|
122
|
-
<AuthButton
|
|
123
|
-
loading={isLoading}
|
|
124
|
-
disabled={confirmCode.length !== AUTH.TOTP_LENGTH}
|
|
125
|
-
>
|
|
126
|
-
{content.button}
|
|
127
|
-
</AuthButton>
|
|
128
|
-
|
|
129
|
-
{onSkip && (
|
|
130
|
-
<div className="auth-actions">
|
|
131
|
-
<AuthLink onClick={onSkip} disabled={isLoading}>
|
|
132
|
-
{content.skip}
|
|
133
|
-
</AuthLink>
|
|
134
|
-
</div>
|
|
135
|
-
)}
|
|
136
|
-
</form>
|
|
137
|
-
</AuthContainer>
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export const SetupQRCode = memo(SetupQRCodeRaw);
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { memo } from 'react';
|
|
4
|
-
|
|
5
|
-
import { useTwoFactorSetup } from '@djangocfg/api/auth';
|
|
6
|
-
|
|
7
|
-
import { useAuthFormContext } from '../../../context';
|
|
8
|
-
import { SetupComplete } from './SetupComplete';
|
|
9
|
-
import { SetupLoading } from './SetupLoading';
|
|
10
|
-
import { SetupQRCode } from './SetupQRCode';
|
|
11
|
-
|
|
12
|
-
export interface SetupStepProps {
|
|
13
|
-
onComplete?: () => void;
|
|
14
|
-
onSkip?: () => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* SetupStep - Orchestrator for 2FA setup flow (requires AuthFormContext).
|
|
19
|
-
* For use outside AuthLayout use SetupStepStandalone.
|
|
20
|
-
*
|
|
21
|
-
* Memoised: re-renders only when `onComplete` or `onSkip` references
|
|
22
|
-
* change. Callers should stabilise these callbacks with useCallback.
|
|
23
|
-
*/
|
|
24
|
-
function SetupStepRaw({ onComplete, onSkip }: SetupStepProps) {
|
|
25
|
-
const { setStep } = useAuthFormContext();
|
|
26
|
-
|
|
27
|
-
const handleComplete = () => {
|
|
28
|
-
onComplete?.();
|
|
29
|
-
setStep('success');
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const handleSkip = () => {
|
|
33
|
-
onSkip?.();
|
|
34
|
-
setStep('success');
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const {
|
|
38
|
-
isLoading,
|
|
39
|
-
error,
|
|
40
|
-
setupData,
|
|
41
|
-
backupCodes,
|
|
42
|
-
backupCodesWarning,
|
|
43
|
-
setupStep,
|
|
44
|
-
startSetup,
|
|
45
|
-
confirmSetup,
|
|
46
|
-
} = useTwoFactorSetup({
|
|
47
|
-
onComplete: handleComplete,
|
|
48
|
-
onError: () => {},
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Start setup on mount
|
|
52
|
-
React.useEffect(() => {
|
|
53
|
-
if (setupStep === 'idle') {
|
|
54
|
-
startSetup();
|
|
55
|
-
}
|
|
56
|
-
}, [setupStep, startSetup]);
|
|
57
|
-
|
|
58
|
-
// Loading state
|
|
59
|
-
if (isLoading && !setupData) {
|
|
60
|
-
return <SetupLoading />;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Complete - show backup codes
|
|
64
|
-
if (setupStep === 'complete' && backupCodes) {
|
|
65
|
-
return (
|
|
66
|
-
<SetupComplete
|
|
67
|
-
backupCodes={backupCodes}
|
|
68
|
-
backupCodesWarning={backupCodesWarning}
|
|
69
|
-
onDone={handleComplete}
|
|
70
|
-
/>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Setup - show QR code
|
|
75
|
-
if (setupData) {
|
|
76
|
-
return (
|
|
77
|
-
<SetupQRCode
|
|
78
|
-
provisioningUri={setupData.provisioningUri}
|
|
79
|
-
secret={setupData.secret}
|
|
80
|
-
isLoading={isLoading}
|
|
81
|
-
error={error}
|
|
82
|
-
onConfirm={confirmSetup}
|
|
83
|
-
onSkip={onSkip ? handleSkip : undefined}
|
|
84
|
-
/>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Fallback to loading
|
|
89
|
-
return <SetupLoading />;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export const SetupStep = memo(SetupStepRaw);
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* SetupStepStandalone — same flow as SetupStep but without AuthFormContext.
|
|
96
|
-
* Use this inside ProfileLayout or any page outside AuthLayout.
|
|
97
|
-
*
|
|
98
|
-
* Memoised: re-renders only when `onComplete` or `onSkip` references
|
|
99
|
-
* change. Callers should stabilise these callbacks with useCallback.
|
|
100
|
-
*/
|
|
101
|
-
function SetupStepStandaloneRaw({ onComplete, onSkip }: SetupStepProps) {
|
|
102
|
-
const {
|
|
103
|
-
isLoading,
|
|
104
|
-
error,
|
|
105
|
-
setupData,
|
|
106
|
-
backupCodes,
|
|
107
|
-
backupCodesWarning,
|
|
108
|
-
setupStep,
|
|
109
|
-
startSetup,
|
|
110
|
-
confirmSetup,
|
|
111
|
-
} = useTwoFactorSetup({ onComplete, onError: () => {} });
|
|
112
|
-
|
|
113
|
-
React.useEffect(() => {
|
|
114
|
-
if (setupStep === 'idle') startSetup();
|
|
115
|
-
}, [setupStep, startSetup]);
|
|
116
|
-
|
|
117
|
-
if (isLoading && !setupData) return <SetupLoading />;
|
|
118
|
-
|
|
119
|
-
if (setupStep === 'complete' && backupCodes) {
|
|
120
|
-
return (
|
|
121
|
-
<SetupComplete
|
|
122
|
-
backupCodes={backupCodes}
|
|
123
|
-
backupCodesWarning={backupCodesWarning}
|
|
124
|
-
onDone={() => onComplete?.()}
|
|
125
|
-
/>
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (setupData) {
|
|
130
|
-
return (
|
|
131
|
-
<SetupQRCode
|
|
132
|
-
provisioningUri={setupData.provisioningUri}
|
|
133
|
-
secret={setupData.secret}
|
|
134
|
-
isLoading={isLoading}
|
|
135
|
-
error={error}
|
|
136
|
-
onConfirm={confirmSetup}
|
|
137
|
-
onSkip={onSkip}
|
|
138
|
-
/>
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return <SetupLoading />;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export const SetupStepStandalone = memo(SetupStepStandaloneRaw);
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { memo, useMemo } from 'react';
|
|
4
|
-
|
|
5
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
6
|
-
|
|
7
|
-
import { AUTH } from '../../constants';
|
|
8
|
-
import { useAuthFormContext } from '../../context';
|
|
9
|
-
import {
|
|
10
|
-
AuthButton,
|
|
11
|
-
AuthContainer,
|
|
12
|
-
AuthError,
|
|
13
|
-
AuthHeader,
|
|
14
|
-
AuthLink,
|
|
15
|
-
AuthOTPInput,
|
|
16
|
-
} from '../shared';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* TwoFactorStep - Apple-style 2FA verification.
|
|
20
|
-
*
|
|
21
|
-
* Minimal design with:
|
|
22
|
-
* - TOTP code input (default)
|
|
23
|
-
* - Backup code input (alternate)
|
|
24
|
-
* - Clean toggle between modes
|
|
25
|
-
*
|
|
26
|
-
* Memoised: no props; reads from context. Prevents re-renders when
|
|
27
|
-
* AuthContent switches steps or sibling branches update.
|
|
28
|
-
*/
|
|
29
|
-
function TwoFactorStepRaw() {
|
|
30
|
-
const {
|
|
31
|
-
twoFactorCode,
|
|
32
|
-
useBackupCode,
|
|
33
|
-
error,
|
|
34
|
-
is2FALoading,
|
|
35
|
-
twoFactorWarning,
|
|
36
|
-
twoFactorAttemptsRemaining,
|
|
37
|
-
setTwoFactorCode,
|
|
38
|
-
handle2FASubmit,
|
|
39
|
-
handleUseBackupCode,
|
|
40
|
-
handleUseTOTP,
|
|
41
|
-
handleBackToIdentifier,
|
|
42
|
-
} = useAuthFormContext();
|
|
43
|
-
|
|
44
|
-
// Translations
|
|
45
|
-
const t = useAppT();
|
|
46
|
-
const content = useMemo(() => ({
|
|
47
|
-
title: t('layouts.auth.twoFactor.title'),
|
|
48
|
-
subtitle: {
|
|
49
|
-
totp: t('layouts.auth.twoFactor.subtitle.totp'),
|
|
50
|
-
backup: t('layouts.auth.twoFactor.subtitle.backup'),
|
|
51
|
-
},
|
|
52
|
-
placeholder: {
|
|
53
|
-
backup: t('layouts.auth.twoFactor.placeholder.backup'),
|
|
54
|
-
},
|
|
55
|
-
button: t('layouts.auth.twoFactor.button'),
|
|
56
|
-
toggle: {
|
|
57
|
-
toBackup: t('layouts.auth.twoFactor.toggle.toBackup'),
|
|
58
|
-
toTotp: t('layouts.auth.twoFactor.toggle.toTotp'),
|
|
59
|
-
},
|
|
60
|
-
changeEmail: t('layouts.auth.otp.changeEmail'),
|
|
61
|
-
}), [t]);
|
|
62
|
-
|
|
63
|
-
const subtitle = useBackupCode ? content.subtitle.backup : content.subtitle.totp;
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<AuthContainer step="2fa">
|
|
67
|
-
<div className="auth-2fa-box">
|
|
68
|
-
<div className="auth-2fa-shield" aria-hidden="true">
|
|
69
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
70
|
-
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
71
|
-
</svg>
|
|
72
|
-
</div>
|
|
73
|
-
|
|
74
|
-
<AuthHeader title={content.title} subtitle={subtitle} />
|
|
75
|
-
|
|
76
|
-
<form onSubmit={handle2FASubmit} className="auth-form-group">
|
|
77
|
-
{useBackupCode ? (
|
|
78
|
-
<input
|
|
79
|
-
type="text"
|
|
80
|
-
value={twoFactorCode}
|
|
81
|
-
onChange={(e) => setTwoFactorCode(e.target.value.toUpperCase())}
|
|
82
|
-
placeholder={content.placeholder.backup}
|
|
83
|
-
disabled={is2FALoading}
|
|
84
|
-
maxLength={AUTH.BACKUP_CODE_MAX_LENGTH}
|
|
85
|
-
autoComplete="off"
|
|
86
|
-
autoFocus
|
|
87
|
-
className="auth-input"
|
|
88
|
-
/>
|
|
89
|
-
) : (
|
|
90
|
-
<AuthOTPInput
|
|
91
|
-
value={twoFactorCode}
|
|
92
|
-
onChange={setTwoFactorCode}
|
|
93
|
-
disabled={is2FALoading}
|
|
94
|
-
/>
|
|
95
|
-
)}
|
|
96
|
-
|
|
97
|
-
{twoFactorWarning && (
|
|
98
|
-
<div className="auth-dev-notice">{twoFactorWarning}</div>
|
|
99
|
-
)}
|
|
100
|
-
|
|
101
|
-
<AuthError message={error} />
|
|
102
|
-
|
|
103
|
-
{twoFactorAttemptsRemaining !== null && twoFactorAttemptsRemaining <= 3 && (
|
|
104
|
-
<div className="auth-dev-notice">
|
|
105
|
-
{twoFactorAttemptsRemaining} attempt{twoFactorAttemptsRemaining !== 1 ? 's' : ''} remaining
|
|
106
|
-
</div>
|
|
107
|
-
)}
|
|
108
|
-
|
|
109
|
-
<AuthButton
|
|
110
|
-
loading={is2FALoading}
|
|
111
|
-
disabled={!useBackupCode && twoFactorCode.length !== AUTH.TOTP_LENGTH}
|
|
112
|
-
>
|
|
113
|
-
{content.button}
|
|
114
|
-
</AuthButton>
|
|
115
|
-
|
|
116
|
-
<div className="auth-actions">
|
|
117
|
-
<AuthLink
|
|
118
|
-
onClick={useBackupCode ? handleUseTOTP : handleUseBackupCode}
|
|
119
|
-
disabled={is2FALoading}
|
|
120
|
-
>
|
|
121
|
-
{useBackupCode ? content.toggle.toTotp : content.toggle.toBackup}
|
|
122
|
-
</AuthLink>
|
|
123
|
-
<AuthLink onClick={handleBackToIdentifier} disabled={is2FALoading}>
|
|
124
|
-
{content.changeEmail}
|
|
125
|
-
</AuthLink>
|
|
126
|
-
</div>
|
|
127
|
-
</form>
|
|
128
|
-
</div>
|
|
129
|
-
</AuthContainer>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export const TwoFactorStep = memo(TwoFactorStepRaw);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { useCopyToClipboard } from './useCopyToClipboard';
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
-
|
|
5
|
-
import { AUTH } from '../constants';
|
|
6
|
-
|
|
7
|
-
interface UseCopyToClipboardResult {
|
|
8
|
-
copied: boolean;
|
|
9
|
-
copy: (text: string) => Promise<void>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* useCopyToClipboard - Copy text with temporary feedback state
|
|
14
|
-
*
|
|
15
|
-
* @param duration - How long to show "copied" state (default: 2000ms)
|
|
16
|
-
*/
|
|
17
|
-
export const useCopyToClipboard = (
|
|
18
|
-
duration = AUTH.COPY_FEEDBACK_DURATION
|
|
19
|
-
): UseCopyToClipboardResult => {
|
|
20
|
-
const [copied, setCopied] = useState(false);
|
|
21
|
-
// One timer, cleared on unmount. Copying twice in quick succession stacked
|
|
22
|
-
// two, so the first cleared the flag while the second copy was still fresh.
|
|
23
|
-
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
24
|
-
|
|
25
|
-
useEffect(
|
|
26
|
-
() => () => {
|
|
27
|
-
if (timerRef.current) clearTimeout(timerRef.current);
|
|
28
|
-
},
|
|
29
|
-
[]
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
const copy = useCallback(
|
|
33
|
-
async (text: string) => {
|
|
34
|
-
try {
|
|
35
|
-
await navigator.clipboard.writeText(text);
|
|
36
|
-
setCopied(true);
|
|
37
|
-
if (timerRef.current) clearTimeout(timerRef.current);
|
|
38
|
-
timerRef.current = setTimeout(() => {
|
|
39
|
-
timerRef.current = null;
|
|
40
|
-
setCopied(false);
|
|
41
|
-
}, duration);
|
|
42
|
-
} catch {
|
|
43
|
-
// Clipboard API not available
|
|
44
|
-
console.warn('Clipboard API not available');
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
[duration]
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
return { copied, copy };
|
|
51
|
-
};
|