From d492182b4e116f52cd83e9420fd43d4c74791068 Mon Sep 17 00:00:00 2001 From: Z User Date: Fri, 30 Oct 2026 00:09:50 +0000 Subject: [PATCH] ba048a15-58c0-44e5-8a0c-3cb9d71bd91d --- db/custom.db | Bin 565248 -> 569344 bytes prisma/schema.prisma | 8 +- src/app/api/auth/login/route.ts | 5 ++ src/app/api/auth/register/route.ts | 115 +++++++++++++++++++++++------ src/app/register/page.tsx | 86 +++++++++++++++++++-- src/components/aos/sidebar-nav.tsx | 15 +++- src/lib/ensure-admin.ts | 80 ++++++++++++++++++++ 7 files changed, 275 insertions(+), 34 deletions(-) create mode 100644 src/lib/ensure-admin.ts diff --git a/db/custom.db b/db/custom.db index 4a216174053c0c48c7d588f5800b8c996ad294e7..ad909f5b2ae755a7f22190470a686fdaba2472b3 100755 GIT binary patch delta 2509 zcmZ`*U5p!76`rxjp1bzi*Dl!_cL}@e)hf`|-TArm=MW}o5mfzKLAoUAZc*;c+}W6T zY{&L)5}aib6%Wfpk>Cl5q$P-lNJYF*DMb;lfjhJ7 z#I4(hdws9ZoqN7>&Ue07j|Ucdbu@DKksF}`tO;_*=q zE;8@~_zql!i`4TYaN%ywoMCNmWm#)19n}Rvz{f1@m{x7KmVW5eh=;4S@(O8H-ILtl z(JG=xrSPa+ZyYl^9vVCvpXSU0>u;`s;>EwrqP_7#thB*5%duFAdi|K~_8uyv&E5Bm zn@n$FHqm#l(L43XeZ!HOa_vaFx>R3oRz91V{{Fr#`vVQHiR&l(w<7rG2(XCig9o`eXLV$?tg9gQa^*5o~Jf-`USTHMKjqv)rb(CaF8s`cmns z_7N;8YQ=L^5qWZhi0o@?lP5mBc)c~2i5D_+K0g_sr4bf0{_J&@FGNWlimLFmx4!DF zvw^oB^GgBO1b>D_n*AZ*v9H9<5&ydlzWB^$?3vB@uQs`BKi^DpccoKLFz}b~q3pl2 z=dxnvubK8}kp4KWq@JM8zme+p4yN;FxxCZt%`pt1RAdsjk~~!7&qn^eZ5~cO(5QE? z-RaxEFu#UIR8`VdLD3W}DiZE0vPIXjA*qC}go@xwmM+S&hOi;J*m5v(9mSw_QM4`D z5k*(Bkwz~D=MO8Nfk|Xur3e* zJDP=DA`=G@j0MMaX^M41tD+m0g*3$ybP35eCI$^zvt`Q>UCl5;*V*=!9lQ?%$dg2IOAVi2j2%zCUT}W zHNp6M&VmEr5SKG|Pv;r`m9tFHqYdHhhlme)9r2u*zLX zyun>aHbD%|GH?xstv3x94#l#jIUZ#=B>pYtt2D=2Bw0(1j)Pb57czRg;lqeed^kA! zYqR{1wmNo5%a z>`Eq@rTiUfPUHz`?>lJ->xaBQ997KOPLVsmz?tLp+G?gy}$%Fh& z{#}m!TXsBBs3B*mopF92*qV`J=^+`(3jgiRcdg^drg z=lwTjb}aah4i;IzImh3@g_ey8&Wp9W{+tQMf=|8)c1(?OnYS2N&0fvkpLvVM@JgoJ z+rgzwvzM6}%)LQ*Vn;k_iQ;jQ$bFs+NvB0z#XyegDvo3chGAHxRlq@dfP9ND2TP7hic3j* H9$f!Fw*~A% delta 554 zcmXxeJ!n%=6bJBkKi<1Hjd>@DP13YN(m04;4NnV%oruwhJnZu{ z6-6=aWb7b2SC&tlM$Pr9=g`d|roeaj2sL;FF}MX+VXW>62f^zhM^Jt)mo1K#vNuZu z*RtG-g)Y%v5=Ykbyx)2qN^=Yw3VeeN=`qMEsE6rD0EvdlgZ1s`nNi}$`xYylwEw-s z_5GsHMf|QAiy56w+-0Szj=SaR{c;t_auqK{BsIt$h{-(jE;jIDgRC~_&hkZP7qQJ4 z#$X*}{}j5cTjB6mdWZ}o@pMh?QQ3bJ(rjWc?ITGf-Y@Y0qSkn*?{9r0n-l1jdJti& z778xb)Xkbk3mO7b0kgw5K$uZ>yJOe|xi`Xk-F^paE8y3UxiSYb&KcHryy4BRT^s*{ z$@Td*kT2%gEXKtJks9GX`RhFI?&nx { + 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() +}