ba048a15-58c0-44e5-8a0c-3cb9d71bd91d

This commit is contained in:
Z User committed 2026-10-30 00:09:50 +00:00
1 parent 72ec15c2ab
commit d492182b4e
7 files changed
+275 -34

No files matched your search

BIN
View File
Binary file not shown.
+7 -1
View File
@@ -19,7 +19,12 @@ model User {
id String @id @default(cuid())
email String @unique
name String
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client|member
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client|member|reseller
// --- Account → Company link ---
// For reseller/client accounts: the Company this user manages. Null for
// the platform owner + internal staff. Set at registration.
companyId String?
company Company? @relation(fields: [companyId], references: [id])
// --- Authentication ---
// scrypt-based password hash (format: "salt:hash", both hex). Null = seeded
// user that hasn't claimed their account via registration yet.
@@ -143,6 +148,7 @@ model Company {
agencyApplication AgencyApplication?
instances Instance[]
repoAssignments RepoAssignment[]
users User[]
}
model Person {
+5
View File
@@ -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 })
+91 -24
View File
@@ -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'
+80 -6
View File
@@ -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>
+12 -3
View File
@@ -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>
+80
View File
@@ -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()
}