From b871fb935070d4babf5811ceb3bfa47d4f573e32 Mon Sep 17 00:00:00 2001 From: Mathias Hurler Date: Sun, 30 Aug 2026 09:01:02 +0200 Subject: [PATCH] =?UTF-8?q?Node=20Version=20f=C3=BCr=20Coolify/Nixpacks=20?= =?UTF-8?q?festlegen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .nvmrc | 1 + aktuelle TODO.md | 300 ++++++++++++++++++++++++++++++ claude-todo.md | 292 ++++++++++++++++++++++++++++++ src/components/footer.tsx | 9 +- src/components/header.tsx | 4 +- src/routeTree.gen.ts | 42 +++++ src/routes/datenschutz.tsx | 362 +++++++++++++++++++++++++++++++++++++ src/routes/impressum.tsx | 224 +++++++++++++++++++++++ 8 files changed, 1228 insertions(+), 6 deletions(-) create mode 100644 .nvmrc create mode 100644 aktuelle TODO.md create mode 100644 claude-todo.md create mode 100644 src/routes/datenschutz.tsx create mode 100644 src/routes/impressum.tsx diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/aktuelle TODO.md b/aktuelle TODO.md new file mode 100644 index 0000000..fe31449 --- /dev/null +++ b/aktuelle TODO.md @@ -0,0 +1,300 @@ +### Security- und Plausibilitäts-Review: Kundenportal & Authentifizierung + +Hier ist das detaillierte Sicherheits-, Architektur- und Plausibilitäts-Review für das Kundenportal von **webkulisse**. Das Review analysiert den Ist-Zustand (Authentifizierung auf Basis von Better-Auth und TanStack Start / React Router) und liefert konkrete Handlungsempfehlungen, damit künftige Module (**Rechnungsverwaltung** und **Produktabnahmen**) modular, mandantensicher und ohne Spaghetti-Code integriert werden können. + +--- + +### 1. Priorisierte Findings & Sicherheitsrisiken + +--- + +#### 🔴 KRITISCH: Fehlendes Mandanten- & Autorisierungskonzept für Multi-Kunden-Daten (IDOR-Gefahr) + +* **Dateiverweis:** `src/db/schema.ts`, `src/lib/auth.ts`, `src/lib/session.ts` +* **Problem & Risiko:** + Das aktuelle Schema speichert lediglich flache `user`-Einträge mit einer einfachen Rolle (`role: 'user' | 'admin'`). Es gibt keine Mandanten- bzw. Kundenentität (`Organization`, `Customer` oder `Tenant`). + + Wenn Rechnungen und Abnahmen direkt auf `userId` referenzieren: + 1. Kann ein Kunde nicht mehrere Mitarbeiter/Ansprechpartner haben (typischer B2B-Anwendungsfall: Inhaber + Buchhaltung). + 2. Besteht ohne standardisierte Mandanten-Middleware bei kommenden Server-Funktionen ein extremes Risiko für **IDOR (Insecure Direct Object Reference)** – z. B. wenn ein Kunde über manipulierte IDs Rechnungen oder Abnahmen anderer Kunden abrufen oder freigeben kann. +* **Lösungsempfehlung:** + Einführung einer expliziten `customer`/`organization`-Tabelle und einer typsicheren Server-Function-Middleware, die bei jedem Datenzugriff den aktuellen Mandantenkontext prüft. + +```ts +// src/db/schema.ts - Empfohlenes Schema-Fundament +export const customer = pgTable('customer', { + id: text('id').primaryKey(), + name: text('name').notNull(), // Firmenname z. B. 'Praxis Dr. Schmidt' + customerNumber: text('customer_number').notNull().unique(), // z. B. KD-2026-001 + status: text('status').notNull().default('active'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + +export const user = pgTable('user', { + // ... bestehende Better-Auth Felder + customerId: text('customer_id').references(() => customer.id, { onDelete: 'cascade' }), +}) +``` + +--- + +#### 🔴 KRITISCH: Devtools und sensible Debugger-Panels im Produktions-Bundle + +* **Dateiverweis:** `src/routes/__root.tsx` (Zeilen 94–104), `vite.config.ts` (Zeile 14) +* **Problem & Risiko:** + `` und `` werden im `RootDocument` bedingungslos gerendert. Dadurch werden Router-Zustände, Context-Daten und interne Routing-Strukturen im Browser für jeden Besucher sichtbar. +* **Lösungsempfehlung:** + Devtools nur in der Entwicklungsumgebung laden und im Build strippen: + +```tsx +// src/routes/__root.tsx +{process.env.NODE_ENV !== 'production' ? ( + }]} + /> +) : null} +``` + +--- + +#### 🟠 HOCH: Kein Self-Service-Passwort-Reset / Unsichere initiale Passwort-Weitergabe + +* **Dateiverweis:** `src/routes/_admin/admin.users.new.tsx` (Zeilen 86–101), `src/routes/login.tsx` +* **Problem & Risiko:** + 1. Beim Erstellen eines Kunden (`admin.users.new.tsx`) muss der Admin ein Klartext-Passwort vergeben, welches als normales Input-Feld (`type="text"`) sichtbar ist. + 2. Admins müssen dieses Initialpasswort unverschlüsselt (per Mail/Chat) an Kunden weitergeben. + 3. Es gibt im Kunden-Login keinen "Passwort vergessen?"-Flow (`/reset-password`). + 4. Kunden haben keine Möglichkeit, ihr Passwort nach dem Erst-Login selbstständig zu ändern. +* **Lösungsempfehlung:** + 1. **Einladungs-Link / Magic-Link Flow:** Statt manueller Passwörter generiert der Admin einen zeitlich befristeten Aktivierungslink via Better-Auth Invitation/Reset-Token. + 2. Implementierung eines standardisierten `Passwort vergessen`-Flows im `login.tsx`. + +--- + +#### 🟠 HOCH: In-Memory Rate Limiting bei verteilten Deployments + +* **Dateiverweis:** `src/lib/auth.ts` (Zeilen 39–50) +* **Problem & Risiko:** + Das Rate Limiting ist auf `storage: 'memory'` konfiguriert: + ```ts + rateLimit: { + enabled: true, + storage: 'memory', + customRules: { + '/sign-in/email': { window: 60, max: 5 }, + // ... + } + } + ``` + In Serverless-, Container- oder Node-Cluster-Umgebungen (z. B. Nitro / Cloudflare / Node Multi-Worker) teilt sich jeder Worker einen eigenen Speicher. Bei Server-Neustarts oder mehreren Instanzen ist der Brute-Force-Schutz wirkungslos oder leicht zu umgehen. +* **Lösungsempfehlung:** + Anbindung eines persistenten Speichers (z. B. Redis / Upstash / Postgres-basiertes Rate Limiting) oder Absicherung über Cloudflare WAF / Reverse Proxy. + +--- + +#### 🟡 MITTEL: Client-seitiges Data-Fetching mit Wasserfällen im Admin- & Kundenbereich + +* **Dateiverweis:** `src/routes/_admin/admin.index.tsx` (Zeilen 25–40), `src/routes/_admin/admin.users.$userId.tsx` (Zeilen 35–62) +* **Problem & Architektur-Defizit:** + Daten werden per `useEffect` und lokalem `useState` clientseitig nachgeladen ("Lade Kunden ..."). + * Dies widerspricht dem Konzept von TanStack Start / Router (Loader-first SSR). + * Führt zu Layout-Shifts, fehlenden SSR-Vorteilen und erschwert Caching, Revalidierung und granulare Fehlergrenzen (`CatchBoundary`). +* **Lösungsempfehlung:** + Nutzung von `loader` in Kombination mit Server Functions (`createServerFn`): + +```tsx +// src/routes/_admin/admin.index.tsx +export const Route = createFileRoute('/_admin/admin/')({ + loader: async () => await listUsersFn(), + component: AdminUsersPage, +}) + +function AdminUsersPage() { + const users = Route.useLoaderData() + // Kein useEffect / manual loading state nötig +} +``` + +--- + +#### 🟡 MITTEL: Fehlende Sicherheits-Header (CSP, HSTS, X-Frame-Options) + +* **Dateiverweis:** `vite.config.ts`, `src/routes/__root.tsx` +* **Problem & Risiko:** + Es sind keine HTTP-Security-Header konfiguriert. Im `__root.tsx` wird ein Inline-Script (`themeItnitScript`) ohne Nonce ausgeführt. +* **Lösungsempfehlung:** + In Nitro/Vite oder im Webserver (Nginx/Cloudflare) Header setzen: + * `Content-Security-Policy` (CSP) + * `X-Frame-Options: DENY` (Schutz vor Clickjacking) + * `X-Content-Type-Options: nosniff` + * `Referrer-Policy: strict-origin-when-cross-origin` + * `Strict-Transport-Security: max-age=31536000; includeSubDomains` + +--- + +#### 🟢 NIEDRIG: Fehlende Domain-Zentralisierung & Redundante Rollenprüfungen + +* **Dateiverweis:** `src/components/header.tsx` (Zeilen 22–26), `src/routes/login.tsx` (Zeilen 10, 47), `src/components/app-shell.tsx` (Zeile 58) +* **Problem:** + Die Weiterleitungs- und Rollenlogik `user.role === 'admin' ? '/admin' : '/dashboard'` ist redundant über mehrere Komponenten verstreut. +* **Lösungsempfehlung:** + Zentralisierung in einem Auth-Helper, z. B. `getDefaultRedirect(session)` in `src/lib/auth-utils.ts`. + +--- + +### 2. Konkreter Architektur-Blueprint für Rechnungen & Abnahmen + +Um **Spaghetti-Code** und spätere Umbauten an der Auth-Basis zu vermeiden, sollte die Codebasis jetzt modular strukturiert werden. + +#### A. Empfohlene Feature-basierte Verzeichnisstruktur + +```text +src/ +├── db/ +│ ├── schema/ +│ │ ├── auth.ts # user, session, account, verification +│ │ ├── customers.ts # customer / tenant Stammdaten +│ │ ├── projects.ts # Webseiten-Projekte +│ │ ├── invoices.ts # Rechnungen, Posten, Zahlungsstatus +│ │ └── approvals.ts # Produktabnahmen, Feedback-Runden +│ └── index.ts # Drizzle DB Instance & Schema-Export +├── server/ +│ ├── middleware/ +│ │ ├── auth.ts # Authentifizierungs-Guard +│ │ └── tenant.ts # Mandanten-Isolation (Tenant Context) +│ └── services/ # Pure Business Logic (unabhängig von UI) +│ ├── invoice.service.ts +│ └── approval.service.ts +├── routes/ +│ ├── _authenticated/ +│ │ ├── route.tsx # Auth Guard & Portal Layout +│ │ ├── dashboard.tsx # Kunden-Übersicht +│ │ ├── rechnungen/ # Rechnungsmodul (Kunde) +│ │ │ ├── index.tsx +│ │ │ └── $invoiceId.tsx +│ │ └── abnahmen/ # Produktabnahmen (Kunde) +│ │ ├── index.tsx +│ │ └── $approvalId.tsx +│ └── _admin/ +│ ├── admin.tsx # Admin Layout +│ ├── rechnungen/ # Rechnungsverwaltung (Admin) +│ └── kunden/ # Kundenverwaltung +``` + +--- + +#### B. Datenmodell-Erweiterung (`src/db/schema/`) + +```ts +// 1. Projekte (Klammer um Rechnungen und Abnahmen) +export const project = pgTable('project', { + id: text('id').primaryKey(), + customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + status: text('status').notNull().default('in_progress'), // 'in_progress' | 'review' | 'live' + stagingUrl: text('staging_url'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + +// 2. Rechnungsmodul +export const invoice = pgTable('invoice', { + id: text('id').primaryKey(), + customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }), + projectId: text('project_id').references(() => project.id, { onDelete: 'set null' }), + invoiceNumber: text('invoice_number').notNull().unique(), // z. B. RE-2026-0042 + amountGrossInCents: integer('amount_gross_in_cents').notNull(), + taxRate: numeric('tax_rate').notNull().default('19.00'), + status: text('status').notNull().default('open'), // 'open' | 'paid' | 'overdue' | 'cancelled' + pdfStoragePath: text('pdf_storage_path').notNull(), + dueDate: timestamp('due_date').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + +// 3. Produktabnahme-Modul +export const approval = pgTable('approval', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull().references(() => project.id, { onDelete: 'cascade' }), + customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }), + version: text('version').notNull(), // z. B. 'v1.0 - Design-Entwurf' + status: text('status').notNull().default('pending'), // 'pending' | 'approved' | 'changes_requested' + feedback: text('feedback'), + approvedAt: timestamp('approved_at'), + approvedByUserId: text('approved_by_user_id').references(() => user.id), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) +``` + +--- + +#### C. Sichere Server-Function-Middleware (Mandanten-Isolation) + +Mit TanStack Start Middleware wird sichergestellt, dass jede Abfrage automatisch auf den Mandanten des eingeloggten Benutzers eingeschränkt ist: + +```ts +// src/server/middleware/tenant.ts +import { createMiddleware } from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' +import { auth } from '#/lib/auth.ts' + +export const tenantAuthMiddleware = createMiddleware().server(async ({ next }) => { + const request = getRequest() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session || !session.user) { + throw new Error('UNAUTHORIZED') + } + + return next({ + context: { + session, + userId: session.user.id, + role: session.user.role, + // Bei Kunden wird die customerId erzwungen; Admins dürfen filtern + customerId: (session.user as any).customerId as string | null, + isAdmin: session.user.role === 'admin', + }, + }) +}) +``` + +**Verwendung in Server Functions (IDOR-sicher):** + +```ts +// src/server/functions/invoices.ts +import { createServerFn } from '@tanstack/react-start' +import { tenantAuthMiddleware } from '../middleware/tenant' +import { db } from '#/db/index.ts' +import { invoice } from '#/db/schema.ts' +import { eq, and } from 'drizzle-orm' + +export const getInvoicesForCurrentCustomer = createServerFn({ method: 'GET' }) + .middleware([tenantAuthMiddleware]) + .handler(async ({ context }) => { + if (!context.customerId && !context.isAdmin) { + return [] + } + + // Automatisch auf den Mandanten des eingeloggten Nutzers begrenzt! + return await db.query.invoice.findMany({ + where: context.isAdmin + ? undefined + : eq(invoice.customerId, context.customerId!), + orderBy: (inv, { desc }) => [desc(inv.createdAt)], + }) + }) +``` + +--- + +### 3. Zusammenfassung des Handlungsbedarfs + +| Priorität | Bereich | Maßnahme | +| :--- | :--- | :--- | +| **Kritisch** | Architektur | `Customer`/`Tenant`-Entität im DB-Schema ergänzen & `tenantAuthMiddleware` etablieren. | +| **Kritisch** | Sicherheit | Devtools in `__root.tsx` hinter `process.env.NODE_ENV !== 'production'` kapseln. | +| **Hoch** | Auth / UX | Passwort-Reset-Flow & Einladungs-Link für Kunden einrichten (Klartext-Passwortvergabe im Admin ablösen). | +| **Mittel** | Performance / Code | Admin- und Kunden-Views von `useEffect`-Fetching auf TanStack Route Loader umstellen. | +| **Mittel** | Sicherheit | HTTP-Sicherheitsheader (CSP, HSTS, X-Frame-Options) konfigurieren. | + +Mit dieser Vorbereitung ist die Basis optimal gehärtet und Rechnungs- sowie Abnahme-Features können sauber als eigenständige Domain-Module angedockt werden. \ No newline at end of file diff --git a/claude-todo.md b/claude-todo.md new file mode 100644 index 0000000..56df098 --- /dev/null +++ b/claude-todo.md @@ -0,0 +1,292 @@ +# Code-Review: webkulisse-saas (TanStack Start) + +**Stack:** TanStack Start + Router (file-based), React 19 + Compiler, better-auth 1.5 (admin-Plugin), Drizzle + Postgres, Tailwind 4, shadcn (radix-vega), Nitro, Biome +**Stand:** Landingpage + `/termin` + Auth + Admin-Userverwaltung + Dashboard-Platzhalter + +**Gesamteindruck:** Die Grundstruktur ist gut. Route-Groups (`_authenticated`, `_admin`) sind richtig gewählt, Guards sitzen am Layout statt an jeder Seite, better-auth macht die Schwerarbeit statt selbstgebautem Auth, und die Kommentare in `config.ts` / `booking-dialog.tsx` zeigen, dass du über Entscheidungen nachdenkst. Das Lazy-Loading des cal.com-Bundles ist ein sauberer Move. + +Die Probleme liegen weniger im Auth-Code selbst als in drei Bereichen: **Deploy-Reife** (Devtools, Env-Vars, Rechtstexte), **Datenfetching-Muster** (das ist dein Spaghetti-Risiko für Rechnungen/Abnahmen), und **Frontend-Details**, die dem eigenen Marketing-Versprechen widersprechen. + +--- + +## KRITISCH — vor dem Livegang + +### K1. Impressum und Datenschutzerklärung sind tote Links +`src/components/footer.tsx:111,116` — beide `href="#"`. + +§5 DDG (ehem. TMG) Impressumspflicht und DSGVO Art. 13 sind bei einer gewerblichen Seite nicht optional. Abmahnrisiko besteht real. Zusätzlich brisant: Du verkaufst im Klassik-Paket „Rechtstexte-Assistenz" — eine Agenturseite ohne eigenes Impressum untergräbt genau dieses Verkaufsargument. + +Die Datenschutzerklärung braucht außerdem einen Absatz zum cal.com-Embed (`termin.hurler-webdesign.de`). Dass du selbst hostest, ist gut — das erspart dir den US-Drittlandtransfer, aber die iframe-Einbindung und die dort erhobenen Daten gehören trotzdem beschrieben. + +**Fix:** Zwei echte Routen `/impressum` und `/datenschutz` anlegen, im Footer verlinken, beide mit `robots: noindex` ist *nicht* nötig — die sollen indexiert werden. + +### K2. TanStack Devtools laufen in Produktion mit +`src/routes/__root.tsx:94-104` — `` wird unbedingt gerendert. Zusätzlich stehen `@tanstack/react-devtools`, `@tanstack/react-router-devtools` und `drizzle-kit` in `dependencies` statt `devDependencies` (`package.json`). + +Folge: Jeder Besucher lädt das Devtools-Bundle, und der komplette Route-Tree inklusive aller Admin-Pfade ist im Browser inspizierbar. Kein direktes Auth-Leck, aber unnötige Angriffsfläche und deutlich mehr JS auf einer Seite, die mit „unter 1 Sekunde Ladezeit" wirbt. + +**Fix:** +```tsx +{import.meta.env.DEV && } +``` +Plus die drei Pakete nach `devDependencies` verschieben. + +### K3. Keine Fail-Fast-Prüfung der Environment-Variablen +`src/lib/auth.ts:11-15`, `src/db/index.ts:5` + +```ts +baseURL: process.env.BETTER_AUTH_URL, // undefined möglich +secret: process.env.BETTER_AUTH_SECRET, // undefined möglich +trustedOrigins: process.env.BETTER_AUTH_URL ? [...] : undefined, +db = drizzle(process.env.DATABASE_URL!, ...) // Non-Null-Assertion kaschiert den Fehler +``` + +Der `trustedOrigins`-Fallback ist der gefährlichste Teil: Fehlt `BETTER_AUTH_URL` im Deployment, fällt der Origin-Check weg bzw. leitet sich aus Request-Headern ab. Das ist die Basis des CSRF-Schutzes bei better-auth. Ein vergessenes Env-Var in Coolify und die App startet klaglos mit deaktiviertem Schutz. + +**Fix:** Ein `src/lib/env.ts`, das beim Boot validiert und bei fehlenden Werten wirft — kein `!`, kein stiller Fallback: +```ts +function required(name: string): string { + const v = process.env[name] + if (!v) throw new Error(`Fehlende Umgebungsvariable: ${name}`) + return v +} +export const env = { + DATABASE_URL: required('DATABASE_URL'), + BETTER_AUTH_URL: required('BETTER_AUTH_URL'), + BETTER_AUTH_SECRET: required('BETTER_AUTH_SECRET'), +} +``` +Dazu eine `.env.example` ins Repo (fehlt aktuell komplett). + +--- + +## HOCH + +### H1. Passwort-Reset beendet keine Sessions — die UI behauptet das Gegenteil +`src/routes/_admin/admin.users.$userId.tsx:246-256, 270-273` + +Der Text sagt: *„Überschreibt das aktuelle Passwort sofort. Der Kunde muss sich neu anmelden."* — `authClient.admin.setUserPassword()` invalidiert bestehende Sessions aber nicht. Wer schon eingeloggt ist, bleibt es. + +Das ist genau der Fall, der zählt: Kunde meldet „mein Zugang wurde kompromittiert", du setzt ein neues Passwort, und die Session des Angreifers läuft weiter (bis zu 7 Tage, `expiresIn`). + +**Fix:** `revokeUserSessions()` direkt nach `setUserPassword()` mitlaufen lassen — oder die UI-Aussage korrigieren. Ersteres. + +### H2. Guards nur in `beforeLoad` — heute ok, ab Rechnungen nicht mehr +`src/routes/_authenticated.tsx`, `src/routes/_admin.tsx` + +Aktuell sicher, weil *alle* schreibenden Aktionen über das better-auth-Admin-Plugin laufen, das serverseitig selbst prüft. Die `beforeLoad`-Guards sind reines UX-Routing. + +Das kippt in dem Moment, wo du deine erste eigene Server-Function für Rechnungen schreibst. `beforeLoad` läuft bei Client-Navigation im Browser — es ist keine Autorisierung. Wenn `getInvoicesFn()` sich darauf verlässt, dass der Nutzer „ja über `_authenticated` gekommen ist", kann jeder die Server-Function direkt aufrufen. + +**Fix jetzt, nicht später** — das ist die wichtigste Architekturentscheidung im ganzen Review. Leg eine Middleware an, bevor die erste Rechnungs-Function existiert: + +```ts +// src/lib/middleware.ts +import { createMiddleware } from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' +import { auth } from '#/lib/auth.ts' + +export const authMiddleware = createMiddleware({ type: 'function' }) + .server(async ({ next }) => { + const session = await auth.api.getSession({ headers: getRequest().headers }) + if (!session) throw new Error('UNAUTHORIZED') + return next({ context: { session } }) + }) + +export const adminMiddleware = createMiddleware({ type: 'function' }) + .middleware([authMiddleware]) + .server(async ({ next, context }) => { + if (context.session.user.role !== 'admin') throw new Error('FORBIDDEN') + return next({ context }) + }) +``` + +Regel fürs Team-of-one: **Jede** `createServerFn` bekommt eine dieser beiden Middlewares. Keine Ausnahme. Und jede Rechnungsabfrage filtert zusätzlich auf `context.session.user.id` — nicht auf eine ID aus den Function-Parametern, sonst hast du direkt ein IDOR. + +### H3. Kein Passwort-Reset für Kunden, Klartext-Initialpasswörter +`src/routes/_admin/admin.users.new.tsx:89-100`, `src/lib/auth.ts:25-30` + +- Initialpasswort als `type="text"` — steht im Klartext auf dem Bildschirm. Bewusste Entscheidung (du musst es ja weitergeben), aber es landet auch im Browser-Autofill und ggf. in Screenshots. +- Kein Zwang, es nach dem ersten Login zu ändern. Es gilt unbegrenzt. +- Kein `sendResetPassword` konfiguriert → Kunde, der sein Passwort vergisst, muss dich anrufen. +- Gleichzeitig existieren Rate-Limit-Regeln für `/forget-password` und `/reset-password` (`auth.ts:47-48`) — toter Code für Endpunkte, die nicht funktionieren. + +**Fix, in der Reihenfolge:** Kurzfristig einen Passwortgenerator statt Freitextfeld (16 Zeichen, Copy-Button). Mittelfristig `sendResetPassword` mit Mailer konfigurieren und den Einladungs-Flow auf Setup-Link statt Klartext-Passwort umstellen. Dann wird auch das Rate-Limiting oben wieder sinnvoll. + +### H4. Rate-Limiting im Speicher +`src/lib/auth.ts:43` — `storage: 'memory'` + +Setzt sich bei jedem Deploy zurück und wirkt pro Prozess. Bei einem Container auf Coolify ist das mager, aber tolerabel; sobald du skalierst oder häufig deployst, ist der Brute-Force-Schutz (`max: 5` auf `/sign-in/email`) faktisch offen. + +**Fix:** `storage: 'database'` — die Tabelle hast du eh schon, und du brauchst kein Redis dafür. + +### H5. `redirect`-Search-Param wird gesetzt, aber nie ausgewertet +`src/routes/_authenticated.tsx:8`, `src/routes/_admin.tsx:8` schreiben `search: { redirect: location.href }`. `src/routes/login.tsx` liest ihn nie und navigiert immer nach `/dashboard` bzw. `/admin`. + +Zwei Probleme: Deep-Links nach dem Login sind kaputt (Kunde klickt Link zu einer Rechnung, loggt sich ein, landet auf dem Dashboard). Und `/login` hat kein `validateSearch` — der Param ist untypisiert. + +Wenn du ihn später auswertest, unbedingt validieren, sonst hast du einen Open Redirect: +```ts +validateSearch: (search) => ({ + redirect: typeof search.redirect === 'string' && search.redirect.startsWith('/') + ? search.redirect + : undefined, +}) +``` +Nur relative Pfade. `//evil.com` fängt `startsWith('/')` nicht ab — also zusätzlich `!redirect.startsWith('//')`. + +### H6. Login und Dashboard sind für Suchmaschinen freigegeben +`src/routes/__root.tsx:43` setzt global `robots: index,follow`, kein Override in `login.tsx`, `dashboard.tsx` oder den Admin-Routen. + +Der Kundenlogin landet im Google-Index. Zusätzlich erbt jede Route den harten Canonical `https://webkulisse.de/` (`__root.tsx:79`) — `/login` zeigt kanonisch auf die Startseite, was SEO-technisch Unsinn ist. + +**Fix:** In allen Routen unter `_authenticated` / `_admin` sowie auf `/login` ein `head: () => ({ meta: [{ name: 'robots', content: 'noindex,nofollow' }] })`. Canonical aus dem Root entfernen und pro Route setzen — `termin.tsx` macht das schon richtig, das ist das Muster. + +--- + +## MITTEL — Code-Qualität, hier entsteht der Spaghetti-Code + +### M1. Datenfetching per `useEffect` statt Router-Loader +`admin.index.tsx:25-39`, `admin.users.$userId.tsx:35-61` + +Beide Seiten haben handgeschriebenes `useState` für Daten, Loading und Error plus `useEffect` zum Laden. Das ist bei zwei Seiten überschaubar. Bei Rechnungen, Abnahmen, Dokumenten und Nachrichten sind es acht Seiten mit acht Kopien derselben fünf Zeilen — und genau das wolltest du vermeiden. + +TanStack Router hat dafür Loader. Damit bekommst du Prefetching bei Hover (`defaultPreload: 'intent'` hast du schon aktiv), automatische Pending-States, `router.invalidate()` statt manuellem `load()`, und die Daten sind typisiert über `Route.useLoaderData()`. + +```tsx +export const Route = createFileRoute('/_admin/admin/')({ + loader: () => listUsersFn(), + component: AdminUsersPage, + pendingComponent: () =>

