This commit is contained in:
2026-03-04 14:13:16 +01:00
parent b7d826e29c
commit 56ea3348d6
41 changed files with 846 additions and 162 deletions

View File

@@ -2,7 +2,6 @@
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
import { redirect } from 'next/navigation'
// @ts-ignore
import { hashPassword } from 'better-auth/crypto'
@@ -25,7 +24,6 @@ export async function changePasswordAndDisableMustChange(prevState: any, formDat
}
const userId = session.user.id
const slug = formData.get('slug') as string
// Hash and save new password directly — user is already authenticated so no old password needed
const newHash = await hashPassword(newPassword)
@@ -64,5 +62,9 @@ export async function changePasswordAndDisableMustChange(prevState: any, formDat
// ignore
}
redirect(`/login?message=password_changed&callbackUrl=/${slug}/dashboard`)
return {
success: true,
error: '',
redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`,
}
}

View File

@@ -1,10 +1,17 @@
'use client'
import { useEffect } from 'react'
import { useActionState } from 'react'
import { changePasswordAndDisableMustChange } from '../actions'
export function ForcePasswordChange({ slug }: { slug: string }) {
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '' })
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' })
useEffect(() => {
if (state?.success && state?.redirectTo) {
window.location.href = state.redirectTo
}
}, [state?.success, state?.redirectTo])
return (
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">

View File

@@ -2,6 +2,24 @@ import { prisma } from '@innungsapp/shared'
import { notFound } from 'next/navigation'
import Link from 'next/link'
function jsonToText(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n')
}
return JSON.stringify(value)
}
export default async function TenantLandingPage({
params,
}: {
@@ -26,8 +44,8 @@ export default async function TenantLandingPage({
const secondaryColor = org.secondaryColor || undefined
const title = org.landingPageTitle || org.name || 'Zukunft durch Handwerk'
const text = org.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.'
const features = org.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
const footer = org.landingPageFooter || `© ${new Date().getFullYear()} ${org.name}`
const features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}`
const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`
const buttonText = org.landingPageButtonText || 'Jetzt App laden'

View File

@@ -39,9 +39,32 @@ function getModel(provider: LlmProvider): string {
return process.env.OPENAI_MODEL || 'gpt-4o-mini'
}
function hasApiKey(provider: LlmProvider): boolean {
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
return !!process.env.OPENAI_API_KEY
}
function buildFallbackLandingContent(orgName: string, context: string) {
const cleanOrg = orgName.trim()
const cleanContext = context.trim().replace(/\s+/g, ' ')
const shortContext = cleanContext.slice(0, 180)
const detailSentence = shortContext
? `Dabei stehen insbesondere ${shortContext}.`
: 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.'
return {
title: `${cleanOrg} - Stark im Handwerk`,
text: `${cleanOrg} verbindet Betriebe, stärkt die Gemeinschaft und setzt sich für die Interessen des Handwerks vor Ort ein. ${detailSentence}`,
fallbackUsed: true,
}
}
export async function POST(req: Request) {
let parsedBody: any = null
try {
const body = await req.json()
parsedBody = body
const { orgName, context } = body
if (!orgName || !context) {
@@ -49,9 +72,14 @@ export async function POST(req: Request) {
}
const provider = getProvider()
const client = createClient(provider)
const model = getModel(provider)
if (!hasApiKey(provider)) {
return NextResponse.json(buildFallbackLandingContent(orgName, context))
}
const client = createClient(provider)
const systemMessage = `Sie sind ein professioneller Copywriter für eine moderne deutsche Innung oder Kreishandwerkerschaft.
Erstellen Sie eine moderne, ansprechende Überschrift (Heading) und einen Einleitungstext für eine Landingpage.
@@ -89,6 +117,10 @@ WICHTIG: Geben Sie AUSSCHLIESSLICH ein valides JSON-Objekt zurück, komplett ohn
} catch (error: any) {
console.error('Error generating AI landing page content:', error)
if (parsedBody?.orgName && parsedBody?.context) {
return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context))
}
return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 })
}
}

View File

