Feature: Add password change functionality for admin and customers

- Add customer password change page at /account/change-password
- Add admin password change page at /admin/change-password
- Create AdminUser database table for storing admin credentials
- Admin login now checks database first, falls back to env vars
- Update admin auth to support bcrypt password hashing
- Add change password links to customer account and admin nav
- Fix customer login logs back link (was pointing to non-existent /admin)
- Add success/error message handling for password changes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-19 09:51:52 +01:00
co-authored by Claude Haiku 4.5
parent 36cc6dddff
commit 9623ac2fdb
14 changed files with 430 additions and 29 deletions
@@ -0,0 +1,42 @@
'use server';
import { redirect } from 'next/navigation';
import { getCurrentCustomer, verifyPassword, hashPassword } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function changePassword(formData: FormData) {
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const currentPassword = String(formData.get('currentPassword') ?? '');
const newPassword = String(formData.get('newPassword') ?? '');
const confirmPassword = String(formData.get('confirmPassword') ?? '');
// Validate
if (!currentPassword || !newPassword || !confirmPassword) {
redirect('/account/change-password?error=missing_fields');
}
if (newPassword.length < 8) {
redirect('/account/change-password?error=password_too_short');
}
if (newPassword !== confirmPassword) {
redirect('/account/change-password?error=passwords_dont_match');
}
// Verify current password
const isValid = await verifyPassword(currentPassword, customer.passwordHash);
if (!isValid) {
redirect('/account/change-password?error=invalid_current');
}
// Update password
const hashedPassword = await hashPassword(newPassword);
await prisma.customer.update({
where: { id: customer.id },
data: { passwordHash: hashedPassword },
});
redirect('/account?success=password_changed');
}
+83
View File
@@ -0,0 +1,83 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getCurrentCustomer } from '@/lib/auth';
import { changePassword } from './actions';
const ERROR_MESSAGES: Record<string, string> = {
missing_fields: 'Please fill in all fields.',
password_too_short: 'Password must be at least 8 characters.',
passwords_dont_match: 'Passwords do not match.',
invalid_current: 'Current password is incorrect.',
};
export default async function ChangePasswordPage({ searchParams }: { searchParams: { error?: string } }) {
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<Link href="/account" className="tag-label text-muted hover:text-clay">
Back to account
</Link>
<h1 className="mt-6 font-display text-3xl">Change password</h1>
<p className="mt-2 text-muted">Update your account password</p>
{error && (
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
{error}
</p>
)}
<form action={changePassword} className="mt-8 max-w-md space-y-6">
<div>
<label htmlFor="current" className="tag-label mb-2 block">
Current password
</label>
<input
id="current"
name="currentPassword"
type="password"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="new" className="tag-label mb-2 block">
New password
</label>
<input
id="new"
name="newPassword"
type="password"
required
minLength={8}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">At least 8 characters</p>
</div>
<div>
<label htmlFor="confirm" className="tag-label mb-2 block">
Confirm new password
</label>
<input
id="confirm"
name="confirmPassword"
type="password"
required
minLength={8}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
Update password
</button>
</form>
</div>
);
}
+26 -7
View File
@@ -25,10 +25,16 @@ function statusClasses(status: string, trackingNumber?: string | null) {
return 'bg-splash-orange/10 text-splash-orange';
}
export default async function AccountPage() {
const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.',
};
export default async function AccountPage({ searchParams }: { searchParams: { success?: string } }) {
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
const orders = await prisma.order.findMany({
where: { customerId: customer.id },
orderBy: { createdAt: 'desc' },
@@ -50,6 +56,11 @@ export default async function AccountPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
{success && (
<div className="mb-6 border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700">
{success}
</div>
)}
<div className="flex items-baseline justify-between gap-3">
<div>
<h1 className="font-display text-3xl">My account</h1>
@@ -58,14 +69,22 @@ export default async function AccountPage() {
{customer.email}
</p>
</div>
<form action={logoutCustomer}>
<button
type="submit"
<div className="flex gap-3">
<Link
href="/account/change-password"
className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay"
>
Log out
</button>
</form>
Change password
</Link>
<form action={logoutCustomer}>
<button
type="submit"
className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay"
>
Log out
</button>
</form>
</div>
</div>
<AddressFormSection customer={customer} />
+49
View File
@@ -0,0 +1,49 @@
'use server';
import { redirect } from 'next/navigation';
import { isAdminAuthenticated, getAdminUser, verifyPassword, hashPassword, createOrUpdateAdminUser } from '@/lib/adminAuth';
export async function changeAdminPassword(formData: FormData) {
const isAuthenticated = await isAdminAuthenticated();
if (!isAuthenticated) redirect('/admin/login');
const currentPassword = String(formData.get('currentPassword') ?? '');
const newPassword = String(formData.get('newPassword') ?? '');
const confirmPassword = String(formData.get('confirmPassword') ?? '');
// Validate
if (!currentPassword || !newPassword || !confirmPassword) {
redirect('/admin/change-password?error=missing_fields');
}
if (newPassword.length < 8) {
redirect('/admin/change-password?error=password_too_short');
}
if (newPassword !== confirmPassword) {
redirect('/admin/change-password?error=passwords_dont_match');
}
// Get current admin user from database
const dbAdmin = await getAdminUser();
// Verify current password
let isValid = false;
if (dbAdmin) {
isValid = await verifyPassword(currentPassword, dbAdmin.passwordHash);
} else {
// Fall back to env vars if no database entry
isValid = currentPassword === process.env.ADMIN_PASSWORD;
}
if (!isValid) {
redirect('/admin/change-password?error=invalid_current');
}
// Update or create admin user with new password
const hashedPassword = await hashPassword(newPassword);
const username = dbAdmin?.username ?? process.env.ADMIN_USER ?? 'admin';
await createOrUpdateAdminUser(username, hashedPassword);
redirect('/admin/orders?success=password_changed');
}
+83
View File
@@ -0,0 +1,83 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { isAdminAuthenticated } from '@/lib/adminAuth';
import { changeAdminPassword } from './actions';
const ERROR_MESSAGES: Record<string, string> = {
missing_fields: 'Please fill in all fields.',
password_too_short: 'Password must be at least 8 characters.',
passwords_dont_match: 'Passwords do not match.',
invalid_current: 'Current password is incorrect.',
};
export default async function ChangeAdminPasswordPage({ searchParams }: { searchParams: { error?: string } }) {
const isAuthenticated = await isAdminAuthenticated();
if (!isAuthenticated) redirect('/admin/login');
const error = searchParams.error ? ERROR_MESSAGES[searchParams.error] : null;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<Link href="/admin/orders" className="tag-label text-muted hover:text-clay">
Back to orders
</Link>
<h1 className="mt-6 font-display text-3xl">Change admin password</h1>
<p className="mt-2 text-muted">Update your admin account password</p>
{error && (
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
{error}
</p>
)}
<form action={changeAdminPassword} className="mt-8 max-w-md space-y-6">
<div>
<label htmlFor="current" className="tag-label mb-2 block">
Current password
</label>
<input
id="current"
name="currentPassword"
type="password"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="new" className="tag-label mb-2 block">
New password
</label>
<input
id="new"
name="newPassword"
type="password"
required
minLength={8}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">At least 8 characters</p>
</div>
<div>
<label htmlFor="confirm" className="tag-label mb-2 block">
Confirm new password
</label>
<input
id="confirm"
name="confirmPassword"
type="password"
required
minLength={8}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
Update password
</button>
</form>
</div>
);
}
+2 -2
View File
@@ -31,8 +31,8 @@ export default async function CustomerLoginLogsPage() {
<AdminNav />
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Customer Login Activity</h1>
<Link href="/admin" className="text-sm text-clay hover:text-clay-dark">
Back to admin
<Link href="/admin/orders" className="text-sm text-clay hover:text-clay-dark">
Back to orders
</Link>
</div>
+15 -1
View File
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { createAdminSession, clearAdminSession } from '@/lib/adminAuth';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { getAdminUser, verifyPassword } from '@/lib/adminAuth';
function safeNext(next: FormDataEntryValue | null) {
const path = String(next ?? '');
@@ -28,7 +29,20 @@ export async function loginAdmin(formData: FormData) {
redirect(`/admin/login?error=rate_limited&next=${encodeURIComponent(next)}`);
}
if (username !== process.env.ADMIN_USER || password !== process.env.ADMIN_PASSWORD) {
let isValid = false;
// Check database first (if using database-stored credentials)
const dbAdmin = await getAdminUser();
if (dbAdmin && username === dbAdmin.username) {
isValid = await verifyPassword(password, dbAdmin.passwordHash);
}
// Fall back to env vars if no database entry or match
if (!isValid && username === process.env.ADMIN_USER && password === process.env.ADMIN_PASSWORD) {
isValid = true;
}
if (!isValid) {
// Log failed attempt (wrong credentials)
await prisma.adminLoginLog.create({
data: { success: false, ipAddress: ip, userAgent },
+12 -1
View File
@@ -22,9 +22,15 @@ const STATUS_STYLES: Record<string, string> = {
DELIVERED: 'bg-green-500/10 text-green-600',
};
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string } }) {
const SUCCESS_MESSAGES: Record<string, string> = {
password_changed: 'Password updated successfully.',
};
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string; success?: string } }) {
await autoArchiveOldOrders();
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
const showArchived = searchParams.archived === '1';
const showOnlyPersonalised = searchParams.personalised === '1';
@@ -63,6 +69,11 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
{success && (
<div className="mb-6 border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700">
{success}
</div>
)}
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">{showArchived ? 'Archived orders' : 'Orders'}</h1>
<Link href={showArchived ? '/admin/orders' : '/admin/orders?archived=1'} className="tag-label hover:text-ink">
+3 -18
View File
@@ -7,6 +7,7 @@ import ProductCard from '@/components/ProductCard';
import ProductMockup from '@/components/ProductMockup';
import CherryBlossom from '@/components/CherryBlossom';
import Reveal from '@/components/Reveal';
import RandomGalleryHero from '@/components/RandomGalleryHero';
const REASSURANCES = [
{ label: 'Made to order', body: 'Printed after you check out — nothing sits pre-made on a shelf.' },
@@ -104,24 +105,8 @@ export default async function HomePage() {
</div>
</Reveal>
<Reveal delayMs={150} className="flex gap-4">
{galleryPhotos.slice(0, 2).map((photo, idx) => (
<div key={photo.id} className={`flex-1 ${idx === 1 ? 'translate-y-6' : ''}`}>
<div className="border-2 border-line rounded-lg overflow-hidden aspect-square">
<img
src={photo.imageDataUrl}
alt={photo.caption || 'Customer design'}
className="w-full h-full object-cover"
/>
</div>
{photo.caption && (
<p className="mt-3 text-xs text-muted text-center font-medium">{photo.caption}</p>
)}
{!photo.caption && (
<p className="mt-3 text-xs text-muted text-center font-medium">Customer work</p>
)}
</div>
))}
<Reveal delayMs={150}>
<RandomGalleryHero galleryPhotos={galleryPhotos} />
</Reveal>
</div>
</section>