Skip to content

Commit c4ce82a

Browse files
committed
add disable 2fa flow that requires recent verification
1 parent 33cb454 commit c4ce82a

File tree

4 files changed

+114
-44
lines changed

4 files changed

+114
-44
lines changed

app/routes/settings+/profile.change-email.index/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ export async function loader({ request }: DataFunctionArgs) {
4747
export async function action({ request }: DataFunctionArgs) {
4848
if (await shouldRequestTwoFA(request)) {
4949
// looks like they waited too long enter the email
50-
return redirect(request.url)
50+
return redirectWithToast(request.url, {
51+
title: 'Please Reverify',
52+
description: 'Please reverify your account before proceeding',
53+
})
5154
}
5255
const userId = await requireUserId(request)
5356
const formData = await request.formData()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
2+
import { useFetcher, useLoaderData } from '@remix-run/react'
3+
import { StatusButton } from '~/components/ui/status-button.tsx'
4+
import { requireUserId } from '~/utils/auth.server.ts'
5+
import { prisma } from '~/utils/db.server.ts'
6+
import { redirectWithToast } from '~/utils/flash-session.server.ts'
7+
import { useDoubleCheck } from '~/utils/misc.tsx'
8+
import { useUser } from '~/utils/user.ts'
9+
import { shouldRequestTwoFA } from '../resources+/login.tsx'
10+
import { Verify } from '../resources+/verify.tsx'
11+
import { twoFAVerificationType } from './profile.two-factor.tsx'
12+
import { Icon } from '~/components/ui/icon.tsx'
13+
14+
export const handle = {
15+
breadcrumb: <Icon name="lock-open-1">Disable</Icon>,
16+
}
17+
18+
export async function loader({ request }: DataFunctionArgs) {
19+
const userId = await requireUserId(request)
20+
const verification = await prisma.verification.findFirst({
21+
where: { type: twoFAVerificationType, target: userId },
22+
select: { id: true },
23+
})
24+
if (!verification) {
25+
return redirect('/settings/profile/two-factor')
26+
}
27+
const shouldReverify = await shouldRequestTwoFA(request)
28+
return json({ shouldReverify })
29+
}
30+
31+
export async function action({ request }: DataFunctionArgs) {
32+
if (await shouldRequestTwoFA(request)) {
33+
// looks like they waited too long enter the email
34+
return redirectWithToast(request.url, {
35+
title: 'Please Reverify',
36+
description: 'Please reverify your account before proceeding',
37+
})
38+
}
39+
const userId = await requireUserId(request)
40+
await prisma.verification.deleteMany({
41+
where: { type: twoFAVerificationType, target: userId },
42+
})
43+
return json({ status: 'success' } as const)
44+
}
45+
46+
export default function TwoFactorDisableRoute() {
47+
const data = useLoaderData<typeof loader>()
48+
const user = useUser()
49+
const toggle2FAFetcher = useFetcher<typeof action>()
50+
const dc = useDoubleCheck()
51+
52+
return (
53+
<div className="mx-auto max-w-sm">
54+
{data.shouldReverify ? (
55+
<>
56+
<p>Please reverify your account by submitting your 2FA code</p>
57+
<Verify target={user.id} type="2fa" />
58+
</>
59+
) : (
60+
<toggle2FAFetcher.Form method="POST" preventScrollReset>
61+
<p>
62+
Disabling two factor authentication is not recommended. However, if
63+
you would like to do so, click here:
64+
</p>
65+
<StatusButton
66+
variant="destructive"
67+
status={toggle2FAFetcher.state === 'loading' ? 'pending' : 'idle'}
68+
{...dc.getButtonProps({
69+
className: 'mx-auto',
70+
name: 'intent',
71+
value: 'disable',
72+
type: 'submit',
73+
})}
74+
>
75+
{dc.doubleCheck ? 'Are you sure?' : 'Disable 2FA'}
76+
</StatusButton>
77+
</toggle2FAFetcher.Form>
78+
)}
79+
</div>
80+
)
81+
}

app/routes/settings+/profile.two-factor.index.tsx

Lines changed: 28 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
2-
import { useFetcher, useLoaderData } from '@remix-run/react'
2+
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
3+
import { Icon } from '~/components/ui/icon.tsx'
34
import { StatusButton } from '~/components/ui/status-button.tsx'
45
import { requireUserId } from '~/utils/auth.server.ts'
56
import { prisma } from '~/utils/db.server.ts'
67
import { generateTOTP } from '~/utils/totp.server.ts'
8+
import { shouldRequestTwoFA } from '../resources+/login.tsx'
79
import { twoFAVerificationType } from './profile.two-factor.tsx'
810
import { verificationType as verifyVerificationType } from './profile.two-factor.verify.tsx'
911

@@ -13,63 +15,47 @@ export async function loader({ request }: DataFunctionArgs) {
1315
where: { type: twoFAVerificationType, target: userId },
1416
select: { id: true },
1517
})
16-
return json({ is2FAEnabled: Boolean(verification) })
18+
const shouldReverify = await shouldRequestTwoFA(request)
19+
return json({ is2FAEnabled: Boolean(verification), shouldReverify })
1720
}
1821