Lade Kunden …

, + errorComponent: ({ error }) => , +}) +``` + +Für Mutationen mit Server-State (Rechnungsstatus, Abnahmen) würde ich zusätzlich TanStack Query dazunehmen — steht noch nicht im `package.json`, passt aber nahtlos zum Router. + +### M2. Race Condition beim Laden des Users +`admin.users.$userId.tsx:59-61` — `useEffect(() => { void load() }, [userId])` ohne Abbruch. Wechselt `userId` schnell, kann die ältere Response die neuere überschreiben. Löst sich mit M1 automatisch auf. + +### M3. Fehler beim Sperren werden verschluckt +`admin.index.tsx:41-50` — `toggleBan()` ignoriert das Ergebnis von `banUser`/`unbanUser` komplett. Schlägt der Call fehl, sieht der Admin nichts; erst `load()` zeigt, dass sich nichts geändert hat. Bei einer Sicherheitsfunktion („Kunde sperren") ist stiller Fehlschlag die falsche Wahl. + +### M4. Zwei Quellen der Wahrheit für die Session +`src/components/header.tsx:20` nutzt `authClient.useSession()` (Client-Fetch), während der Router die Session bereits über `__root.tsx:25-28` im Context hat. + +Folge: zusätzlicher Request auf der Landingpage für jeden anonymen Besucher, plus ein Flackern des Buttons von „Kundenlogin" zu „Zum Dashboard" nach der Hydration. + +**Fix:** `useRouteContext({ from: '__root__' })` und die vorhandene Session nutzen. + +### M5. Zwei Import-Aliase parallel +`#/` (29 Stellen, definiert in `package.json` imports) und `@/` (14 Stellen, definiert in `tsconfig.json` paths). Teils in derselben Datei-Ebene: `login.tsx` nutzt `#/`, `termin.tsx` nutzt `@/`. `components.json` ist auf `#/` konfiguriert, neue shadcn-Komponenten kommen also mit `#/`. + +**Fix:** Auf `#/` vereinheitlichen (das ist der Node-Standard und passt zu shadcn), `@/*` aus der tsconfig entfernen, dann fängt TypeScript Rückfälle ab. + +### M6. Schema-Drift: `todos`-Tabelle +`drizzle/0000_majestic_hammerhead.sql:38-42` legt eine `todos`-Tabelle an, die in `src/db/schema.ts` nicht existiert. Überbleibsel aus dem CTA-Template. Ein `db:generate` erzeugt jetzt eine Drop-Migration — oder du hast eine Waisen-Tabelle in Produktion. + +Nebenbei: `todos.json` steht im `.gitignore`, gehört zum selben Rest. + +### M7. Seed-Script ist gitignored +`.gitignore` enthält `scripts`, `package.json` referenziert `tsx scripts/seed-admin.ts` als `db:seed`. Auf einem frischen Clone existiert das Script nicht — du kannst keinen initialen Admin anlegen, und es gibt keinen anderen Weg (`disableSignUp: true`). + +**Fix:** `scripts` aus `.gitignore` raus (falls das Secrets enthält: Secrets über Env-Vars, nicht über gitignorierte Scripts). + +### M8. Keine Error- und NotFound-Boundaries +Nirgends `errorComponent`, `notFoundComponent` oder `defaultNotFoundComponent`. Ein Fehler in einem Loader zeigt aktuell den nackten TanStack-Default. Mindestens im Root definieren. + +### M9. `account.issuer` ist NOT NULL +`schema.ts:35`. Bitte prüfen, ob better-auth 1.5 dieses Feld bei reinen Credential-Accounts (E-Mail/Passwort) immer befüllt — falls nicht, schlägt `createUser` fehl. Da du aktuell keine OAuth-Provider hast, ist das der einzige Pfad, der Accounts anlegt. Falls es funktioniert: gut, dann nur zur Kenntnis. Falls nicht: `.notNull()` entfernen. + +### M10. Fehlende Indizes +`session.user_id` und `account.user_id` haben Foreign Keys, aber keine Indizes. Bei jedem `revokeUserSessions` und jedem Login-Lookup ein Full Scan. Aktuell bei einer Handvoll Kunden irrelevant, aber ein Zweizeiler: +```ts +index('session_user_id_idx').on(table.userId) +``` + +--- + +## DESIGN & FRONTEND + +### D1. Deine Hausschrift lädt nicht +`src/styles.css:9-14`: +```css +src: url('/public/fonts/VendSans-VariableFont_wght.woff2'); +``` +Der `public/`-Ordner *ist* der Web-Root. Der korrekte Pfad ist `/fonts/VendSans-VariableFont_wght.woff2`. Aktuell: 404, `--font-sans` fällt auf System-Sans zurück. Die gesamte Seite rendert also in einer anderen Schrift als du designt hast. + +Dazu fehlt `font-display: swap` — ohne das gibt es einen unsichtbaren Text-Blitz beim Laden. + +### D2. Ungenutzte Schriften und Bilder — 6 MB im `public/` +- `nunito-sans` wird in `styles.css:5` importiert, aber nirgends verwendet (`--font-heading` nutzt Public Sans). +- `AlegreyaSC-Bold.woff2` (104 KB), `SourceCodePro` (88 KB), `SourceSerif4` (416 KB) liegen ungenutzt im `public/fonts/`. +- Vier von fünf Bildern sind ungenutzt: `desk-light.webp` (**2,0 MB**), `wood-desk-clean-dark.webp` (1,3 MB), `desk-red-orange.webp` (792 KB), `desk-clean-dark.webp` (488 KB). + +Nitro kopiert `public/` unverändert ins Deployment. Du schleppst also ~5 MB toten Ballast mit. + +### D3. Placeholder-Bilder in Produktion, während echte Assets ungenutzt danebenliegen +`hero.tsx:58` und `footer.tsx:50` laden von `https://placehold.co`. Das ist im Hero-Bereich — das Erste, was ein Besucher sieht — und obendrein eine externe Abhängigkeit, die dir die Ladezeit versaut und datenschutzrechtlich in die Erklärung müsste. + +Gleichzeitig hast du fünf passende Schreibtisch-Fotos im Projekt liegen, die keiner nutzt. Das wirkt wie ein reiner Vergessens-Fehler. + +### D4. Bilder ohne responsive Auslieferung +`features.tsx` lädt `laptop-planning.webp` (684 KB) mit `width="5184" height="3456"`, angezeigt wird es maximal 1152 px breit. Kein `srcset`, kein `sizes`. Auf dem Smartphone lädt jemand ein 5K-Bild für eine 400px-Anzeige. + +Außerdem: `logo.png` ist 228 KB für eine 40×40-Darstellung — und dient gleichzeitig als Favicon *und* als Open-Graph-Bild. + +Das alles steht direkt gegen dein zentrales Verkaufsversprechen („Unter 1 Sekunde Ladezeit", `hero.tsx:6`). Die eigene Seite ist das Portfolio-Stück — hier sollte ein Lighthouse-Run bei 100 landen. + +### D5. Header bricht außerhalb der Startseite +`src/components/header.tsx`: +- Zeile 53: Logo ist `` statt ``. Auf `/termin` führt ein Klick aufs Logo nirgendwohin. +- Zeilen 9-14: Alle Nav-Links sind Anker (`#zielgruppen`, `#pricing` …). Auf `/termin` existieren diese Anker nicht — die komplette Hauptnavigation ist dort tot. + +**Fix:** `` statt roher Anker, dann funktioniert es von jeder Route aus. + +### D6. Open Graph unbrauchbar +`__root.tsx:60,69` — `og:image` ist `/img/logo.png`, ein relativer Pfad. Open Graph verlangt absolute URLs; Facebook, LinkedIn und WhatsApp zeigen also gar kein Bild. Zusätzlich ist ein Logo im falschen Format — 1200×630 wäre richtig. + +### D7. Kleinigkeiten +- `styles.css:2-3`: `tw-animate-css` wird zweimal importiert (einmal mit einfachen, einmal mit doppelten Anführungszeichen). +- `__root.tsx:13`: Variable heißt `themeItnitScript` (Tippfehler „Itnit"). +- `header.tsx:100`: `aria-label="Menü öffnen"` bleibt statisch, auch wenn das Menü offen ist. Screenreader-Nutzer bekommen die falsche Ansage. +- `.rise-in` (`styles.css:336`) hat keinen `prefers-reduced-motion`-Fallback. +- Kein `robots.txt`, keine `sitemap.xml`. +- `admin.index.tsx:82`: Tabelle ohne `` und ohne `scope="col"` an den Headern. +- `admin.users.$userId.tsx:429`: natives `confirm()` fürs Löschen. Funktioniert, passt aber stilistisch nicht zum Rest — du hast bereits einen Dialog in `components/ui/dialog.tsx`. + +--- + +## PLAUSIBILITÄT / INHALT + +### P1. Platzhalter-Telefonnummer +`footer.tsx:32,92` — `+49 176 22222222`. Steht zweimal auf der Seite als Kontaktweg. + +### P2. Uneinheitliche Marke +Die Seite heißt „webkulisse", die Domain ist `webkulisse.de`, die Anbieterangabe im Footer lautet „Mathias Hurler Webdesign", und der Buchungskalender liegt auf `termin.hurler-webdesign.de`. Der Besucher sieht beim Klick auf „Erstgespräch buchen" also plötzlich eine fremde Domain. Rechtlich in Ordnung (Anbieterangabe muss der echte Name sein), aber für das Vertrauen im Buchungsmoment ungünstig. Ein CNAME auf `termin.webkulisse.de` löst das. + +### P3. Werbeaussagen gegen den eigenen Code prüfen +`features.tsx:11-13`: *„Kein Content-Management-System, keine Plugins, keine Datenbank."* — Das gilt fürs Kompakt-Paket, nicht für „Ihre neue Webapp" (das hat laut `pricing.tsx:54-56` Login und Kundenportal, also zwangsläufig eine Datenbank). Deine eigene Seite ist das beste Gegenbeispiel. Ich würde die Formulierung an den Paket-Kontext binden, sonst widerspricht sich die Seite selbst. + +Ebenso `pricing.tsx:24`: Das Kompakt-Paket verspricht ein Kontaktformular. Bei einer Seite „ohne Datenbank" braucht das einen Mail-Service — nur zur Sicherheit, dass der Aufwand eingepreist ist. + +### P4. Showcase ist leer +`showcase.tsx` zeigt drei „In Kürze"-Kacheln. Auf einer Agenturseite ist der Referenz-Bereich das stärkste Verkaufsargument; leer wirkt er schwächer als gar nicht vorhanden. Solange nichts da ist, würde ich den Abschnitt ausblenden statt Platzhalter zu zeigen. + +--- + +## Empfohlene Reihenfolge + +**Vor dem Livegang:** K1 (Rechtstexte), K2 (Devtools), K3 (Env-Validierung), H6 (noindex), D1 (Font-Pfad), D3 (Placeholder-Bilder), P1 (Telefonnummer) + +**Direkt danach:** H1 (Session-Revoke), H4 (Rate-Limit in DB), H5 (Redirect), M6/M7 (Schema-Drift, Seed-Script), D2/D4 (Assets) + +**Bevor die erste Rechnungs-Funktion entsteht:** H2 (Auth-Middleware), M1 (Loader-Muster), M5 (Alias vereinheitlichen), M8 (Error-Boundaries) + +H2 und M1 sind die beiden, die über Spaghetti-Code entscheiden. Alles andere ist reparierbar; ein Datenzugriffs-Muster, das sich über zwanzig Dateien verteilt hat, nicht mehr ohne größeren Umbau. + +--- + +## Wenn du das an Junie gibst + +Der Teil, den Junie am besten übernehmen kann, ist der mechanische: M5 (Alias-Vereinheitlichung über alle Dateien), M1 (Umbau auf Loader), D7 (Kleinigkeiten), M3/M2 (Fehlerbehandlung). Bei H2 würde ich die Middleware selbst schreiben und Junie nur die Anwendung auf neue Server-Functions überlassen — das ist die Stelle, an der du verstehen willst, was passiert. \ No newline at end of file diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 921efb3..6cd3cf0 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -1,4 +1,5 @@ import { ArrowRight, Mail, MapPin, Phone } from "lucide-react"; +import { Link } from "@tanstack/react-router"; import { Button } from "@/components/ui/button.tsx"; import { BookingDialog } from "@/components/booking-dialog.tsx"; @@ -108,14 +109,14 @@ export const Footer = () => {

© {year} webkulisse.de · Alle Rechte vorbehalten.

diff --git a/src/components/header.tsx b/src/components/header.tsx index 28dcaf9..6877f1d 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -50,7 +50,7 @@ export default function Header() { ].join(" ")} >
- + Logo Webkulisse webkulisse - +