Skip to content

Commit 27a88dc

Browse files
committed
fix 2fa workflow
1 parent bf94873 commit 27a88dc

File tree

5 files changed

+40
-42
lines changed

5 files changed

+40
-42
lines changed

app/routes/_auth+/login.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Spacer } from '~/components/spacer.tsx'
99
import { authenticator, requireAnonymous } from '~/utils/auth.server.ts'
1010
import { commitSession, getSession } from '~/utils/session.server.ts'
1111
import { InlineLogin } from '../resources+/login.tsx'
12-
import { unverifiedSessionKey } from '../resources+/verify.tsx'
12+
import { Verifier, unverifiedSessionKey } from '../resources+/verify.tsx'
1313

1414
export async function loader({ request }: DataFunctionArgs) {
1515
await requireAnonymous(request)
@@ -49,11 +49,11 @@ export default function LoginPage() {
4949
</p>
5050
</div>
5151
<Spacer size="xs" />
52-
<InlineLogin
53-
redirectTo={redirectTo}
54-
formError={data.formError}
55-
status={data.unverified ? 'unverified' : 'idle'}
56-
/>
52+
{data.unverified ? (
53+
<Verifier redirectTo={redirectTo} />
54+
) : (
55+
<InlineLogin redirectTo={redirectTo} formError={data.formError} />
56+
)}
5757
</div>
5858
</div>
5959
)

app/routes/resources+/login.tsx

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { commitSession, getSession } from '~/utils/session.server.ts'
1414
import { passwordSchema, usernameSchema } from '~/utils/user-validation.ts'
1515
import { checkboxSchema } from '~/utils/zod-extensions.ts'
1616
import { twoFAVerificationType } from '../settings+/profile.two-factor.tsx'
17-
import { Verifier, unverifiedSessionKey } from './verify.tsx'
17+
import { unverifiedSessionKey } from './verify.tsx'
1818

1919
const ROUTE_PATH = '/resources/login'
2020

@@ -93,29 +93,25 @@ export async function action({ request }: DataFunctionArgs) {
9393
}),
9494
},
9595
}
96-
if (user2FA) {
97-
return json({ status: 'unverified', submission } as const, responseInit)
98-
} else {
99-
if (redirectTo) {
100-
throw redirect(safeRedirect(redirectTo), responseInit)
101-
}
96+
if (user2FA || !redirectTo) {
10297
return json({ status: 'success', submission } as const, responseInit)
98+
} else {
99+
throw redirect(safeRedirect(redirectTo), responseInit)
103100
}
104101
}
105102

