Add account settings page and polish avatar/SSO button edge cases

- New /account route: edit name/email, change password, delete account
  (detaches the user's orders instead of leaving a dangling reference,
  since orders.customerId has no DB-level FK to better-auth's user table)
- Better Auth: allow email changes without a verification email (no
  email sending is wired up anywhere in this app) and enable account
  deletion
- Header avatar: fall back to the initials placeholder if the profile
  image fails to load, not just when there's no image URL
- Fix low-contrast "Continue with Google" button in dark mode (it used
  the page background color and a hardcoded dark-gray label, so it
  nearly disappeared against a dark background)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 22:23:50 +02:00
co-authored by Claude Sonnet 5
parent 7f72baf97d
commit 815939cbc6
9 changed files with 483 additions and 4 deletions
+2 -2
View File
@@ -21,8 +21,8 @@
}
.google {
background: $color-background;
color: #3c4043;
background: $color-surface;
color: $color-text;
border: 1px solid $color-border;
&:hover {
+15
View File
@@ -983,6 +983,21 @@ export const getOrdersForCustomer = async (
return summarizeOrders(db, orders)
}
// Called when an account is deleted. orders.customerId is a soft reference
// to better-auth's user table (no DB-level FK), so deleting the user row
// would otherwise just leave those orders pointing at an id that no longer
// exists. Detaching them instead keeps the Order history intact for
// kitchen/admin purposes, just no longer attributed to an account.
export const detachCustomerOrders = async (
db: Db,
customerId: string,
): Promise<void> => {
await db
.update(schema.orders)
.set({ customerId: null })
.where(eq(schema.orders.customerId, customerId))
}
export type AdminOrderSummary = CustomerOrderSummary & {
customer:
| { type: 'account'; customerId: string }
+32 -2
View File
@@ -14,6 +14,22 @@ const AccountIcon = () => (
</svg>
)
const SettingsIcon = () => (
<svg
className={styles.menuIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
)
const OrdersIcon = () => (
<svg
className={styles.menuIcon}
@@ -68,6 +84,7 @@ const BetterAuthHeader = () => {
const { clear: clearBasket } = useBasket()
const router = useRouter()
const [open, setOpen] = useState(false)
const [imageFailed, setImageFailed] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const pathname = useRouterState({
select: (state) => state.location.pathname,
@@ -77,6 +94,10 @@ const BetterAuthHeader = () => {
setOpen(false)
}, [pathname])
useEffect(() => {
setImageFailed(false)
}, [session?.user?.image])
useEffect(() => {
if (!open) return
@@ -112,8 +133,13 @@ const BetterAuthHeader = () => {
}
>
{session?.user ? (
session.user.image ? (
<img src={session.user.image} alt="" className={styles.avatarImage} />
session.user.image && !imageFailed ? (
<img
src={session.user.image}
alt=""
className={styles.avatarImage}
onError={() => setImageFailed(true)}
/>
) : (
<span className={styles.avatarInitial}>
{session.user.name.charAt(0).toUpperCase() || 'U'}
@@ -133,6 +159,10 @@ const BetterAuthHeader = () => {
<OrdersIcon />
Orders
</Link>
<Link to="/account" role="menuitem" className={styles.menuButton}>
<SettingsIcon />
Account
</Link>
{session.user.role === 'admin' && (
<Link
to="/admin"
+17
View File
@@ -2,6 +2,9 @@ import { betterAuth } from 'better-auth'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
import { Pool } from 'pg'
import { db } from '#/db/index.ts'
import { detachCustomerOrders } from '#/domain/ordering.ts'
// Each provider is only registered once its credentials are configured, so
// local dev without OAuth apps set up still works via email/password.
export const auth = betterAuth({
@@ -20,6 +23,20 @@ export const auth = betterAuth({
input: false,
},
},
// No email-sending set up anywhere in this app (no verification flow,
// no password-reset email either), so a changed address is accepted
// immediately rather than left pending on a confirmation link that
// could never be sent.
changeEmail: {
enabled: true,
updateEmailWithoutVerification: true,
},
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
await detachCustomerOrders(db, user.id)
},
},
},
socialProviders: {
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
+21
View File
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AccountRouteImport } from './routes/account'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as BasketRouteImport } from './routes/basket'
import { Route as HelpRouteImport } from './routes/help'
@@ -32,6 +33,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AccountRoute = AccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -115,6 +121,7 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
@@ -134,6 +141,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
'/login': typeof LoginRoute
@@ -153,6 +161,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/admin': typeof AdminRouteWithChildren
'/basket': typeof BasketRoute
'/help': typeof HelpRoute
@@ -174,6 +183,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/account'
| '/admin'
| '/basket'
| '/help'
@@ -193,6 +203,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/account'
| '/basket'
| '/help'
| '/login'
@@ -211,6 +222,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/account'
| '/admin'
| '/basket'
| '/help'
@@ -231,6 +243,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AccountRoute: typeof AccountRoute
AdminRoute: typeof AdminRouteWithChildren
BasketRoute: typeof BasketRoute
HelpRoute: typeof HelpRoute
@@ -251,6 +264,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/account': {
id: '/account'
path: '/account'
fullPath: '/account'
preLoaderRoute: typeof AccountRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
@@ -390,6 +410,7 @@ const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AccountRoute: AccountRoute,
AdminRoute: AdminRouteWithChildren,
BasketRoute: BasketRoute,
HelpRoute: HelpRoute,
+143
View File
@@ -0,0 +1,143 @@
@use '../styles/variables' as *;
.page {
max-width: 480px;
margin: 0 auto;
padding: $space-5 $space-3;
display: flex;
flex-direction: column;
gap: $space-5;
}
.heading {
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-surface;
}
.sectionHeading {
margin: 0;
font-size: 1.05rem;
}
.form {
display: flex;
flex-direction: column;
gap: $space-2;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
font-size: 0.85rem;
color: $color-text-muted;
input {
padding: $space-2;
border: 1px solid $color-border;
border-radius: $radius;
font-size: 1rem;
color: $color-text;
background: $color-background;
}
}
.submitButton {
align-self: flex-start;
padding: $space-2 $space-4;
border: none;
border-radius: $radius;
background: $color-primary;
color: $color-primary-contrast;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error {
color: $color-danger;
font-size: 0.85rem;
margin: 0;
}
.success {
color: $color-success;
font-size: 0.85rem;
margin: 0;
}
.dangerZone {
border-color: $color-danger;
}
.dangerText {
margin: 0;
color: $color-text-muted;
font-size: 0.9rem;
}
.dangerActions {
display: flex;
align-items: center;
gap: $space-2;
}
.deleteButton {
align-self: flex-start;
padding: $space-2 $space-4;
border: 1px solid $color-danger;
border-radius: $radius;
background: $color-background;
color: $color-danger;
cursor: pointer;
font-weight: 600;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
background: $color-danger;
color: $color-primary-contrast;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cancelButton {
padding: $space-2 $space-4;
border: 1px solid $color-border;
border-radius: $radius;
background: $color-background;
color: $color-text;
cursor: pointer;
&:hover:not(:disabled) {
border-color: $color-text;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
+238
View File
@@ -0,0 +1,238 @@
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { authClient } from '#/lib/auth-client.ts'
import { getCurrentUser } from '#/server/auth.ts'
import styles from './account.module.scss'
const AccountPage = () => {
const user = Route.useLoaderData()
const navigate = useNavigate()
const [name, setName] = useState(user.name)
const [email, setEmail] = useState(user.email)
const [savedName, setSavedName] = useState(user.name)
const [savedEmail, setSavedEmail] = useState(user.email)
const [profileSubmitting, setProfileSubmitting] = useState(false)
const [profileError, setProfileError] = useState<string | null>(null)
const [profileSaved, setProfileSaved] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [passwordSubmitting, setPasswordSubmitting] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [passwordSaved, setPasswordSaved] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deletePassword, setDeletePassword] = useState('')
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const submitProfile = async (event: FormEvent) => {
event.preventDefault()
setProfileSubmitting(true)
setProfileError(null)
setProfileSaved(false)
try {
if (name !== savedName) {
const { error } = await authClient.updateUser({ name })
if (error) throw new Error(error.message ?? 'Could not update your name.')
setSavedName(name)
}
if (email !== savedEmail) {
const { error } = await authClient.changeEmail({ newEmail: email })
if (error) throw new Error(error.message ?? 'Could not update your email.')
setSavedEmail(email)
}
setProfileSaved(true)
} catch (err) {
setProfileError(
err instanceof Error ? err.message : 'Something went wrong.',
)
} finally {
setProfileSubmitting(false)
}
}
const submitPassword = async (event: FormEvent) => {
event.preventDefault()
setPasswordSubmitting(true)
setPasswordError(null)
setPasswordSaved(false)
try {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
})
if (error) throw new Error(error.message ?? 'Could not change your password.')
setCurrentPassword('')
setNewPassword('')
setPasswordSaved(true)
} catch (err) {
setPasswordError(
err instanceof Error ? err.message : 'Something went wrong.',
)
} finally {
setPasswordSubmitting(false)
}
}
const deleteAccount = async () => {
setDeleting(true)
setDeleteError(null)
try {
const { error } = await authClient.deleteUser(
deletePassword ? { password: deletePassword } : {},
)
if (error) throw new Error(error.message ?? 'Could not delete your account.')
await navigate({ to: '/' })
} catch (err) {
setDeleteError(
err instanceof Error ? err.message : 'Something went wrong.',
)
setDeleting(false)
}
}
return (
<main className={styles.page}>
<h1 className={styles.heading}>Account</h1>
<section className={styles.section}>
<h2 className={styles.sectionHeading}>Profile</h2>
<form className={styles.form} onSubmit={submitProfile}>
<label className={styles.field}>
Name
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</label>
<label className={styles.field}>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={profileSubmitting}
>
{profileSubmitting ? 'Saving…' : 'Save changes'}
</button>
{profileError && <p className={styles.error}>{profileError}</p>}
{profileSaved && <p className={styles.success}>Saved.</p>}
</form>
</section>
<section className={styles.section}>
<h2 className={styles.sectionHeading}>Password</h2>
<form className={styles.form} onSubmit={submitPassword}>
<label className={styles.field}>
Current password
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
required
/>
</label>
<label className={styles.field}>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
minLength={8}
required
/>
</label>
<button
type="submit"
className={styles.submitButton}
disabled={passwordSubmitting}
>
{passwordSubmitting ? 'Changing…' : 'Change password'}
</button>
{passwordError && <p className={styles.error}>{passwordError}</p>}
{passwordSaved && <p className={styles.success}>Password changed.</p>}
</form>
</section>
<section className={`${styles.section} ${styles.dangerZone}`}>
<h2 className={styles.sectionHeading}>Delete account</h2>
<p className={styles.dangerText}>
This permanently deletes your account. Your past orders stay on
record but are no longer linked to you. This can’t be undone.
</p>
{confirmingDelete ? (
<div className={styles.form}>
<label className={styles.field}>
Confirm your password
<input
type="password"
value={deletePassword}
onChange={(event) => setDeletePassword(event.target.value)}
/>
</label>
<div className={styles.dangerActions}>
<button
type="button"
className={styles.deleteButton}
disabled={deleting}
onClick={() => void deleteAccount()}
>
{deleting ? 'Deleting…' : 'Permanently delete my account'}
</button>
<button
type="button"
className={styles.cancelButton}
disabled={deleting}
onClick={() => {
setConfirmingDelete(false)
setDeletePassword('')
setDeleteError(null)
}}
>
Cancel
</button>
</div>
{deleteError && <p className={styles.error}>{deleteError}</p>}
</div>
) : (
<button
type="button"
className={styles.deleteButton}
onClick={() => setConfirmingDelete(true)}
>
Delete account
</button>
)}
</section>
</main>
)
}
export const Route = createFileRoute('/account')({
loader: async () => {
try {
return await getCurrentUser()
} catch (error) {
if (error instanceof Error && error.message === 'Not signed in') {
throw redirect({ to: '/login' })
}
throw error
}
},
component: AccountPage,
})
+11
View File
@@ -33,3 +33,14 @@ export const requireAdminSession = createServerFn({ method: 'GET' }).handler(
await requireAdmin()
},
)
// The account settings route's loader calls this to both fetch the
// signed-in user and gate the page — thrown message is matched against by
// the route the same way withAdminGuard matches "Admin access required".
export const getCurrentUser = createServerFn({ method: 'GET' }).handler(
async () => {
const user = await getSessionUser()
if (!user) throw new Error('Not signed in')
return user
},
)