@@ -1,10 +1,9 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers'
export async function POST() {
const session = await auth.api.getSession({ headers: await headers() })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}

View File

@@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers'
// @ts-ignore
import { hashPassword } from 'better-auth/crypto'
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}

View File

@@ -1,9 +1,9 @@
import { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.api.getSession({ headers: req.headers })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return new Response('Unauthorized', { status: 401 })
}

View File

@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

View File

@@ -1,6 +1,6 @@
/**
* DEV-ONLY: Sets a password for the demo admin user via better-auth.
* Call once after seeding: GET http://localhost:3032/api/setup
* Call once after seeding: GET http://localhost:3010/api/setup
* Remove this file before going to production.
*/
import { NextResponse } from 'next/server'

View File

@@ -2,14 +2,21 @@ import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function POST(req: NextRequest) {
// Auth check
const session = await auth.api.getSession({ headers: req.headers })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
@@ -39,7 +46,7 @@ export async function POST(req: NextRequest) {
const ext = path.extname(file.name)
const fileName = `${randomUUID()}${ext}`
const uploadPath = path.join(process.cwd(), UPLOAD_DIR)
const uploadPath = getUploadRoot()
await mkdir(uploadPath, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())

View File

@@ -2,21 +2,28 @@ import { NextRequest, NextResponse } from 'next/server'
import { readFile } from 'fs/promises'
import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
// Added comment to force recompile after ENOSPC
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: filePathParams } = await params;
const filePath = path.join(process.cwd(), UPLOAD_DIR, ...filePathParams)
const { path: filePathParams } = await params
const uploadRoot = getUploadRoot()
const filePath = path.join(uploadRoot, ...filePathParams)
// Security: prevent path traversal
const resolved = path.resolve(filePath)
const uploadDir = path.resolve(path.join(process.cwd(), UPLOAD_DIR))
if (!resolved.startsWith(uploadDir)) {
const uploadDir = path.resolve(uploadRoot)
if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) {
return new NextResponse('Forbidden', { status: 403 })
}

View File

@@ -1,4 +1,4 @@
import { auth } from '@/lib/auth'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers'
import Link from 'next/link'
@@ -7,7 +7,7 @@ import { redirect } from 'next/navigation'
export default async function GlobalDashboardRedirect() {
const headerList = await headers()
const host = headerList.get('host') || ''
const session = await auth.api.getSession({ headers: headerList })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) })
if (!session?.user) {
redirect('/login')
@@ -93,9 +93,8 @@ export default async function GlobalDashboardRedirect() {
<form action={async () => {
'use server'
const { auth } = await import('@/lib/auth')
const { headers } = await import('next/headers')
await auth.api.signOut({ headers: await headers() })
const { auth, getSanitizedHeaders } = await import('@/lib/auth')
await auth.api.signOut({ headers: await getSanitizedHeaders() })
redirect('/login')
}}>
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">

View File

@@ -6,7 +6,7 @@ import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032'),
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
export default function PasswortAendernPage() {

View File

@@ -86,6 +86,9 @@ export function CreateOrgForm() {
}
const [isHeroUploading, setIsHeroUploading] = useState(false)
const appBaseUrl = (typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010')).replace(/\/$/, '')
const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -182,7 +185,7 @@ export function CreateOrgForm() {
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label>
<input type="text" name="slug" required value={formData.slug} onChange={handleChange} placeholder="z.B. tischler-berlin" pattern="^[a-z0-9\-]+$" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
<p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${formData.slug}.localhost:3032` : 'ihr-slug.localhost:3032'}</span></p>
<p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${appBaseUrl}/${formData.slug}` : `${appBaseUrl}/ihr-slug`}</span></p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Planungs-Modell</label>
@@ -407,8 +410,8 @@ export function CreateOrgForm() {
<div className="bg-[#F8FEFB] p-6 rounded-2xl border border-[#E1F5EA] text-left mb-8">
<p className="text-[10px] font-bold text-[#8CAB99] uppercase tracking-[0.15em] mb-4">Ihre neue Landingpage (Localhost) / Subdomain</p>
<a href={`http://${formData.slug}.localhost:3032`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
{formData.slug}.localhost:3032
<a href={`${appBaseUrl}/${formData.slug}`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
{appBaseUrl}/{formData.slug}
</a>
</div>

View File

@@ -1,10 +1,9 @@
'use server'
import { prisma } from '@innungsapp/shared'
import { auth } from '@/lib/auth'
import { prisma, Prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } 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
@@ -14,6 +13,14 @@ function normalizeEmail(email: string | null | undefined): string {
return (email ?? '').trim().toLowerCase()
}
function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (!value) {
return Prisma.DbNull
}
return value
}
/**
* Sets a credential (email+password) account for a user.
* Uses direct DB write with better-auth's hashPassword for compatibility.
@@ -39,7 +46,7 @@ async function setCredentialPassword(userId: string, password: string) {
async function requireSuperAdmin() {
const session = await auth.api.getSession({ headers: await headers() })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
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
@@ -165,8 +172,8 @@ export async function createOrganization(prevState: any, formData: FormData) {
landingPageHeroImage: validatedData.landingPageHeroImage || null,
// @ts-ignore
landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity,
landingPageFeatures: validatedData.landingPageFeatures || null,
landingPageFooter: validatedData.landingPageFooter || null,
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null,
@@ -221,7 +228,7 @@ export async function createOrganization(prevState: any, formData: FormData) {
adminName: user.name || validatedData.adminEmail.split('@')[0],
orgName: org.name,
password: validatedData.adminPassword,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032',
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
})
} catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden:', emailError)
@@ -276,8 +283,8 @@ export async function updateOrganization(id: string, prevState: any, formData: F
landingPageTitle: validatedData.landingPageTitle || null,
landingPageText: validatedData.landingPageText || null,
landingPageHeroImage: validatedData.landingPageHeroImage || null,
landingPageFeatures: validatedData.landingPageFeatures || null,
landingPageFooter: validatedData.landingPageFooter || null,
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null,
@@ -383,7 +390,7 @@ export async function createAdmin(prevState: any, formData: FormData) {
adminName: validatedData.name,
orgName: org?.name || 'Ihre Innung',
password: validatedData.password,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032',
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
})
} catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError)

View File

@@ -1,5 +1,4 @@
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { redirect } from 'next/navigation'
import Link from 'next/link'
@@ -8,7 +7,7 @@ export default async function SuperAdminLayout({
}: {
children: React.ReactNode
}) {
const session = await auth.api.getSession({ headers: await headers() })
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user) {
redirect('/login')

View File

@@ -3,6 +3,24 @@
import { useActionState, useState } from 'react'
import { updateOrganization } from '../../actions'
function jsonToText(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n')
}
return JSON.stringify(value)
}
interface Props {
org: {
id: string
@@ -18,8 +36,8 @@ interface Props {
landingPageButtonText: string | null
landingPageHeroImage: string | null
landingPageHeroOverlayOpacity: number | null
landingPageFeatures: string | null
landingPageFooter: string | null
landingPageFeatures: unknown
landingPageFooter: unknown
appStoreUrl: string | null
playStoreUrl: string | null
}
@@ -36,19 +54,8 @@ export function EditOrgForm({ org }: Props) {
const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946')
const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF')
let initialFeatures = ''
try {
if (org.landingPageFeatures) {
const parsed = JSON.parse(org.landingPageFeatures)
if (Array.isArray(parsed)) {
initialFeatures = parsed.join('\n')
} else {
initialFeatures = org.landingPageFeatures
}
}
} catch {
initialFeatures = org.landingPageFeatures || ''
}
const initialFeatures = jsonToText(org.landingPageFeatures)
const initialFooter = jsonToText(org.landingPageFooter)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => {
const file = e.target.files?.[0]
@@ -321,7 +328,7 @@ export function EditOrgForm({ org }: Props) {
<label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label>
<textarea
name="landingPageFooter"
defaultValue={org.landingPageFooter ?? ''}
defaultValue={initialFooter}
rows={2}
placeholder="© 2024 Innung. Alle Rechte vorbehalten."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"