Postgres
This commit is contained in:
@@ -79,6 +79,10 @@ COPY --from=builder /app/apps/admin/public ./apps/admin/public
|
||||
# Copy Prisma schema + migrations for runtime migrations
|
||||
COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma
|
||||
|
||||
# Copy Prisma Client package for runtime seed scripts.
|
||||
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma ./node_modules/@prisma
|
||||
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Copy Prisma Engine binaries directly to .next/server (where Next.js looks for them)
|
||||
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/libquery_engine-debian-openssl-3.0.x.so.node /app/apps/admin/.next/server/
|
||||
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/schema.prisma /app/apps/admin/.next/server/
|
||||
@@ -89,9 +93,6 @@ RUN npm install -g prisma@5.22.0
|
||||
# Create uploads directory
|
||||
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||
|
||||
# Create SQLite data directory
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
# Copy entrypoint
|
||||
COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
RUN chmod +x ./docker-entrypoint.sh
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@ const authClient = createAuthClient({
|
||||
// Keep auth requests on the current origin (important for tenant subdomains).
|
||||
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'),
|
||||
})
|
||||
|
||||
interface LoginFormProps {
|
||||
@@ -52,7 +52,25 @@ export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
|
||||
// mustChangePassword is handled by the dashboard ForcePasswordChange component
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const callbackUrl = params.get('callbackUrl')
|
||||
window.location.href = callbackUrl || '/dashboard'
|
||||
|
||||
let target = '/dashboard'
|
||||
if (callbackUrl?.startsWith('/')) {
|
||||
target = callbackUrl
|
||||
|
||||
// Normalize stale tenant-prefixed callback URLs like /test/dashboard
|
||||
// when already on the tenant subdomain test.localhost.
|
||||
const hostname = window.location.hostname
|
||||
const parts = hostname.split('.')
|
||||
const isTenantSubdomain =
|
||||
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
|
||||
const tenantSlug = isTenantSubdomain ? parts[0] : null
|
||||
|
||||
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
|
||||
target = target.slice(tenantSlug.length + 1) || '/dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = target
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ const authClient = createAuthClient({
|
||||
// Keep auth requests on the current origin (important for tenant subdomains).
|
||||
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'),
|
||||
})
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Keep DATABASE_URL consistent for every Prisma command
|
||||
export DATABASE_URL="${DATABASE_URL:-file:/app/data/prod.db}"
|
||||
export DATABASE_URL="${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
|
||||
MIGRATIONS_DIR="./packages/shared/prisma/migrations"
|
||||
|
||||
# Debug: Check environment variables
|
||||
@@ -22,14 +22,34 @@ echo "NODE_ENV: ${NODE_ENV:-[not set]}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
run_with_retries() {
|
||||
attempt=1
|
||||
max_attempts=20
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
echo "Command failed after ${max_attempts} attempts."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Database not ready yet. Retry ${attempt}/${max_attempts} in 3s..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
# Prefer migration-based deploys. Fall back to db push when no migrations exist yet.
|
||||
set -- "$MIGRATIONS_DIR"/*/migration.sql
|
||||
if [ -f "$1" ]; then
|
||||
echo "Applying Prisma migrations..."
|
||||
npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
|
||||
run_with_retries npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
|
||||
else
|
||||
echo "No Prisma migrations found. Syncing schema with db push..."
|
||||
npx prisma db push --skip-generate --schema=./packages/shared/prisma/schema.prisma
|
||||
run_with_retries npx prisma db push --skip-generate --schema=./packages/shared/prisma/schema.prisma
|
||||
fi
|
||||
|
||||
echo "Starting Next.js server..."
|
||||
|
||||
@@ -9,7 +9,7 @@ import { headers } from 'next/headers'
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: 'sqlite',
|
||||
provider: 'postgresql',
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
@@ -17,13 +17,13 @@ export const auth = betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL!,
|
||||
trustedOrigins: [
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032',
|
||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032',
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010',
|
||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3010',
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3032',
|
||||
'http://localhost:3010',
|
||||
'http://localhost:8081',
|
||||
'http://*.localhost:3032',
|
||||
'http://*.localhost:3010',
|
||||
'http://*.localhost:3000',
|
||||
'https://*.innungsapp.de',
|
||||
'https://*.innungsapp.com',
|
||||
@@ -55,17 +55,17 @@ export const auth = betterAuth({
|
||||
|
||||
export type Auth = typeof auth
|
||||
|
||||
export async function getSanitizedHeaders() {
|
||||
const allHeaders = await headers()
|
||||
const sanitizedHeaders = new Headers(allHeaders)
|
||||
export async function getSanitizedHeaders(sourceHeaders?: HeadersInit) {
|
||||
const baseHeaders = sourceHeaders ? new Headers(sourceHeaders) : new Headers(await headers())
|
||||
const sanitizedHeaders = new Headers(baseHeaders)
|
||||
|
||||
// Avoid ENOTFOUND by forcing host to localhost for internal better-auth fetches
|
||||
// We use the host defined in BETTER_AUTH_URL
|
||||
try {
|
||||
const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3032')
|
||||
const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3010')
|
||||
sanitizedHeaders.set('host', betterAuthUrl.host)
|
||||
} catch (e) {
|
||||
sanitizedHeaders.set('host', 'localhost:3032')
|
||||
sanitizedHeaders.set('host', 'localhost:3010')
|
||||
}
|
||||
|
||||
return sanitizedHeaders
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim()
|
||||
const SMTP_HOST_IS_PLACEHOLDER = SMTP_HOST === '' || SMTP_HOST.toLowerCase() === 'smtp.example.com'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
host: SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth:
|
||||
@@ -10,6 +13,16 @@ const transporter = nodemailer.createTransport({
|
||||
: undefined,
|
||||
})
|
||||
|
||||
async function sendMailOrSkip(mailOptions: any, emailType: string) {
|
||||
if (SMTP_HOST_IS_PLACEHOLDER) {
|
||||
const target = typeof mailOptions?.to === 'string' ? mailOptions.to : 'unknown-recipient'
|
||||
console.warn(`[email] SMTP not configured. Skipping ${emailType} email to ${target}.`)
|
||||
return
|
||||
}
|
||||
|
||||
await transporter.sendMail(mailOptions)
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
to,
|
||||
magicUrl,
|
||||
@@ -17,7 +30,7 @@ export async function sendMagicLinkEmail({
|
||||
to: string
|
||||
magicUrl: string
|
||||
}) {
|
||||
await transporter.sendMail({
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: 'Ihr Login-Link für InnungsApp',
|
||||
@@ -44,7 +57,7 @@ export async function sendMagicLinkEmail({
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}, 'magic link')
|
||||
}
|
||||
|
||||
export async function sendInviteEmail({
|
||||
@@ -61,7 +74,7 @@ export async function sendInviteEmail({
|
||||
// Generate magic link for the invite
|
||||
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
|
||||
|
||||
await transporter.sendMail({
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Einladung zur InnungsApp — ${orgName}`,
|
||||
@@ -86,7 +99,7 @@ export async function sendInviteEmail({
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}, 'invite')
|
||||
}
|
||||
|
||||
export async function sendAdminCredentialsEmail({
|
||||
@@ -102,7 +115,7 @@ export async function sendAdminCredentialsEmail({
|
||||
password: string
|
||||
loginUrl: string
|
||||
}) {
|
||||
await transporter.sendMail({
|
||||
await sendMailOrSkip({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Admin-Zugang für — ${orgName}`,
|
||||
@@ -134,5 +147,5 @@ export async function sendAdminCredentialsEmail({
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}, 'admin credentials')
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NextRequest } from 'next/server'
|
||||
const PUBLIC_PREFIXES = [
|
||||
'/login',
|
||||
'/api/auth',
|
||||
'/api/health',
|
||||
'/api/trpc/stellen.listPublic',
|
||||
'/api/setup',
|
||||
'/registrierung',
|
||||
@@ -11,6 +12,7 @@ const PUBLIC_PREFIXES = [
|
||||
'/datenschutz',
|
||||
]
|
||||
const PUBLIC_EXACT_PATHS = ['/']
|
||||
const TENANT_SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
|
||||
|
||||
// Reserved subdomains that shouldn't be treated as tenant slugs
|
||||
const RESERVED_SUBDOMAINS = [
|
||||
@@ -40,6 +42,19 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize stale tenant-prefixed shared paths like /test/login to /login
|
||||
// before auth checks, otherwise callbackUrl can get stuck on /test/login.
|
||||
if (slug) {
|
||||
for (const sharedPath of TENANT_SHARED_PATHS) {
|
||||
const prefixedPath = `/${slug}${sharedPath}`
|
||||
if (pathname === prefixedPath || pathname.startsWith(`${prefixedPath}/`)) {
|
||||
const canonicalUrl = request.nextUrl.clone()
|
||||
canonicalUrl.pathname = pathname.replace(prefixedPath, sharedPath)
|
||||
return NextResponse.redirect(canonicalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow static files from /public
|
||||
const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api')
|
||||
const isPublic =
|
||||
@@ -62,8 +77,7 @@ export function middleware(request: NextRequest) {
|
||||
if (slug) {
|
||||
// Paths that should not be rewritten into the slug folder
|
||||
// because they are shared across the entire app
|
||||
const SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
|
||||
const isSharedPath = SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
|
||||
const isSharedPath = TENANT_SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
|
||||
pathname.startsWith('/_next') ||
|
||||
/\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function createContext({ req }: FetchCreateContextFnOptions) {
|
||||
const session = await auth.api.getSession({ headers: req.headers })
|
||||
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
|
||||
return {
|
||||
req,
|
||||
session,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { sendInviteEmail, sendAdminCredentialsEmail } from '@/lib/email'
|
||||
import crypto from 'node:crypto'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
@@ -51,7 +50,8 @@ const nonEmptyString = (min = 2) =>
|
||||
const MemberInput = z.object({
|
||||
name: z.string().min(2),
|
||||
betrieb: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
|
||||
sparte: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
|
||||
// Member.sparte is required in Prisma; map "not selected" to a safe default.
|
||||
sparte: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional().default('Sonstiges')),
|
||||
ort: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
|
||||
telefon: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
@@ -216,14 +216,17 @@ export const membersRouter = router({
|
||||
|
||||
// 1. Create the member record
|
||||
const member = await ctx.prisma.member.create({
|
||||
data: { ...rest, orgId: ctx.orgId } as any,
|
||||
data: {
|
||||
...rest,
|
||||
sparte: rest.sparte || 'Sonstiges',
|
||||
orgId: ctx.orgId,
|
||||
} as any,
|
||||
})
|
||||
|
||||
// 2. Create a User account if a password was provided OR role is 'admin',
|
||||
// so the role is always persisted (no email sent here).
|
||||
if (password || role === 'admin') {
|
||||
try {
|
||||
const authHeaders = await getSanitizedHeaders()
|
||||
const existing = await ctx.prisma.user.findUnique({ where: { email: input.email } })
|
||||
let userId: string | undefined = existing?.id
|
||||
const effectivePassword = password || crypto.randomBytes(8).toString('hex')
|
||||
@@ -279,11 +282,14 @@ export const membersRouter = router({
|
||||
.input(MemberInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { role, password, ...memberData } = input
|
||||
const authHeaders = await getSanitizedHeaders()
|
||||
|
||||
// 1. Create member record
|
||||
const member = await ctx.prisma.member.create({
|
||||
data: { ...memberData, orgId: ctx.orgId } as any,
|
||||
data: {
|
||||
...memberData,
|
||||
sparte: memberData.sparte || 'Sonstiges',
|
||||
orgId: ctx.orgId,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user