Initial commit: Lageplan v1.0 - Next.js 15.5, React 19
This commit is contained in:
46
src/app/api/auth/change-password/route.ts
Normal file
46
src/app/api/auth/change-password/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const { currentPassword, newPassword } = await req.json()
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
|
||||
}
|
||||
|
||||
const dbUser = await (prisma as any).user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { password: true },
|
||||
})
|
||||
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(currentPassword, dbUser.password)
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Aktuelles Kennwort ist falsch' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12)
|
||||
await (prisma as any).user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
73
src/app/api/auth/forgot-password/route.ts
Normal file
73
src/app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { sendEmail, getSmtpConfig } from '@/lib/email'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { email } = await req.json()
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await (prisma as any).user.findUnique({ where: { email } })
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user) {
|
||||
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
|
||||
}
|
||||
|
||||
// Generate reset token (32 bytes hex = 64 chars)
|
||||
const resetToken = randomBytes(32).toString('hex')
|
||||
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
|
||||
|
||||
await (prisma as any).user.update({
|
||||
where: { id: user.id },
|
||||
data: { resetToken, resetTokenExpiry },
|
||||
})
|
||||
|
||||
// Try to send email
|
||||
const smtpConfig = await getSmtpConfig()
|
||||
const host = req.headers.get('host') || 'localhost:3000'
|
||||
const protocol = host.includes('localhost') ? 'http' : 'https'
|
||||
const resetUrl = `${protocol}://${host}/reset-password?token=${resetToken}`
|
||||
|
||||
if (smtpConfig) {
|
||||
try {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
'Passwort zurücksetzen – Lageplan',
|
||||
`
|
||||
<div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
|
||||
<h2 style="color: #dc2626;">Passwort zurücksetzen</h2>
|
||||
<p>Hallo ${user.name},</p>
|
||||
<p>Sie haben eine Passwort-Zurücksetzung angefordert. Klicken Sie auf den folgenden Link:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${resetUrl}" style="background: #dc2626; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: bold;">
|
||||
Passwort zurücksetzen
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">Dieser Link ist 1 Stunde gültig.</p>
|
||||
<p style="color: #666; font-size: 14px;">Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||
<p style="color: #999; font-size: 12px;">Lageplan – Feuerwehr Krokier-App</p>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
|
||||
} catch (emailErr) {
|
||||
console.error('Failed to send reset email:', emailErr)
|
||||
// Fall through to show token directly
|
||||
}
|
||||
}
|
||||
|
||||
// No SMTP configured or email failed → log token server-side only, never expose to client
|
||||
console.log(`[Password Reset] No SMTP configured. Reset URL: ${resetUrl}`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet. (SMTP nicht konfiguriert — siehe Server-Logs)',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
55
src/app/api/auth/login/route.ts
Normal file
55
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { login, createToken } from '@/lib/auth'
|
||||
import { loginSchema } from '@/lib/validations'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const validated = loginSchema.safeParse(body)
|
||||
if (!validated.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ungültige Eingabedaten' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, password } = validated.data
|
||||
const result = await login(email, password)
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Login fehlgeschlagen' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update lastLoginAt
|
||||
try {
|
||||
await (prisma as any).user.update({
|
||||
where: { id: result.user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
const token = await createToken(result.user)
|
||||
|
||||
;(await cookies()).set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return NextResponse.json({ user: result.user })
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Interner Serverfehler' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function POST() {
|
||||
;(await cookies()).delete('auth-token')
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
33
src/app/api/auth/me/route.ts
Normal file
33
src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSession()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ user: null, tenant: null })
|
||||
}
|
||||
|
||||
// Enrich with tenant subscription info for non-server-admins
|
||||
let tenant: any = null
|
||||
if (user.tenantId) {
|
||||
tenant = await (prisma as any).tenant.findUnique({
|
||||
where: { id: user.tenantId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
plan: true,
|
||||
subscriptionStatus: true,
|
||||
trialEndsAt: true,
|
||||
subscriptionEndsAt: true,
|
||||
maxUsers: true,
|
||||
maxProjects: true,
|
||||
logoUrl: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ user, tenant })
|
||||
}
|
||||
175
src/app/api/auth/register/route.ts
Normal file
175
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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'
|
||||
|
||||
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(6, 'Passwort muss mindestens 6 Zeichen haben'),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
43
src/app/api/auth/reset-password/route.ts
Normal file
43
src/app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { hashPassword } from '@/lib/auth'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { token, password } = await req.json()
|
||||
if (!token || !password) {
|
||||
return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await (prisma as any).user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Ungültiger oder abgelaufener Link. Bitte fordern Sie einen neuen Link an.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
await (prisma as any).user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Passwort wurde erfolgreich geändert.' })
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
49
src/app/api/auth/verify-email/route.ts
Normal file
49
src/app/api/auth/verify-email/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
function getBaseUrl(req: NextRequest): string {
|
||||
// Use NEXTAUTH_URL if set, ensure it has a protocol
|
||||
if (process.env.NEXTAUTH_URL) {
|
||||
const url = process.env.NEXTAUTH_URL.trim()
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
return `https://${url}`
|
||||
}
|
||||
const proto = req.headers.get('x-forwarded-proto') || 'https'
|
||||
const host = req.headers.get('host') || 'localhost:3000'
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const base = getBaseUrl(req)
|
||||
try {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(`${base}/login?error=invalid-token`)
|
||||
}
|
||||
|
||||
// Find user by verification token
|
||||
const user = await (prisma as any).user.findFirst({
|
||||
where: { emailVerificationToken: token },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(`${base}/login?error=invalid-token`)
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
await (prisma as any).user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
emailVerificationToken: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Redirect to login with success message
|
||||
return NextResponse.redirect(`${base}/login?verified=true`)
|
||||
} catch (error) {
|
||||
console.error('Email verification error:', error)
|
||||
return NextResponse.redirect(`${base}/login?error=verification-failed`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user