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:
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
|
||||||
|
</project>
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.google {
|
.google {
|
||||||
background: $color-background;
|
background: $color-surface;
|
||||||
color: #3c4043;
|
color: $color-text;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -983,6 +983,21 @@ export const getOrdersForCustomer = async (
|
|||||||
return summarizeOrders(db, orders)
|
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 & {
|
export type AdminOrderSummary = CustomerOrderSummary & {
|
||||||
customer:
|
customer:
|
||||||
| { type: 'account'; customerId: string }
|
| { type: 'account'; customerId: string }
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ const AccountIcon = () => (
|
|||||||
</svg>
|
</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 = () => (
|
const OrdersIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className={styles.menuIcon}
|
className={styles.menuIcon}
|
||||||
@@ -68,6 +84,7 @@ const BetterAuthHeader = () => {
|
|||||||
const { clear: clearBasket } = useBasket()
|
const { clear: clearBasket } = useBasket()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [imageFailed, setImageFailed] = useState(false)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const pathname = useRouterState({
|
const pathname = useRouterState({
|
||||||
select: (state) => state.location.pathname,
|
select: (state) => state.location.pathname,
|
||||||
@@ -77,6 +94,10 @@ const BetterAuthHeader = () => {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageFailed(false)
|
||||||
|
}, [session?.user?.image])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
@@ -112,8 +133,13 @@ const BetterAuthHeader = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
session.user.image ? (
|
session.user.image && !imageFailed ? (
|
||||||
<img src={session.user.image} alt="" className={styles.avatarImage} />
|
<img
|
||||||
|
src={session.user.image}
|
||||||
|
alt=""
|
||||||
|
className={styles.avatarImage}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.avatarInitial}>
|
<span className={styles.avatarInitial}>
|
||||||
{session.user.name.charAt(0).toUpperCase() || 'U'}
|
{session.user.name.charAt(0).toUpperCase() || 'U'}
|
||||||
@@ -133,6 +159,10 @@ const BetterAuthHeader = () => {
|
|||||||
<OrdersIcon />
|
<OrdersIcon />
|
||||||
Orders
|
Orders
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/account" role="menuitem" className={styles.menuButton}>
|
||||||
|
<SettingsIcon />
|
||||||
|
Account
|
||||||
|
</Link>
|
||||||
{session.user.role === 'admin' && (
|
{session.user.role === 'admin' && (
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { betterAuth } from 'better-auth'
|
|||||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||||
import { Pool } from 'pg'
|
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
|
// Each provider is only registered once its credentials are configured, so
|
||||||
// local dev without OAuth apps set up still works via email/password.
|
// local dev without OAuth apps set up still works via email/password.
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
@@ -20,6 +23,20 @@ export const auth = betterAuth({
|
|||||||
input: false,
|
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: {
|
socialProviders: {
|
||||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as AccountRouteImport } from './routes/account'
|
||||||
import { Route as AdminRouteImport } from './routes/admin'
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
import { Route as BasketRouteImport } from './routes/basket'
|
import { Route as BasketRouteImport } from './routes/basket'
|
||||||
import { Route as HelpRouteImport } from './routes/help'
|
import { Route as HelpRouteImport } from './routes/help'
|
||||||
@@ -32,6 +33,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AccountRoute = AccountRouteImport.update({
|
||||||
|
id: '/account',
|
||||||
|
path: '/account',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AdminRoute = AdminRouteImport.update({
|
const AdminRoute = AdminRouteImport.update({
|
||||||
id: '/admin',
|
id: '/admin',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@@ -115,6 +121,7 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/account': typeof AccountRoute
|
||||||
'/admin': typeof AdminRouteWithChildren
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/help': typeof HelpRoute
|
'/help': typeof HelpRoute
|
||||||
@@ -134,6 +141,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/account': typeof AccountRoute
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/help': typeof HelpRoute
|
'/help': typeof HelpRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -153,6 +161,7 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/account': typeof AccountRoute
|
||||||
'/admin': typeof AdminRouteWithChildren
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/help': typeof HelpRoute
|
'/help': typeof HelpRoute
|
||||||
@@ -174,6 +183,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/account'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
| '/help'
|
| '/help'
|
||||||
@@ -193,6 +203,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/account'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
| '/help'
|
| '/help'
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -211,6 +222,7 @@ export interface FileRouteTypes {
|
|||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/account'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
| '/help'
|
| '/help'
|
||||||
@@ -231,6 +243,7 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AccountRoute: typeof AccountRoute
|
||||||
AdminRoute: typeof AdminRouteWithChildren
|
AdminRoute: typeof AdminRouteWithChildren
|
||||||
BasketRoute: typeof BasketRoute
|
BasketRoute: typeof BasketRoute
|
||||||
HelpRoute: typeof HelpRoute
|
HelpRoute: typeof HelpRoute
|
||||||
@@ -251,6 +264,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/account': {
|
||||||
|
id: '/account'
|
||||||
|
path: '/account'
|
||||||
|
fullPath: '/account'
|
||||||
|
preLoaderRoute: typeof AccountRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/admin': {
|
'/admin': {
|
||||||
id: '/admin'
|
id: '/admin'
|
||||||
path: '/admin'
|
path: '/admin'
|
||||||
@@ -390,6 +410,7 @@ const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AccountRoute: AccountRoute,
|
||||||
AdminRoute: AdminRouteWithChildren,
|
AdminRoute: AdminRouteWithChildren,
|
||||||
BasketRoute: BasketRoute,
|
BasketRoute: BasketRoute,
|
||||||
HelpRoute: HelpRoute,
|
HelpRoute: HelpRoute,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -33,3 +33,14 @@ export const requireAdminSession = createServerFn({ method: 'GET' }).handler(
|
|||||||
await requireAdmin()
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user