ba048a15-58c0-44e5-8a0c-3cb9d71bd91d
This commit is contained in:
1 parent
72ec15c2ab
commit
d492182b4e
7 files changed
+275
-34
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
otpExpiry,
|
||||
otpMaxAttempts,
|
||||
} from '@/lib/auth'
|
||||
import { ensureAdminUser } from '@/lib/ensure-admin'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -35,6 +36,10 @@ const loginSchema = z.object({
|
||||
// exist. Rate limiting is handled by the global middleware (120 req/min/IP).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Ensure the admin account exists (env-managed) so it's always loggable,
|
||||
// even on a fresh deploy with an empty database.
|
||||
await ensureAdminUser()
|
||||
|
||||
const parsed = loginSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 400 })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
import { hashPassword, validatePasswordStrength } from '@/lib/auth'
|
||||
import { ensureAdminUser, isAdminEmail } from '@/lib/ensure-admin'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -9,38 +10,57 @@ const registerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email().max(200).toLowerCase(),
|
||||
password: z.string().min(8).max(200),
|
||||
accountType: z.enum(['user', 'reseller']).default('user'),
|
||||
// Reseller-only: the company they're registering as. Required when
|
||||
// accountType='reseller'. For 'user' this is ignored.
|
||||
companyName: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
// POST /api/auth/register
|
||||
//
|
||||
// Email + password registration. Creates (or claims) a user account.
|
||||
// Self-service registration for normal users + resellers. The platform owner
|
||||
// is NOT created here — the owner account is bootstrapped from env
|
||||
// (ADMIN_EMAIL + ADMIN_PASSWORD) by ensureAdminUser(), which we call first so
|
||||
// the admin email is reserved before accepting signups.
|
||||
//
|
||||
// Claim flow: if the email already belongs to a seeded User with no
|
||||
// passwordHash (e.g. demo fixtures), registration sets the password on
|
||||
// that existing user instead of failing — this lets seeded team members
|
||||
// claim their accounts.
|
||||
//
|
||||
// Role assignment:
|
||||
// - First account with a password on the system → owner (bootstrap)
|
||||
// - Everyone else → 'member' (default staff role; can be elevated by an
|
||||
// owner in settings later).
|
||||
// accountType:
|
||||
// 'user' → role='client', no company created (internal/staff-like).
|
||||
// 'reseller' → role='reseller' + a new Company with isAgency=true,
|
||||
// agencyStatus='pending' + an AgencyApplication record.
|
||||
// Admin must approve before the reseller can onboard clients.
|
||||
//
|
||||
// Email verification: a verification code is emailed if SMTP is configured.
|
||||
// Until verified, the user can still log in (verification is encouraged but
|
||||
// not blocking for this single-tenant agency tool). Resend via /verify-email.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Ensure the admin account exists so the reserved email is taken.
|
||||
await ensureAdminUser()
|
||||
|
||||
const parsed = registerSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { name, email, password } = parsed.data
|
||||
const { name, email, password, accountType, companyName } = parsed.data
|
||||
|
||||
// The admin email is reserved — never registerable here.
|
||||
if (isAdminEmail(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Diese E-Mail ist reserviert. Bitte melden Sie sich direkt an.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const pwError = validatePasswordStrength(password)
|
||||
if (pwError) {
|
||||
return NextResponse.json({ error: pwError }, { status: 400 })
|
||||
}
|
||||
|
||||
if (accountType === 'reseller' && !companyName?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Für Reseller-Registrierung ist ein Firmenname erforderlich.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check for an existing user with this email that already has a password
|
||||
// (claimed) — that's a real conflict.
|
||||
const existing = await db.user.findUnique({ where: { email } })
|
||||
@@ -52,21 +72,55 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(password)
|
||||
const role = accountType === 'reseller' ? 'reseller' : 'client'
|
||||
|
||||
// Decide the role: first account with a password becomes the owner.
|
||||
const usersWithPassword = await db.user.count({ where: { NOT: { passwordHash: null } } })
|
||||
const role = usersWithPassword === 0 ? 'owner' : 'member'
|
||||
// For resellers: create the Company + AgencyApplication up front, then link
|
||||
// the new User to it. We do this in a transaction so a partial failure
|
||||
// doesn't leave orphan records.
|
||||
let companyId: string | null = null
|
||||
if (accountType === 'reseller') {
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
name: companyName!.trim(),
|
||||
email,
|
||||
source: 'self-signup',
|
||||
isAgency: true,
|
||||
agencyStatus: 'pending',
|
||||
agencyAppliedAt: new Date(),
|
||||
},
|
||||
})
|
||||
await tx.agencyApplication.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actor: `signup:${email}`,
|
||||
action: 'reseller.signup',
|
||||
entity: 'Company',
|
||||
entityId: company.id,
|
||||
after: JSON.stringify({ name: company.name, email, status: 'pending' }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
return company.id
|
||||
})
|
||||
companyId = result
|
||||
}
|
||||
|
||||
let user
|
||||
if (existing) {
|
||||
// Claim the seeded account (keep id, role may be elevated if first).
|
||||
// Claim a seeded (password-less) user — keep id, set role + password.
|
||||
user = await db.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { name, passwordHash, role: usersWithPassword === 0 ? 'owner' : existing.role },
|
||||
data: { name, passwordHash, role, companyId },
|
||||
})
|
||||
} else {
|
||||
user = await db.user.create({
|
||||
data: { name, email, passwordHash, role },
|
||||
data: { name, email, passwordHash, role, companyId },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,15 +143,28 @@ export async function POST(req: Request) {
|
||||
html: `<h2>Willkommen bei AOS!</h2><p>Ihr Bestätigungscode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p>Mit freundlichen Grüßen<br>DFM Solutions</p>`,
|
||||
}).catch(() => {})
|
||||
|
||||
// Don't auto-login after registration: require explicit login so the
|
||||
// user learns the flow (and enters 2FA if they later enable it).
|
||||
// Notify the platform owner about new reseller signups (best-effort).
|
||||
if (accountType === 'reseller') {
|
||||
const adminEmail = process.env.ADMIN_EMAIL
|
||||
if (adminEmail) {
|
||||
await sendEmail({
|
||||
to: adminEmail,
|
||||
subject: `Neue Reseller-Bewerbung — ${companyName}`,
|
||||
text: `Eine neue Reseller-Bewerbung ist eingegangen.\n\nFirma: ${companyName}\nE-Mail: ${email}\n\nBitte im Admin-Bereich prüfen und genehmigen.`,
|
||||
html: `<p>Eine neue Reseller-Bewerbung ist eingegangen.</p><p><strong>Firma:</strong> ${companyName}<br><strong>E-Mail:</strong> ${email}</p><p>Bitte im Admin-Bereich prüfen und genehmigen.</p>`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: 'Konto erstellt. Bitte melden Sie sich an.',
|
||||
message: accountType === 'reseller'
|
||||
? 'Reseller-Konto erstellt. Nach Prüfung durch den Administrator erhalten Sie Zugang.'
|
||||
: 'Konto erstellt. Bitte melden Sie sich an.',
|
||||
role,
|
||||
accountType,
|
||||
emailVerificationSent: true,
|
||||
// If this is the very first owner, flag it so the UI can show guidance.
|
||||
isFirstOwner: role === 'owner',
|
||||
...(accountType === 'reseller' ? { resellerStatus: 'pending' } : {}),
|
||||
}, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
|
||||
@@ -6,12 +6,17 @@ import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Lock, Mail, User, ShieldCheck } from 'lucide-react'
|
||||
import { Loader2, Lock, Mail, User, ShieldCheck, Building2, Briefcase } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AccountType = 'user' | 'reseller'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [accountType, setAccountType] = useState<AccountType>('user')
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [companyName, setCompanyName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -27,6 +32,10 @@ export default function RegisterPage() {
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name || !email || !password) return
|
||||
if (accountType === 'reseller' && !companyName.trim()) {
|
||||
setError('Für Reseller-Registrierung ist ein Firmenname erforderlich.')
|
||||
return
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError('Passwörter stimmen nicht überein.')
|
||||
return
|
||||
@@ -37,7 +46,13 @@ export default function RegisterPage() {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name, email: email.toLowerCase(), password }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password,
|
||||
accountType,
|
||||
...(accountType === 'reseller' ? { companyName: companyName.trim() } : {}),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
@@ -67,9 +82,68 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account type selector */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccountType('user')}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-lg border p-3 text-center transition',
|
||||
accountType === 'user'
|
||||
? 'border-foreground bg-foreground/5'
|
||||
: 'border-border hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<User className={cn('size-5', accountType === 'user' ? 'text-foreground' : 'text-muted-foreground')} />
|
||||
<span className={cn('text-xs font-medium', accountType === 'user' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
Benutzer
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccountType('reseller')}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-lg border p-3 text-center transition',
|
||||
accountType === 'reseller'
|
||||
? 'border-foreground bg-foreground/5'
|
||||
: 'border-border hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Building2 className={cn('size-5', accountType === 'reseller' ? 'text-foreground' : 'text-muted-foreground')} />
|
||||
<span className={cn('text-xs font-medium', accountType === 'reseller' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
Reseller
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{accountType === 'reseller' && (
|
||||
<p className="flex items-start gap-1.5 rounded-md border border-border bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
|
||||
<Briefcase className="mt-0.5 size-3 shrink-0" />
|
||||
<span>
|
||||
Reseller können eigene Kunden anlegen und lizenzieren. Nach der Registrierung prüft der Administrator Ihre Bewerbung — Sie erhalten Zugang nach Freigabe.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{accountType === 'reseller' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyName">Firmenname</Label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="companyName"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Muster Agentur GmbH"
|
||||
autoComplete="organization"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Label htmlFor="name">{accountType === 'reseller' ? 'Ansprechpartner' : 'Name'}</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -78,7 +152,7 @@ export default function RegisterPage() {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Max Mustermann"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
autoFocus={accountType !== 'reseller'}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
@@ -150,10 +224,10 @@ export default function RegisterPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full gap-2"
|
||||
disabled={loading || !name || !email || !password || !confirm || !pwRules.every((r) => r.ok)}
|
||||
disabled={loading || !name || !email || !password || !confirm || !pwRules.every((r) => r.ok) || (accountType === 'reseller' && !companyName.trim())}
|
||||
>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Konto erstellen
|
||||
{accountType === 'reseller' ? 'Reseller-Konto erstellen' : 'Konto erstellen'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
GitBranch,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { translate } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -104,6 +105,14 @@ const NAV: {
|
||||
export function SidebarNav() {
|
||||
const { view, setView, setSidebarOpen, setCommandOpen, setPortalMode, locale } = useAos()
|
||||
const t = (k: string) => translate(k, locale)
|
||||
const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me')
|
||||
const user = meData?.user
|
||||
const initials = (user?.name ?? '?')
|
||||
.split(' ')
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
@@ -202,11 +211,11 @@ export function SidebarNav() {
|
||||
<div className="border-t border-sidebar-border px-3 py-3">
|
||||
<Button variant="ghost" className="h-auto w-full justify-start gap-3 px-2 py-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
|
||||
MB
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex flex-col items-start leading-tight">
|
||||
<span className="text-sm font-medium">Max Brandt</span>
|
||||
<span className="text-[11px] text-muted-foreground">Owner</span>
|
||||
<span className="text-sm font-medium">{user?.name ?? '—'}</span>
|
||||
<span className="text-[11px] text-muted-foreground capitalize">{user?.role ?? ''}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { hashPassword, verifyPassword } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* ensureAdminUser — idempotent bootstrap of the platform owner account.
|
||||
*
|
||||
* Reads ADMIN_EMAIL + ADMIN_PASSWORD from env and makes sure a User exists
|
||||
* with that email and role='owner'. If the user exists but the password
|
||||
* doesn't match the env value, it's updated (so changing ADMIN_PASSWORD in
|
||||
* env propagates on next boot without a manual migration).
|
||||
*
|
||||
* Memoized per-process: the DB work happens at most once. Safe to call from
|
||||
* every login/register request — the cached promise resolves instantly after
|
||||
* the first run.
|
||||
*
|
||||
* Called from:
|
||||
* - login route (so the admin can always log in, even on a fresh deploy)
|
||||
* - register route (so the admin email is reserved before accepting signups)
|
||||
*/
|
||||
let adminBootstrapPromise: Promise<void> | null = null
|
||||
|
||||
export function ensureAdminUser(): Promise<void> {
|
||||
if (!adminBootstrapPromise) {
|
||||
adminBootstrapPromise = doEnsureAdminUser().catch((err) => {
|
||||
// Reset so the next call retries — bootstrap failure shouldn't permanently
|
||||
// break login/register, but we want to know about it.
|
||||
adminBootstrapPromise = null
|
||||
console.error('[ensureAdminUser] failed:', err instanceof Error ? err.message : err)
|
||||
})
|
||||
}
|
||||
return adminBootstrapPromise
|
||||
}
|
||||
|
||||
async function doEnsureAdminUser(): Promise<void> {
|
||||
const email = process.env.ADMIN_EMAIL
|
||||
const password = process.env.ADMIN_PASSWORD
|
||||
if (!email || !password) {
|
||||
// No admin configured — skip silently (dev convenience).
|
||||
return
|
||||
}
|
||||
|
||||
const existing = await db.user.findUnique({ where: { email } })
|
||||
|
||||
if (!existing) {
|
||||
// Create the admin account.
|
||||
await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: 'Administrator',
|
||||
role: 'owner',
|
||||
passwordHash: hashPassword(password),
|
||||
emailVerifiedAt: new Date(), // admin is pre-verified
|
||||
},
|
||||
})
|
||||
console.log(`[ensureAdminUser] created owner account for ${email}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Exists — make sure role + password are in sync with env.
|
||||
const needsRoleUpdate = existing.role !== 'owner'
|
||||
const needsPwUpdate = !existing.passwordHash || !verifyPassword(password, existing.passwordHash)
|
||||
if (needsRoleUpdate || needsPwUpdate) {
|
||||
await db.user.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
role: 'owner',
|
||||
...(needsPwUpdate ? { passwordHash: hashPassword(password) } : {}),
|
||||
},
|
||||
})
|
||||
if (needsPwUpdate) {
|
||||
console.log(`[ensureAdminUser] synced password for ${email} from env`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the given email is the reserved admin email. */
|
||||
export function isAdminEmail(email: string): boolean {
|
||||
const adminEmail = process.env.ADMIN_EMAIL
|
||||
return !!adminEmail && email.toLowerCase() === adminEmail.toLowerCase()
|
||||
}
|
||||
Reference in new issue
Block a user