469 lines
17 KiB
TypeScript
469 lines
17 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(),
|
|
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.' }
|
|
}
|
|
}
|