Refactor authentication and account management with modular UI components (#242)

This commit is contained in:
2026-08-23 01:30:58 -04:00
committed by Aaron Po
parent 6a44afd6b6
commit bfd45842fe
109 changed files with 14743 additions and 10792 deletions

View File

@@ -0,0 +1,76 @@
import { Link } from 'react-router';
const AuthFlowDemoCard = () => (
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">Auth Flow Demo</h2>
<p className="text-sm text-base-content/70">
This demo showcases the following authentication features:
</p>
<ul className="list">
<li className="list-row">
<div>
<p className="font-semibold">Login</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
username &amp; password
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Register</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
user details
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Session</p>
<p className="text-sm text-base-content/60">
JWT access &amp; refresh tokens stored in an HTTP-only cookie
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Protected Routes</p>
<p className="text-sm text-base-content/60">
This dashboard requires authentication via{' '}
<code className="kbd kbd-sm">requireAuth()</code>
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Token Refresh</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
header
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Account Management</p>
<p className="text-sm text-base-content/60">
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
<code className="kbd kbd-sm">/email</code>,{' '}
<code className="kbd kbd-sm">/password</code>,{' '}
<code className="kbd kbd-sm">/profile</code>, and DELETE{' '}
<code className="kbd kbd-sm">/account</code> — see{' '}
<Link to="/account" className="link">
Account Settings
</Link>
</p>
</div>
</li>
</ul>
</div>
</div>
);
export default AuthFlowDemoCard;

View File

@@ -0,0 +1,98 @@
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react';
import { Trash, WarningTriangle } from 'iconoir-react';
import { useState } from 'react';
import { useNavigation, useSubmit } from 'react-router';
import { useSectionError } from '../hooks/useSectionError';
import type { ActionResult } from '../types';
interface DeleteAccountSectionProps {
userAccountId: string;
result: ActionResult | undefined;
}
const DeleteAccountSection = ({ userAccountId, result }: DeleteAccountSectionProps) => {
const navigation = useNavigation();
const submit = useSubmit();
const [dialogOpen, setDialogOpen] = useState(false);
const isSubmitting = navigation.formData?.get('intent') === 'delete';
useSectionError(result, 'delete');
return (
<>
<div className="card bg-error/5 border border-error/30 shadow">
<div className="card-body">
<div className="flex items-center justify-between gap-5">
<div className="flex items-start gap-3">
<span className="text-error mt-1">
<Trash className="size-5" aria-hidden="true" />
</span>
<div>
<h2 className="card-title text-lg">Delete Account</h2>
<p className="text-sm text-base-content/70">
Permanently delete your account. This cannot be undone.
</p>
</div>
</div>
<button
type="button"
className="btn btn-error btn-outline btn-sm"
onClick={() => setDialogOpen(true)}
>
Delete
</button>
</div>
</div>
</div>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="card w-full max-w-sm bg-base-100 shadow-xl">
<div className="card-body items-center text-center gap-3">
<WarningTriangle className="size-10 text-error" aria-hidden="true" />
<DialogTitle className="text-lg font-bold">
Delete your account?
</DialogTitle>
<p className="text-sm text-base-content/70">
This action is permanent and cannot be reversed. Account ID{' '}
<span className="font-mono text-xs">{userAccountId}</span> and all
associated data will be removed.
</p>
<div className="flex flex-col gap-2 w-full pt-2">
<button
type="button"
className="btn btn-error btn-sm w-full"
disabled={isSubmitting}
onClick={() => submit({ intent: 'delete' }, { method: 'post' })}
>
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-sm" />{' '}
Deleting...
</>
) : (
'Yes, delete my account'
)}
</button>
<button
type="button"
className="btn btn-ghost btn-sm w-full"
onClick={() => setDialogOpen(false)}
>
Cancel
</button>
</div>
</div>
</DialogPanel>
</div>
</Dialog>
</>
);
};
export default DeleteAccountSection;

View File