1922
export async function action({ request }: DataFunctionArgs) {
20-
const form = await request.formData()
2123
const userId = await requireUserId(request)
22-
const intent = form.get('intent')
23-
switch (intent) {
24-
case 'enable': {
25-
const { otp: _otp, ...config } = generateTOTP()
26-
// delete any existing entries
27-
await prisma.verification.deleteMany({
28-
where: { type: verifyVerificationType, target: userId },
29-
})
30-
await prisma.verification.create({
31-
data: { ...config, type: verifyVerificationType, target: userId },
32-
})
33-
return redirect('/settings/profile/two-factor/verify')
34-
}
35-
case 'disable': {
36-
await prisma.verification.deleteMany({
37-
where: { type: twoFAVerificationType, target: userId },
38-
})
39-
break
40-
}
41-
default: {
42-
return json({ status: 'error', message: 'Invalid intent' } as const)
43-
}
44-
}
45-
return json({ status: 'success' } as const)
24+
const { otp: _otp, ...config } = generateTOTP()
25+
// delete any existing entries
26+
await prisma.verification.deleteMany({
27+
where: { type: verifyVerificationType, target: userId },
28+
})
29+
await prisma.verification.create({
30+
data: { ...config, type: verifyVerificationType, target: userId },
31+
})
32+
return redirect('/settings/profile/two-factor/verify')
4633
}
4734

4835
export default function TwoFactorRoute() {
49-
const data = useLoaderData<typeof loader>() || {}
36+
const data = useLoaderData<typeof loader>()
5037
const toggle2FAFetcher = useFetcher<typeof action>()
5138

5239
return (
5340
<div className="flex flex-col gap-4">
5441
{data.is2FAEnabled ? (
5542
<>
56-
<p className="text-sm">You have enabled two-factor authentication.</p>
57-
<toggle2FAFetcher.Form method="POST" preventScrollReset>
58-
<StatusButton
59-
variant="secondary"
60-
type="submit"
61-
name="intent"
62-
value="disable"
63-
status={toggle2FAFetcher.state === 'loading' ? 'pending' : 'idle'}
64-
className="mx-auto"
65-
>
66-
Disable 2FA
67-
</StatusButton>
68-
</toggle2FAFetcher.Form>
43+
<p className="text-lg">
44+
<Icon name="check">
45+
You have enabled two-factor authentication.
46+
</Icon>
47+
</p>
48+
<Link to="disable">
49+
<Icon name="lock-open-1">Disable 2FA</Icon>
50+
</Link>
6951
</>
7052
) : (
7153
<>
72-
<p>You have not enabled two-factor authentication yet.</p>
54+
<p>
55+
<Icon name="lock-open-1">
56+
You have not enabled two-factor authentication yet.
57+
</Icon>
58+
</p>
7359
<p className="text-sm">
7460
Two factor authentication adds an extra layer of security to your
7561
account. You will need to enter a code from an authenticator app

tests/e2e/2fa.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ test('Users can add 2FA to their account and use it when logging in', async ({
2929
await main.getByRole('button', { name: /confirm/i }).click()
3030

3131
await expect(main).toHaveText(/You have enabled two-factor authentication./i)
32-
await expect(main.getByRole('button', { name: /disable 2fa/i })).toBeVisible()
32+
await expect(main.getByRole('link', { name: /disable 2fa/i })).toBeVisible()
3333

3434
await page.getByRole('link', { name: user.name ?? user.username }).click()
3535
await page.getByRole('menuitem', { name: /logout/i }).click()

0 commit comments

Comments
 (0)