diff --git a/prisma/migrations/20260719084736_add_admin_user_table/migration.sql b/prisma/migrations/20260719084736_add_admin_user_table/migration.sql new file mode 100644 index 0000000..742d8de --- /dev/null +++ b/prisma/migrations/20260719084736_add_admin_user_table/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_username_key" ON "AdminUser"("username"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de1a354..9b2ee1e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -449,6 +449,15 @@ model OrderItem { designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image } +// Admin user account — stores admin credentials (can override env vars if present) +model AdminUser { + id String @id @default(cuid()) + username String @unique + passwordHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // Audit log for admin logins — tracks when the admin panel is accessed model AdminLoginLog { id String @id @default(cuid()) diff --git a/src/app/account/change-password/actions.ts b/src/app/account/change-password/actions.ts new file mode 100644 index 0000000..8ddac2d --- /dev/null +++ b/src/app/account/change-password/actions.ts @@ -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'); +} diff --git a/src/app/account/change-password/page.tsx b/src/app/account/change-password/page.tsx new file mode 100644 index 0000000..4ddd878 --- /dev/null +++ b/src/app/account/change-password/page.tsx @@ -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 = { + 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 ( +
+ + ← Back to account + + +

Change password

+

Update your account password

+ + {error && ( +

+ {error} +

+ )} + +
+
+ + +
+ +
+ + +

At least 8 characters

+
+ +
+ + +
+ + +
+
+ ); +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 136206f..3c13ba7 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -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 = { + 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 (
+ {success && ( +
+ ✓ {success} +
+ )}

My account

@@ -58,14 +69,22 @@ export default async function AccountPage() { {customer.email}

-
- -
+ Change password + +
+ +
+
diff --git a/src/app/admin/change-password/actions.ts b/src/app/admin/change-password/actions.ts new file mode 100644 index 0000000..36e1cf6 --- /dev/null +++ b/src/app/admin/change-password/actions.ts @@ -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'); +} diff --git a/src/app/admin/change-password/page.tsx b/src/app/admin/change-password/page.tsx new file mode 100644 index 0000000..04d8896 --- /dev/null +++ b/src/app/admin/change-password/page.tsx @@ -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 = { + 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 ( +
+ + ← Back to orders + + +

Change admin password

+

Update your admin account password

+ + {error && ( +

+ {error} +

+ )} + +
+
+ + +
+ +
+ + +

At least 8 characters

+
+ +
+ + +
+ + +
+
+ ); +} diff --git a/src/app/admin/customer-login-logs/page.tsx b/src/app/admin/customer-login-logs/page.tsx index 75eae92..e2b7870 100644 --- a/src/app/admin/customer-login-logs/page.tsx +++ b/src/app/admin/customer-login-logs/page.tsx @@ -31,8 +31,8 @@ export default async function CustomerLoginLogsPage() {

Customer Login Activity

- - Back to admin + + Back to orders
diff --git a/src/app/admin/login/actions.ts b/src/app/admin/login/actions.ts index 10c5429..0868920 100644 --- a/src/app/admin/login/actions.ts +++ b/src/app/admin/login/actions.ts @@ -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 }, diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx index 8c37392..9068711 100644 --- a/src/app/admin/orders/page.tsx +++ b/src/app/admin/orders/page.tsx @@ -22,9 +22,15 @@ const STATUS_STYLES: Record = { DELIVERED: 'bg-green-500/10 text-green-600', }; -export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string } }) { +const SUCCESS_MESSAGES: Record = { + 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 (
+ {success && ( +
+ ✓ {success} +
+ )}

{showArchived ? 'Archived orders' : 'Orders'}

diff --git a/src/app/page.tsx b/src/app/page.tsx index 34306e4..9f45ad0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {
- - {galleryPhotos.slice(0, 2).map((photo, idx) => ( -
-
- {photo.caption -
- {photo.caption && ( -

{photo.caption}

- )} - {!photo.caption && ( -

Customer work

- )} -
- ))} + +
diff --git a/src/components/AdminNav.tsx b/src/components/AdminNav.tsx index 59d8f39..667a7e9 100644 --- a/src/components/AdminNav.tsx +++ b/src/components/AdminNav.tsx @@ -36,6 +36,9 @@ export default function AdminNav() { Gallery + + Change password + ); } diff --git a/src/components/RandomGalleryHero.tsx b/src/components/RandomGalleryHero.tsx new file mode 100644 index 0000000..f83096b --- /dev/null +++ b/src/components/RandomGalleryHero.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +interface GalleryPhoto { + id: string; + imageDataUrl: string; + caption: string | null; +} + +interface Props { + galleryPhotos: GalleryPhoto[]; +} + +export default function RandomGalleryHero({ galleryPhotos }: Props) { + const [photos, setPhotos] = useState([]); + + useEffect(() => { + if (galleryPhotos.length === 0) { + setPhotos([]); + return; + } + + // Pick 2 random photos + const shuffled = [...galleryPhotos].sort(() => Math.random() - 0.5); + setPhotos(shuffled.slice(0, 2)); + + // Rotate every 8 seconds + const interval = setInterval(() => { + const newShuffled = [...galleryPhotos].sort(() => Math.random() - 0.5); + setPhotos(newShuffled.slice(0, 2)); + }, 8000); + + return () => clearInterval(interval); + }, [galleryPhotos]); + + if (photos.length === 0) { + return null; + } + + return ( +
+ {photos.map((photo, idx) => ( +
+
+ {photo.caption +
+ {photo.caption && ( +

{photo.caption}

+ )} + {!photo.caption && ( +

Customer work

+ )} +
+ ))} +
+ ); +} diff --git a/src/lib/adminAuth.ts b/src/lib/adminAuth.ts index 51bdc77..9f61d46 100644 --- a/src/lib/adminAuth.ts +++ b/src/lib/adminAuth.ts @@ -1,5 +1,7 @@ +import bcrypt from 'bcryptjs'; import { SignJWT, jwtVerify } from 'jose'; import { cookies } from 'next/headers'; +import { prisma } from '@/lib/prisma'; export const ADMIN_SESSION_COOKIE = 'admin_session'; const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 7; // 7 days @@ -45,3 +47,28 @@ export async function isAdminAuthenticated() { return false; } } + +export async function hashPassword(password: string) { + return bcrypt.hash(password, 10); +} + +export async function verifyPassword(password: string, hash: string) { + return bcrypt.compare(password, hash); +} + +export async function getAdminUser() { + return await prisma.adminUser.findFirst(); +} + +export async function createOrUpdateAdminUser(username: string, passwordHash: string) { + const existing = await prisma.adminUser.findUnique({ where: { username } }); + if (existing) { + return prisma.adminUser.update({ + where: { username }, + data: { passwordHash }, + }); + } + return prisma.adminUser.create({ + data: { username, passwordHash }, + }); +}