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 GitHub
parent c514eb9814
commit 1621313daa
109 changed files with 14743 additions and 10792 deletions

View File

@@ -0,0 +1,49 @@
import { confirmEmail, requireAuth } from '../auth.server';
import ConfirmFailure from '../components/ConfirmFailure';
import ConfirmSuccess from '../components/ConfirmSuccess';
import type { Route } from './+types/confirm';
export const meta = ({}: Route.MetaArgs) => {
return [{ title: 'Confirm Email | The Biergarten App' }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await requireAuth(request);
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
return { success: false as const, error: 'Missing confirmation token.' };
}
try {
const payload = await confirmEmail(token, auth.accessToken);
return {
success: true as const,
confirmedDate: payload.confirmedDate,
};
} catch (err) {
return {
success: false as const,
error: err instanceof Error ? err.message : 'Confirmation failed.',
};
}
};
const Confirm = ({ loaderData }: Route.ComponentProps) => {
return (
<div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body items-center text-center gap-4">
{loaderData.success ? (
<ConfirmSuccess confirmedDate={loaderData.confirmedDate} />
) : (
<ConfirmFailure error={loaderData.error} />
)}
</div>
</div>
</div>
);
};
export default Confirm;

View File

@@ -0,0 +1,88 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { LogIn } from 'iconoir-react';
import { useForm } from 'react-hook-form';
import { redirect, useNavigation, useSubmit } from 'react-router';
import { createAuthSession, getOptionalAuth, login } from '../auth.server';
import LoginForm from '../components/LoginForm';
import RegisterCallout from '../components/RegisterCallout';
import { useActionErrorToast } from '../hooks/useActionErrorToast';
import { loginSchema, type LoginSchema } from '../schemas';
import type { Route } from './+types/login';
export const meta = ({}: Route.MetaArgs) => {
return [{ title: 'Login | The Biergarten App' }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request);
if (auth) throw redirect('/dashboard');
return null;
};
export const action = async ({ request }: Route.ActionArgs) => {
const formData = await request.formData();
const result = loginSchema.safeParse({
username: formData.get('username'),
password: formData.get('password'),
});
if (!result.success) {
return { error: result.error.issues[0].message };
}
try {
const payload = await login(result.data.username, result.data.password);
return createAuthSession(payload, '/dashboard');
} catch (err) {
return { error: err instanceof Error ? err.message : 'Login failed.' };
}
};
const Login = ({ actionData }: Route.ComponentProps) => {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.state === 'submitting';
const { register, handleSubmit, formState } = useForm<LoginSchema>({
resolver: zodResolver(loginSchema),
});
const onSubmit = handleSubmit((data) => {
submit(data, { method: 'post' });
});
useActionErrorToast(actionData?.error);
return (
<div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body gap-4">
<div className="text-center">
<h1 className="card-title text-3xl justify-center gap-2">
<LogIn className="size-7" aria-hidden="true" />
Login
</h1>
<p className="text-base-content/70">Sign in to your Biergarten account</p>
</div>
{actionData?.error && (
<div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span>
</div>
)}
<LoginForm
onSubmit={onSubmit}
formState={formState}
register={register}
submitting={isSubmitting}
/>
<RegisterCallout />
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,10 @@
import { redirect } from 'react-router';
import { destroySession, getSession } from '../auth.server';
import type { Route } from './+types/logout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const session = await getSession(request);
return redirect('/', {
headers: { 'Set-Cookie': await destroySession(session) },
});
};

View File

@@ -0,0 +1,106 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { redirect, useNavigation, useSubmit } from 'react-router';
import { createAuthSession, getOptionalAuth, register } from '../auth.server';
import LoginCallout from '../components/LoginCallout';
import RegisterForm from '../components/RegisterForm';
import { useActionErrorToast } from '../hooks/useActionErrorToast';
import { registerSchema, type RegisterSchema } from '../schemas';
import type { Route } from './+types/register';
export const meta = ({}: Route.MetaArgs) => {
return [{ title: 'Register | The Biergarten App' }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request);
if (auth) throw redirect('/dashboard');
return null;
};
export const action = async ({ request }: Route.ActionArgs) => {
const formData = await request.formData();
const result = registerSchema.safeParse({
username: formData.get('username'),
firstName: formData.get('firstName'),
lastName: formData.get('lastName'),
email: formData.get('email'),
dateOfBirth: formData.get('dateOfBirth'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
});
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors as Record<
keyof RegisterSchema,
string[] | undefined
>;
return { error: null, fieldErrors };
}
try {
const body = {
username: result.data.username,
firstName: result.data.firstName,
lastName: result.data.lastName,
email: result.data.email,
dateOfBirth: result.data.dateOfBirth,
password: result.data.password,
};
const payload = await register(body);
return createAuthSession(payload, '/dashboard');
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Registration failed.',
fieldErrors: null,
};
}
};
const Register = ({ actionData }: Route.ComponentProps) => {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.state === 'submitting';
const {
register: field,
handleSubmit,
formState,
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
const onSubmit = handleSubmit((data) => {
submit(data, { method: 'post' });
});
useActionErrorToast(actionData?.error);
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
<div className="card-body gap-4">
<div className="text-center">
<h1 className="card-title text-3xl justify-center">Register</h1>
<p className="text-base-content/70">Create your Biergarten account</p>
</div>
{actionData?.error && (
<div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span>
</div>
)}
<RegisterForm
onSubmit={onSubmit}
formState={formState}
register={field}
submitting={isSubmitting}
/>
<LoginCallout />
</div>
</div>
</div>
);
};
export default Register;