diff --git a/heat-stack/app/routes/settings+/profile.password.tsx b/heat-stack/app/routes/settings+/profile.password.tsx index 5dcf6cf9..91ede00f 100644 --- a/heat-stack/app/routes/settings+/profile.password.tsx +++ b/heat-stack/app/routes/settings+/profile.password.tsx @@ -1,7 +1,9 @@ import { conform, useForm } from '@conform-to/react' import { getFieldsetConstraint, parse } from '@conform-to/zod' +import { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, redirect, type DataFunctionArgs } from '@remix-run/node' import { Form, Link, useActionData } from '@remix-run/react' +import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { z } from 'zod' import { ErrorList, Field } from '#app/components/forms.tsx' import { Button } from '#app/components/ui/button.tsx' @@ -12,13 +14,16 @@ import { requireUserId, verifyUserPassword, } from '#app/utils/auth.server.ts' +import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { useIsPending } from '#app/utils/misc.tsx' import { redirectWithToast } from '#app/utils/toast.server.ts' import { PasswordSchema } from '#app/utils/user-validation.ts' +import { type BreadcrumbHandle } from './profile.tsx' -export const handle = { +export const handle: BreadcrumbHandle & SEOHandle = { breadcrumb: Password, + getSitemapEntries: () => null, } const ChangePasswordForm = z @@ -31,7 +36,7 @@ const ChangePasswordForm = z if (confirmNewPassword !== newPassword) { ctx.addIssue({ path: ['confirmNewPassword'], - code: 'custom', + code: z.ZodIssueCode.custom, message: 'The passwords must match', }) } @@ -57,6 +62,7 @@ export async function action({ request }: DataFunctionArgs) { const userId = await requireUserId(request) await requirePassword(userId) const formData = await request.formData() + await validateCSRF(formData, request.headers) const submission = await parse(formData, { async: true, schema: ChangePasswordForm.superRefine( @@ -66,7 +72,7 @@ export async function action({ request }: DataFunctionArgs) { if (!user) { ctx.addIssue({ path: ['currentPassword'], - code: 'custom', + code: z.ZodIssueCode.custom, message: 'Incorrect password.', }) } @@ -126,6 +132,7 @@ export default function ChangePasswordRoute() { return (
+ Password, + getSitemapEntries: () => null, } -const CreatePasswordForm = z - .object({ - newPassword: PasswordSchema, - confirmNewPassword: PasswordSchema, - }) - .superRefine(({ confirmNewPassword, newPassword }, ctx) => { - if (confirmNewPassword !== newPassword) { - ctx.addIssue({ - path: ['confirmNewPassword'], - code: 'custom', - message: 'The passwords must match', - }) - } - }) +const CreatePasswordForm = PasswordAndConfirmPasswordSchema async function requireNoPassword(userId: string) { const password = await prisma.password.findUnique({ @@ -66,7 +55,7 @@ export async function action({ request }: DataFunctionArgs) { return json({ status: 'error', submission } as const, { status: 400 }) } - const { newPassword } = submission.value + const { password } = submission.value await prisma.user.update({ select: { username: true }, @@ -74,7 +63,7 @@ export async function action({ request }: DataFunctionArgs) { data: { password: { create: { - hash: await getPasswordHash(newPassword), + hash: await getPasswordHash(password), }, }, }, @@ -101,15 +90,15 @@ export default function CreatePasswordRoute() {
diff --git a/heat-stack/app/routes/settings+/profile.photo.tsx b/heat-stack/app/routes/settings+/profile.photo.tsx index 5fc95aa6..30264243 100644 --- a/heat-stack/app/routes/settings+/profile.photo.tsx +++ b/heat-stack/app/routes/settings+/profile.photo.tsx @@ -1,5 +1,6 @@ import { conform, useForm } from '@conform-to/react' import { getFieldsetConstraint, parse } from '@conform-to/zod' +import { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, redirect, @@ -7,15 +8,21 @@ import { unstable_parseMultipartFormData, type DataFunctionArgs, } from '@remix-run/node' -import { Form, useActionData, useLoaderData } from '@remix-run/react' +import { + Form, + useActionData, + useLoaderData, + useNavigation, +} from '@remix-run/react' import { useState } from 'react' -import { ServerOnly } from 'remix-utils' +import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { z } from 'zod' import { ErrorList } from '#app/components/forms.tsx' import { Button } from '#app/components/ui/button.tsx' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireUserId } from '#app/utils/auth.server.ts' +import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { getUserImgSrc, @@ -23,9 +30,11 @@ import { useDoubleCheck, useIsPending, } from '#app/utils/misc.tsx' +import { type BreadcrumbHandle } from './profile.tsx' -export const handle = { +export const handle: BreadcrumbHandle & SEOHandle = { breadcrumb: Photo, + getSitemapEntries: () => null, } const MAX_SIZE = 1024 * 1024 * 3 // 3MB @@ -34,7 +43,7 @@ const DeleteImageSchema = z.object({ intent: z.literal('delete'), }) -const SubmitFormSchema = z.object({ +const NewImageSchema = z.object({ intent: z.literal('submit'), photoFile: z .instanceof(File) @@ -42,7 +51,7 @@ const SubmitFormSchema = z.object({ .refine(file => file.size <= MAX_SIZE, 'Image size must be less than 3MB'), }) -const PhotoFormSchema = z.union([DeleteImageSchema, SubmitFormSchema]) +const PhotoFormSchema = z.union([DeleteImageSchema, NewImageSchema]) export async function loader({ request }: DataFunctionArgs) { const userId = await requireUserId(request) @@ -65,6 +74,7 @@ export async function action({ request }: DataFunctionArgs) { request, unstable_createMemoryUploadHandler({ maxPartSize: MAX_SIZE }), ) + await validateCSRF(formData, request.headers) const submission = await parse(formData, { schema: PhotoFormSchema.transform(async data => { @@ -112,18 +122,26 @@ export default function PhotoRoute() { const doubleCheckDeleteImage = useDoubleCheck() const actionData = useActionData() + const navigation = useNavigation() const [form, fields] = useForm({ id: 'profile-photo', constraint: getFieldsetConstraint(PhotoFormSchema), lastSubmission: actionData?.submission, onValidate({ formData }) { - return parse(formData, { schema: PhotoFormSchema }) + // otherwise, the best error zod gives us is "Invalid input" which is not + // enough + if (formData.get('intent') === 'delete') { + return parse(formData, { schema: DeleteImageSchema }) + } + return parse(formData, { schema: NewImageSchema }) }, shouldRevalidate: 'onBlur', }) const isPending = useIsPending() + const pendingIntent = isPending ? navigation.formData?.get('intent') : null + const lastSubmissionIntent = actionData?.submission.value?.intent const [newImageSrc, setNewImageSrc] = useState(null) @@ -136,6 +154,7 @@ export default function PhotoRoute() { onReset={() => setNewImageSrc(null)} {...form.props} > + {data.user?.name - { - const file = e.currentTarget.files?.[0] - if (file) { - const reader = new FileReader() - reader.onload = event => { - setNewImageSrc(event.target?.result?.toString() ?? null) +
+ {/* + We're doing some kinda odd things to make it so this works well + without JavaScript. Basically, we're using CSS to ensure the right + buttons show up based on the input's "valid" state (whether or not + an image has been selected). Progressive enhancement FTW! + */} + { + const file = e.currentTarget.files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = event => { + setNewImageSrc(event.target?.result?.toString() ?? null) + } + reader.readAsDataURL(file) } - reader.readAsDataURL(file) + }} + /> + + - {newImageSrc ? ( -
+ > + Save Photo + + + {data.user.image?.id ? ( - Save Photo + + {doubleCheckDeleteImage.doubleCheck + ? 'Are you sure?' + : 'Delete'} + - -
- ) : ( -
- - - {/* This is here for progressive enhancement. If the client doesn't - hydrate (or hasn't yet) this button will be available to submit the - selected photo. */} - - {() => ( - - )} - - {data.user.image?.id ? ( - - ) : null} -
- )} + ) : null} +
diff --git a/heat-stack/app/routes/settings+/profile.tsx b/heat-stack/app/routes/settings+/profile.tsx index 09f7df2d..cf8869cb 100644 --- a/heat-stack/app/routes/settings+/profile.tsx +++ b/heat-stack/app/routes/settings+/profile.tsx @@ -1,3 +1,4 @@ +import { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, type DataFunctionArgs } from '@remix-run/node' import { Link, Outlet, useMatches } from '@remix-run/react' import { z } from 'zod' @@ -8,8 +9,12 @@ import { prisma } from '#app/utils/db.server.ts' import { cn, invariantResponse } from '#app/utils/misc.tsx' import { useUser } from '#app/utils/user.ts' -export const handle = { +export const BreadcrumbHandle = z.object({ breadcrumb: z.any() }) +export type BreadcrumbHandle = z.infer + +export const handle: BreadcrumbHandle & SEOHandle = { breadcrumb: Edit Profile, + getSitemapEntries: () => null, } export async function loader({ request }: DataFunctionArgs) { @@ -23,7 +28,7 @@ export async function loader({ request }: DataFunctionArgs) { } const BreadcrumbHandleMatch = z.object({ - handle: z.object({ breadcrumb: z.any() }), + handle: BreadcrumbHandle, }) export default function EditUserProfile() { @@ -32,7 +37,7 @@ export default function EditUserProfile() { const breadcrumbs = matches .map(m => { const result = BreadcrumbHandleMatch.safeParse(m) - if (!result.success) return null + if (!result.success || !result.data.handle.breadcrumb) return null return ( {result.data.handle.breadcrumb} @@ -42,29 +47,31 @@ export default function EditUserProfile() { .filter(Boolean) return ( -
-
    -
  • - - Profile - -
  • - {breadcrumbs.map((breadcrumb, i, arr) => ( -
  • - โ–ถ๏ธ {breadcrumb} +
    +
    +
      +
    • + + Profile +
    • - ))} -
    + {breadcrumbs.map((breadcrumb, i, arr) => ( +
  • + โ–ถ๏ธ {breadcrumb} +
  • + ))} +
+
-
+
diff --git a/heat-stack/app/routes/settings+/profile.two-factor.disable.tsx b/heat-stack/app/routes/settings+/profile.two-factor.disable.tsx index 1b627f86..89282877 100644 --- a/heat-stack/app/routes/settings+/profile.two-factor.disable.tsx +++ b/heat-stack/app/routes/settings+/profile.two-factor.disable.tsx @@ -1,16 +1,21 @@ +import { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, type DataFunctionArgs } from '@remix-run/node' import { useFetcher } from '@remix-run/react' +import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireRecentVerification } from '#app/routes/_auth+/verify.tsx' import { requireUserId } from '#app/utils/auth.server.ts' +import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { useDoubleCheck } from '#app/utils/misc.tsx' import { redirectWithToast } from '#app/utils/toast.server.ts' +import { type BreadcrumbHandle } from './profile.tsx' import { twoFAVerificationType } from './profile.two-factor.tsx' -export const handle = { +export const handle: BreadcrumbHandle & SEOHandle = { breadcrumb: Disable, + getSitemapEntries: () => null, } export async function loader({ request }: DataFunctionArgs) { @@ -20,6 +25,7 @@ export async function loader({ request }: DataFunctionArgs) { export async function action({ request }: DataFunctionArgs) { await requireRecentVerification(request) + await validateCSRF(await request.formData(), request.headers) const userId = await requireUserId(request) await prisma.verification.delete({ where: { target_type: { target: userId, type: twoFAVerificationType } }, @@ -36,7 +42,8 @@ export default function TwoFactorDisableRoute() { return (
- + +

Disabling two factor authentication is not recommended. However, if you would like to do so, click here: diff --git a/heat-stack/app/routes/settings+/profile.two-factor.index.tsx b/heat-stack/app/routes/settings+/profile.two-factor.index.tsx index 22c92259..dbc3a18f 100644 --- a/heat-stack/app/routes/settings+/profile.two-factor.index.tsx +++ b/heat-stack/app/routes/settings+/profile.two-factor.index.tsx @@ -1,13 +1,20 @@ -import { generateTOTP } from '@epic-web/totp' +import { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, redirect, type DataFunctionArgs } from '@remix-run/node' import { Link, useFetcher, useLoaderData } from '@remix-run/react' +import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireUserId } from '#app/utils/auth.server.ts' +import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' +import { generateTOTP } from '#app/utils/totp.server.ts' import { twoFAVerificationType } from './profile.two-factor.tsx' import { twoFAVerifyVerificationType } from './profile.two-factor.verify.tsx' +export const handle: SEOHandle = { + getSitemapEntries: () => null, +} + export async function loader({ request }: DataFunctionArgs) { const userId = await requireUserId(request) const verification = await prisma.verification.findUnique({ @@ -19,6 +26,7 @@ export async function loader({ request }: DataFunctionArgs) { export async function action({ request }: DataFunctionArgs) { const userId = await requireUserId(request) + await validateCSRF(await request.formData(), request.headers) const { otp: _otp, ...config } = generateTOTP() const verificationData = { ...config, @@ -68,7 +76,8 @@ export default function TwoFactorRoute() { {' '} to log in.

- + + 2FA, + getSitemapEntries: () => null, } export const twoFAVerificationType = '2fa' satisfies VerificationTypes diff --git a/heat-stack/app/routes/settings+/profile.two-factor.verify.tsx b/heat-stack/app/routes/settings+/profile.two-factor.verify.tsx index 71a4f484..dd5bbf17 100644 --- a/heat-stack/app/routes/settings+/profile.two-factor.verify.tsx +++ b/heat-stack/app/routes/settings+/profile.two-factor.verify.tsx @@ -1,6 +1,6 @@ import { conform, useForm } from '@conform-to/react' import { getFieldsetConstraint, parse } from '@conform-to/zod' -import { getTOTPAuthUri } from '@epic-web/totp' +import { type SEOHandle } from '@nasa-gcn/remix-seo' import { json, redirect, type DataFunctionArgs } from '@remix-run/node' import { Form, @@ -9,25 +9,34 @@ import { useNavigation, } from '@remix-run/react' import * as QRCode from 'qrcode' +import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { z } from 'zod' -import { Field } from '#app/components/forms.tsx' +import { ErrorList, Field } from '#app/components/forms.tsx' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { isCodeValid } from '#app/routes/_auth+/verify.tsx' import { requireUserId } from '#app/utils/auth.server.ts' +import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { getDomainUrl, useIsPending } from '#app/utils/misc.tsx' import { redirectWithToast } from '#app/utils/toast.server.ts' +import { getTOTPAuthUri } from '#app/utils/totp.server.ts' +import { type BreadcrumbHandle } from './profile.tsx' import { twoFAVerificationType } from './profile.two-factor.tsx' -export const handle = { +export const handle: BreadcrumbHandle & SEOHandle = { breadcrumb: Verify, + getSitemapEntries: () => null, } +const CancelSchema = z.object({ intent: z.literal('cancel') }) const VerifySchema = z.object({ + intent: z.literal('verify'), code: z.string().min(6).max(6), }) +const ActionSchema = z.union([CancelSchema, VerifySchema]) + export const twoFAVerifyVerificationType = '2fa-verify' export async function loader({ request }: DataFunctionArgs) { @@ -64,16 +73,12 @@ export async function loader({ request }: DataFunctionArgs) { export async function action({ request }: DataFunctionArgs) { const userId = await requireUserId(request) const formData = await request.formData() + await validateCSRF(formData, request.headers) - if (formData.get('intent') === 'cancel') { - await prisma.verification.deleteMany({ - where: { type: twoFAVerifyVerificationType, target: userId }, - }) - return redirect('/settings/profile/two-factor') - } const submission = await parse(formData, { schema: () => - VerifySchema.superRefine(async (data, ctx) => { + ActionSchema.superRefine(async (data, ctx) => { + if (data.intent === 'cancel') return null const codeIsValid = await isCodeValid({ code: data.code, type: twoFAVerifyVerificationType, @@ -85,7 +90,7 @@ export async function action({ request }: DataFunctionArgs) { code: z.ZodIssueCode.custom, message: `Invalid code`, }) - return + return z.NEVER } }), async: true, @@ -98,17 +103,27 @@ export async function action({ request }: DataFunctionArgs) { return json({ status: 'error', submission } as const, { status: 400 }) } - await prisma.verification.update({ - where: { - target_type: { type: twoFAVerifyVerificationType, target: userId }, - }, - data: { type: twoFAVerificationType }, - }) - return redirectWithToast('/settings/profile/two-factor', { - type: 'success', - title: 'Enabled', - description: 'Two-factor authentication has been enabled.', - }) + switch (submission.value.intent) { + case 'cancel': { + await prisma.verification.deleteMany({ + where: { type: twoFAVerifyVerificationType, target: userId }, + }) + return redirect('/settings/profile/two-factor') + } + case 'verify': { + await prisma.verification.update({ + where: { + target_type: { type: twoFAVerifyVerificationType, target: userId }, + }, + data: { type: twoFAVerificationType }, + }) + return redirectWithToast('/settings/profile/two-factor', { + type: 'success', + title: 'Enabled', + description: 'Two-factor authentication has been enabled.', + }) + } + } } export default function TwoFactorRoute() { @@ -118,12 +133,18 @@ export default function TwoFactorRoute() { const isPending = useIsPending() const pendingIntent = isPending ? navigation.formData?.get('intent') : null + const lastSubmissionIntent = actionData?.submission.value?.intent const [form, fields] = useForm({ id: 'verify-form', - constraint: getFieldsetConstraint(VerifySchema), + constraint: getFieldsetConstraint(ActionSchema), lastSubmission: actionData?.submission, onValidate({ formData }) { + // otherwise, the best error zod gives us is "Invalid input" which is not + // enough + if (formData.get('intent') === 'cancel') { + return parse(formData, { schema: CancelSchema }) + } return parse(formData, { schema: VerifySchema }) }, }) @@ -154,6 +175,7 @@ export default function TwoFactorRoute() {

+ + +
+ +
+
Submit { }, }) - const cookieSession = await sessionStorage.getSession() - cookieSession.set(sessionKey, session.id) - const setCookieHeader = await sessionStorage.commitSession(cookieSession) + const authSession = await authSessionStorage.getSession() + authSession.set(sessionKey, session.id) + const setCookieHeader = await authSessionStorage.commitSession(authSession) const parsedCookie = setCookieParser.parseString(setCookieHeader) const cookieHeader = new URLSearchParams({ [parsedCookie.name]: parsedCookie.value, diff --git a/heat-stack/app/routes/users+/$username_+/__note-editor.tsx b/heat-stack/app/routes/users+/$username_+/__note-editor.tsx index e74ce751..0c083a5e 100644 --- a/heat-stack/app/routes/users+/$username_+/__note-editor.tsx +++ b/heat-stack/app/routes/users+/$username_+/__note-editor.tsx @@ -17,8 +17,9 @@ import { type DataFunctionArgs, type SerializeFrom, } from '@remix-run/node' -import { Form, useFetcher } from '@remix-run/react' +import { Form, useActionData, useNavigation } from '@remix-run/react' import { useRef, useState } from 'react' +import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { floatingToolbarClassName } from '#app/components/floating-toolbar.tsx' @@ -29,6 +30,7 @@ import { Label } from '#app/components/ui/label.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { Textarea } from '#app/components/ui/textarea.tsx' import { requireUserId } from '#app/utils/auth.server.ts' +import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { cn, getNoteImgSrc } from '#app/utils/misc.tsx' @@ -78,6 +80,7 @@ export async function action({ request }: DataFunctionArgs) { request, createMemoryUploadHandler({ maxPartSize: MAX_UPLOAD_SIZE }), ) + await validateCSRF(formData, request.headers) const submission = await parse(formData, { schema: NoteEditorSchema.superRefine(async (data, ctx) => { @@ -89,7 +92,7 @@ export async function action({ request }: DataFunctionArgs) { }) if (!note) { ctx.addIssue({ - code: 'custom', + code: z.ZodIssueCode.custom, message: 'Note not found', }) } @@ -131,11 +134,11 @@ export async function action({ request }: DataFunctionArgs) { }) if (submission.intent !== 'submit') { - return json({ status: 'idle', submission } as const) + return json({ submission } as const) } if (!submission.value) { - return json({ status: 'error', submission } as const, { status: 400 }) + return json({ submission } as const, { status: 400 }) } const { @@ -183,13 +186,15 @@ export function NoteEditor({ } > }) { - const noteFetcher = useFetcher() - const isPending = noteFetcher.state !== 'idle' + const actionData = useActionData() + + const navigation = useNavigation() + const isPending = navigation.state !== 'idle' const [form, fields] = useForm({ id: 'note-editor', constraint: getFieldsetConstraint(NoteEditorSchema), - lastSubmission: noteFetcher.data?.submission, + lastSubmission: actionData?.submission, onValidate({ formData }) { return parse(formData, { schema: NoteEditorSchema }) }, @@ -204,11 +209,12 @@ export function NoteEditor({ return (
+ {/* This hidden submit button is here to ensure that when the user hits "enter" on an input field, the primary form function is submitted @@ -241,7 +247,7 @@ export function NoteEditor({ className="relative border-b-2 border-muted-foreground" >