mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Refactor authentication and account management with modular UI components (#242)
This commit is contained in:
@@ -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 & 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 & 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal file
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
10
web/frontend/app/features/account/hooks/useSectionError.ts
Normal file
10
web/frontend/app/features/account/hooks/useSectionError.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { showErrorToast } from '../../../components/ui/toast/toast';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
export const useSectionError = (result: ActionResult | undefined, intent: ActionResult['intent']) => {
|
||||
useEffect(() => {
|
||||
if (!result || result.intent !== intent) return;
|
||||
showErrorToast(result.error);
|
||||
}, [result, intent]);
|
||||
};
|
||||
269
web/frontend/app/features/account/routes/account.tsx
Normal file
269
web/frontend/app/features/account/routes/account.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Settings } from 'iconoir-react';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import { data, Link, redirect, useSearchParams } from 'react-router';
|
||||
import { showSuccessToast } from '../../../components/ui/toast/toast';
|
||||
import {
|
||||
commitSession,
|
||||
deleteAccount,
|
||||
destroySession,
|
||||
getSession,
|
||||
getUserAccount,
|
||||
requireAuth,
|
||||
updateEmail,
|
||||
updatePassword,
|
||||
updateProfile,
|
||||
updateUsername,
|
||||
} from '../../auth/auth.server';
|
||||
import DeleteAccountSection from '../components/DeleteAccountSection';
|
||||
import EmailSection from '../components/EmailSection';
|
||||
import PasswordSection from '../components/PasswordSection';
|
||||
import ProfileSection from '../components/ProfileSection';
|
||||
import UsernameSection from '../components/UsernameSection';
|
||||
import {
|
||||
updateEmailSchema,
|
||||
updatePasswordSchema,
|
||||
updateProfileSchema,
|
||||
updateUsernameSchema,
|
||||
} from '../schemas';
|
||||
import { initialSectionState, sectionReducer } from '../state';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
import type { Route } from './+types/account';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => [{ title: 'Account Settings | The Biergarten App' }];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
|
||||
try {
|
||||
const account = await getUserAccount(auth.userAccountId);
|
||||
details = {
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
email: account.email,
|
||||
// Trim the backend's ISO datetime down to the yyyy-MM-dd a date input expects.
|
||||
dateOfBirth: account.dateOfBirth.slice(0, 10),
|
||||
};
|
||||
} catch {
|
||||
// Fall through with blank fields; the update forms still work standalone.
|
||||
}
|
||||
|
||||
return { username: auth.username, userAccountId: auth.userAccountId, ...details };
|
||||
};
|
||||
|
||||
const redirectWithToast = (message: string, headers?: HeadersInit) =>
|
||||
redirect(`/account?toast=${encodeURIComponent(message)}`, headers ? { headers } : undefined);
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get('intent');
|
||||
|
||||
switch (intent) {
|
||||
case 'delete': {
|
||||
try {
|
||||
await deleteAccount(auth.accessToken);
|
||||
} catch (err) {
|
||||
return {
|
||||
intent: 'delete' as const,
|
||||
error: err instanceof Error ? err.message : 'Failed to delete account.',
|
||||
};
|
||||
}
|
||||
const session = await getSession(request);
|
||||
return redirect('/', { headers: { 'Set-Cookie': await destroySession(session) } });
|
||||
}
|
||||
|
||||
case 'username': {
|
||||
const result = updateUsernameSchema.safeParse({
|
||||
newUsername: formData.get('newUsername'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
const payload = await updateUsername(auth.accessToken, result.data.newUsername);
|
||||
const session = await getSession(request);
|
||||
session.set('username', payload.username);
|
||||
return redirectWithToast('Username updated successfully.', {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update username.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'email': {
|
||||
const result = updateEmailSchema.safeParse({ newEmail: formData.get('newEmail') });
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
const payload = await updateEmail(auth.accessToken, result.data.newEmail);
|
||||
return redirectWithToast(
|
||||
payload.emailConfirmed
|
||||
? 'Email updated successfully.'
|
||||
: 'Email updated. Please re-confirm your new address.',
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update email.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'profile': {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
await updateProfile(
|
||||
auth.accessToken,
|
||||
result.data.firstName,
|
||||
result.data.lastName,
|
||||
result.data.dateOfBirth,
|
||||
);
|
||||
return redirectWithToast('Profile updated successfully.');
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update profile.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'password': {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmNewPassword: formData.get('confirmNewPassword'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
await updatePassword(
|
||||
auth.accessToken,
|
||||
result.data.currentPassword,
|
||||
result.data.newPassword,
|
||||
);
|
||||
return redirectWithToast('Password changed successfully.');
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update password.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw data('Unknown request.', { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
const { username, userAccountId, firstName, lastName, email, dateOfBirth } = loaderData;
|
||||
const result = actionData as ActionResult | undefined;
|
||||
|
||||
const [sections, dispatch] = useReducer(sectionReducer, initialSectionState);
|
||||
// Bumped once per successful update so the section subtree below remounts
|
||||
// with fresh `key`s, picking up the new loader values (and, for
|
||||
// PasswordSection, clearing fields that have no persisted value to key
|
||||
// off) without needing an effect to imperatively reset each form.
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const toast = searchParams.get('toast');
|
||||
|
||||
// Collapsing the sections and bumping resetKey are state adjustments in
|
||||
// response to a changed prop (the `toast` search param), so they happen
|
||||
// directly during render - React's documented alternative to an effect
|
||||
// for this case - guarded by `handledToast` so it only runs once per
|
||||
// redirect. Showing the toast and clearing the param are genuine side
|
||||
// effects on an external system (react-hot-toast, the URL) and stay in
|
||||
// the effect below.
|
||||
const [handledToast, setHandledToast] = useState<string | null>(null);
|
||||
if (toast && toast !== handledToast) {
|
||||
setHandledToast(toast);
|
||||
dispatch({ type: 'CLOSE_ALL' });
|
||||
setResetKey((key) => key + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
showSuccessToast(toast);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('toast');
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [toast, setSearchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="size-7" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Account Settings</h1>
|
||||
<p className="text-base-content/70">
|
||||
Manage the account details for{' '}
|
||||
<span className="font-mono">{username}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsernameSection
|
||||
key={`username-${resetKey}`}
|
||||
username={username}
|
||||
open={sections.usernameOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_USERNAME_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<EmailSection
|
||||
key={`email-${resetKey}`}
|
||||
email={email}
|
||||
open={sections.emailOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_EMAIL_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<ProfileSection
|
||||
key={`profile-${resetKey}`}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
dateOfBirth={dateOfBirth}
|
||||
open={sections.profileOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_PROFILE_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<PasswordSection
|
||||
key={`password-${resetKey}`}
|
||||
open={sections.passwordOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_PASSWORD_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<DeleteAccountSection userAccountId={userAccountId} result={result} />
|
||||
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="link link-hover text-sm text-base-content/60 inline-block"
|
||||
>
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountPage;
|
||||
31
web/frontend/app/features/account/routes/dashboard.tsx
Normal file
31
web/frontend/app/features/account/routes/dashboard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { requireAuth } from '../../auth/auth.server';
|
||||
import AuthFlowDemoCard from '../components/AuthFlowDemoCard';
|
||||
import SessionInfoCard from '../components/SessionInfoCard';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Dashboard | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
return {
|
||||
username: auth.username,
|
||||
userAccountId: auth.userAccountId,
|
||||
};
|
||||
};
|
||||
|
||||
const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { username, userAccountId } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
||||
<AuthFlowDemoCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
48
web/frontend/app/features/account/schemas.ts
Normal file
48
web/frontend/app/features/account/schemas.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const updateUsernameSchema = z.object({
|
||||
newUsername: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(64, 'Username must be at most 64 characters')
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Only letters, numbers, dots, underscores, and hyphens'),
|
||||
});
|
||||
|
||||
export type UpdateUsernameSchema = z.infer<typeof updateUsernameSchema>;
|
||||
|
||||
export const updateEmailSchema = z.object({
|
||||
newEmail: z.string().email('Invalid email address').max(128, 'Email is too long'),
|
||||
});
|
||||
|
||||
export type UpdateEmailSchema = z.infer<typeof updateEmailSchema>;
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
firstName: z.string().min(1, 'First name is required').max(128, 'First name is too long'),
|
||||
lastName: z.string().min(1, 'Last name is required').max(128, 'Last name is too long'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
});
|
||||
|
||||
export type UpdateProfileSchema = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
export const updatePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number')
|
||||
.regex(/[^a-zA-Z0-9]/, 'Password must contain a special character'),
|
||||
confirmNewPassword: z.string().min(1, 'Please confirm your new password'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmNewPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmNewPassword'],
|
||||
})
|
||||
.refine((data) => data.newPassword !== data.currentPassword, {
|
||||
message: 'New password must be different from the current password',
|
||||
path: ['newPassword'],
|
||||
});
|
||||
|
||||
export type UpdatePasswordSchema = z.infer<typeof updatePasswordSchema>;
|
||||
25
web/frontend/app/features/account/state.ts
Normal file
25
web/frontend/app/features/account/state.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { SectionAction, SectionState } from './types';
|
||||
|
||||
export const initialSectionState: SectionState = {
|
||||
usernameOpen: false,
|
||||
emailOpen: false,
|
||||
profileOpen: false,
|
||||
passwordOpen: false,
|
||||
};
|
||||
|
||||
export const sectionReducer = (state: SectionState, action: SectionAction): SectionState => {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_USERNAME_VISIBILITY':
|
||||
return { ...initialSectionState, usernameOpen: !state.usernameOpen };
|
||||
case 'TOGGLE_EMAIL_VISIBILITY':
|
||||
return { ...initialSectionState, emailOpen: !state.emailOpen };
|
||||
case 'TOGGLE_PROFILE_VISIBILITY':
|
||||
return { ...initialSectionState, profileOpen: !state.profileOpen };
|
||||
case 'TOGGLE_PASSWORD_VISIBILITY':
|
||||
return { ...initialSectionState, passwordOpen: !state.passwordOpen };
|
||||
case 'CLOSE_ALL':
|
||||
return initialSectionState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
31
web/frontend/app/features/account/types.ts
Normal file
31
web/frontend/app/features/account/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Success never reaches the component as action data - every intent redirects
|
||||
// back to this route (with a `?toast=` message) instead of returning a
|
||||
// payload, so the only shape an action can hand back here is an error.
|
||||
export interface ActionResult {
|
||||
intent: 'username' | 'email' | 'profile' | 'password' | 'delete';
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SectionState {
|
||||
usernameOpen: boolean;
|
||||
emailOpen: boolean;
|
||||
profileOpen: boolean;
|
||||
passwordOpen: boolean;
|
||||
}
|
||||
|
||||
export type ActionType =
|
||||
| 'TOGGLE_USERNAME_VISIBILITY'
|
||||
| 'TOGGLE_EMAIL_VISIBILITY'
|
||||
| 'TOGGLE_PROFILE_VISIBILITY'
|
||||
| 'TOGGLE_PASSWORD_VISIBILITY'
|
||||
| 'CLOSE_ALL';
|
||||
|
||||
export interface SectionAction {
|
||||
type: ActionType;
|
||||
}
|
||||
|
||||
export interface SectionProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
result: ActionResult | undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user