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 <noreply@anthropic.com>
This commit is contained in:
parent
a71ef8f9fc
commit
a68e1084ad
|
|
@ -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<string | null>(null)
|
||||
|
||||
// 2FA state
|
||||
const [show2FA, setShow2FA] = useState(false)
|
||||
const [pendingToken, setPendingToken] = useState<string | null>(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 (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{useBackupCode
|
||||
? 'Enter one of your backup codes'
|
||||
: 'Enter the code from your authenticator app'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handle2FASubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{loginError && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="twoFactorCode">
|
||||
{useBackupCode ? 'Backup Code' : 'Authentication Code'}
|
||||
</Label>
|
||||
<Input
|
||||
id="twoFactorCode"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern={useBackupCode ? '[A-Za-z0-9\\s-]*' : '[0-9]*'}
|
||||
placeholder={useBackupCode ? 'XXXX-XXXX' : '123456'}
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="px-0 text-sm"
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode)
|
||||
setTwoFactorCode('')
|
||||
}}
|
||||
>
|
||||
{useBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: 'Use a backup code'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular login form
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
LetsBe Hub
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Sign in to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{setupSuccess && (
|
||||
<div className="p-3 text-sm text-green-700 bg-green-50 rounded-md">
|
||||
Account created successfully! Please sign in.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || loginError) && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
|
||||
{error === 'CredentialsSignin'
|
||||
? 'Invalid email or password'
|
||||
: loginError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={userType === 'staff' ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setUserType('staff')}
|
||||
>
|
||||
Staff Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={userType === 'customer' ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setUserType('customer')}
|
||||
>
|
||||
Customer Login
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginFormSkeleton() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
LetsBe Hub
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Loading...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string | null>(null)
|
||||
|
||||
// 2FA state
|
||||
const [show2FA, setShow2FA] = useState(false)
|
||||
const [pendingToken, setPendingToken] = useState<string | null>(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 (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Two-Factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{useBackupCode
|
||||
? 'Enter one of your backup codes'
|
||||
: 'Enter the code from your authenticator app'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handle2FASubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{loginError && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="twoFactorCode">
|
||||
{useBackupCode ? 'Backup Code' : 'Authentication Code'}
|
||||
</Label>
|
||||
<Input
|
||||
id="twoFactorCode"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern={useBackupCode ? '[A-Za-z0-9\\s-]*' : '[0-9]*'}
|
||||
placeholder={useBackupCode ? 'XXXX-XXXX' : '123456'}
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="px-0 text-sm"
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode)
|
||||
setTwoFactorCode('')
|
||||
}}
|
||||
>
|
||||
{useBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: 'Use a backup code'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular login form
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
LetsBe Hub
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Sign in to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{(error || loginError) && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
|
||||
{error === 'CredentialsSignin'
|
||||
? 'Invalid email or password'
|
||||
: loginError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={userType === 'staff' ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setUserType('staff')}
|
||||
>
|
||||
Staff Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={userType === 'customer' ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setUserType('customer')}
|
||||
>
|
||||
Customer Login
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginFormSkeleton() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
LetsBe Hub
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Loading...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-10 bg-gray-200 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<Suspense fallback={<LoginFormSkeleton />}>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
LetsBe Hub
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Loading...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Shield className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
Welcome to LetsBe Hub
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Create your administrator account to get started
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Enter your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a password (min 8 characters)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
const staffCount = await prisma.staff.count()
|
||||
return staffCount === 0
|
||||
}
|
||||
Loading…
Reference in New Issue