Skip to content

Commit 2799fbe

Browse files
committed
add email change and adjust how verification works a bit
1 parent e86b801 commit 2799fbe

File tree

18 files changed

+635
-294
lines changed

18 files changed

+635
-294
lines changed

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
"@remix-run/router",
55
"express",
66
"@radix-ui/**",
7-
"react-router"
7+
"react-router",
8+
"stream/consumers",
9+
"node:stream/consumers"
810
],
911
"tailwindCSS.experimental.classRegex": [["cn\\(([^)]*)\\)"]]
1012
}

app/root.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { useToast } from './utils/useToast.tsx'
5050
import { useOptionalUser, useUser } from './utils/user.ts'
5151
import rdtStylesheetUrl from 'remix-development-tools/stylesheet.css'
5252
const RemixDevTools =
53-
process.env.NODE_ENV === 'development'
53+
process.env.NODE_ENV === 'development' && false
5454
? lazy(() => import('remix-development-tools'))
5555
: undefined
5656

app/routes/_auth+/forgot-password/index.tsx

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ import { GeneralErrorBoundary } from '~/components/error-boundary.tsx'
1212
import { ErrorList, Field } from '~/components/forms.tsx'
1313
import { StatusButton } from '~/components/ui/status-button.tsx'
1414
import {
15+
type VerifyFunctionArgs,
1516
getRedirectToUrl,
1617
prepareVerification,
1718
} from '~/routes/resources+/verify.tsx'
1819
import { prisma } from '~/utils/db.server.ts'
1920
import { sendEmail } from '~/utils/email.server.ts'
2021
import { emailSchema, usernameSchema } from '~/utils/user-validation.ts'
2122
import { ForgotPasswordEmail } from './email.server.tsx'
23+
import { invariant, invariantResponse } from '~/utils/misc.ts'
24+
import { commitSession, getSession } from '~/utils/session.server.ts'
25+
import { resetPasswordUsernameSessionKey } from '../reset-password.tsx'
2226

