Skip to content

Commit a8007ce

Browse files
committed
feat: Recovery Setup/Disable Modal
1 parent fccc890 commit a8007ce

File tree

2 files changed

+183
-116
lines changed

2 files changed

+183
-116
lines changed

apps/web/src/app/[orgShortCode]/settings/user/security/_components/reset-modals.tsx

Lines changed: 82 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
} from '@/src/components/input-otp';
2626
import { ms } from 'itty-time';
2727
import Image from 'next/image';
28-
// import { downloadAsFile } from '@/src/lib/utils';
28+
import { downloadAsFile } from '@/src/lib/utils';
29+
import { toast } from 'sonner';
2930

3031
export function PasswordModal({
3132
open,
@@ -367,53 +368,84 @@ const MemoizedQrCode = memo(
367368
(prev, next) => prev.text === next.text
368369
);
369370

370-
/* export const recoveryCodeModal = () =>
371-
Modal<unknown, { recoveryCode: string; username: string }>(
372-
({ onResolve, open, args }) => {
373-
const [downloaded, setDownloaded] = useState(false);
371+
export function RecoveryCodeModal({
372+
open,
373+
mode,
374+
onResolve,
375+
onClose,
376+
verificationToken
377+
}: ModalComponent<{ mode: 'reset' | 'disable'; verificationToken: string }>) {
378+
const {
379+
mutateAsync: resetRecovery,
380+
isLoading: resetRecoveryLoading,
381+
error: resetError
382+
} = api.account.security.resetRecoveryCode.useMutation();
383+
const {
384+
mutateAsync: disableRecovery,
385+
isLoading: disableRecoveryLoading,
386+
error: disableError
387+
} = api.account.security.disableRecoveryCode.useMutation();
374388

375-
return (
376-
<Dialog.Root open={open}>
377-
<Dialog.Content className="w-full max-w-96 p-4">
378-
<Dialog.Title className="mx-auto w-fit py-2">
379-
Recovery Code
380-
</Dialog.Title>
381-
<Flex
382-
className="w-full p-2"
383-
direction="column"
384-
gap="4">
385-
<Text
386-
size="2"
387-
weight="bold"
388-
align="center">
389-
Save this recovery code in a safe place, without this code you
390-
would not be able to recover your account
391-
</Text>
392-
<Card>
393-
<Text className="break-words font-mono">
394-
{args?.recoveryCode}
395-
</Text>
396-
</Card>
397-
<Button
398-
size="2"
399-
onClick={() => {
400-
downloadAsFile(
401-
`${args?.username}-recovery-code.txt`,
402-
args?.recoveryCode ?? ''
403-
);
404-
setDownloaded(true);
405-
}}>
406-
{!downloaded ? 'Download' : 'Download Again'}
407-
</Button>
408-
<Button
409-
size="2"
410-
disabled={!downloaded}
411-
onClick={() => onResolve({})}>
412-
Close
413-
</Button>
414-
</Flex>
415-
</Dialog.Content>
416-
</Dialog.Root>
417-
);
418-
}
419-
); */
389+
const [code, setCode] = useState<string>('');
390+
391+
return (
392+
<Dialog.Root open={open}>
393+
<Dialog.Content className="w-full max-w-96 p-4">
394+
<Dialog.Title className="mx-auto w-fit">
395+
{mode === 'reset' ? 'Set up Recovery' : 'Disable Recovery'}
396+
</Dialog.Title>
397+
<Dialog.Description className="mx-auto flex w-fit text-balance p-2 text-center text-sm font-bold">
398+
{mode === 'reset' ? (
399+
<span>
400+
You are going to setup/reset your Recovery Code, If you already
401+
had a recovery code that would be invalidated after this.
402+
</span>
403+
) : (
404+
<span>Are you sure you want to disable your Recovery Code?</span>
405+
)}
406+
</Dialog.Description>
407+
<div className="flex w-full flex-col gap-2">
408+
<div className="text-red-10 p-1">
409+
{resetError?.message ?? disableError?.message}
410+
</div>
411+
{code ? (
412+
<Button
413+
className="w-full"
414+
onClick={() => downloadAsFile('recovery-code.txt', code)}>
415+
Download Again
416+
</Button>
417+
) : (
418+
<Button
419+
className="w-full"
420+
loading={resetRecoveryLoading || disableRecoveryLoading}
421+
onClick={async () => {
422+
if (mode === 'reset') {
423+
const { recoveryCode } = await resetRecovery({
424+
verificationToken
425+
});
426+
downloadAsFile('recovery-code.txt', recoveryCode);
427+
toast.success('Recovery Code has been downloaded');
428+
setCode(recoveryCode);
429+
} else {
430+
await disableRecovery({ verificationToken });
431+
onResolve(null);
432+
}
433+
}}>
434+
{mode === 'reset'
435+
? 'Setup/Reset Recovery Code'
436+
: 'Disable Recovery'}
437+
</Button>
438+
)}
439+
<Button
440+
className="w-full"
441+
variant="soft"
442+
disabled={resetRecoveryLoading || disableRecoveryLoading}
443+
color="gray"
444+
onClick={() => (code ? onResolve(null) : onClose())}>
445+
{code ? 'Close' : 'Cancel'}
446+
</Button>
447+
</div>
448+
</Dialog.Content>
449+
</Dialog.Root>
450+
);
451+
}

apps/web/src/app/[orgShortCode]/settings/user/security/page.tsx

Lines changed: 101 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { Flex, Heading, Spinner, Text, Switch, Button } from '@radix-ui/themes';
44
import { useEffect, useState } from 'react';
55
import { api } from '@/src/lib/trpc';
66
import { VerificationModal } from './_components/verification-modal';
7-
import { PasswordModal, TOTPModal } from './_components/reset-modals';
7+
import {
8+
PasswordModal,
9+
TOTPModal,
10+
RecoveryCodeModal
11+
} from './_components/reset-modals';
812
import useAwaitableModal from '@/src/hooks/use-awaitable-modal';
913
import { toast } from 'sonner';
1014

@@ -47,6 +51,14 @@ export default function Page() {
4751
verificationToken: ''
4852
});
4953

