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:
co-authored by
Claude Haiku 4.5
parent
36cc6dddff
commit
9623ac2fdb
@@ -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");
|
||||||
@@ -449,6 +449,15 @@ model OrderItem {
|
|||||||
designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image
|
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
|
// Audit log for admin logins — tracks when the admin panel is accessed
|
||||||
model AdminLoginLog {
|
model AdminLoginLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,10 +25,16 @@ function statusClasses(status: string, trackingNumber?: string | null) {
|
|||||||
return 'bg-splash-orange/10 text-splash-orange';
|
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();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) redirect('/account/login');
|
if (!customer) redirect('/account/login');
|
||||||
|
|
||||||
|
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
|
||||||
|
|
||||||
const orders = await prisma.order.findMany({
|
const orders = await prisma.order.findMany({
|
||||||
where: { customerId: customer.id },
|
where: { customerId: customer.id },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -50,6 +56,11 @@ export default async function AccountPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
<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 className="flex items-baseline justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-3xl">My account</h1>
|
<h1 className="font-display text-3xl">My account</h1>
|
||||||
@@ -58,6 +69,13 @@ export default async function AccountPage() {
|
|||||||
{customer.email}
|
{customer.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Change password
|
||||||
|
</Link>
|
||||||
<form action={logoutCustomer}>
|
<form action={logoutCustomer}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -67,6 +85,7 @@ export default async function AccountPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AddressFormSection customer={customer} />
|
<AddressFormSection customer={customer} />
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,8 +31,8 @@ export default async function CustomerLoginLogsPage() {
|
|||||||
<AdminNav />
|
<AdminNav />
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
<h1 className="font-display text-3xl">Customer Login Activity</h1>
|
<h1 className="font-display text-3xl">Customer Login Activity</h1>
|
||||||
<Link href="/admin" className="text-sm text-clay hover:text-clay-dark">
|
<Link href="/admin/orders" className="text-sm text-clay hover:text-clay-dark">
|
||||||
Back to admin
|
Back to orders
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { createAdminSession, clearAdminSession } from '@/lib/adminAuth';
|
import { createAdminSession, clearAdminSession } from '@/lib/adminAuth';
|
||||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||||
|
import { getAdminUser, verifyPassword } from '@/lib/adminAuth';
|
||||||
|
|
||||||
function safeNext(next: FormDataEntryValue | null) {
|
function safeNext(next: FormDataEntryValue | null) {
|
||||||
const path = String(next ?? '');
|
const path = String(next ?? '');
|
||||||
@@ -28,7 +29,20 @@ export async function loginAdmin(formData: FormData) {
|
|||||||
redirect(`/admin/login?error=rate_limited&next=${encodeURIComponent(next)}`);
|
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)
|
// Log failed attempt (wrong credentials)
|
||||||
await prisma.adminLoginLog.create({
|
await prisma.adminLoginLog.create({
|
||||||
data: { success: false, ipAddress: ip, userAgent },
|
data: { success: false, ipAddress: ip, userAgent },
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ const STATUS_STYLES: Record<string, string> = {
|
|||||||
DELIVERED: 'bg-green-500/10 text-green-600',
|
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();
|
await autoArchiveOldOrders();
|
||||||
|
|
||||||
|
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
|
||||||
|
|
||||||
const showArchived = searchParams.archived === '1';
|
const showArchived = searchParams.archived === '1';
|
||||||
const showOnlyPersonalised = searchParams.personalised === '1';
|
const showOnlyPersonalised = searchParams.personalised === '1';
|
||||||
|
|
||||||
@@ -63,6 +69,11 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||||
<AdminNav />
|
<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">
|
<div className="flex items-baseline justify-between">
|
||||||
<h1 className="font-display text-3xl">{showArchived ? 'Archived orders' : 'Orders'}</h1>
|
<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">
|
<Link href={showArchived ? '/admin/orders' : '/admin/orders?archived=1'} className="tag-label hover:text-ink">
|
||||||
|
|||||||
+3
-18
@@ -7,6 +7,7 @@ import ProductCard from '@/components/ProductCard';
|
|||||||
import ProductMockup from '@/components/ProductMockup';
|
import ProductMockup from '@/components/ProductMockup';
|
||||||
import CherryBlossom from '@/components/CherryBlossom';
|
import CherryBlossom from '@/components/CherryBlossom';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
|
import RandomGalleryHero from '@/components/RandomGalleryHero';
|
||||||
|
|
||||||
const REASSURANCES = [
|
const REASSURANCES = [
|
||||||
{ label: 'Made to order', body: 'Printed after you check out — nothing sits pre-made on a shelf.' },
|
{ 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>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delayMs={150} className="flex gap-4">
|
<Reveal delayMs={150}>
|
||||||
{galleryPhotos.slice(0, 2).map((photo, idx) => (
|
<RandomGalleryHero galleryPhotos={galleryPhotos} />
|
||||||
<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>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export default function AdminNav() {
|
|||||||
<Link href="/admin/gallery" className="tag-label hover:text-ink">
|
<Link href="/admin/gallery" className="tag-label hover:text-ink">
|
||||||
Gallery
|
Gallery
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/change-password" className="tag-label hover:text-ink">
|
||||||
|
Change password
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<GalleryPhoto[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{photos.map((photo, idx) => (
|
||||||
|
<div key={photo.id} className={`flex-1 transition-opacity duration-500 ${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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import { SignJWT, jwtVerify } from 'jose';
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
||||||
const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
||||||
@@ -45,3 +47,28 @@ export async function isAdminAuthenticated() {
|
|||||||
return false;
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user