Skip to content

feat: Recovery Setup/Disable Modal #458

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
} from '@/src/components/input-otp';
import { ms } from 'itty-time';
import Image from 'next/image';
// import { downloadAsFile } from '@/src/lib/utils';
import { downloadAsFile } from '@/src/lib/utils';
import { toast } from 'sonner';

export function PasswordModal({
open,
Expand Down Expand Up @@ -367,53 +368,84 @@ const MemoizedQrCode = memo(
(prev, next) => prev.text === next.text
);

/* export const recoveryCodeModal = () =>
Modal<unknown, { recoveryCode: string; username: string }>(
({ onResolve, open, args }) => {
const [downloaded, setDownloaded] = useState(false);
export function RecoveryCodeModal({
open,
mode,
onResolve,
onClose,
verificationToken
}: ModalComponent<{ mode: 'reset' | 'disable'; verificationToken: string }>) {
const {
mutateAsync: resetRecovery,
isLoading: resetRecoveryLoading,
error: resetError
} = api.account.security.resetRecoveryCode.useMutation();
const {
mutateAsync: disableRecovery,
isLoading: disableRecoveryLoading,
error: disableError
} = api.account.security.disableRecoveryCode.useMutation();

return (
<Dialog.Root open={open}>
<Dialog.Content className="w-full max-w-96 p-4">
<Dialog.Title className="mx-auto w-fit py-2">
Recovery Code
</Dialog.Title>
<Flex
className="w-full p-2"
direction="column"
gap="4">
<Text
size="2"
weight="bold"
align="center">
Save this recovery code in a safe place, without this code you
would not be able to recover your account
</Text>
<Card>
<Text className="break-words font-mono">
{args?.recoveryCode}
</Text>
</Card>
<Button
size="2"
onClick={() => {
downloadAsFile(
`${args?.username}-recovery-code.txt`,
args?.recoveryCode ?? ''
);
setDownloaded(true);
}}>
{!downloaded ? 'Download' : 'Download Again'}
</Button>
<Button
size="2"
disabled={!downloaded}
onClick={() => onResolve({})}>
Close
</Button>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}
); */
const [code, setCode] = useState<string>('');

return (
<Dialog.Root open={open}>
<Dialog.Content className="w-full max-w-96 p-4">
<Dialog.Title className="mx-auto w-fit">
{mode === 'reset' ? 'Set up Recovery' : 'Disable Recovery'}
</Dialog.Title>
<Dialog.Description className="mx-auto flex w-fit text-balance p-2 text-center text-sm font-bold">
{mode === 'reset' ? (
<span>
You are going to setup/reset your Recovery Code, If you already
had a recovery code that would be invalidated after this.
</span>
) : (
<span>Are you sure you want to disable your Recovery Code?</span>
)}
</Dialog.Description>
<div className="flex w-full flex-col gap-2">
<div className="text-red-10 p-1">
{resetError?.message ?? disableError?.message}
</div>
{code ? (
<Button
className="w-full"
onClick={() => downloadAsFile('recovery-code.txt', code)}>
Download Again
</Button>
) : (
<Button
className="w-full"
loading={resetRecoveryLoading || disableRecoveryLoading}
onClick={async () => {
if (mode === 'reset') {
const { recoveryCode } = await resetRecovery({
verificationToken
});
downloadAsFile('recovery-code.txt', recoveryCode);
toast.success('Recovery Code has been downloaded');
setCode(recoveryCode);
} else {
await disableRecovery({ verificationToken });
onResolve(null);
}
}}>
{mode === 'reset'
? 'Setup/Reset Recovery Code'
: 'Disable Recovery'}
</Button>
)}
<Button
className="w-full"
variant="soft"
disabled={resetRecoveryLoading || disableRecoveryLoading}
color="gray"
onClick={() => (code ? onResolve(null) : onClose())}>
{code ? 'Close' : 'Cancel'}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
);
}
167 changes: 101 additions & 66 deletions apps/web/src/app/[orgShortCode]/settings/user/security/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { Flex, Heading, Spinner, Text, Switch, Button } from '@radix-ui/themes';
import { useEffect, useState } from 'react';
import { api } from '@/src/lib/trpc';
import { VerificationModal } from './_components/verification-modal';
import { PasswordModal, TOTPModal } from './_components/reset-modals';
import {
PasswordModal,
TOTPModal,
RecoveryCodeModal
} from './_components/reset-modals';
import useAwaitableModal from '@/src/hooks/use-awaitable-modal';
import { toast } from 'sonner';

Expand Down Expand Up @@ -47,6 +51,14 @@ export default function Page() {
verificationToken: ''
});

