Node Version für Coolify/Nixpacks festlegen

This commit is contained in:
2026-08-30 09:01:02 +02:00
parent 625d026f59
commit b871fb9350
8 changed files with 1228 additions and 6 deletions
+1
View File
@@ -0,0 +1 @@
24
+300
View File
@@ -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 94104), `vite.config.ts` (Zeile 14)
* **Problem & Risiko:**
`<TanStackDevtools />` und `<TanStackRouterDevtoolsPanel />` 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' ? (
<TanStackDevtools
config={{ position: 'bottom-right' }}
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
/>
) : null}
```
---
#### 🟠 HOCH: Kein Self-Service-Passwort-Reset / Unsichere initiale Passwort-Weitergabe
* **Dateiverweis:** `src/routes/_admin/admin.users.new.tsx` (Zeilen 86101), `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 3950)
* **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 2540), `src/routes/_admin/admin.users.$userId.tsx` (Zeilen 3562)
* **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 2226), `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.
+292
View File
@@ -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``<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:**
```tsx
{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`
```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: () => <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-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 `<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,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 `<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.
+5 -4
View File
@@ -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 = () => {
<p>© {year} webkulisse.de · Alle Rechte vorbehalten.</p>
<ul className="flex gap-4">
<li>
<a href="#" className="no-underline hover:underline">
<Link to="/impressum" className="no-underline hover:underline">
Impressum
</a>
</Link>
</li>
<li>
<a href="#" className="no-underline hover:underline">
<Link to="/datenschutz" className="no-underline hover:underline">
Datenschutz
</a>
</Link>
</li>
</ul>
</div>
+2 -2
View File
@@ -50,7 +50,7 @@ export default function Header() {
].join(" ")}
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 md:px-6">
<a href="#" aria-label="Startseite" className="flex items-center gap-2 no-underline">
<Link to="/" aria-label="Startseite" className="flex items-center gap-2 no-underline">
<img
alt="Logo Webkulisse"
height="520"
@@ -61,7 +61,7 @@ export default function Header() {
<span className="hidden font-heading text-lg font-bold tracking-tight sm:inline">
webkulisse
</span>
</a>
</Link>
<nav aria-label="Hauptnavigation" className="hidden md:block">
<ul className="flex items-center gap-1">
+42
View File
@@ -12,6 +12,8 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminRouteImport } from './routes/_admin'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as DatenschutzRouteImport } from './routes/datenschutz'
import { Route as ImpressumRouteImport } from './routes/impressum'
import { Route as LoginRouteImport } from './routes/login'
import { Route as TerminRouteImport } from './routes/termin'
import { Route as AdminAdminRouteImport } from './routes/_admin/admin'
@@ -34,6 +36,16 @@ const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const DatenschutzRoute = DatenschutzRouteImport.update({
id: '/datenschutz',
path: '/datenschutz',
getParentRoute: () => rootRouteImport,
} as any)
const ImpressumRoute = ImpressumRouteImport.update({
id: '/impressum',
path: '/impressum',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -77,6 +89,8 @@ const AdminAdminUsersNewRoute = AdminAdminUsersNewRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/datenschutz': typeof DatenschutzRoute
'/impressum': typeof ImpressumRoute
'/login': typeof LoginRoute
'/termin': typeof TerminRoute
'/admin': typeof AdminAdminRouteWithChildren
@@ -88,6 +102,8 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/datenschutz': typeof DatenschutzRoute
'/impressum': typeof ImpressumRoute
'/login': typeof LoginRoute
'/termin': typeof TerminRoute
'/dashboard': typeof AuthenticatedDashboardRoute
@@ -101,6 +117,8 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/_admin': typeof AdminRouteWithChildren
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/datenschutz': typeof DatenschutzRoute
'/impressum': typeof ImpressumRoute
'/login': typeof LoginRoute
'/termin': typeof TerminRoute
'/_admin/admin': typeof AdminAdminRouteWithChildren
@@ -114,6 +132,8 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/datenschutz'
| '/impressum'
| '/login'
| '/termin'
| '/admin'
@@ -125,6 +145,8 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/datenschutz'
| '/impressum'
| '/login'
| '/termin'
| '/dashboard'
@@ -137,6 +159,8 @@ export interface FileRouteTypes {
| '/'
| '/_admin'
| '/_authenticated'
| '/datenschutz'
| '/impressum'
| '/login'
| '/termin'
| '/_admin/admin'
@@ -151,6 +175,8 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRouteWithChildren
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
DatenschutzRoute: typeof DatenschutzRoute
ImpressumRoute: typeof ImpressumRoute
LoginRoute: typeof LoginRoute
TerminRoute: typeof TerminRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
@@ -179,6 +205,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/datenschutz': {
id: '/datenschutz'
path: '/datenschutz'
fullPath: '/datenschutz'
preLoaderRoute: typeof DatenschutzRouteImport
parentRoute: typeof rootRouteImport
}
'/impressum': {
id: '/impressum'
path: '/impressum'
fullPath: '/impressum'
preLoaderRoute: typeof ImpressumRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -280,6 +320,8 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRouteWithChildren,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
DatenschutzRoute: DatenschutzRoute,
ImpressumRoute: ImpressumRoute,
LoginRoute: LoginRoute,
TerminRoute: TerminRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
+362
View File
@@ -0,0 +1,362 @@
import { createFileRoute } from "@tanstack/react-router";
import Header from "@/components/header.tsx";
import { Footer } from "@/components/footer.tsx";
export const Route = createFileRoute("/datenschutz")({
component: DatenschutzPage,
head: () => ({
meta: [
{ title: "Datenschutzerklärung - webkulisse" },
{
name: "description",
content:
"Informationen zur Verarbeitung personenbezogener Daten nach Art. 13 DSGVO auf webkulisse.de.",
},
],
links: [{ rel: "canonical", href: "https://webkulisse.de/datenschutz" }],
}),
});
function DatenschutzPage() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<section className="mx-auto max-w-3xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<span className="island-kicker">Rechtliches</span>
<h1 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Datenschutzerklärung
</h1>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Diese Erklärung informiert Sie über Art, Umfang und Zweck der
Verarbeitung personenbezogener Daten auf webkulisse.de gemäß
Art. 13 und 14 DSGVO sowie über Ihre Rechte.
</p>
</section>
<section className="mx-auto max-w-3xl px-4 pb-16 md:px-6 md:pb-24">
<div className="island-shell rounded-3xl p-6 sm:p-10">
<div className="space-y-10 text-(--sea-ink)">
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
1. Verantwortlicher
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Verantwortlich im Sinne der Datenschutz-Grundverordnung
(DSGVO) und anderer nationaler Datenschutzgesetze der
Mitgliedstaaten sowie sonstiger datenschutzrechtlicher
Bestimmungen ist:
</p>
<address className="not-italic leading-relaxed">
Hurler Webdesign
<br />
Mathias Hurler
<br />
Untermagerbein 30
<br />
86751 Mönchsdeggingen
<br />
Deutschland
<br />
E-Mail:{" "}
<a
href="mailto:info@webkulisse.de"
className="underline underline-offset-2"
>
info@webkulisse.de
</a>
</address>
<p className="leading-relaxed text-(--sea-ink-soft)">
Ein Datenschutzbeauftragter ist gesetzlich nicht
erforderlich (Art. 37 DSGVO, § 38 BDSG).
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
2. Ihre Rechte als betroffene Person
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Sie haben uns gegenüber jederzeit die folgenden Rechte
hinsichtlich der Sie betreffenden personenbezogenen Daten:
</p>
<ul className="list-disc space-y-1 pl-6 leading-relaxed text-(--sea-ink-soft)">
<li>Recht auf Auskunft (Art. 15 DSGVO)</li>
<li>Recht auf Berichtigung (Art. 16 DSGVO)</li>
<li>
Recht auf Löschung, sofern gesetzliche
Aufbewahrungspflichten dem nicht entgegenstehen
(Art. 17 DSGVO)
</li>
<li>
Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO)
</li>
<li>Recht auf Datenübertragbarkeit (Art. 20 DSGVO)</li>
<li>Recht auf Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)</li>
<li>
Recht auf Widerruf einer erteilten Einwilligung mit
Wirkung für die Zukunft (Art. 7 Abs. 3 DSGVO)
</li>
</ul>
<p className="leading-relaxed text-(--sea-ink-soft)">
Zur Wahrnehmung Ihrer Rechte genügt eine formlose
Nachricht an die oben genannte E-Mail-Adresse.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Sie haben zudem das Recht, sich bei einer
Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77
DSGVO). Zuständig ist für uns das Bayerische Landesamt
für Datenschutzaufsicht (BayLDA), Promenade 18, 91522
Ansbach.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
3. Server-Log-Dateien
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Beim Aufruf unserer Website übermittelt Ihr Browser
technisch bedingt Daten an unseren Server. Diese werden
kurzfristig in sogenannten Log-Dateien gespeichert.
Folgende Informationen werden dabei bis zur automatisierten
Löschung erfasst:
</p>
<ul className="list-disc space-y-1 pl-6 leading-relaxed text-(--sea-ink-soft)">
<li>anonymisierte IP-Adresse des anfragenden Rechners</li>
<li>Datum und Uhrzeit des Zugriffs</li>
<li>aufgerufene Seite bzw. Datei</li>
<li>HTTP-Statuscode und übertragene Datenmenge</li>
<li>Referrer-URL (zuvor besuchte Seite)</li>
<li>Browsertyp, Sprache und Version</li>
<li>Betriebssystem des Endgeräts</li>
</ul>
<p className="leading-relaxed text-(--sea-ink-soft)">
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser
berechtigtes Interesse liegt in der Sicherstellung eines
stabilen und sicheren Betriebs unserer Website sowie in
der Abwehr von Angriffen. Die Daten werden spätestens
nach 30 Tagen gelöscht, sofern keine weitere Aufbewahrung
zu Beweiszwecken erforderlich ist.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
4. SSL-/TLS-Verschlüsselung
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Diese Website nutzt aus Sicherheitsgründen und zum Schutz
der Übertragung vertraulicher Inhalte eine SSL-/TLS-
Verschlüsselung. Sie erkennen eine verschlüsselte
Verbindung an dem Präfix https://" in der Adresszeile
Ihres Browsers.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
5. Cookies und lokaler Speicher
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Wir verwenden keine Cookies für Werbung, Reichweitenmessung
oder ähnliche Auswertungen. Für den Betrieb der Website
setzen wir ausschließlich technisch notwendige Speicher
ein:
</p>
<ul className="list-disc space-y-1 pl-6 leading-relaxed text-(--sea-ink-soft)">
<li>
<strong>Theme-Präferenz (Local Storage):</strong>{" "}
Speichert Ihre Auswahl zwischen hellem und dunklem
Erscheinungsbild ausschließlich lokal in Ihrem Browser.
Es findet keine Übertragung an unseren Server statt.
</li>
<li>
<strong>Session-Cookie (nur bei Login):</strong> Wenn
Sie sich in Ihrem Kundenbereich anmelden, setzen wir
ein HttpOnly-Session-Cookie, das Ihre Anmeldung über
Seitenwechsel hinweg erhält. Das Cookie ist an unsere
Domain gebunden und wird mit dem Logout bzw. spätestens
nach sieben Tagen automatisch entwertet.
</li>
</ul>
<p className="leading-relaxed text-(--sea-ink-soft)">
Rechtsgrundlage für den Einsatz technisch notwendiger
Cookies ist § 25 Abs. 2 Nr. 2 TDDDG sowie Art. 6 Abs. 1
lit. f DSGVO.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
6. Kontaktaufnahme per E-Mail oder Telefon
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Wenn Sie uns per E-Mail oder Telefon kontaktieren, werden
Ihre Angaben (Name, Kontaktdaten, Anliegen) zur Bearbeitung
der Anfrage und für den Fall von Anschlussfragen bei uns
gespeichert. Diese Daten geben wir nicht ohne Ihre
Einwilligung weiter.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Rechtsgrundlage ist bei Anfragen im Rahmen bestehender
oder anzubahnender Vertragsverhältnisse Art. 6 Abs. 1
lit. b DSGVO, im Übrigen Art. 6 Abs. 1 lit. f DSGVO
(berechtigtes Interesse an der Beantwortung Ihrer
Anfrage). Ihre Anfrage wird gelöscht, sobald sie
abschließend bearbeitet ist und der Löschung keine
gesetzlichen Aufbewahrungspflichten entgegenstehen.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
7. Online-Terminbuchung (selbst gehostetes Cal.com)
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Auf der Seite <em>Termin buchen"</em> binden wir per
iFrame unseren eigenen Terminkalender ein. Dieser läuft
auf einer von uns selbst betriebenen Instanz der
Open-Source-Software <strong>Cal.com</strong> unter der
Subdomain{" "}
<a
href="https://termin.hurler-webdesign.de"
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2"
>
termin.hurler-webdesign.de
</a>
. Es handelt sich also nicht um einen externen
Dienstleister und nicht um einen Drittanbieter im
Rechtssinne - die Daten verlassen unsere eigene
Infrastruktur in der Europäischen Union nicht. Eine
Übermittlung in ein Drittland (insbesondere in die USA)
findet nicht statt.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Beim Aufruf der Seite wird der iFrame und damit die
Kalenderoberfläche geladen. Dabei werden technisch bedingt
dieselben Zugriffsdaten wie in Ziffer 3 beschrieben in
unseren Logs erfasst.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Wenn Sie einen Termin buchen, verarbeiten wir zusätzlich
die folgenden von Ihnen aktiv angegebenen Daten:
</p>
<ul className="list-disc space-y-1 pl-6 leading-relaxed text-(--sea-ink-soft)">
<li>Name</li>
<li>E-Mail-Adresse</li>
<li>gewählter Termin (Datum und Uhrzeit, Zeitzone)</li>
<li>
optionale Angaben, die Sie freiwillig im Notizfeld
machen
</li>
</ul>
<p className="leading-relaxed text-(--sea-ink-soft)">
Diese Daten nutzen wir ausschließlich zur Vorbereitung,
Durchführung und Nachbereitung des vereinbarten Termins
sowie zum Versand der Termin- und Erinnerungs-E-Mails.
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO
(Durchführung vorvertraglicher Maßnahmen auf Ihre
Anfrage). Die Terminbuchungen werden gelöscht, sobald sie
für die Zweckerreichung nicht mehr erforderlich sind,
spätestens sechs Monate nach Durchführung des Termins,
sofern keine gesetzlichen Aufbewahrungspflichten
entgegenstehen.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
8. Kundenbereich (Login)
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Zugänge zum internen Kundenbereich werden ausschließlich
durch uns nach vorheriger Absprache eingerichtet - eine
öffentliche Registrierung ist nicht möglich. Für den
Betrieb des Logins verarbeiten wir:
</p>
<ul className="list-disc space-y-1 pl-6 leading-relaxed text-(--sea-ink-soft)">
<li>E-Mail-Adresse</li>
<li>Name (soweit angegeben)</li>
<li>
Passwort in Form eines nicht rückrechenbaren Hashwerts
(kein Klartext)
</li>
<li>
Session-Informationen (Cookie-ID, Ablaufzeitpunkt,
IP-Adresse, User-Agent), damit Ihr Login erhalten
bleibt und wir bei Missbrauchsverdacht reagieren
können
</li>
<li>Zeitpunkte von Anmeldung und letzter Aktivität</li>
</ul>
<p className="leading-relaxed text-(--sea-ink-soft)">
Grundlage ist Art. 6 Abs. 1 lit. b DSGVO
(Vertragserfüllung) sowie Art. 6 Abs. 1 lit. f DSGVO
(Sicherstellung eines sicheren Betriebs). Zugangsdaten
werden gelöscht, sobald das Kundenverhältnis endet und
keine gesetzlichen Aufbewahrungspflichten
entgegenstehen.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Zur Absicherung gegen automatisierte Angriffe (z. B.
Brute-Force auf Anmeldeversuche) setzen wir ein
serverseitiges Rate-Limit ein. Grundlage: Art. 6 Abs. 1
lit. f DSGVO.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
9. Hosting
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Diese Website und die zugehörigen Dienste werden auf
Servern innerhalb der Europäischen Union gehostet. Mit
unseren Hosting-Dienstleistern besteht - soweit
erforderlich - ein Vertrag zur Auftragsverarbeitung nach
Art. 28 DSGVO. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f
DSGVO (berechtigtes Interesse an einer effizienten und
sicheren Bereitstellung unseres Onlineangebots).
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
10. Keine externen Analyse- oder Marketingdienste
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Wir setzen bewusst keine Analyse-Tools wie Google
Analytics, Matomo, Meta-Pixel oder ähnliche Werkzeuge zur
Nachverfolgung Ihres Nutzungsverhaltens ein. Es werden
keine Nutzerprofile erstellt und keine Daten an
Werbepartner übermittelt.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
11. Aktualität und Änderung dieser Erklärung
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Diese Datenschutzerklärung ist aktuell gültig. Durch die
Weiterentwicklung unserer Website oder durch geänderte
gesetzliche bzw. behördliche Vorgaben kann es notwendig
werden, sie anzupassen. Die jeweils aktuelle Fassung ist
jederzeit unter dieser Adresse abrufbar.
</p>
</div>
<p className="pt-4 text-sm text-(--sea-ink-soft)">
Stand: August 2026
</p>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { createFileRoute } from "@tanstack/react-router";
import Header from "@/components/header.tsx";
import { Footer } from "@/components/footer.tsx";
export const Route = createFileRoute("/impressum")({
component: ImpressumPage,
head: () => ({
meta: [
{ title: "Impressum - webkulisse" },
{
name: "description",
content:
"Anbieterkennzeichnung nach § 5 DDG sowie inhaltlich Verantwortlicher nach § 18 Abs. 2 MStV.",
},
],
links: [{ rel: "canonical", href: "https://webkulisse.de/impressum" }],
}),
});
function ImpressumPage() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<section className="mx-auto max-w-3xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<span className="island-kicker">Rechtliches</span>
<h1 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Impressum
</h1>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG) sowie § 18 Abs. 2
Medienstaatsvertrag (MStV).
</p>
</section>
<section className="mx-auto max-w-3xl px-4 pb-16 md:px-6 md:pb-24">
<div className="island-shell rounded-3xl p-6 sm:p-10">
<div className="space-y-10 text-(--sea-ink)">
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Diensteanbieter
</h2>
<address className="not-italic leading-relaxed">
Hurler Webdesign
<br />
Mathias Hurler (Einzelunternehmer)
<br />
Untermagerbein 30
<br />
86751 Mönchsdeggingen
<br />
Deutschland
</address>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">Kontakt</h2>
<ul className="space-y-1 leading-relaxed">
<li>
Telefon:{" "}
<a
href="tel:+491718084830"
className="underline underline-offset-2"
>
+49 171 8084830
</a>
</li>
<li>
E-Mail:{" "}
<a
href="mailto:info@webkulisse.de"
className="underline underline-offset-2"
>
info@webkulisse.de
</a>
</li>
</ul>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Umsatzsteuer
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Kleinunternehmer im Sinne von § 19 UStG. Es wird daher keine
Umsatzsteuer ausgewiesen.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Redaktionell verantwortlich
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV:
</p>
<address className="not-italic leading-relaxed">
Mathias Hurler
<br />
Untermagerbein 30
<br />
86751 Mönchsdeggingen
</address>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Verbraucherstreitbeilegung
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Wir sind nicht bereit und nicht verpflichtet, an
Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle im Sinne des
Verbraucherstreitbeilegungsgesetzes (VSBG) teilzunehmen.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Die Europäische Kommission stellt eine Plattform zur
Online-Streitbeilegung (OS) bereit, die Sie unter{" "}
<a
href="https://ec.europa.eu/consumers/odr/"
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2"
>
https://ec.europa.eu/consumers/odr/
</a>{" "}
finden. Unsere E-Mail-Adresse haben Sie oben.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Haftung für Inhalte
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für
eigene Inhalte auf diesen Seiten nach den allgemeinen
Gesetzen verantwortlich. Nach den §§ 8 bis 10 DDG sind wir
als Diensteanbieter jedoch nicht verpflichtet, übermittelte
oder gespeicherte fremde Informationen zu überwachen oder
nach Umständen zu forschen, die auf eine rechtswidrige
Tätigkeit hinweisen.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Verpflichtungen zur Entfernung oder Sperrung der Nutzung
von Informationen nach den allgemeinen Gesetzen bleiben
hiervon unberührt. Eine diesbezügliche Haftung ist jedoch
erst ab dem Zeitpunkt der Kenntnis einer konkreten
Rechtsverletzung möglich. Bei Bekanntwerden entsprechender
Rechtsverletzungen werden wir diese Inhalte umgehend
entfernen.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Haftung für Links
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Unser Angebot enthält Links zu externen Websites Dritter,
auf deren Inhalte wir keinen Einfluss haben. Deshalb können
wir für diese fremden Inhalte auch keine Gewähr übernehmen.
Für die Inhalte der verlinkten Seiten ist stets der
jeweilige Anbieter oder Betreiber der Seiten
verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt
der Verlinkung auf mögliche Rechtsverstöße überprüft.
Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung
nicht erkennbar.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Eine permanente inhaltliche Kontrolle der verlinkten Seiten
ist jedoch ohne konkrete Anhaltspunkte einer
Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von
Rechtsverletzungen werden wir derartige Links umgehend
entfernen.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Urheberrecht
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Die durch den Seitenbetreiber erstellten Inhalte und Werke
auf diesen Seiten unterliegen dem deutschen Urheberrecht.
Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art
der Verwertung außerhalb der Grenzen des Urheberrechtes
bedürfen der schriftlichen Zustimmung des jeweiligen Autors
bzw. Erstellers.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Soweit die Inhalte auf dieser Seite nicht vom Betreiber
erstellt wurden, werden die Urheberrechte Dritter beachtet.
Insbesondere werden Inhalte Dritter als solche
gekennzeichnet. Sollten Sie trotzdem auf eine
Urheberrechtsverletzung aufmerksam werden, bitten wir um
einen entsprechenden Hinweis. Bei Bekanntwerden von
Rechtsverletzungen werden wir derartige Inhalte umgehend
entfernen.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Bildnachweise
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Sofern nicht anders gekennzeichnet, stammen die auf dieser
Website verwendeten Bilder aus eigener Produktion oder aus
lizenzfrei nutzbaren Quellen.
</p>
</div>
<p className="pt-4 text-sm text-(--sea-ink-soft)">
Stand: August 2026
</p>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}