106103
export function InlineLogin({
107104
redirectTo,
108105
formError,
109-
status = 'idle',
110106
}: {
111-
status?: 'idle' | 'error' | 'unverified'
112107
redirectTo?: string
113108
formError?: string | null
114109
}) {
115110
const loginFetcher = useFetcher<typeof action>()
116111

117112
const [form, fields] = useForm({
118113
id: 'inline-login',
114+
defaultValue: { redirectTo },
119115
constraint: getFieldsetConstraint(loginFormSchema),
120116
lastSubmission: loginFetcher.data?.submission,
121117
onValidate({ formData }) {
@@ -124,12 +120,6 @@ export function InlineLogin({
124120
shouldRevalidate: 'onBlur',
125121
})
126122

127-
const fetcherStatus = loginFetcher.data?.status ?? status
128-
129-
if (fetcherStatus === 'unverified') {
130-
return <Verifier redirectTo={redirectTo} />
131-
}
132-
133123
return (
134124
<div>
135125
<div className="mx-auto w-full max-w-md px-8">
@@ -144,7 +134,7 @@ export function InlineLogin({
144134
htmlFor: fields.username.id,
145135
children: 'Username',
146136
}}
147-
inputProps={conform.input(fields.username)}
137+
inputProps={{ ...conform.input(fields.username), autoFocus: true }}
148138
errors={fields.username.errors}
149139
/>
150140

@@ -177,11 +167,7 @@ export function InlineLogin({
177167
</div>
178168
</div>
179169

180-
<input
181-
value={redirectTo}
182-
{...conform.input(fields.redirectTo)}
183-
type="hidden"
184-
/>
170+
<input {...conform.input(fields.redirectTo)} type="hidden" />
185171
<ErrorList errors={[...form.errors, formError]} id={form.errorId} />
186172

187173
<div className="flex items-center justify-between gap-6 pt-3">
@@ -190,7 +176,9 @@ export function InlineLogin({
190176
size="md"
191177
variant="primary"
192178
status={
193-
loginFetcher.state === 'submitting' ? 'pending' : fetcherStatus
179+
loginFetcher.state === 'submitting'
180+
? 'pending'
181+
: loginFetcher.data?.status
194182
}
195183
type="submit"
196184
disabled={loginFetcher.state !== 'idle'}

app/routes/resources+/verify.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,29 @@ import { conform, useForm } from '@conform-to/react'
22
import { getFieldsetConstraint, parse } from '@conform-to/zod'
33
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
44
import { useFetcher } from '@remix-run/react'
5+
import invariant from 'tiny-invariant'
56
import { z } from 'zod'
67
import { twoFAVerificationType } from '~/routes/settings+/profile.two-factor.tsx'
78
import { authenticator } from '~/utils/auth.server.ts'
89
import { prisma } from '~/utils/db.server.ts'
910
import { Button, ErrorList, Field } from '~/utils/forms.tsx'
1011
import { safeRedirect } from '~/utils/misc.ts'
11-
import {
12-
commitSession,
13-
destroySession,
14-
getSession,
15-
} from '~/utils/session.server.ts'
12+
import { commitSession, getSession } from '~/utils/session.server.ts'
1613
import { verifyTOTP } from '~/utils/totp.server.ts'
1714

1815
const ROUTE_PATH = '/resources/verify'
1916
export const unverifiedSessionKey = 'unverified-sessionId'
2017

21-
const verifySchema = z.object({
22-
code: z.string().min(6).max(6),
23-
redirectTo: z.string().optional(),
24-
})
18+
const verifySchema = z.union([
19+
z.object({
20+
intent: z.literal('cancel'),
21+
}),
22+
z.object({
23+
intent: z.literal('confirm'),
24+
code: z.string().min(6).max(6),
25+
redirectTo: z.string().optional(),
26+
}),
27+
])
2528

2629
export async function action({ request }: DataFunctionArgs) {
2730
const form = await request.formData()
@@ -36,24 +39,28 @@ export async function action({ request }: DataFunctionArgs) {
3639
where: { id: sessionId },
3740
select: { userId: true },
3841
})
42+
3943
// if there's no session for their session ID, something is *way* wrong.
4044
// destory it and let them try over again... Or we'll do that if they wanna cancel.
4145
if (!session || form.get('intent') === 'cancel') {
4246
const redirectTo = form.get('redirectTo')
4347
const params =
44-
typeof redirectTo === 'string' && redirectTo
48+
typeof redirectTo === 'string' && redirectTo && redirectTo !== '/'
4549
? new URLSearchParams({ redirectTo })
4650
: null
51+
cookieSession.unset(unverifiedSessionKey)
4752
throw redirect(['/login', params?.toString()].filter(Boolean).join('?'), {
4853
headers: {
49-
'Set-Cookie': await destroySession(cookieSession),
54+
'Set-Cookie': await commitSession(cookieSession),
5055
},
5156
})
5257
}
5358

5459
const submission = await parse(form, {
5560
schema: () =>
5661
verifySchema.superRefine(async (data, ctx) => {
62+
if (data.intent === 'cancel') return
63+
5764
const verification = await prisma.verification.findFirst({
5865
where: {
5966
type: twoFAVerificationType,
@@ -105,6 +112,8 @@ export async function action({ request }: DataFunctionArgs) {
105112
)
106113
}
107114

115+
invariant(submission.value.intent === 'confirm', 'invalid intent')
116+
108117
const { redirectTo } = submission.value
109118
cookieSession.unset(unverifiedSessionKey)
110119
cookieSession.set(authenticator.sessionKey, sessionId)
@@ -143,7 +152,7 @@ export function Verifier({
143152
<input type="hidden" name="redirectTo" value={redirectTo} />
144153
<Field
145154
labelProps={{ children: '2FA Code' }}
146-
inputProps={conform.input(fields.code)}
155+
inputProps={{ ...conform.input(fields.code), autoFocus: true }}
147156
errors={fields.code.errors}
148157
/>
149158
<div className="flex flex-row-reverse justify-between">

playwright.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ const PORT = process.env.PORT || '3000'
55

66
export default {
77
testDir: './tests/e2e',
8-
timeout: 30 * 1000,
8+
timeout: 15 * 1000,
99
expect: {
10-
timeout: 10 * 1000,
10+
timeout: 5 * 1000,
1111
},
1212
fullyParallel: true,
1313
forbidOnly: !!process.env.CI,

tests/e2e/2fa.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ test('Users can add 2FA to their account and use it when logging in', async ({
4040
await page.getByRole('menuitem', { name: /logout/i }).click()
4141

4242
await page.goto('/login')
43+
await expect(page).toHaveURL(`/login`)
4344
await page.getByRole('textbox', { name: /username/i }).fill(user.username)
4445
await page.getByLabel(/^password$/i).fill(password)
4546
await page.getByRole('button', { name: /log in/i }).click()

0 commit comments

Comments
 (0)