const [RecoveryModalRoot, openRecoveryModal] = useAwaitableModal(
RecoveryCodeModal,
{
verificationToken: '',
mode: 'reset'
}
);

async function waitForVerification() {
if (!initData) throw new Error('No init data');
if (verificationToken) return verificationToken;
Expand Down Expand Up @@ -93,85 +105,108 @@ export default function Page() {
)}

{!isInitDataLoading && initData && (
<Flex
className="my-4"
direction="column"
gap="5">
<Text
as="label"
size="3"
weight="medium">
<Flex
gap="2"
align="center">
Enable Password and 2FA Login
<Switch
size="2"
checked={isPassword2FaEnabled}
disabled={
isDisablingLegacySecurity ||
!(isPassword2FaEnabled && canDisableLegacySecurity)
}
onCheckedChange={async () => {
const token = await waitForVerification();
if (!token) return;

if (isPassword2FaEnabled) {
disableLegacySecurity({
verificationToken: verificationToken ?? token
});
await refreshSecurityData();
setIsPassword2FaEnabled(false);
} else {
const passwordSet = await openPasswordModal({
<div className="my-4 flex flex-col gap-5">
<div className="flex flex-col gap-3">
<span className="text-lg font-bold">Legacy Security</span>
<Text
as="label"
size="3"
weight="medium">
<Flex
gap="2"
align="center">
Enable Password and 2FA Login
<Switch
size="2"
checked={isPassword2FaEnabled}
disabled={
isDisablingLegacySecurity ||
!(isPassword2FaEnabled && canDisableLegacySecurity)
}
onCheckedChange={async () => {
const token = await waitForVerification();
if (!token) return;

if (isPassword2FaEnabled) {
disableLegacySecurity({
verificationToken: verificationToken ?? token
});
await refreshSecurityData();
setIsPassword2FaEnabled(false);
} else {
const passwordSet = await openPasswordModal({
verificationToken: verificationToken ?? token
}).catch(() => false);
const otpSet = await openTOTPModal({
verificationToken: verificationToken ?? token
}).catch(() => false);
if (!passwordSet || !otpSet) return;
await refreshSecurityData();
setIsPassword2FaEnabled(true);
}
}}
/>
{isDisablingLegacySecurity && <Spinner loading />}
</Flex>
</Text>

<div className="flex gap-2">
{initData?.passwordSet && (
<Button
onClick={async () => {
const token = await waitForVerification();
if (!token) return;
await openPasswordModal({
verificationToken: verificationToken ?? token
}).catch(() => false);
const otpSet = await openTOTPModal({
}).catch(() => null);
}}>
Reset Password
</Button>
)}

{initData?.twoFactorEnabled && (
<Button
onClick={async () => {
const token = await waitForVerification();
if (!token) return;
await openTOTPModal({
verificationToken: verificationToken ?? token
}).catch(() => false);
if (!passwordSet || !otpSet) return;
await refreshSecurityData();
setIsPassword2FaEnabled(true);
}
}}
/>
{isDisablingLegacySecurity && <Spinner loading />}
</Flex>
</Text>

<div className="flex gap-2">
{initData?.passwordSet && (
<Button
onClick={async () => {
const token = await waitForVerification();
if (!token) return;
await openPasswordModal({
verificationToken: verificationToken ?? token
}).catch(() => null);
}}>
Reset Password
</Button>
)}

{initData?.twoFactorEnabled && (
}).catch(() => null);
}}>
Reset 2FA
</Button>
)}
</div>
</div>
{(initData.recoveryCodeSet || isPassword2FaEnabled) && (
<div className="flex flex-col gap-3">
<span className="text-lg font-bold">Account Recovery</span>
<Button
className="w-fit"
onClick={async () => {
const token = await waitForVerification();
if (!token) return;
await openTOTPModal({
verificationToken: verificationToken ?? token
await openRecoveryModal({
verificationToken: verificationToken ?? token,
mode: isPassword2FaEnabled ? 'reset' : 'disable'
}).catch(() => null);
await refreshSecurityData();
}}>
Reset 2FA
{initData.recoveryCodeSet
? isPassword2FaEnabled
? 'Reset Recovery Code'
: 'Disable Recovery Code'
: 'Setup Recovery'}
</Button>
)}
</div>
</Flex>
</div>
)}
</div>
)}

<VerificationModalRoot />
<PasswordModalRoot />
<TOTPModalRoot />
<RecoveryModalRoot />
</Flex>
);
}
Loading