@@ -0,0 +1,57 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Mail } from 'iconoir-react';
import { useForm } from 'react-hook-form';
import { useNavigation, useSubmit } from 'react-router';
import FormField from '../../../components/ui/forms/FormField';
import SubmitButton from '../../../components/ui/forms/SubmitButton';
import { useSectionError } from '../hooks/useSectionError';
import { updateEmailSchema, type UpdateEmailSchema } from '../schemas';
import type { SectionProps } from '../types';
import SectionCard from './SectionCard';
const EmailSection = ({ email, open, onToggle, result }: SectionProps & { email: string }) => {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.formData?.get('intent') === 'email';
const emailForm = useForm<UpdateEmailSchema>({
resolver: zodResolver(updateEmailSchema),
defaultValues: { newEmail: email },
});
useSectionError(result, 'email');
return (
<SectionCard
icon={<Mail className="size-5" aria-hidden="true" />}
title="Email Address"
description="Change your email. You'll need to re-confirm the new address."
open={open}
onToggle={onToggle}
>
<form
className="space-y-3"
onSubmit={emailForm.handleSubmit((values) =>
submit({ ...values, intent: 'email' }, { method: 'post' }),
)}
>
<FormField
id="newEmail"
type="email"
autoComplete="email"
placeholder="jane@example.com"
label="New Email"
error={emailForm.formState.errors.newEmail?.message}
{...emailForm.register('newEmail')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Update Email"
submittingText="Updating..."
/>
</form>
</SectionCard>
);
};
export default EmailSection;

View File

@@ -0,0 +1,75 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Lock } from 'iconoir-react';
import { useForm } from 'react-hook-form';
import { useNavigation, useSubmit } from 'react-router';
import FormField from '../../../components/ui/forms/FormField';
import SubmitButton from '../../../components/ui/forms/SubmitButton';
import { useSectionError } from '../hooks/useSectionError';
import { updatePasswordSchema, type UpdatePasswordSchema } from '../schemas';
import type { SectionProps } from '../types';
import SectionCard from './SectionCard';
const PasswordSection = ({ open, onToggle, result }: SectionProps) => {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.formData?.get('intent') === 'password';
const passwordForm = useForm<UpdatePasswordSchema>({
resolver: zodResolver(updatePasswordSchema),
});
useSectionError(result, 'password');
return (
<SectionCard
icon={<Lock className="size-5" aria-hidden="true" />}
title="Password"
description="Change the password used to sign in."
open={open}
onToggle={onToggle}
>
<form
className="space-y-3"
onSubmit={passwordForm.handleSubmit((values) =>
submit({ ...values, intent: 'password' }, { method: 'post' }),
)}
>
<FormField
id="currentPassword"
type="password"
autoComplete="current-password"
placeholder="••••••••"
label="Current Password"
error={passwordForm.formState.errors.currentPassword?.message}
{...passwordForm.register('currentPassword')}
/>
<FormField
id="newPassword"
type="password"
autoComplete="new-password"
placeholder="••••••••"
label="New Password"
hint="8+ chars: uppercase, lowercase, digit, special character"
error={passwordForm.formState.errors.newPassword?.message}
{...passwordForm.register('newPassword')}
/>
<FormField
id="confirmNewPassword"
type="password"
autoComplete="new-password"
placeholder="••••••••"
label="Confirm New Password"
error={passwordForm.formState.errors.confirmNewPassword?.message}
{...passwordForm.register('confirmNewPassword')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Change Password"
submittingText="Changing..."
/>
</form>
</SectionCard>
);
};
export default PasswordSection;

View File

@@ -0,0 +1,82 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { User } from 'iconoir-react';
import { useForm } from 'react-hook-form';
import { useNavigation, useSubmit } from 'react-router';
import FormField from '../../../components/ui/forms/FormField';
import SubmitButton from '../../../components/ui/forms/SubmitButton';
import { useSectionError } from '../hooks/useSectionError';
import { updateProfileSchema, type UpdateProfileSchema } from '../schemas';
import type { SectionProps } from '../types';
import SectionCard from './SectionCard';
const ProfileSection = ({
firstName,
lastName,
dateOfBirth,
open,
onToggle,
result,
}: SectionProps & { firstName: string; lastName: string; dateOfBirth: string }) => {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.formData?.get('intent') === 'profile';
const profileForm = useForm<UpdateProfileSchema>({
resolver: zodResolver(updateProfileSchema),
defaultValues: { firstName, lastName, dateOfBirth },
});
useSectionError(result, 'profile');
return (
<SectionCard
icon={<User className="size-5" aria-hidden="true" />}
title="Profile"
description="Update your name and date of birth."
open={open}
onToggle={onToggle}
>
<form
className="space-y-3"
onSubmit={profileForm.handleSubmit((values) =>
submit({ ...values, intent: 'profile' }, { method: 'post' }),
)}
>
<div className="grid grid-cols-2 gap-3">
<FormField
id="firstName"
type="text"
autoComplete="given-name"
placeholder="Jane"
label="First Name"
error={profileForm.formState.errors.firstName?.message}
{...profileForm.register('firstName')}
/>
<FormField
id="lastName"
type="text"
autoComplete="family-name"
placeholder="Doe"
label="Last Name"
error={profileForm.formState.errors.lastName?.message}
{...profileForm.register('lastName')}
/>
</div>
<FormField
id="dateOfBirth"
type="date"
label="Date of Birth"
error={profileForm.formState.errors.dateOfBirth?.message}
{...profileForm.register('dateOfBirth')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Update Profile"
submittingText="Updating..."
/>
</form>
</SectionCard>
);
};
export default ProfileSection;

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
interface SectionCardProps {
icon: ReactNode;
title: string;
description: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}
const SectionCard = ({ icon, title, description, open, onToggle, children }: SectionCardProps) => (
<div className="card bg-base-100 shadow">
<div className="card-body">
<div className="flex w-full items-center justify-between gap-5">
<div className="flex items-start gap-3">
<span className="text-base-content/60 mt-1">{icon}</span>
<div>
<h2 className="card-title text-lg">{title}</h2>
<p className="text-sm text-base-content/70">{description}</p>
</div>
</div>
<input
type="checkbox"
className="toggle toggle-primary"
checked={open}
onChange={onToggle}
aria-label={`Toggle ${title.toLowerCase()} form`}
/>
</div>
{open && <div className="mt-4">{children}</div>}
</div>
</div>
);
export default SectionCard;

View File

@@ -0,0 +1,42 @@
import { Link } from 'react-router';
interface SessionInfoCardProps {
username: string;
userAccountId: string;
}
const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
<p className="text-base-content/70">
You are successfully authenticated. This is a protected page that requires a valid
session.
</p>
<div className="bg-base-200 rounded-box p-4 mt-2">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
Session Info
</p>
<div className="stats stats-vertical w-full">
<div className="stat py-2">
<div className="stat-title">Username</div>
<div className="stat-value text-lg font-mono">{username}</div>
</div>
<div className="stat py-2">
<div className="stat-title">User ID</div>
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
</div>
</div>
</div>
<div className="card-actions mt-4">
<Link to="/account" className="btn btn-outline btn-sm">
Account Settings
</Link>
</div>
</div>
</div>
);
export default SessionInfoCard;

View File

@@ -0,0 +1,61 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { User } from 'iconoir-react';
import { useForm } from 'react-hook-form';
import { useNavigation, useSubmit } from 'react-router';
import FormField from '../../../components/ui/forms/FormField';
import SubmitButton from '../../../components/ui/forms/SubmitButton';
import { useSectionError } from '../hooks/useSectionError';
import { updateUsernameSchema, type UpdateUsernameSchema } from '../schemas';
import type { SectionProps } from '../types';
import SectionCard from './SectionCard';
const UsernameSection = ({
username,
open,
onToggle,
result,
}: SectionProps & { username: string }) => {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.formData?.get('intent') === 'username';
const usernameForm = useForm<UpdateUsernameSchema>({
resolver: zodResolver(updateUsernameSchema),
defaultValues: { newUsername: username },
});
useSectionError(result, 'username');
return (
<SectionCard
icon={<User className="size-5" aria-hidden="true" />}
title="Username"
description="Change the username you sign in with."
open={open}
onToggle={onToggle}
>
<form
className="space-y-3"
onSubmit={usernameForm.handleSubmit((values) =>
submit({ ...values, intent: 'username' }, { method: 'post' }),
)}
>
<FormField
id="newUsername"
type="text"
placeholder={username}
label="New Username"
error={usernameForm.formState.errors.newUsername?.message}
{...usernameForm.register('newUsername')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Update Username"
submittingText="Updating..."
/>
</form>
</SectionCard>
);
};
export default UsernameSection;