stadtwerke/innungsapp/apps/admin/app/superadmin/actions.ts

471 lines
18 KiB
TypeScript

'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(),
landingPageFooter: 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(),
landingPageFooter: 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.' }
}
}