Files
my2/CLAUDE.md
Bastian Masanek 47fe14c6cc Add guideline for URL creation in English
- Updated CLAUDE.md to specify that URLs should always be created in English, using the example "/checkout" instead of "/kasse".

This change aims to standardize URL formatting across the codebase for better accessibility and consistency.
2025-11-03 15:37:53 +01:00

1235 lines
38 KiB
Markdown
Raw Blame History

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**my.experimenta.science** is an e-commerce platform for the experimenta Science Center. It allows visitors to purchase Makerspace annual passes (MVP) and extends their existing web shop.
**Domain:** `my.experimenta.science`
**Status:** In Planning / Initial Setup
---
## 📋 Task Management & Progress Tracking
**WICHTIG:** Dieses Projekt nutzt ein strukturiertes Task-System im `tasks/` Ordner.
### Workflow für alle Implementierungen:
**1. Vor Start einer Arbeitssession:**
- Lies `tasks/00-PROGRESS.md` → Identifiziere aktuelle Phase
- Öffne die relevante Phase-Datei (z.B. `tasks/03-authentication.md`)
- Prüfe Dependencies der Phase (sind alle abhängigen Phasen abgeschlossen?)
**2. Während der Implementierung:**
- Arbeite Tasks sequenziell ab (von oben nach unten in der Phase-Datei)
- Markiere abgeschlossene Tasks: `- [ ]``- [x]`
- Aktualisiere Progress-Zeile in Phase-Datei: `Progress: X/Y tasks (Z%)`
- Dokumentiere wichtige Entscheidungen im Notes-Bereich
- Bei Blocker: Status auf 🚫 setzen, Grund dokumentieren
**3. Nach jedem abgeschlossenen Task:**
- Update Phase-Datei (Task checkbox + Progress-Zeile)
- Update `tasks/00-PROGRESS.md`:
- Aktualisiere Progress in "Quick Status" Tabelle
- Aktualisiere "Current Work" Section (welche Phase, welcher Task, nächste Schritte)
- Falls Phase abgeschlossen: Status auf ✅, Started/Completed Datum eintragen
**4. Phase-Abschluss:**
- Prüfe: Alle Acceptance Criteria erfüllt?
- Setze Status in Phase-Datei auf ✅ Done
- Setze Status in `00-PROGRESS.md` auf ✅ Done
- Trage "Completed" Datum ein
- **⚠️ Frage den Benutzer explizit, ob mit der nächsten Phase fortgefahren werden soll**
- **Erst nach Bestätigung:** Identifiziere nächste Phase und starte mit Schritt 1
### Niemals vergessen:
- ⚠️ **Immer** `00-PROGRESS.md` aktualisieren nach jedem abgeschlossenen Task
- ⚠️ **Immer** Phase-Datei und PROGRESS.md synchron halten
- ⚠️ **Immer** bei Unterbrechung "Current Work" in PROGRESS.md dokumentieren
- ⚠️ **Niemals** mehrere Phasen gleichzeitig bearbeiten (sequenziell arbeiten)
**Siehe:** [`tasks/README.md`](./tasks/README.md) für vollständige Dokumentation des Task-Systems.
---
## Code Style Requirements
- **All code comments MUST be written in English**
- User-facing content (UI text, emails) should be in German
- **User communication MUST use informal "Du" form** (not formal "Sie" form)
- Examples: "Dein Konto", "Melde dich an", "Bestätige deine E-Mail"
- This applies to all UI text, form labels, buttons, messages, and emails
- Documentation should be in German (PRD, README)
## Tech Stack
- **Framework:** Nuxt 4 (Vue 3 Composition API + TypeScript)
- **UI Library:** shadcn-nuxt (<https://nuxt.com/modules/shadcn>)
- **Styling:** Tailwind CSS v4
- **Database:** PostgreSQL 16+
- **ORM:** Drizzle ORM (TypeScript-first, performant)
- **Important:** Use `(table) => [...]` for indexes/constraints, NOT `(table) => ({...})`
- **Queue System:** BullMQ (MIT License) - Async job processing
- **In-Memory Store:** Redis 7 - Queue storage, sessions, caching
- **Auth:** Cidaas (OIDC/OAuth2) - external platform
- **Payment:** PayPal (MVP), more providers later
- **i18n:** @nuxtjs/i18n - German (default) + English
- **Validation:** Zod + VeeValidate
- **State Management:** Pinia (minimal use, prefer composables)
- **Testing:** Vitest (unit/integration), Playwright (E2E)
- **Deployment:** Docker on Hetzner Proxmox
- **CI/CD:** GitLab
## Architecture Principles
### Authentication & User Management
**Critical:** Cidaas is **only** for authentication (login/registration). User profiles and roles are stored in the local PostgreSQL database.
- Cidaas provides: OAuth2 authentication, user identity
- Local DB stores: User profile, roles, preferences, purchase history
- Custom UI for login/registration (not Cidaas hosted pages)
- Link users via `experimenta_id` field
### Data Flow
1. **Product Sync:** NAV ERP <20> Push to `/api/erp/products` <20> PostgreSQL
2. **Order Flow:** User checkout <20> PayPal payment <20> Order in DB <20> **X-API** <20> NAV ERP
3. **Auth Flow:** User login <20> Cidaas OAuth <20> Create/update local user profile <20> Session
### Key Integrations
- **NAV ERP:** Microsoft Dynamics NAV - Push products/stock to our API
- **X-API:** Order submission to NAV ERP via `/shopware/order` endpoint
- Environments: x-api-{dev|stage|live}.experimenta.science
- **Authentication:** HTTP Basic Auth (username + password)
- Credentials stored in environment variables (never hardcoded)
- Converts our JSON orders to SOAP for NAV ERP
- Critical: Prices must be in cents (Integer), dates in ISO 8601 UTC
- **Cidaas:** Auth platform by Widas (company requirement)
## MVP Scope (Phase 1)
**In Scope:**
- User registration and login (email-based via Cidaas)
- Display Makerspace annual passes ("Makerspace-Jahreskarten")
- Shopping cart functionality
- Checkout process
- PayPal payment integration
- NAV ERP product sync (receive products via push)
**Out of Scope (Post-MVP):**
- User roles (Educator, Company) - MVP only supports "Privatperson" implicitly
- Educator annual passes ("Pädagogische Jahreskarten")
- Approval workflows
- Experimenta tickets + Science Dome seat reservations
- Lab courses for schools
- Multi-payment providers
## Project Structure (Once Initialized)
```
/
├── app/
│ ├── components/ # Vue components (Nuxt 4 structure)
│ │ └── ui/ # shadcn-nuxt components
│ ├── layouts/ # Nuxt layouts
│ ├── lib/ # Client-side utilities
│ │ └── utils.ts # Shared utilities
│ └── pages/ # File-based routing (Nuxt 4)
├── server/
│ ├── api/ # API routes (Nitro)
│ │ ├── auth/ # Cidaas OAuth handlers
│ │ ├── products/ # Product endpoints
│ │ ├── cart/ # Shopping cart
│ │ ├── orders/ # Order management
│ │ ├── payment/ # PayPal integration
│ │ └── erp/ # NAV ERP endpoints (API key protected)
│ ├── database/
│ │ ├── schema.ts # Drizzle schema definitions
│ │ └── migrations/ # DB migrations
│ └── utils/ # Server-side utilities
├── components/ # Legacy components (to be migrated to app/)
├── composables/ # Vue composables
├── pages/ # Legacy pages (to be migrated to app/)
├── docs/ # Documentation
│ ├── PRD.md # Product Requirements Document
│ ├── TECH_STACK.md # Tech decisions & rationale
│ └── ARCHITECTURE.md # System architecture
└── docker-compose.yml # Docker setup
```
## Database Schema
See `docs/ARCHITECTURE.md` for full schema. Key tables:
- `users` - User profiles (linked to Cidaas via `experimenta_id`)
- **Includes billing address fields:** `salutation`, `date_of_birth`, `street`, `post_code`, `city`, `country_code`
- Address fields are optional, filled during checkout or profile edit
- Pre-fills checkout form for returning customers
- `products` - Products synced from NAV ERP
- `carts` / `cart_items` - Shopping cart
- `orders` / `order_items` - Orders and line items
- **`roles`** - Role definitions (private, educator, company)
- **Primary Key:** `code` (enum: 'private' | 'educator' | 'company') - No separate UUID id!
- Benefit: Direct readability in junction tables
- **`user_roles`** - Many-to-Many User ↔ Roles (with approval workflow prepared for Phase 2/3)
- Foreign Key: `roleCode` references `roles.code`
- **`product_role_visibility`** - Many-to-Many Product ↔ Roles (controls product visibility)
- Foreign Key: `roleCode` references `roles.code`
**Role-based Visibility (MVP):**
- Products are ONLY visible if they have `product_role_visibility` entries
- Users ONLY see products matching their approved roles in `user_roles`
- Opt-in visibility: No role assignment = invisible to everyone
- **Auto-assignment:** New users automatically receive `'private'` role on first login
Use Drizzle ORM for all database operations. Most tables use UUID primary keys (except `roles` which uses enum code as PK).
## Development Commands (Once Set Up)
```bash
# Install dependencies
pnpm install
# Development server
pnpm dev
# Build for production
pnpm build
# Run production build locally
pnpm preview
# Database migrations
pnpm db:generate # Generate migrations from schema changes
pnpm db:migrate # Apply migrations to database
pnpm db:studio # Open Drizzle Studio (DB GUI)
# Testing
pnpm test # Run unit tests (Vitest)
pnpm test:e2e # Run E2E tests (Playwright)
pnpm test:coverage # Generate coverage report
# Code quality
pnpm lint # ESLint
pnpm format # Prettier
pnpm typecheck # TypeScript type checking
```
## Development Tools & Testing
### Test Credentials (Cidaas Staging)
**📖 Complete test credentials and testing guide:** [`docs/TESTING.md`](./docs/TESTING.md)
**Important:**
- Test credentials are documented in [`docs/TESTING.md`](./docs/TESTING.md)
- ⚠️ **Only for staging environment** - NEVER use in production
- Used by Playwright E2E tests and Vitest integration tests
### Playwright MCP Server
Ein Playwright MCP Server ist installiert und verfügbar für:
- **UI/UX Überprüfung:** Visuelle Validierung von Komponenten und Layouts
- **Funktionale Tests:** Interaktive Tests während der Entwicklung
- **Responsive Design Checks:** Überprüfung der Mobile-First Implementierung
- **User Flow Testing:** Validierung kompletter Benutzer-Workflows (z.B. Checkout-Prozess)
**Wann nutzen:**
- Nach Implementierung oder Änderung von UI-Komponenten
- Bei Layout-Anpassungen (besonders mobile vs. desktop)
- Zum Testen von Formularen und Interaktionen
- Zur Validierung des gesamten Checkout-Flows
- Bei Integration von shadcn-nuxt Komponenten
**Workflow:**
1. Starte den Dev-Server (`pnpm dev`)
2. Nutze Playwright MCP Tools um die Anwendung zu navigieren und zu testen
3. Validiere visuelle Darstellung und Funktionalität
4. Dokumentiere gefundene Issues oder bestätige erfolgreiche Implementierung
## Important Constraints
1. **Cidaas Custom UI:** We must implement custom login/registration forms in our app (not hosted by Cidaas). Use OIDC/OAuth2 flow with Cidaas as identity provider.
2. **Mobile-First:** All UI must be optimized for mobile devices first, then scale up to desktop.
3. **Corporate Design:** Use experimenta style guide for colors and fonts (configured in Tailwind).
4. **NAV ERP Integration:** Products are pushed to us (we don't pull). Implement robust endpoint with API key authentication and validation.
5. **No Roles in MVP:** Don't implement role selection or role-based features yet. All users are implicitly "Privatperson" (private individual).
6. **TypeScript Strict Mode:** Use strict TypeScript everywhere. All schemas defined with Zod.
7. **Bilingual Support:** App must support German (default) and English. Use @nuxtjs/i18n for all user-facing text. Routes: `/produkte` (de), `/en/products` (en).
## Security Considerations
- Never commit secrets (`.env` is gitignored)
- All API endpoints must validate input (use Zod schemas)
- ERP endpoints must be protected with API key authentication
- Use session-based auth (HTTP-only cookies)
- Validate JWT tokens from Cidaas using `jose` library
- Implement rate limiting on public endpoints
## Common Patterns
### API Route Example
```typescript
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
// Validate path params with Zod
const { id } = await getValidatedRouterParams(
event,
z.object({
id: z.string().uuid(),
}).parse
)
// Query with Drizzle
const product = await db.query.products.findFirst({
where: eq(products.id, id),
})
if (!product) {
throw createError({
statusCode: 404,
message: 'Product not found',
})
}
return product
})
```
### Protected Route Example
```typescript
// middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to, from) => {
const { user } = await useAuth()
if (!user.value) {
return navigateTo('/login')
}
})
```
### Checkout with Saved Address Pattern
```typescript
// pages/checkout.vue
<script setup lang="ts">
const { user } = await useAuth()
const { saveAddress } = useUserProfile()
// Pre-fill form if user has saved address
const form = reactive({
salutation: user.value?.salutation || '',
dateOfBirth: user.value?.dateOfBirth || '',
street: user.value?.street || '',
postCode: user.value?.postCode || '',
city: user.value?.city || '',
countryCode: user.value?.countryCode || 'DE',
// Pre-checked if user doesn't have address yet
saveForFuture: !user.value?.street
})
async function handleCheckout() {
// Validate form
const validated = await checkoutSchema.parseAsync(form)
// Save address to profile if checkbox is checked
if (form.saveForFuture) {
await saveAddress({
salutation: validated.salutation,
dateOfBirth: validated.dateOfBirth,
street: validated.street,
postCode: validated.postCode,
city: validated.city,
countryCode: validated.countryCode,
})
}
// Continue with payment
await processPayment(validated)
}
</script>
<template>
<form @submit.prevent="handleCheckout">
<h2>{{ $t('checkout.billingAddress') }}</h2>
<!-- Address form fields -->
<Input v-model="form.street" :label="$t('checkout.street')" required />
<Input v-model="form.postCode" :label="$t('checkout.postCode')" required />
<Input v-model="form.city" :label="$t('checkout.city')" required />
<!-- Save address checkbox -->
<Checkbox
v-model="form.saveForFuture"
:label="$t('checkout.saveAddress')"
/>
<Button type="submit">{{ $t('checkout.continue') }}</Button>
</form>
</template>
```
### X-API Order Transformation Pattern
```typescript
// server/utils/xapi.ts
// Transform order from DB to X-API format
export function transformOrderToXAPI(order: Order, user: User) {
return {
shopPOSOrder: {
documentType: 'Order',
externalDocumentNo: order.orderNumber,
salesChannel: 'Shop',
shoppingCartCompletion: order.createdAt.toISOString(),
visitType: 'Private',
amountIncludingVAT: Math.round(order.totalAmount * 100), // EUR -> Cents!
language: 'DEU',
salesLine: order.items.map((item, index) => ({
type: 'Item',
lineNo: String((index + 1) * 10000), // 10000, 20000, 30000...
no: item.product.navProductId,
quantity: item.quantity,
unitPrice: Math.round(item.priceSnapshot * 100), // EUR -> Cents!
vatPct: 7,
visitorCategory: '120', // Annual pass holders
ticketCode: generateUniqueTicketCode(),
// Required for annual passes!
annualPass: {
salutationCode: mapSalutation(user.salutation), // 'HERR', 'FRAU', 'K_ANGABE'
firstName: user.firstName,
lastName: user.lastName,
streetAndHouseNumber: user.address.street,
postCode: user.address.postCode,
city: user.address.city,
countryCode: user.address.countryCode || 'DE',
dateOfBirth: user.dateOfBirth,
eMail: user.email,
validFrom: new Date().toISOString().split('T')[0], // YYYY-MM-DD
},
})),
payment: [
{
paymentEntryNo: 10000,
amount: Math.round(order.totalAmount * 100), // EUR -> Cents!
paymentType: 'Shop PayPal',
createdOn: order.paymentCompletedAt.toISOString(),
reference: order.paymentId,
paymentId: order.paymentId,
},
],
personContact: {
experimentaAccountID: user.experimentaId, // Our experimenta_id!
eMail: user.email,
salutationCode: mapSalutation(user.salutation),
firstName: user.firstName,
lastName: user.lastName,
streetAndHouseNumber: order.billingAddress.street,
postCode: order.billingAddress.postCode,
city: order.billingAddress.city,
countryCode: order.billingAddress.countryCode || 'DE',
phoneNumber: user.phone || '',
guest: false,
},
},
}
}
// Submit to X-API with retry logic and Basic Auth
export async function submitOrderToXAPI(payload: XAPIOrderPayload) {
const config = useRuntimeConfig()
const maxRetries = 3
const retryDelays = [1000, 3000, 9000] // Exponential backoff
// Prepare Basic Auth header
const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString('base64')
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(`${config.xApiBaseUrl}/shopware/order`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${authString}`,
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`X-API error ${response.status}: ${errorText}`)
}
return await response.json()
} catch (error) {
if (attempt === maxRetries - 1) throw error
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]))
}
}
}
```
**Critical transformations:**
- Prices: EUR (Decimal) → Cents (Integer) using `Math.round(price * 100)`
- Dates: JavaScript Date → ISO 8601 UTC using `.toISOString()`
- Line numbers: Sequential multiples of 10000
- Salutation: 'male' → 'HERR', 'female' → 'FRAU', other → 'K_ANGABE'
## Role-based Product Visibility Patterns (MVP)
### Auto-Assignment of 'private' Role (MVP)
**Requirement**: All users must have at least one role to see products. New users automatically receive the `private` role on first login.
**Implementation** in `server/api/auth/login.post.ts`:
```typescript
if (!user) {
// First time login - create user profile
const [newUser] = await db.insert(users).values({
experimentaId: cidaasUser.sub,
email: cidaasUser.email,
firstName: cidaasUser.given_name || '',
lastName: cidaasUser.family_name || '',
}).returning()
user = newUser
// Auto-assign 'private' role on first login
await assignRoleToUser(newUser.id, 'private', {
adminNotes: 'Auto-assigned on first login',
})
} else {
// Update last login timestamp
await db.update(users)
.set({ updatedAt: new Date() })
.where(eq(users.id, user.id))
// Safety check: If existing user has no roles, assign 'private' role
const userRoleCodes = await getUserApprovedRoleCodes(user.id)
if (userRoleCodes.length === 0) {
await assignRoleToUser(user.id, 'private', {
adminNotes: 'Auto-assigned for existing user without roles',
})
}
}
```
**Key Points:**
- ✅ New users → `private` role automatically assigned
- ✅ Existing users without roles → `private` role assigned (safety check)
- ✅ Status always `approved` (no approval workflow in MVP)
- ✅ Admin notes track auto-assignment source
### Role-based Filtering Pattern
```typescript
// Server-side: GET /api/products - Filter products by user roles
import { getVisibleProductIdsForUser } from '~/server/utils/roles'
export default defineEventHandler(async (event) => {
const { user } = await getUserSession(event)
// MVP: Unauthenticated users see NO products (opt-in visibility)
if (!user) {
return []
}
// Get product IDs visible to this user (based on approved roles)
const visibleProductIds = await getVisibleProductIdsForUser(user.id)
if (visibleProductIds.length === 0) {
return []
}
// Fetch products with role-based filtering
const products = await db.query.products.findMany({
where: and(
eq(products.active, true),
inArray(products.id, visibleProductIds) // Role filter
)
})
return products
})
```
### ERP Category to Role Mapping Pattern
```typescript
// Auto-assign roles when importing products from NAV ERP
import { assignRolesToProductByCategory } from '~/server/utils/roles'
// server/api/erp/products.post.ts
export default defineEventHandler(async (event) => {
const { navProductId, category, ...productData } = await readBody(event)
// 1. Create/update product
const [product] = await db.insert(products)
.values({ navProductId, category, ...productData })
.onConflictDoUpdate({ target: products.navProductId, set: productData })
.returning()
// 2. Auto-assign roles based on category mapping
await assignRolesToProductByCategory(product.id, category)
return product
})
```
**Category Mapping:**
```typescript
// Defined in server/utils/roles.ts
const categoryRoleMapping = {
'makerspace-annual-pass': ['private', 'educator'],
'annual-pass': ['private'],
'educator-annual-pass': ['educator'],
'company-annual-pass': ['company']
}
```
### Get User Approved Roles Pattern
```typescript
// server/utils/roles.ts - Utility functions
import { getUserApprovedRoles, getUserApprovedRoleCodes } from '~/server/utils/roles'
// Get full role objects
const roles = await getUserApprovedRoles(userId)
// => [{ id: '...', code: 'private', displayName: 'Privatperson', ... }]
// Get just role codes (lightweight)
const roleCodes = await getUserApprovedRoleCodes(userId)
// => ['private', 'educator']
```
### Manual Role Assignment Pattern (MVP)
```typescript
// server/utils/roles.ts - For manual role assignment
import { assignRoleToUser } from '~/server/utils/roles'
// Assign role to user (MVP: always approved)
await assignRoleToUser(userId, 'private')
// With optional metadata (prepared for Phase 2/3)
await assignRoleToUser(userId, 'educator', {
organizationName: 'Hölderlin-Gymnasium Heilbronn',
adminNotes: 'Verified via school email domain'
})
```
### Check Product Visibility Pattern
```typescript
// server/utils/roles.ts - Check if specific product is visible
import { isProductVisibleForUser } from '~/server/utils/roles'
const canView = await isProductVisibleForUser(productId, userId)
// => true if user has approved role matching product's role assignments
// => false if product has no role assignments (opt-in!) or user lacks role
```
### JSONB Status History Pattern (Phase 2/3 prepared)
```typescript
// user_roles.statusHistory stores complete audit trail
const historyEntry = {
status: 'approved',
organizationName: 'Hölderlin-Gymnasium',
adminNotes: 'Verified via Lehrerausweis',
changedAt: new Date().toISOString(),
changedBy: adminUserId // null for auto-assignments
}
await db.update(userRoles)
.set({
status: 'approved',
statusHistory: sql`${userRoles.statusHistory} || ${JSON.stringify(historyEntry)}::jsonb`
})
.where(eq(userRoles.id, userRoleId))
```
**Important Visibility Rules (MVP):**
- Unauthenticated users → See NO products
- User without approved roles → See NO products
- Product without role assignments → Visible to NO ONE (opt-in)
- Product with role assignments → Visible only to users with matching approved role
## Authentication Patterns
See [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) for complete Cidaas OAuth2 implementation guide.
### Current Implementation: Password Grant Flow (MVP)
**Important:** The current implementation uses the **Resource Owner Password Credentials Grant** (OAuth2 Password Flow) instead of the Authorization Code Flow with PKCE.
**Why Password Grant for MVP:**
-**Simpler UX:** User stays in our app, no redirects to Cidaas
-**Faster development:** Less complex flow, fewer endpoints needed
-**Sufficient for MVP:** Private users logging in with email/password
- ⚠️ **Trade-off:** Client app handles passwords directly (less secure than authorization code flow)
- ⚠️ **Limitation:** Doesn't support SSO/Social logins (requires redirect flow)
**Future Enhancement:** For Phase 2+, we may implement Authorization Code Flow with PKCE to support:
- Social login (Google, Facebook, Apple)
- Single Sign-On (SSO) for organizations
- Better security (app never sees password)
### Password Grant Login Pattern (Current Implementation)
```typescript
// app/composables/useAuth.ts - Client-side auth composable
export function useAuth() {
const { loggedIn, user, clear, fetch } = useUserSession()
async function login(email: string, password: string) {
// Direct login via Password Grant (no redirect)
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password },
})
// Refresh session data
await fetch()
// Redirect to homepage or intended destination
navigateTo('/')
} catch (error) {
// Handle login error (invalid credentials, etc.)
throw error
}
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await clear()
navigateTo('/')
}
return { user, loggedIn, login, logout }
}
```
**Server-side:**
```typescript
// server/api/auth/login.post.ts - Password Grant login
import { loginWithPassword, fetchUserInfo } from '~/server/utils/cidaas'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
export default defineEventHandler(async (event) => {
// 1. Validate input
const body = await readBody(event)
const { email, password } = loginSchema.parse(body)
try {
// 2. Authenticate with Cidaas via Password Grant
const tokens = await loginWithPassword(email, password)
// 3. Fetch user info from Cidaas
const cidaasUser = await fetchUserInfo(tokens.access_token)
// 4. Create/update user in local DB
const db = useDatabase()
let user = await db.query.users.findFirst({
where: eq(users.experimentaId, cidaasUser.sub),
})
if (!user) {
// First time login - create user
const [newUser] = await db
.insert(users)
.values({
experimentaId: cidaasUser.sub,
email: cidaasUser.email,
firstName: cidaasUser.given_name || '',
lastName: cidaasUser.family_name || '',
})
.returning()
user = newUser
} else {
// Update last login timestamp
await db
.update(users)
.set({ updatedAt: new Date() })
.where(eq(users.id, user.id))
}
// 5. Create encrypted session
await setUserSession(event, {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
})
return { success: true }
} catch (error) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid credentials',
})
}
})
```
### OAuth2 Authorization Code Flow Pattern (Future Enhancement)
```typescript
// composables/useAuth.ts - Client-side auth composable
export function useAuth() {
const { loggedIn, user, clear, fetch } = useUserSession()
async function login(email: string) {
// Initiate OAuth2 Authorization Code Flow with PKCE
const { redirectUrl } = await $fetch('/api/auth/login', {
method: 'POST',
body: { email },
})
// Redirect to Cidaas login page
navigateTo(redirectUrl, { external: true })
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await clear()
navigateTo('/')
}
return { user, loggedIn, login, logout }
}
```
**Server-side:**
```typescript
// server/api/auth/login.post.ts - Initiate OAuth2 flow
export default defineEventHandler(async (event) => {
const { email } = await readBody(event)
// 1. Generate PKCE challenge
const { verifier, challenge } = await generatePKCE()
// 2. Store PKCE verifier in cookie (5min TTL)
setCookie(event, 'pkce_verifier', verifier, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
})
// 3. Generate state (CSRF protection)
const state = generateState(32)
setCookie(event, 'oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
})
// 4. Build Cidaas authorization URL
const config = useRuntimeConfig()
const authUrl = new URL(config.cidaas.authorizeUrl)
authUrl.searchParams.set('client_id', config.cidaas.clientId)
authUrl.searchParams.set('redirect_uri', config.cidaas.redirectUri)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
authUrl.searchParams.set('login_hint', email)
return { redirectUrl: authUrl.toString() }
})
```
### OAuth2 Callback Pattern
```typescript
// server/api/auth/callback.get.ts - Handle OAuth2 callback
export default defineEventHandler(async (event) => {
const { code, state } = getQuery(event)
// 1. Validate state (CSRF protection)
const storedState = getCookie(event, 'oauth_state')
if (!storedState || state !== storedState) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state parameter',
})
}
// 2. Retrieve PKCE verifier
const verifier = getCookie(event, 'pkce_verifier')
if (!verifier) {
throw createError({
statusCode: 400,
statusMessage: 'PKCE verifier not found',
})
}
try {
// 3. Exchange code for tokens
const tokens = await exchangeCodeForToken(code as string, verifier)
// 4. Validate ID token
const idTokenPayload = await verifyIdToken(tokens.id_token)
// 5. Fetch user info from Cidaas
const cidaasUser = await fetchUserInfo(tokens.access_token)
// 6. Create/update user in local DB
const db = useDatabase()
let user = await db.query.users.findFirst({
where: eq(users.experimentaId, cidaasUser.sub),
})
if (!user) {
// First time login - create user
const [newUser] = await db
.insert(users)
.values({
experimentaId: cidaasUser.sub,
email: cidaasUser.email,
firstName: cidaasUser.given_name,
lastName: cidaasUser.family_name,
})
.returning()
user = newUser
} else {
// Update last login
await db.update(users).set({ updatedAt: new Date() }).where(eq(users.id, user.id))
}
// 7. Create encrypted session
await setUserSession(event, {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
})
// 8. Clean up temporary cookies
deleteCookie(event, 'oauth_state')
deleteCookie(event, 'pkce_verifier')
// 9. Redirect to homepage
return sendRedirect(event, '/')
} catch (error) {
console.error('OAuth callback error:', error)
deleteCookie(event, 'oauth_state')
deleteCookie(event, 'pkce_verifier')
return sendRedirect(event, '/auth?error=login_failed')
}
})
```
### Protected Route Middleware Pattern
```typescript
// middleware/auth.ts - Require authentication
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
// Store intended destination for post-login redirect
useCookie('redirect_after_login', {
maxAge: 600, // 10 minutes
path: '/',
}).value = to.fullPath
return navigateTo('/auth')
}
})
```
**Usage in pages:**
```vue
<!-- pages/profile.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth', // Require authentication
})
const { user } = useAuth()
</script>
<template>
<div>
<h1>Welcome, {{ user.firstName }}!</h1>
</div>
</template>
```
### Protected API Endpoint Pattern
```typescript
// server/api/orders/index.get.ts - Protected API route
export default defineEventHandler(async (event) => {
// Require authentication (throws 401 if not logged in)
const { user } = await requireUserSession(event)
// Fetch user's orders
const db = useDatabase()
const orders = await db.query.orders.findMany({
where: eq(orders.userId, user.id),
orderBy: desc(orders.createdAt),
})
return orders
})
```
### User Registration Pattern
```typescript
// server/api/auth/register.post.ts - Register via Cidaas
import { z } from 'zod'
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8).regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/),
firstName: z.string().min(2),
lastName: z.string().min(2),
})
export default defineEventHandler(async (event) => {
// Validate input
const body = await readBody(event)
const validated = registerSchema.parse(body)
// Register user via Cidaas API
const result = await registerUser({
email: validated.email,
password: validated.password,
given_name: validated.firstName,
family_name: validated.lastName,
locale: 'de',
})
return {
success: true,
message: 'Registration successful. Please verify your email.',
}
})
```
### Session Management Pattern
```typescript
// Server-side: Create session
await setUserSession(event, {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
loggedInAt: new Date().toISOString(),
})
// Server-side: Check session
const { user } = await getUserSession(event)
if (!user) {
throw createError({ statusCode: 401, message: 'Not authenticated' })
}
// Server-side: Require session
const { user } = await requireUserSession(event) // Throws 401 if not logged in
// Server-side: Clear session
await clearUserSession(event)
```
```vue
<!-- Client-side: Use session in components -->
<script setup lang="ts">
const { user, loggedIn } = useUserSession()
// user.value: { id, email, firstName, ... } or null
// loggedIn.value: boolean
</script>
<template>
<div v-if="loggedIn">
<p>Welcome {{ user.firstName }}!</p>
</div>
<div v-else>
<NuxtLink to="/auth">Login</NuxtLink>
</div>
</template>
```
### JWT Validation Pattern
```typescript
// server/utils/jwt.ts - Validate Cidaas ID tokens
import { jwtVerify, createRemoteJWKSet } from 'jose'
const config = useRuntimeConfig()
const JWKS = createRemoteJWKSet(new URL(config.cidaas.jwksUrl))
export async function verifyIdToken(idToken: string) {
try {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: config.cidaas.issuer,
audience: config.cidaas.clientId,
})
return payload
} catch (error) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid ID token',
})
}
}
```
### Rate Limiting Pattern
```typescript
// server/middleware/rate-limit.ts - Rate limit auth endpoints
interface RateLimitEntry {
count: number
resetAt: number
}
const rateLimitStore = new Map<string, RateLimitEntry>()
export default defineEventHandler((event) => {
const path = event.path
if (!path.startsWith('/api/auth/')) return
const ip = getRequestIP(event) || 'unknown'
const limits = {
'/api/auth/login': { maxAttempts: 5, windowMs: 15 * 60 * 1000 },
'/api/auth/register': { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
}
const limit = limits[path]
if (!limit) return
const key = `${ip}:${path}`
const now = Date.now()
const entry = rateLimitStore.get(key)
if (!entry || entry.resetAt < now) {
rateLimitStore.set(key, { count: 1, resetAt: now + limit.windowMs })
return
}
entry.count++
if (entry.count > limit.maxAttempts) {
throw createError({
statusCode: 429,
statusMessage: 'Too many requests',
})
}
})
```
### Key Security Practices
1. **PKCE for OAuth2:**
- Always use PKCE (Proof Key for Code Exchange)
- Prevents authorization code interception attacks
- Generate random verifier, compute SHA-256 challenge
- Store verifier in temporary cookie (5min TTL)
2. **State Parameter:**
- Generate random state for CSRF protection
- Validate state on callback
- Store in temporary cookie (5min TTL)
3. **Encrypted Sessions:**
- Use `nuxt-auth-utils` for session management
- Sessions encrypted with AES-256-GCM
- HTTP-only, Secure, SameSite=Lax cookies
- 30-day expiration (configurable)
4. **Rate Limiting:**
- Login: 5 attempts / 15min per IP
- Register: 3 attempts / hour per IP
- Implement in middleware, not individual endpoints
5. **Input Validation:**
- Always validate with Zod schemas
- Validate on both client and server
- Never trust client-side validation alone
**Implementation Details:**
For complete implementation with all utilities (PKCE generator, Cidaas API client, full endpoint code), see:
- [`docs/CIDAAS_INTEGRATION.md`](./docs/CIDAAS_INTEGRATION.md) - Complete implementation guide
- [`docs/ARCHITECTURE.md#3.6`](./docs/ARCHITECTURE.md#36-authentication--authorization-cidaas-oauth2oidc) - Architecture diagrams and flows
## Documentation
- **PRD:** See `docs/PRD.md` for complete requirements
- **Tech Stack:** See `docs/TECH_STACK.md` for technology decisions
- **Architecture:** See `docs/ARCHITECTURE.md` for system design and data flows
- **experimenta Info:** See `docs/EXPERIMENTA_INFO.md` for company information (address, opening hours, legal links, contact details)
## Deployment
- **Platform:** Docker containers on Hetzner Proxmox
- **CI/CD:** GitLab pipelines (build <20> test <20> deploy)
- **Environments:** Staging (auto-deploy) + Production (manual approval)
- **Database Backups:** Daily automated backups with 7-day retention
## Future Phases
After MVP, we'll add:
- **Phase 2:** Educator roles + approval workflow + educator annual passes
- **Phase 3:** Experimenta tickets + Science Dome seat reservations
- **Phase 4:** Lab courses for schools
Keep the codebase modular to accommodate these features without major refactoring.
- Always use context7 when I need code generation, setup or configuration steps, or
library/API documentation. This means you should automatically use the Context7 MCP
tools to resolve library id and get library docs without me having to explicitly ask.
- URLs should always be created in English and not Germanized. Example: "/checkout" instead of "/kasse"