'use server' import { prisma } from '@innungsapp/shared' import { auth } from '@/lib/auth' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { headers } from 'next/headers' import { z } from 'zod' import { sendAdminCredentialsEmail } from '@/lib/email' // @ts-ignore import { hashPassword } from 'better-auth/crypto' function normalizeEmail(email: string | null | undefined): string { return (email ?? '').trim().toLowerCase() } /** * Sets a credential (email+password) account for a user. * Uses direct DB write with better-auth's hashPassword for compatibility. */ async function setCredentialPassword(userId: string, password: string) { const hashedPassword = await hashPassword(password) const updated = await prisma.account.updateMany({ where: { userId, providerId: 'credential' }, data: { password: hashedPassword, accountId: userId }, }) if (updated.count === 0) { await prisma.account.create({ data: { id: crypto.randomUUID(), userId, accountId: userId, providerId: 'credential', password: hashedPassword, }, }) } } async function requireSuperAdmin() { const session = await auth.api.getSession({ headers: await headers() }) const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de' // An admin is either specifically the superadmin email OR has the 'admin' role from better-auth admin plugin const isSuperAdmin = session?.user && ( session.user.email === superAdminEmail || (session.user as any).role === 'admin' ) if (!isSuperAdmin) { return null } return session } const createOrgSchema = z.object({ name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), slug: z .string() .min(2, 'Slug muss mindestens 2 Zeichen lang sein') .regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'), contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')), adminEmail: z.string().email('Ungueltige Admin E-Mail').optional().or(z.literal('')), adminPassword: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein').optional().or(z.literal('')), logoUrl: z.string().optional().nullable(), plan: z.enum(['pilot', 'standard', 'pro', 'verband']).default('pilot'), primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), landingPageTitle: z.string().optional(), landingPageText: z.string().optional(), landingPageHeroImage: z.string().optional().nullable(), landingPageHeroOverlayOpacity: z.number().min(0).max(100).optional().default(50), landingPageFeatures: z.string().optional(), landingPageSectionTitle: z.string().optional(), landingPageButtonText: z.string().optional(), appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), }) const updateOrgSchema = z.object({ name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), plan: z.enum(['pilot', 'standard', 'pro', 'verband']), contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')), logoUrl: z.string().optional().nullable(), primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')), landingPageTitle: z.string().optional(), landingPageText: z.string().optional(), landingPageHeroImage: z.string().optional().nullable(), landingPageFeatures: z.string().optional(), landingPageSectionTitle: z.string().optional(), landingPageButtonText: z.string().optional(), appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')), }) const createAdminSchema = z.object({ orgId: z.string(), name: z.string().min(2, 'Name ist zu kurz'), email: z.string().email('Ungueltige E-Mail Adresse'), password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'), }) const createMemberSchema = z.object({ orgId: z.string(), name: z.string().min(2, 'Name ist zu kurz'), email: z.string().email('Ungueltige E-Mail Adresse'), betrieb: z.string().min(2, 'Betrieb ist zu kurz'), sparte: z.string().min(2, 'Sparte ist zu kurz'), ort: z.string().min(2, 'Ort ist zu kurz'), }) export async function createOrganization(prevState: any, formData: FormData) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } try { const rawData = { name: (formData.get('name') as string).trim(), slug: (formData.get('slug') as string).trim().toLowerCase(), contactEmail: (formData.get('contactEmail') as string).trim(), adminEmail: normalizeEmail(formData.get('adminEmail') as string), adminPassword: formData.get('adminPassword') as string, logoUrl: formData.get('logoUrl') as string, plan: (formData.get('plan') as string) || 'pilot', primaryColor: formData.get('primaryColor') as string, secondaryColor: formData.get('secondaryColor') as string, landingPageTitle: (formData.get('landingPageTitle') as string).trim(), landingPageText: (formData.get('landingPageText') as string).trim(), landingPageHeroImage: formData.get('landingPageHeroImage') as string, landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'), landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(), landingPageFooter: (formData.get('landingPageFooter') as string).trim(), landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(), landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(), appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(), playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(), } const validatedData = createOrgSchema.parse(rawData) const existingOrg = await prisma.organization.findUnique({ where: { slug: validatedData.slug }, }) if (existingOrg) { return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' } } const org = await prisma.organization.create({ data: { name: validatedData.name, slug: validatedData.slug, contactEmail: validatedData.contactEmail || validatedData.adminEmail || null, plan: validatedData.plan, primaryColor: validatedData.primaryColor || '#E63946', secondaryColor: validatedData.secondaryColor || null, logoUrl: validatedData.logoUrl || null, landingPageTitle: validatedData.landingPageTitle || null, landingPageText: validatedData.landingPageText || null, landingPageHeroImage: validatedData.landingPageHeroImage || null, // @ts-ignore landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity, landingPageFeatures: validatedData.landingPageFeatures || null, landingPageFooter: validatedData.landingPageFooter || null, landingPageSectionTitle: validatedData.landingPageSectionTitle || null, landingPageButtonText: validatedData.landingPageButtonText || null, appStoreUrl: validatedData.appStoreUrl || null, playStoreUrl: validatedData.playStoreUrl || null, }, }) if (validatedData.adminEmail) { let user = await prisma.user.findUnique({ where: { email: validatedData.adminEmail } }) if (!user) { user = await prisma.user.create({ data: { id: crypto.randomUUID(), name: validatedData.adminEmail.split('@')[0], email: validatedData.adminEmail, emailVerified: true, mustChangePassword: !!validatedData.adminPassword, }, }) } else { // If user exists, we still want to make sure they are verified and maybe force password change user = await prisma.user.update({ where: { id: user.id }, data: { emailVerified: true, ...(validatedData.adminPassword ? { mustChangePassword: true } : {}), }, }) } await prisma.userRole.upsert({ where: { orgId_userId: { orgId: org.id, userId: user.id, }, }, update: { role: 'admin' }, create: { orgId: org.id, userId: user.id, role: 'admin', }, }) if (validatedData.adminPassword) { await setCredentialPassword(user.id, validatedData.adminPassword) try { await sendAdminCredentialsEmail({ to: validatedData.adminEmail, adminName: user.name || validatedData.adminEmail.split('@')[0], orgName: org.name, password: validatedData.adminPassword, loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032', }) } catch (emailError) { console.error('E-Mail konnte nicht gesendet werden:', emailError) } } } revalidatePath('/superadmin') return { success: true, error: '' } } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: error.errors[0].message } } return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' } } } export async function updateOrganization(id: string, prevState: any, formData: FormData) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } try { const rawData = { name: (formData.get('name') as string).trim(), plan: formData.get('plan') as string, contactEmail: (formData.get('contactEmail') as string).trim(), logoUrl: formData.get('logoUrl') as string, primaryColor: formData.get('primaryColor') as string, secondaryColor: formData.get('secondaryColor') as string, landingPageTitle: (formData.get('landingPageTitle') as string).trim(), landingPageText: (formData.get('landingPageText') as string).trim(), landingPageHeroImage: formData.get('landingPageHeroImage') as string, landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(), landingPageFooter: (formData.get('landingPageFooter') as string).trim(), landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(), landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(), appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(), playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(), } const validatedData = updateOrgSchema.parse(rawData) await prisma.organization.update({ where: { id }, data: { name: validatedData.name, plan: validatedData.plan, contactEmail: validatedData.contactEmail || null, logoUrl: validatedData.logoUrl || null, primaryColor: validatedData.primaryColor || '#E63946', secondaryColor: validatedData.secondaryColor || null, landingPageTitle: validatedData.landingPageTitle || null, landingPageText: validatedData.landingPageText || null, landingPageHeroImage: validatedData.landingPageHeroImage || null, landingPageFeatures: validatedData.landingPageFeatures || null, landingPageFooter: validatedData.landingPageFooter || null, landingPageSectionTitle: validatedData.landingPageSectionTitle || null, landingPageButtonText: validatedData.landingPageButtonText || null, appStoreUrl: validatedData.appStoreUrl || null, playStoreUrl: validatedData.playStoreUrl || null, }, }) revalidatePath('/superadmin') revalidatePath(`/superadmin/organizations/${id}`) return { success: true, error: '' } } catch (error) { if (error instanceof z.ZodError) { return { success: false, error: error.errors[0].message } } return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' } } } export async function toggleAiFeature(id: string, enabled: boolean) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } await prisma.organization.update({ where: { id }, data: { aiEnabled: enabled }, }) revalidatePath('/superadmin') revalidatePath(`/superadmin/organizations/${id}`) return { success: true, error: '' } } export async function deleteOrganization(id: string) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } await prisma.organization.delete({ where: { id } }) revalidatePath('/superadmin') redirect('/superadmin') } export async function createAdmin(prevState: any, formData: FormData) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } try { const rawData = { orgId: formData.get('orgId') as string, name: (formData.get('name') as string).trim(), email: normalizeEmail(formData.get('email') as string), password: formData.get('password') as string, } const validatedData = createAdminSchema.parse(rawData) let user = await prisma.user.findUnique({ where: { email: validatedData.email } }) if (!user) { user = await prisma.user.create({ data: { id: crypto.randomUUID(), name: validatedData.name, email: validatedData.email, emailVerified: true, mustChangePassword: true, }, }) } else { user = await prisma.user.update({ where: { id: user.id }, data: { emailVerified: true, mustChangePassword: true, }, }) } await setCredentialPassword(user.id, validatedData.password) await prisma.userRole.upsert({ where: { orgId_userId: { orgId: validatedData.orgId, userId: user.id, }, }, update: { role: 'admin' }, create: { orgId: validatedData.orgId, userId: user.id, role: 'admin', }, }) const org = await prisma.organization.findUnique({ where: { id: validatedData.orgId }, select: { name: true }, }) try { await sendAdminCredentialsEmail({ to: validatedData.email, adminName: validatedData.name, orgName: org?.name || 'Ihre Innung', password: validatedData.password, loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032', }) } catch (emailError) { console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError) } revalidatePath(`/superadmin/organizations/${validatedData.orgId}`) return { success: true, error: '' } } catch (error) { console.error('Failed to create admin:', error) if (error instanceof z.ZodError) { return { success: false, error: error.errors[0].message } } return { success: false, error: 'Ein Fehler ist aufgetreten.' } } } export async function removeUserRole(id: string, orgId: string) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } await prisma.userRole.delete({ where: { id } }) revalidatePath(`/superadmin/organizations/${orgId}`) return { success: true, error: '' } } export async function updateUserRole(id: string, orgId: string, role: string) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } await prisma.userRole.update({ where: { id }, data: { role }, }) revalidatePath(`/superadmin/organizations/${orgId}`) return { success: true, error: '' } } export async function removeMember(id: string, orgId: string) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } await prisma.member.delete({ where: { id } }) revalidatePath(`/superadmin/organizations/${orgId}`) return { success: true, error: '' } } export async function createMember(prevState: any, formData: FormData) { const session = await requireSuperAdmin() if (!session) return { success: false, error: 'Nicht autorisiert.' } try { const rawData = { orgId: formData.get('orgId') as string, name: (formData.get('name') as string).trim(), email: normalizeEmail(formData.get('email') as string), betrieb: (formData.get('betrieb') as string).trim(), sparte: (formData.get('sparte') as string).trim(), ort: (formData.get('ort') as string).trim(), } const validatedData = createMemberSchema.parse(rawData) await prisma.member.create({ data: { orgId: validatedData.orgId, name: validatedData.name, email: validatedData.email, betrieb: validatedData.betrieb, sparte: validatedData.sparte, ort: validatedData.ort, status: 'aktiv', }, }) revalidatePath(`/superadmin/organizations/${validatedData.orgId}`) return { success: true, error: '' } } catch (error) { console.error('Failed to create member:', error) if (error instanceof z.ZodError) { return { success: false, error: error.errors[0].message } } return { success: false, error: 'Ein Fehler ist aufgetreten.' } } }