54+
const [RecoveryModalRoot, openRecoveryModal] = useAwaitableModal(
55+
RecoveryCodeModal,
56+
{
57+
verificationToken: '',
58+
mode: 'reset'
59+
}
60+
);
61+
5062
async function waitForVerification() {
5163
if (!initData) throw new Error('No init data');
5264
if (verificationToken) return verificationToken;
@@ -93,85 +105,108 @@ export default function Page() {
93105
)}
94106

95107
{!isInitDataLoading && initData && (
96-
<Flex
97-
className="my-4"
98-
direction="column"
99-
gap="5">
100-
<Text
101-
as="label"
102-
size="3"
103-
weight="medium">
104-
<Flex
105-
gap="2"
106-
align="center">
107-
Enable Password and 2FA Login
108-
<Switch
109-
size="2"
110-
checked={isPassword2FaEnabled}
111-
disabled={
112-
isDisablingLegacySecurity ||
113-
!(isPassword2FaEnabled && canDisableLegacySecurity)
114-
}
115-
onCheckedChange={async () => {
116-
const token = await waitForVerification();
117-
if (!token) return;
118-
119-
if (isPassword2FaEnabled) {
120-
disableLegacySecurity({
121-
verificationToken: verificationToken ?? token
122-
});
123-
await refreshSecurityData();
124-
setIsPassword2FaEnabled(false);
125-
} else {
126-
const passwordSet = await openPasswordModal({
108+
<div className="my-4 flex flex-col gap-5">
109+
<div className="flex flex-col gap-3">
110+
<span className="text-lg font-bold">Legacy Security</span>
111+
<Text
112+
as="label"
113+
size="3"
114+
weight="medium">
115+
<Flex
116+
gap="2"
117+
align="center">
118+
Enable Password and 2FA Login
119+
<Switch
120+
size="2"
121+
checked={isPassword2FaEnabled}
122+
disabled={
123+
isDisablingLegacySecurity ||
124+
!(isPassword2FaEnabled && canDisableLegacySecurity)
125+
}
126+
onCheckedChange={async () => {
127+
const token = await waitForVerification();
128+
if (!token) return;
129+
130+
if (isPassword2FaEnabled) {
131+
disableLegacySecurity({
132+
verificationToken: verificationToken ?? token
133+
});
134+
await refreshSecurityData();
135+
setIsPassword2FaEnabled(false);
136+
} else {
137+
const passwordSet = await openPasswordModal({
138+
verificationToken: verificationToken ?? token
139+
}).catch(() => false);
140+
const otpSet = await openTOTPModal({
141+
verificationToken: verificationToken ?? token
142+
}).catch(() => false);
143+
if (!passwordSet || !otpSet) return;
144+
await refreshSecurityData();
145+
setIsPassword2FaEnabled(true);
146+
}
147+
}}
148+
/>
149+
{isDisablingLegacySecurity && <Spinner loading />}
150+
</Flex>
151+
</Text>
152+
153+
<div className="flex gap-2">
154+
{initData?.passwordSet && (
155+
<Button
156+
onClick={async () => {
157+
const token = await waitForVerification();
158+
if (!token) return;
159+
await openPasswordModal({
127160
verificationToken: verificationToken ?? token
128-
}).catch(() => false);
129-
const otpSet = await openTOTPModal({
161+
}).catch(() => null);
162+
}}>
163+
Reset Password
164+
</Button>
165+
)}
166+
167+
{initData?.twoFactorEnabled && (
168+
<Button
169+
onClick={async () => {
170+
const token = await waitForVerification();
171+
if (!token) return;
172+
await openTOTPModal({
130173
verificationToken: verificationToken ?? token
131-
}).catch(() => false);
132-
if (!passwordSet || !otpSet) return;
133-
await refreshSecurityData();
134-
setIsPassword2FaEnabled(true);
135-
}
136-
}}
137-
/>
138-
{isDisablingLegacySecurity && <Spinner loading />}
139-
</Flex>
140-
</Text>
141-
142-
<div className="flex gap-2">
143-
{initData?.passwordSet && (
144-
<Button
145-
onClick={async () => {
146-
const token = await waitForVerification();
147-
if (!token) return;
148-
await openPasswordModal({
149-
verificationToken: verificationToken ?? token
150-
}).catch(() => null);
151-
}}>
152-
Reset Password
153-
</Button>
154-
)}
155-
156-
{initData?.twoFactorEnabled && (
174+
}).catch(() => null);
175+
}}>
176+
Reset 2FA
177+
</Button>
178+
)}
179+
</div>
180+
</div>
181+
{(initData.recoveryCodeSet || isPassword2FaEnabled) && (
182+
<div className="flex flex-col gap-3">
183+
<span className="text-lg font-bold">Account Recovery</span>
157184
<Button
185+
className="w-fit"
158186
onClick={async () => {
159187
const token = await waitForVerification();
160188
if (!token) return;
161-
await openTOTPModal({
162-
verificationToken: verificationToken ?? token
189+
await openRecoveryModal({
190+
verificationToken: verificationToken ?? token,
191+
mode: isPassword2FaEnabled ? 'reset' : 'disable'
163192
}).catch(() => null);
193+
await refreshSecurityData();
164194
}}>
165-
Reset 2FA
195+
{initData.recoveryCodeSet
196+
? isPassword2FaEnabled
197+
? 'Reset Recovery Code'
198+
: 'Disable Recovery Code'
199+
: 'Setup Recovery'}
166200
</Button>
167-
)}
168-
</div>
169-
</Flex>
201+
</div>
202+
)}
203+
</div>
170204
)}
171205

172206
<VerificationModalRoot />
173207
<PasswordModalRoot />
174208
<TOTPModalRoot />
209+
<RecoveryModalRoot />
175210
</Flex>
176211
);
177212
}

0 commit comments

Comments
 (0)