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: `

Willkommen bei AOS!

Ihr Bestätigungscode lautet:

${code}

Der Code ist 5 Minuten gültig.

Mit freundlichen Grüßen
DFM Solutions

`, }).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: `

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('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() {

+ {/* Account type selector */} +
+ + +
+ {accountType === 'reseller' && ( +

+ + + Reseller können eigene Kunden anlegen und lizenzieren. Nach der Registrierung prüft der Administrator Ihre Bewerbung — Sie erhalten Zugang nach Freigabe. + +

+ )} +
+ {accountType === 'reseller' && ( +
+ +
+ + setCompanyName(e.target.value)} + placeholder="Muster Agentur GmbH" + autoComplete="organization" + autoFocus + className="pl-9" + /> +
+
+ )}
- +
setName(e.target.value)} placeholder="Max Mustermann" autoComplete="name" - autoFocus + autoFocus={accountType !== 'reseller'} className="pl-9" />
@@ -150,10 +224,10 @@ export default function RegisterPage() { diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index b8ac3b0..159e81b 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -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 (
@@ -202,11 +211,11 @@ export function SidebarNav() {
diff --git a/src/lib/ensure-admin.ts b/src/lib/ensure-admin.ts new file mode 100644 index 0000000..142b210 --- /dev/null +++ b/src/lib/ensure-admin.ts @@ -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 | null = null + +export function ensureAdminUser(): Promise { + 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 { + 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() +}