feat(setup): add initial setup wizard for admin account and SMTP configuration

This commit is contained in:
DanielS
2026-07-03 09:55:24 +02:00
parent f5f577ea73
commit 6dc1b1c00c
6 changed files with 560 additions and 2 deletions

View File

@@ -1,6 +1,12 @@
import { LoginForm } from "@/components/login-form";
import { isSetupNeeded } from "@/lib/actions/setup";
import { redirect } from "next/navigation";
export default async function Page() {
if (await isSetupNeeded()) {
redirect("/setup");
}
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">

View File

@@ -1,9 +1,15 @@
import { createClient } from '@/lib/supabase/server'
import { HomeClient } from '@/components/HomeClient'
import { isSetupNeeded } from '@/lib/actions/setup'
import { redirect } from 'next/navigation'
export const dynamic = "force-dynamic"
export default async function Home() {
if (await isSetupNeeded()) {
redirect('/setup')
}
let user = null
try {
const supabase = await createClient()

15
shop/app/setup/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { isSetupNeeded } from '@/lib/actions/setup'
import { redirect } from 'next/navigation'
import { SetupWizard } from '@/components/SetupWizard'
export const dynamic = 'force-dynamic'
export default async function SetupPage() {
const needed = await isSetupNeeded()
if (!needed) {
redirect('/')
}
return <SetupWizard />
}

View File

@@ -11,8 +11,9 @@ interface HeaderWrapperProps {
export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
const pathname = usePathname();
const isAdmin = pathname?.startsWith("/admin");
const isSetup = pathname === "/setup";
if (isAdmin) {
if (isAdmin || isSetup) {
return <>{children}</>;
}

View File

@@ -0,0 +1,406 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createClient } from '@/lib/supabase/client'
import { completeSetup } from '@/lib/actions/setup'
export function SetupWizard() {
const router = useRouter()
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
// Step 2 Form: Admin account
const [adminForm, setAdminForm] = useState({
email: '',
password: '',
confirmPassword: '',
companyName: '',
firstName: '',
lastName: '',
})
// Step 3 Form: SMTP Config
const [smtpForm, setSmtpForm] = useState({
host: '',
port: '587',
secure: false,
user: '',
pass: '',
})
// Validation checks for Admin form
const isAdminFormValid =
adminForm.email.includes('@') &&
adminForm.password.length >= 6 &&
adminForm.password === adminForm.confirmPassword &&
adminForm.companyName.trim().length > 0 &&
adminForm.firstName.trim().length > 0 &&
adminForm.lastName.trim().length > 0
// Validation checks for SMTP form
const isSmtpFormValid =
smtpForm.host.trim().length > 0 &&
!isNaN(Number(smtpForm.port)) &&
smtpForm.user.trim().length > 0
const handleFinishSetup = async () => {
if (!isAdminFormValid || !isSmtpFormValid) return
setLoading(true)
setErrorMsg('')
try {
// 1. Submit details via server action
const res = await completeSetup(
{
email: adminForm.email,
password: adminForm.password,
companyName: adminForm.companyName,
firstName: adminForm.firstName,
lastName: adminForm.lastName,
},
{
host: smtpForm.host,
port: Number(smtpForm.port),
secure: smtpForm.secure,
user: smtpForm.user,
pass: smtpForm.pass,
}
)
if (!res.success) {
setErrorMsg(res.error || 'Fehler beim Setup.')
setLoading(false)
return
}
// 2. Automatischer Login für flüssiges Erlebnis
const supabase = createClient()
const { error: loginError } = await supabase.auth.signInWithPassword({
email: adminForm.email,
password: adminForm.password,
})
if (loginError) {
console.error('Auto login failed:', loginError)
// Redirect anyway since setup is done
}
// 3. Weiterleitung
router.push('/')
router.refresh()
} catch (e: any) {
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
setLoading(false)
}
}
return (
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
{/* Dynamic Background Glow */}
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/10 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-500/10 blur-[120px]" />
<div className="w-full max-w-xl relative z-10">
{/* Step Indicator Header */}
<div className="flex items-center justify-between mb-8 px-2">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight text-gradient bg-gradient-to-r from-blue-400 to-indigo-400">CASPOS</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">Initialisierung</span>
</div>
<div className="flex gap-1.5">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-1.5 rounded-full transition-all duration-300 ${
s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
}`}
/>
))}
</div>
</div>
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400 mx-auto shadow-inner">
<Sparkles className="w-8 h-8 animate-pulse" />
</div>
<div className="text-center space-y-2">
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
<p className="text-slate-400 leading-relaxed">
Richten Sie Ihren persönlichen Lizenz- und Angebots-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
</p>
</div>
<div className="p-4 rounded-xl bg-white/5 border border-white/5 text-sm text-slate-400 space-y-2">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Komplett self-hosted & sicher</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Automatische Schema-Updates auf dem neuesten Stand</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>SMTP Mailversand für direkte Angebotsbestätigungen</span>
</div>
</div>
<Button
onClick={() => setStep(2)}
className="w-full py-6 text-base font-bold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 shadow-lg shadow-blue-500/15 group"
>
Setup starten
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</motion.div>
)}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-blue-400" />
Admin-Konto anlegen
</h2>
<p className="text-slate-400 text-sm">
Erstellen Sie den ersten Administrator-Benutzer. Dieser erhält vollen Zugriff auf das System.
</p>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Vorname *</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.firstName}
onChange={(e) => setAdminForm({ ...adminForm, firstName: e.target.value })}
placeholder="Vorname"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Nachname *</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.lastName}
onChange={(e) => setAdminForm({ ...adminForm, lastName: e.target.value })}
placeholder="Nachname"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Unternehmen / Partnername *</Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.companyName}
onChange={(e) => setAdminForm({ ...adminForm, companyName: e.target.value })}
placeholder="Name Ihrer Firma"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
type="email"
value={adminForm.email}
onChange={(e) => setAdminForm({ ...adminForm, email: e.target.value })}
placeholder="admin@firma.de"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Passwort * (min. 6 Zeichen)</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
type="password"
value={adminForm.password}
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
placeholder="••••••"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Passwort bestätigen *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
type="password"
value={adminForm.confirmPassword}
onChange={(e) => setAdminForm({ ...adminForm, confirmPassword: e.target.value })}
placeholder="••••••"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
</div>
</div>
<div className="flex gap-3">
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
Zurück
</Button>
<Button
onClick={() => setStep(3)}
disabled={!isAdminFormValid}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zu SMTP
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</motion.div>
)}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Mail className="w-6 h-6 text-blue-400" />
SMTP-Mailserver konfigurieren
</h2>
<p className="text-slate-400 text-sm">
Tragen Sie Ihre Mailserver-Daten ein, um automatisierte E-Mails an Partner und Kunden senden zu können.
</p>
</div>
{errorMsg && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
{errorMsg}
</div>
)}
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Host *</Label>
<Input
value={smtpForm.host}
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
placeholder="smtp.domain.de"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
<Input
value={smtpForm.port}
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
placeholder="587"
className="bg-white/5 border-white/10 text-white"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Benutzername *</Label>
<Input
value={smtpForm.user}
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
placeholder="mail@domain.de"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Passwort</Label>
<Input
type="password"
value={smtpForm.pass}
onChange={(e) => setSmtpForm({ ...smtpForm, pass: e.target.value })}
placeholder="••••••••••••"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="smtp_secure"
checked={smtpForm.secure}
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
Sichere Verbindung (SSL/TLS anstelle STARTTLS)
</Label>
</div>
</div>
<div className="flex gap-3">
<Button
variant="ghost"
onClick={() => setStep(2)}
disabled={loading}
className="text-white hover:bg-white/5"
>
Zurück
</Button>
<Button
onClick={handleFinishSetup}
disabled={!isSmtpFormValid || loading}
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Setup wird abgeschlossen...
</>
) : (
'Einrichtung abschließen'
)}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

124
shop/lib/actions/setup.ts Normal file
View File

@@ -0,0 +1,124 @@
'use server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
export type AdminSetupData = {
email: string
password: string
companyName: string
firstName: string
lastName: string
}
export type SmtpSetupData = {
host: string
port: number
secure: boolean
user: string
pass: string
}
/**
* Checks if any user exists in the public.users table.
* If count is 0, setup is required.
*/
export async function isSetupNeeded(): Promise<boolean> {
try {
const admin = createAdminClient()
const { count, error } = await admin
.from('users')
.select('*', { count: 'exact', head: true })
if (error) {
console.error('Error checking setup status:', error)
return false
}
return count === 0
} catch (e) {
console.error('Exception checking setup status:', e)
return false
}
}
/**
* Completes the initial setup by creating the first admin user,
* updating their role and profile, and storing SMTP settings.
*/
export async function completeSetup(
adminData: AdminSetupData,
smtpData: SmtpSetupData
): Promise<{ success: boolean; error?: string }> {
try {
// 1. Double check if setup is actually needed to prevent double runs
const needed = await isSetupNeeded()
if (!needed) {
return { success: false, error: 'Setup bereits abgeschlossen.' }
}
const admin = createAdminClient()
// 2. Create user in Supabase Auth
const { data: authData, error: authError } = await admin.auth.admin.createUser({
email: adminData.email,
password: adminData.password,
email_confirm: true,
})
if (authError || !authData.user) {
return { success: false, error: `Fehler beim Erstellen des Admin-Kontos: ${authError?.message}` }
}
const userId = authData.user.id
// 3. Update user role to admin in public.users
const { error: roleError } = await admin
.from('users')
.update({ role: 'admin' })
.eq('id', userId)
if (roleError) {
console.error('Error setting user role to admin:', roleError)
}
// 4. Update company and name details in public.profiles
const { error: profileError } = await admin
.from('profiles')
.update({
company_name: adminData.companyName,
first_name: adminData.firstName,
last_name: adminData.lastName,
email: adminData.email,
})
.eq('id', userId)
if (profileError) {
console.error('Error updating admin profile:', profileError)
}
// 5. Store SMTP configuration in public.settings
const { error: smtpError } = await admin
.from('settings')
.upsert({
id: 'smtp',
host: smtpData.host,
port: smtpData.port,
secure: smtpData.secure,
user: smtpData.user,
pass: smtpData.pass,
updated_at: new Date().toISOString(),
})
if (smtpError) {
console.error('Error saving SMTP settings:', smtpError)
return { success: false, error: `Fehler beim Speichern der SMTP-Einstellungen: ${smtpError.message}` }
}
revalidatePath('/')
return { success: true }
} catch (e: any) {
console.error('Exception during completeSetup:', e)
return { success: false, error: e.message || 'Unerwarteter Fehler beim Setup.' }
}
}