From a68e1084adc277a670015a3edb7dfd89ba251d50 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 17 Jan 2026 16:10:24 +0100 Subject: [PATCH] Add initial setup screen for first admin account - Add /setup page that appears when no staff exist - Create first OWNER account with name, email, password - Login page redirects to /setup if setup required - Setup page redirects to /login after completion - API guards prevent setup after first account exists Co-Authored-By: Claude Opus 4.5 --- src/app/(auth)/login/login-form.tsx | 280 +++++++++++++++++++++++++++ src/app/(auth)/login/page.tsx | 281 +--------------------------- src/app/(auth)/setup/page.tsx | 215 +++++++++++++++++++++ src/app/api/v1/setup/route.ts | 94 ++++++++++ src/lib/setup.ts | 10 + 5 files changed, 608 insertions(+), 272 deletions(-) create mode 100644 src/app/(auth)/login/login-form.tsx create mode 100644 src/app/(auth)/setup/page.tsx create mode 100644 src/app/api/v1/setup/route.ts create mode 100644 src/lib/setup.ts diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000..d737c4d --- /dev/null +++ b/src/app/(auth)/login/login-form.tsx @@ -0,0 +1,280 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { signIn } from 'next-auth/react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' + +export function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const callbackUrl = searchParams.get('callbackUrl') || '/' + const error = searchParams.get('error') + const setupSuccess = searchParams.get('setup') === 'success' + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [userType, setUserType] = useState<'customer' | 'staff'>('staff') + const [isLoading, setIsLoading] = useState(false) + const [loginError, setLoginError] = useState(null) + + // 2FA state + const [show2FA, setShow2FA] = useState(false) + const [pendingToken, setPendingToken] = useState(null) + const [twoFactorCode, setTwoFactorCode] = useState('') + const [useBackupCode, setUseBackupCode] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + setLoginError(null) + + try { + const result = await signIn('credentials', { + email, + password, + userType, + redirect: false, + callbackUrl, + }) + + if (result?.error) { + // Check if 2FA is required + if (result.error.startsWith('2FA_REQUIRED:')) { + const token = result.error.replace('2FA_REQUIRED:', '') + setPendingToken(token) + setShow2FA(true) + setLoginError(null) + } else { + setLoginError(result.error) + } + } else if (result?.ok) { + router.push(userType === 'staff' ? '/admin' : '/') + router.refresh() + } + } catch { + setLoginError('An unexpected error occurred') + } finally { + setIsLoading(false) + } + } + + const handle2FASubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + setLoginError(null) + + try { + const result = await signIn('credentials', { + pendingToken, + twoFactorToken: twoFactorCode.replace(/[\s-]/g, ''), // Remove spaces and dashes + redirect: false, + callbackUrl, + }) + + if (result?.error) { + setLoginError(result.error) + } else if (result?.ok) { + router.push(userType === 'staff' ? '/admin' : '/') + router.refresh() + } + } catch { + setLoginError('An unexpected error occurred') + } finally { + setIsLoading(false) + } + } + + const handleBack = () => { + setShow2FA(false) + setPendingToken(null) + setTwoFactorCode('') + setUseBackupCode(false) + setLoginError(null) + } + + // 2FA verification form + if (show2FA) { + return ( + + + + Two-Factor Authentication + + + {useBackupCode + ? 'Enter one of your backup codes' + : 'Enter the code from your authenticator app'} + + +
+ + {loginError && ( +
+ {loginError} +
+ )} + +
+ + setTwoFactorCode(e.target.value)} + required + autoComplete="one-time-code" + autoFocus + /> +
+ + +
+ + + + + +
+
+ ) + } + + // Regular login form + return ( + + + + LetsBe Hub + + + Sign in to your account + + +
+ + {setupSuccess && ( +
+ Account created successfully! Please sign in. +
+ )} + + {(error || loginError) && ( +
+ {error === 'CredentialsSignin' + ? 'Invalid email or password' + : loginError || error} +
+ )} + +
+ + +
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+
+ + + + +
+
+ ) +} + +export function LoginFormSkeleton() { + return ( + + + + LetsBe Hub + + + Loading... + + + +
+
+
+ + + ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index bec17b9..9cf1985 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,278 +1,15 @@ -'use client' +import { Suspense } from 'react' +import { redirect } from 'next/navigation' +import { isSetupRequired } from '@/lib/setup' +import { LoginForm, LoginFormSkeleton } from './login-form' -import { Suspense, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { signIn } from 'next-auth/react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card' - -function LoginForm() { - const router = useRouter() - const searchParams = useSearchParams() - const callbackUrl = searchParams.get('callbackUrl') || '/' - const error = searchParams.get('error') - - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [userType, setUserType] = useState<'customer' | 'staff'>('staff') - const [isLoading, setIsLoading] = useState(false) - const [loginError, setLoginError] = useState(null) - - // 2FA state - const [show2FA, setShow2FA] = useState(false) - const [pendingToken, setPendingToken] = useState(null) - const [twoFactorCode, setTwoFactorCode] = useState('') - const [useBackupCode, setUseBackupCode] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - setLoginError(null) - - try { - const result = await signIn('credentials', { - email, - password, - userType, - redirect: false, - callbackUrl, - }) - - if (result?.error) { - // Check if 2FA is required - if (result.error.startsWith('2FA_REQUIRED:')) { - const token = result.error.replace('2FA_REQUIRED:', '') - setPendingToken(token) - setShow2FA(true) - setLoginError(null) - } else { - setLoginError(result.error) - } - } else if (result?.ok) { - router.push(userType === 'staff' ? '/admin' : '/') - router.refresh() - } - } catch { - setLoginError('An unexpected error occurred') - } finally { - setIsLoading(false) - } +export default async function LoginPage() { + // Check if initial setup is required + const setupRequired = await isSetupRequired() + if (setupRequired) { + redirect('/setup') } - const handle2FASubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - setLoginError(null) - - try { - const result = await signIn('credentials', { - pendingToken, - twoFactorToken: twoFactorCode.replace(/[\s-]/g, ''), // Remove spaces and dashes - redirect: false, - callbackUrl, - }) - - if (result?.error) { - setLoginError(result.error) - } else if (result?.ok) { - router.push(userType === 'staff' ? '/admin' : '/') - router.refresh() - } - } catch { - setLoginError('An unexpected error occurred') - } finally { - setIsLoading(false) - } - } - - const handleBack = () => { - setShow2FA(false) - setPendingToken(null) - setTwoFactorCode('') - setUseBackupCode(false) - setLoginError(null) - } - - // 2FA verification form - if (show2FA) { - return ( - - - - Two-Factor Authentication - - - {useBackupCode - ? 'Enter one of your backup codes' - : 'Enter the code from your authenticator app'} - - -
- - {loginError && ( -
- {loginError} -
- )} - -
- - setTwoFactorCode(e.target.value)} - required - autoComplete="one-time-code" - autoFocus - /> -
- - -
- - - - - -
-
- ) - } - - // Regular login form - return ( - - - - LetsBe Hub - - - Sign in to your account - - -
- - {(error || loginError) && ( -
- {error === 'CredentialsSignin' - ? 'Invalid email or password' - : loginError || error} -
- )} - -
- - -
- -
- - setEmail(e.target.value)} - required - autoComplete="email" - /> -
- -
- - setPassword(e.target.value)} - required - autoComplete="current-password" - /> -
-
- - - - -
-
- ) -} - -function LoginFormSkeleton() { - return ( - - - - LetsBe Hub - - - Loading... - - - -
-
-
- - - ) -} - -export default function LoginPage() { return (
}> diff --git a/src/app/(auth)/setup/page.tsx b/src/app/(auth)/setup/page.tsx new file mode 100644 index 0000000..ee4407a --- /dev/null +++ b/src/app/(auth)/setup/page.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Shield, Loader2 } from 'lucide-react' + +export default function SetupPage() { + const router = useRouter() + + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isCheckingSetup, setIsCheckingSetup] = useState(true) + const [error, setError] = useState(null) + + // Check if setup is still required on mount + useEffect(() => { + async function checkSetup() { + try { + const response = await fetch('/api/v1/setup') + const data = await response.json() + + if (!data.setupRequired) { + // Setup already complete, redirect to login + router.replace('/login') + } + } catch (err) { + console.error('Failed to check setup status:', err) + } finally { + setIsCheckingSetup(false) + } + } + + checkSetup() + }, [router]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + // Validate passwords match + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + // Validate password length + if (password.length < 8) { + setError('Password must be at least 8 characters') + return + } + + setIsLoading(true) + + try { + const response = await fetch('/api/v1/setup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, email, password }), + }) + + const data = await response.json() + + if (!response.ok) { + if (data.details?.fieldErrors) { + // Get first field error + const fieldErrors = data.details.fieldErrors + const firstError = Object.values(fieldErrors).flat()[0] + setError(firstError as string) + } else { + setError(data.error || 'Failed to create account') + } + return + } + + // Success - redirect to login + router.push('/login?setup=success') + } catch (err) { + console.error('Setup error:', err) + setError('An unexpected error occurred') + } finally { + setIsLoading(false) + } + } + + // Show loading while checking setup status + if (isCheckingSetup) { + return ( +
+ + + + LetsBe Hub + + + Loading... + + + + + + +
+ ) + } + + return ( +
+ + +
+ +
+ + Welcome to LetsBe Hub + + + Create your administrator account to get started + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + required + autoComplete="name" + autoFocus + /> +
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + minLength={8} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + /> +
+
+ + + + +
+
+
+ ) +} diff --git a/src/app/api/v1/setup/route.ts b/src/app/api/v1/setup/route.ts new file mode 100644 index 0000000..dca6e14 --- /dev/null +++ b/src/app/api/v1/setup/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { isSetupRequired } from '@/lib/setup' + +// Schema for creating the first owner +const createOwnerSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), +}) + +/** + * GET /api/v1/setup + * Check if initial setup is required (no staff exist) + */ +export async function GET() { + try { + const setupRequired = await isSetupRequired() + return NextResponse.json({ setupRequired }) + } catch (error) { + console.error('Setup check error:', error) + return NextResponse.json( + { error: 'Failed to check setup status' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/setup + * Create the first owner account + * Only works when no staff exist in the database + */ +export async function POST(request: Request) { + try { + // Check if setup is still required + const setupRequired = await isSetupRequired() + if (!setupRequired) { + return NextResponse.json( + { error: 'Setup has already been completed' }, + { status: 403 } + ) + } + + // Parse and validate request body + const body = await request.json() + const result = createOwnerSchema.safeParse(body) + + if (!result.success) { + return NextResponse.json( + { error: 'Validation failed', details: result.error.flatten() }, + { status: 400 } + ) + } + + const { name, email, password } = result.data + + // Check if email is already taken (shouldn't happen but just in case) + const existingStaff = await prisma.staff.findUnique({ + where: { email }, + }) + + if (existingStaff) { + return NextResponse.json( + { error: 'Email already exists' }, + { status: 400 } + ) + } + + // Hash password with bcrypt (salt rounds = 12) + const passwordHash = await bcrypt.hash(password, 12) + + // Create the first owner + await prisma.staff.create({ + data: { + name, + email, + passwordHash, + role: 'OWNER', + status: 'ACTIVE', + }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Setup error:', error) + return NextResponse.json( + { error: 'Failed to create owner account' }, + { status: 500 } + ) + } +} diff --git a/src/lib/setup.ts b/src/lib/setup.ts new file mode 100644 index 0000000..e79a96b --- /dev/null +++ b/src/lib/setup.ts @@ -0,0 +1,10 @@ +import { prisma } from './prisma' + +/** + * Check if the initial setup is required. + * Setup is required when there are no staff members in the database. + */ +export async function isSetupRequired(): Promise { + const staffCount = await prisma.staff.count() + return staffCount === 0 +}