From 815939cbc634c6b02634ba5ab986b2a5b8b0e026 Mon Sep 17 00:00:00 2001 From: lukas Date: Wed, 30 Sep 2026 22:23:50 +0200 Subject: [PATCH] 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 --- .idea/watcherTasks.xml | 4 + src/components/auth/SsoButtons.module.scss | 4 +- src/domain/ordering.ts | 15 ++ src/integrations/better-auth/header-user.tsx | 34 ++- src/lib/auth.ts | 17 ++ src/routeTree.gen.ts | 21 ++ src/routes/account.module.scss | 143 +++++++++++ src/routes/account.tsx | 238 +++++++++++++++++++ src/server/auth.ts | 11 + 9 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 .idea/watcherTasks.xml create mode 100644 src/routes/account.module.scss create mode 100644 src/routes/account.tsx diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml new file mode 100644 index 0000000..fb0d65a --- /dev/null +++ b/.idea/watcherTasks.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/components/auth/SsoButtons.module.scss b/src/components/auth/SsoButtons.module.scss index 2737320..d0950d8 100644 --- a/src/components/auth/SsoButtons.module.scss +++ b/src/components/auth/SsoButtons.module.scss @@ -21,8 +21,8 @@ } .google { - background: $color-background; - color: #3c4043; + background: $color-surface; + color: $color-text; border: 1px solid $color-border; &:hover { diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index bc0a65c..fb69baa 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -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 => { + await db + .update(schema.orders) + .set({ customerId: null }) + .where(eq(schema.orders.customerId, customerId)) +} + export type AdminOrderSummary = CustomerOrderSummary & { customer: | { type: 'account'; customerId: string } diff --git a/src/integrations/better-auth/header-user.tsx b/src/integrations/better-auth/header-user.tsx index cb267d3..14ef8eb 100644 --- a/src/integrations/better-auth/header-user.tsx +++ b/src/integrations/better-auth/header-user.tsx @@ -14,6 +14,22 @@ const AccountIcon = () => ( ) +const SettingsIcon = () => ( + +) + const OrdersIcon = () => ( { const { clear: clearBasket } = useBasket() const router = useRouter() const [open, setOpen] = useState(false) + const [imageFailed, setImageFailed] = useState(false) const wrapperRef = useRef(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 ? ( - + session.user.image && !imageFailed ? ( + setImageFailed(true)} + /> ) : ( {session.user.name.charAt(0).toUpperCase() || 'U'} @@ -133,6 +159,10 @@ const BetterAuthHeader = () => { Orders + + + Account + {session.user.role === 'admin' && ( { + await detachCustomerOrders(db, user.id) + }, + }, }, socialProviders: { ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 34038f1..1eaafbf 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/account.module.scss b/src/routes/account.module.scss new file mode 100644 index 0000000..afb200b --- /dev/null +++ b/src/routes/account.module.scss @@ -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; + } +} diff --git a/src/routes/account.tsx b/src/routes/account.tsx new file mode 100644 index 0000000..fc66d62 --- /dev/null +++ b/src/routes/account.tsx @@ -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(null) + const [profileSaved, setProfileSaved] = useState(false) + + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [passwordSubmitting, setPasswordSubmitting] = useState(false) + const [passwordError, setPasswordError] = useState(null) + const [passwordSaved, setPasswordSaved] = useState(false) + + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [deletePassword, setDeletePassword] = useState('') + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState(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 ( +
+

Account

+ +
+

Profile

+
+ + + + {profileError &&

{profileError}

} + {profileSaved &&

Saved.

} +
+
+ +
+

Password

+
+ + + + {passwordError &&

{passwordError}

} + {passwordSaved &&

Password changed.

} +
+
+ +
+

Delete account

+

+ This permanently deletes your account. Your past orders stay on + record but are no longer linked to you. This can’t be undone. +

+ {confirmingDelete ? ( +
+ +
+ + +
+ {deleteError &&

{deleteError}

} +
+ ) : ( + + )} +
+
+ ) +} + +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, +}) diff --git a/src/server/auth.ts b/src/server/auth.ts index c8bb3b3..8e1be1d 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -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 + }, +)