181 lines
7.6 KiB
TypeScript
181 lines
7.6 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { prisma } from '@/lib/db'
|
|
import { hashPassword } from '@/lib/auth'
|
|
import { sendEmail } from '@/lib/email'
|
|
import { randomBytes } from 'crypto'
|
|
import { z } from 'zod'
|
|
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
|
|
|
const registerSchema = z.object({
|
|
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
|
name: z.string().min(2, 'Name zu kurz').max(200),
|
|
email: z.string().email('Ungültige E-Mail-Adresse'),
|
|
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
|
|
})
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const ip = getClientIp(req)
|
|
const rl = registerLimiter.check(ip)
|
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
|
|
|
const body = await req.json()
|
|
const data = registerSchema.parse(body)
|
|
|
|
// Check if email already exists
|
|
const existingUser = await (prisma as any).user.findUnique({
|
|
where: { email: data.email },
|
|
include: { memberships: true },
|
|
})
|
|
if (existingUser) {
|
|
// If the user is an orphan (no memberships) or never verified their email,
|
|
// clean them up so they can re-register
|
|
const isOrphan = !existingUser.memberships || existingUser.memberships.length === 0
|
|
const isUnverified = existingUser.emailVerified === false
|
|
if (isOrphan || isUnverified) {
|
|
// Force-delete orphan/unverified user and all their remaining data
|
|
try {
|
|
await (prisma as any).upgradeRequest.deleteMany({ where: { requestedById: existingUser.id } })
|
|
await (prisma as any).iconAsset.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
|
|
await (prisma as any).project.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
|
|
await (prisma as any).tenantMembership.deleteMany({ where: { userId: existingUser.id } })
|
|
await (prisma as any).user.delete({ where: { id: existingUser.id } })
|
|
console.log(`[Register] Cleaned up orphan/unverified user: ${data.email}`)
|
|
} catch (cleanupErr) {
|
|
console.error('[Register] Failed to cleanup existing user:', cleanupErr)
|
|
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert. Bitte kontaktieren Sie den Administrator.' }, { status: 400 })
|
|
}
|
|
} else {
|
|
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert.' }, { status: 400 })
|
|
}
|
|
}
|
|
|
|
// Generate slug from organization name
|
|
let slug = data.organizationName
|
|
.toLowerCase()
|
|
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
|
|
// Ensure slug is unique
|
|
const existingTenant = await (prisma as any).tenant.findUnique({ where: { slug } })
|
|
if (existingTenant) {
|
|
slug = `${slug}-${Date.now().toString(36)}`
|
|
}
|
|
|
|
// Hash password
|
|
const hashedPassword = await hashPassword(data.password)
|
|
|
|
// Generate email verification token
|
|
const verificationToken = randomBytes(32).toString('hex')
|
|
|
|
// Create tenant (no trial, directly ACTIVE) with privacy consent
|
|
const tenant = await (prisma as any).tenant.create({
|
|
data: {
|
|
name: data.organizationName,
|
|
slug,
|
|
plan: 'FREE',
|
|
subscriptionStatus: 'ACTIVE',
|
|
maxUsers: 5,
|
|
maxProjects: 10,
|
|
contactEmail: data.email,
|
|
privacyAccepted: body.privacyAccepted === true,
|
|
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
|
|
adminAccessAccepted: body.adminAccessAccepted === true,
|
|
},
|
|
})
|
|
|
|
// Create user as TENANT_ADMIN with email not yet verified
|
|
const user = await (prisma as any).user.create({
|
|
data: {
|
|
email: data.email,
|
|
password: hashedPassword,
|
|
name: data.name,
|
|
role: 'TENANT_ADMIN',
|
|
emailVerified: false,
|
|
emailVerificationToken: verificationToken,
|
|
},
|
|
})
|
|
|
|
// Create tenant membership
|
|
await (prisma as any).tenantMembership.create({
|
|
data: {
|
|
userId: user.id,
|
|
tenantId: tenant.id,
|
|
role: 'TENANT_ADMIN',
|
|
},
|
|
})
|
|
|
|
// Send verification email
|
|
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
|
|
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
|
baseUrl = `https://${baseUrl}`
|
|
}
|
|
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${verificationToken}`
|
|
try {
|
|
await sendEmail(
|
|
data.email,
|
|
'E-Mail-Adresse bestätigen — Lageplan',
|
|
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
|
|
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
|
|
<h1 style="margin:0;font-size:22px;">E-Mail bestätigen</h1>
|
|
</div>
|
|
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
|
|
<p>Hallo <strong>${data.name}</strong>,</p>
|
|
<p>Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto für <strong>${data.organizationName}</strong> zu aktivieren.</p>
|
|
<div style="text-align:center;margin:24px 0;">
|
|
<a href="${verifyUrl}" style="background:#dc2626;color:white;padding:12px 32px;text-decoration:none;border-radius:8px;font-weight:600;display:inline-block;">
|
|
E-Mail bestätigen
|
|
</a>
|
|
</div>
|
|
<p style="color:#666;font-size:13px;">Falls der Button nicht funktioniert, kopieren Sie diesen Link:<br/>
|
|
<a href="${verifyUrl}" style="word-break:break-all;">${verifyUrl}</a></p>
|
|
</div>
|
|
</div>`
|
|
)
|
|
} catch (e) {
|
|
console.warn('Failed to send verification email:', e)
|
|
}
|
|
|
|
// Notify server admin about new registration (#13)
|
|
try {
|
|
const adminSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'notify_registration_email' } })
|
|
const adminEmail = adminSetting?.value
|
|
if (adminEmail) {
|
|
await sendEmail(
|
|
adminEmail,
|
|
`Neue Registrierung — ${data.organizationName}`,
|
|
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
|
|
<div style="background:#1e293b;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
|
|
<h1 style="margin:0;font-size:22px;">Neue Registrierung</h1>
|
|
</div>
|
|
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
|
|
<p><strong>Organisation:</strong> ${data.organizationName}</p>
|
|
<p><strong>Name:</strong> ${data.name}</p>
|
|
<p><strong>E-Mail:</strong> ${data.email}</p>
|
|
<p><strong>Mandant-Slug:</strong> ${slug}</p>
|
|
<p><strong>Datum:</strong> ${new Date().toLocaleString('de-CH')}</p>
|
|
</div>
|
|
</div>`
|
|
)
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to send registration notification:', e)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: 'Registrierung erfolgreich! Bitte bestätigen Sie Ihre E-Mail-Adresse.',
|
|
tenantSlug: tenant.slug,
|
|
requiresVerification: true,
|
|
}, { status: 201 })
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
const firstError = error.errors[0]
|
|
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
|
}
|
|
console.error('Registration error:', error)
|
|
return NextResponse.json({ error: 'Registrierung fehlgeschlagen. Bitte versuchen Sie es später.' }, { status: 500 })
|
|
}
|
|
}
|