2327
const ForgotPasswordSchema = z.object({
2428
usernameOrEmail: z.union([emailSchema, usernameSchema]),
@@ -28,20 +32,20 @@ export async function action({ request }: DataFunctionArgs) {
2832
const formData = await request.formData()
2933
const submission = await parse(formData, {
3034
schema: ForgotPasswordSchema.superRefine(async (data, ctx) => {
31-
if (data.usernameOrEmail.includes('@')) return
32-
33-
// check the username exists. Usernames have to be unique anyway so anyone
34-
// signing up can check whether a username exists by trying to sign up
35-
// with it.
36-
const user = await prisma.user.findUnique({
37-
where: { username: data.usernameOrEmail },
35+
const user = await prisma.user.findFirst({
36+
where: {
37+
OR: [
38+
{ email: data.usernameOrEmail },
39+
{ username: data.usernameOrEmail },
40+
],
41+
},
3842
select: { id: true },
3943
})
4044
if (!user) {
4145
ctx.addIssue({
4246
path: ['usernameOrEmail'],
4347
code: z.ZodIssueCode.custom,
44-
message: 'No user exists with this username',
48+
message: 'No user exists with this username or email',
4549
})
4650
return
4751
}
@@ -62,42 +66,54 @@ export async function action({ request }: DataFunctionArgs) {
6266
target: usernameOrEmail,
6367
})
6468

65-
// fire, forget, and don't wait to combat timing attacks
66-
void sendVerifyEmail({ request, target: usernameOrEmail })
67-
68-
return redirect(redirectTo.toString())
69-
}
70-
71-
async function sendVerifyEmail({
72-
request,
73-
target,
74-
}: {
75-
request: Request
76-
target: string
77-
}) {
7869
const user = await prisma.user.findFirst({
79-
where: { OR: [{ email: target }, { username: target }] },
70+
where: { OR: [{ email: usernameOrEmail }, { username: usernameOrEmail }] },
8071
select: { email: true, username: true },
8172
})
82-
if (!user) {
83-
// maybe they're trying to see whether a user exists? We're not gonna tell them...
84-
return
85-
}
73+
invariantResponse(user, 'User should exist')
8674

8775
const { verifyUrl, otp } = await prepareVerification({
8876
period: 10 * 60,
8977
request,
9078
type: 'forgot-password',
91-
target,
79+
target: usernameOrEmail,
9280
})
9381

94-
await sendEmail({
82+
const response = await sendEmail({
9583
to: user.email,
9684
subject: `Epic Notes Password Reset`,
9785
react: (
9886
<ForgotPasswordEmail onboardingUrl={verifyUrl.toString()} otp={otp} />
9987
),
10088
})
89+
90+
if (response.status === 'success') {
91+
return redirect(redirectTo.toString())
92+
} else {
93+
submission.error[''] = response.error.message
94+
return json({ status: 'error', submission } as const, { status: 500 })
95+
}
96+
}
97+
98+
export async function handleVerification({
99+
request,
100+
submission,
101+
}: VerifyFunctionArgs) {
102+
invariant(submission.value, 'submission.value should be defined by now')
103+
const target = submission.value.target
104+
const user = await prisma.user.findFirst({
105+
where: { OR: [{ email: target }, { username: target }] },
106+
select: { email: true, username: true },
107+
})
108+
// we don't want to say the user is not found if the email is not found
109+
// because that would allow an attacker to check if an email is registered
110+
invariantResponse(user, 'Invalid code')
111+
112+
const session = await getSession(request.headers.get('cookie'))
113+
session.set(resetPasswordUsernameSessionKey, user.username)
114+
return redirect('/reset-password', {
115+
headers: { 'Set-Cookie': await commitSession(session) },
116+
})
101117
}
102118

103119
export const meta: V2_MetaFunction = () => {

app/routes/_auth+/onboarding.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
import { checkboxSchema } from '~/utils/zod-extensions.ts'
3030
import { redirectWithConfetti } from '~/utils/flash-session.server.ts'
3131
import { prisma } from '~/utils/db.server.ts'
32+
import { invariant } from '~/utils/misc.ts'
33+
import { type VerifyFunctionArgs } from '../resources+/verify.tsx'
3234

3335
export const onboardingEmailSessionKey = 'onboardingEmail'
3436

@@ -125,6 +127,18 @@ export async function action({ request }: DataFunctionArgs) {
125127
})
126128
}
127129

130+
export async function handleVerification({
131+
request,
132+
submission,
133+
}: VerifyFunctionArgs) {
134+
invariant(submission.value, 'submission.value should be defined by now')
135+
const session = await getSession(request.headers.get('cookie'))
136+
session.set(onboardingEmailSessionKey, submission.value.target)
137+
return redirect('/onboarding', {
138+
headers: { 'Set-Cookie': await commitSession(session) },
139+
})
140+
}
141+
128142
export const meta: V2_MetaFunction = () => {
129143
return [{ title: 'Setup Epic Notes Account' }]
130144
}

app/routes/_auth+/reset-password.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export default function ResetPasswordPage() {
118118
inputProps={{
119119
...conform.input(fields.password, { type: 'password' }),
120120
autoComplete: 'new-password',
121+
autoFocus: true,
121122
}}
122123
errors={fields.password.errors}
123124
/>

app/routes/_auth+/verify.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { json, type DataFunctionArgs } from '@remix-run/node'
22
import { useLoaderData, useSearchParams } from '@remix-run/react'
3-
import { Verify, codeQueryParam, validate } from '../resources+/verify.tsx'
3+
import {
4+
Verify,
5+
codeQueryParam,
6+
validateRequest,
7+
} from '../resources+/verify.tsx'
48
import { Spacer } from '~/components/spacer.tsx'
59

610
export async function loader({ request }: DataFunctionArgs) {
@@ -17,7 +21,7 @@ export async function loader({ request }: DataFunctionArgs) {
1721
},
1822
} as const)
1923
}
20-
return validate(request, params)
24+
return validateRequest(request, params)
2125
}
2226

2327
export default function VerifyRoute() {

0 commit comments

Comments
 (0)