Files
webkulisse-saas-tanstack/claude-todo.md
T

20 KiB
Raw Blame History

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

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<TanStackDevtools> 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:

{import.meta.env.DEV && <TanStackDevtools ... />}

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

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:

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:

// 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:43storage: '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:

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().

export const Route = createFileRoute('/_admin/admin/')({
  loader: () => listUsersFn(),
  component: AdminUsersPage,
  pendingComponent: () => <p>Lade Kunden </p>,
  errorComponent: ({ error }) => <ErrorBox error={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-61useEffect(() => { 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-50toggleBan() 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:

index('session_user_id_idx').on(table.userId)

DESIGN & FRONTEND

D1. Deine Hausschrift lädt nicht

src/styles.css:9-14:

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 <a href="#"> statt <Link to="/">. 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: <Link to="/" hash="pricing"> statt roher Anker, dann funktioniert es von jeder Route aus.

D6. Open Graph unbrauchbar

__root.tsx:60,69og: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 <caption> 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.