diff --git a/db/custom.db b/db/custom.db index 4a21617..ad909f5 100755 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ec4f823..894e9ca 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8a969f7..44c69a9 100755 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 }) diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a1b59df..183ca87 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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: `
Ihr Bestätigungscode lautet:
${code}
Der Code ist 5 Minuten gültig.
Mit freundlichen Grüßen
DFM Solutions
Eine neue Reseller-Bewerbung ist eingegangen.
Firma: ${companyName}
E-Mail: ${email}
Bitte im Admin-Bereich prüfen und genehmigen.
`, + }).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' diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 8f8abf6..5db402a 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -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
+