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'
|
export default async function LoginPage() {
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
// Check if initial setup is required
|
||||||
import { signIn } from 'next-auth/react'
|
const setupRequired = await isSetupRequired()
|
||||||
import { Button } from '@/components/ui/button'
|
if (setupRequired) {
|
||||||
import { Input } from '@/components/ui/input'
|
redirect('/setup')
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<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 />}>
|
<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