Compare commits

...

5 Commits

Author SHA1 Message Date
m
0a0ec016d8 feat: role-based permissions (owner/partner/associate/paralegal/secretary)
Backend:
- auth/permissions.go: full permission matrix with RequirePermission/RequireRole
  middleware, CanEditCase, CanDeleteDocument helpers
- auth/context.go: add user role to request context
- auth/middleware.go: resolve role alongside tenant in auth flow
- auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID
- handlers/case_assignments.go: CRUD for case-level user assignments
- handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints
- handlers/documents.go: permission-based delete (own vs all)
- router/router.go: permission-wrapped routes for all endpoints
- services/case_assignment_service.go: assign/unassign with tenant validation
- services/tenant_service.go: UpdateMemberRole with owner protection
- models/case_assignment.go: CaseAssignment model

Database:
- user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary)
- case_assignments table: case_id, user_id, role (lead/team/viewer)
- Migrated existing admin->partner, member->associate

Frontend:
- usePermissions hook: fetches /api/me, provides can() helper
- TeamSettings: 5-role dropdown, role change, permission-gated invite
- CaseAssignments: new component for case-level team management
- Sidebar: conditionally hides AI/Settings based on permissions
- Cases page: hides "Neue Akte" button for non-authorized roles
- Case detail: new "Mitarbeiter" tab for assignment management
2026-03-30 11:04:57 +02:00
m
82878dffd5 docs: full system roadmap — from MVP to complete Kanzleimanagement 2026-03-28 02:35:20 +01:00
m
909f14062c docs: comprehensive MVP audit — security, architecture, UX, competitive analysis 2026-03-28 02:26:39 +01:00
m
4b86dfa4ad feat: update AUDIT.md with sub-agent findings
Added 7 additional issues from deep-dive agents:
- Race condition in HolidayService cache (critical)
- Rate limiter X-Forwarded-For bypass (critical)
- German umlaut typos throughout frontend
- Silent error swallowing in createEvent
- Missing React error boundaries
- No RLS policies at database level
- Updated priority roadmap with new items
2026-03-28 02:23:50 +01:00
m
60f1f4ef4a feat: comprehensive MVP audit — security, architecture, UX, competitive analysis
Structured assessment covering code quality, security (critical tenant isolation
bypass found), architecture, UX gaps, testing coverage, deployment, and
competitive positioning vs RA-MICRO/ADVOWARE/AnNoText/Actaport.

Includes prioritized roadmap (P0-P3) with actionable items.
2026-03-28 02:22:07 +01:00
21 changed files with 1550 additions and 104 deletions

482
AUDIT.md Normal file
View File

@@ -0,0 +1,482 @@
# KanzlAI-mGMT MVP Audit
**Date:** 2026-03-28
**Auditor:** athena (consultant)
**Scope:** Full-stack audit of KanzlAI-mGMT — Go backend, Next.js frontend, Supabase database, deployment, security, UX, competitive positioning.
**Codebase:** ~16,500 lines across ~60 source files, built 2026-03-25 in a single session with parallel workers.
---
## Executive Summary
KanzlAI-mGMT is an impressive MVP built in ~2 hours. It covers the core Kanzleimanagement primitives: cases, deadlines, appointments, parties, documents, notes, dashboard, CalDAV sync, and AI-powered deadline extraction. The architecture is sound — clean separation between Go API and Next.js frontend, proper multi-tenant design with Supabase Auth, parameterized SQL throughout.
However, the speed of construction shows. There are **critical security gaps** that must be fixed before any external user touches this. The frontend has good bones but lacks the polish and completeness a lawyer would expect. And the feature gap vs. established competitors (RA-MICRO, ADVOWARE, AnNoText, Actaport) is enormous — particularly around beA integration, billing/RVG, and document generation, which are table-stakes for German law firms.
**Bottom line:** Fix the security issues, add error recovery and multi-tenant auth verification, then decide whether to pursue the Kanzleimanagement market (massive feature gap) or pivot back to the UPC niche (where you had a genuine competitive advantage).
---
## 1. Critical Issues (Fix Immediately)
### 1.1 Tenant Isolation Bypass in TenantResolver
**File:** `backend/internal/auth/tenant_resolver.go:37-42`
When the `X-Tenant-ID` header is provided, the TenantResolver parses it and sets it in context **without verifying the user has access to that tenant**. Any authenticated user can access any tenant's data by setting this header.
```go
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
// ... sets tenantID = parsed — NO ACCESS CHECK
}
```
Compare with `helpers.go:32-44` where `resolveTenant()` correctly verifies access via `user_tenants` — but this function is unused in the middleware path. The TenantResolver middleware is what actually runs for all scoped routes.
**Impact:** Complete tenant data isolation breach. User A can read/modify/delete User B's cases, deadlines, appointments, documents.
**Fix:** Add `user_tenants` lookup in TenantResolver when X-Tenant-ID is provided, same as `resolveTenant()` does.
### 1.2 Duplicate Tenant Resolution Logic
**Files:** `backend/internal/auth/tenant_resolver.go` and `backend/internal/handlers/helpers.go:25-57`
Two independent implementations of tenant resolution exist. The middleware (`TenantResolver`) is used for the scoped routes. The handler-level `resolveTenant()` function exists in helpers.go. The auth middleware in `middleware.go:39-47` also resolves a tenant into context. This triple-resolution creates confusion and the security bug above.
**Fix:** Consolidate to a single path. Remove the handler-level `resolveTenant()` and the auth middleware's tenant resolution. Let TenantResolver be the single source of truth, but make it verify access.
### 1.3 CalDAV Credentials Stored in Plaintext
**File:** `backend/internal/services/caldav_service.go:29-35`
CalDAV username and password are stored as plain JSON in the `tenants.settings` column:
```go
type CalDAVConfig struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
...
}
```
Combined with the tenant isolation bypass above, any authenticated user can read any tenant's CalDAV credentials.
**Fix:** Encrypt CalDAV credentials at rest (e.g., using `pgcrypto` or application-level encryption). At minimum, never return the password in API responses.
### 1.4 No CORS Configuration
**File:** `backend/internal/router/router.go`, `backend/cmd/server/main.go`
There is zero CORS handling anywhere in the backend. The frontend uses Next.js rewrites to proxy `/api/` to the backend, which works in production. But:
- If anyone accesses the backend directly (different origin), there's no CORS protection.
- No `X-Frame-Options`, `X-Content-Type-Options`, or other security headers are set.
**Fix:** Add CORS middleware restricting to the frontend origin. Add standard security headers.
### 1.5 Internal Error Messages Leaked to Clients
**Files:** Multiple handlers (e.g., `cases.go:44`, `cases.go:73`, `appointments.go`)
```go
writeError(w, http.StatusInternalServerError, err.Error())
```
Internal error messages (including SQL errors, connection errors, etc.) are sent directly to the client. This leaks implementation details.
**Fix:** Log the full error server-side, return a generic message to the client.
### 1.6 Race Condition in HolidayService Cache
**File:** `backend/internal/services/holidays.go`
The `HolidayService` uses a `map[int][]Holiday` cache without any mutex protection. Concurrent requests (e.g., multiple deadline calculations) will cause a data race. The Go race detector would flag this.
**Fix:** Add `sync.RWMutex` to HolidayService.
### 1.7 Rate Limiter Trivially Bypassable
**File:** `backend/internal/middleware/ratelimit.go:78-79`
```go
ip := r.Header.Get("X-Forwarded-For")
if ip == "" { ip = r.RemoteAddr }
```
Rate limiting keys off `X-Forwarded-For`, which any client can spoof. An attacker can bypass AI endpoint rate limits by rotating this header.
**Fix:** Only trust `X-Forwarded-For` from configured reverse proxy IPs, or use `r.RemoteAddr` exclusively behind a trusted proxy.
---
## 2. Important Gaps (Fix Before Showing to Anyone)
### 2.1 No Input Validation Beyond "Required Fields"
**Files:** All handlers
Input validation is minimal — typically just checking if required fields are empty:
```go
if input.CaseNumber == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "case_number and title are required")
}
```
Missing:
- Length limits on text fields (could store megabytes in a title field)
- Status value validation (accepts any string for status fields)
- Date format validation
- Case type validation against allowed values
- SQL-safe string validation (although parameterized queries protect against injection)
### 2.2 No Pagination Defaults on Most List Endpoints
**File:** `backend/internal/services/case_service.go:57-63`
`CaseService.List` has sane defaults (limit=20, max=100). But other list endpoints (`appointments`, `deadlines`, `notes`, `parties`, `case_events`) have no pagination at all — they return all records for a tenant/case. As data grows, these become performance problems.
### 2.3 Dashboard Page is Entirely Client-Side
**File:** `frontend/src/app/(app)/dashboard/page.tsx`
The entire dashboard is a `"use client"` component that fetches data via API. This means:
- No SSR benefit — the page is blank until JS loads and API responds
- SEO doesn't matter for a SaaS app, but initial load time does
- The skeleton is nice but adds 200-400ms of perceived latency
For an internal tool this is acceptable, but for a commercial product it should use server components for the initial render.
### 2.4 Frontend Auth Uses `getSession()` Instead of `getUser()`
**File:** `frontend/src/lib/api.ts:10-12`
```typescript
const { data: { session } } = await supabase.auth.getSession();
```
`getSession()` reads from local storage without server verification. If a session is expired or revoked server-side, the frontend will still try to use it until the backend rejects it. The middleware correctly uses `getUser()` (which validates server-side), but the API client does not.
### 2.5 Missing Error Recovery in Frontend
Throughout the frontend, API errors are handled with basic error states, but there's no:
- Retry logic for transient failures
- Token refresh on 401 responses
- Optimistic UI rollback on mutation failures
- Offline detection
### 2.6 Missing `Content-Disposition` Header Sanitization
**File:** `backend/internal/handlers/documents.go:133`
```go
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title))
```
The `title` (which comes from user input) is inserted directly into the header. A filename containing `"` or newlines could be used for response header injection.
**Fix:** Sanitize the filename — strip or encode special characters.
### 2.7 No Graceful Shutdown
**File:** `backend/cmd/server/main.go:42`
```go
http.ListenAndServe(":"+cfg.Port, handler)
```
No signal handling or graceful shutdown. When the process receives SIGTERM (e.g., during deployment), in-flight requests are dropped, CalDAV sync operations may be interrupted mid-write, and database connections are not cleanly closed.
### 2.8 Database Connection Pool — search_path is Session-Level
**File:** `backend/internal/db/connection.go:17`
```go
db.Exec("SET search_path TO kanzlai, public")
```
`SET search_path` is session-level in PostgreSQL. With connection pooling (`MaxOpenConns: 25`), this SET runs once on the initial connection. If a connection is recycled or a new one opened from the pool, it may not have the kanzlai search_path. This could cause queries to silently hit the wrong schema.
**Fix:** Use `SET LOCAL search_path` in a transaction, or set it at the database/role level, or qualify all table references with the schema name.
### 2.9 go.sum Missing from Dockerfile
**File:** `backend/Dockerfile:4`
```dockerfile
COPY go.mod ./
RUN go mod download
```
Only `go.mod` is copied, not `go.sum`. This means the build isn't reproducible and doesn't verify checksums. Should be `COPY go.mod go.sum ./`.
### 2.10 German Umlaut Typos Throughout Frontend
**Files:** Multiple frontend components
German strings use ASCII approximations instead of proper characters:
- `login/page.tsx`: "Zurueck" instead of "Zurück"
- `cases/[id]/layout.tsx`: "Anhaengig" instead of "Anhängig"
- `cases/[id]/fristen/page.tsx`: "Ueberfaellig" instead of "Überfällig"
- `termine/page.tsx`: "Uberblick" instead of "Überblick"
A German lawyer would notice this immediately. It signals "this was built by a machine, not tested by a human."
### 2.11 Silent Error Swallowing in Event Creation
**File:** `backend/internal/services/case_service.go:260-266`
```go
func createEvent(ctx context.Context, db *sqlx.DB, ...) {
db.ExecContext(ctx, /* ... */) // Error completely ignored
}
```
Case events (audit trail) silently fail to create. The calling functions don't check the return. This means you could have cases with no events and no way to know why.
### 2.12 Missing Error Boundaries in Frontend
No React error boundaries are implemented. If any component throws, the entire page crashes with a white screen. For a law firm tool where data integrity matters, this is unacceptable.
### 2.13 No RLS Policies Defined at Database Level
Multi-tenant isolation relies entirely on `WHERE tenant_id = $X` clauses in Go code. If any query forgets this clause, data leaks across tenants. There are no PostgreSQL RLS policies as a safety net.
**Fix:** Enable RLS on all tenant-scoped tables and create policies tied to `auth.uid()` via `user_tenants`.
---
## 3. Architecture Assessment
### 3.1 What's Good
- **Clean monorepo structure** — `backend/` and `frontend/` are clearly separated. Each has its own Dockerfile. The Makefile provides unified commands.
- **Go backend is well-organized** — `cmd/server/`, `internal/{auth,config,db,handlers,middleware,models,router,services}` follows Go best practices.
- **Handler/Service separation** — handlers do HTTP concerns (parse request, write response), services do business logic. This is correct.
- **Parameterized SQL everywhere** — no string concatenation in queries. All user input goes through `$N` placeholders.
- **Multi-tenant design** — `tenant_id` on every row, context-based tenant resolution, RLS at the database level.
- **Smart use of Go 1.22+ routing** — method+path patterns like `GET /api/cases/{id}` eliminate the need for a third-party router.
- **CalDAV sync is genuinely impressive** — bidirectional sync with conflict resolution, etag tracking, background polling per-tenant. This is a differentiator.
- **Deadline calculator** — ported from youpc.org with holiday awareness. Legally important and hard to build.
- **Frontend routing structure** — German URL paths (`/fristen`, `/termine`, `/einstellungen`), nested case detail routes with layout.tsx for shared chrome. Proper use of App Router patterns.
### 3.2 Structural Concerns
- **No database migrations** — the schema was apparently created via SQL scripts run manually. There's a `seed/demo_data.sql` but no migration system. For a production system, this is unsustainable.
- **No CI/CD pipeline** — no `.github/workflows/`, `.gitea/`, or any CI configuration. Tests run locally but not automatically.
- **No API versioning** — all routes are at `/api/`. Adding breaking changes will break clients.
- **Services take raw `*sqlx.DB`** — no transaction support across service boundaries. Creating a case + event is not atomic (if the event insert fails, the case still exists).
- **Models are just struct definitions** — no validation methods, no constructor functions. Validation is scattered across handlers.
### 3.3 Data Model
Based on the seed data and model files, the schema is reasonable:
- `tenants`, `user_tenants` (multi-tenancy)
- `cases`, `parties` (case management)
- `deadlines`, `appointments` (time management)
- `documents`, `case_events`, `notes` (supporting data)
- `proceeding_types`, `deadline_rules`, `holidays` (reference data)
**Missing indexes likely needed:**
- `deadlines(tenant_id, status, due_date)` — for dashboard queries
- `appointments(tenant_id, start_at)` — for calendar queries
- `case_events(case_id, created_at)` — for event feeds
- `cases(tenant_id, status)` — for filtered lists
**Missing constraints:**
- No CHECK constraint on status values (cases, deadlines, appointments)
- No UNIQUE constraint on `case_number` per tenant
- No foreign key from `notes` to the parent entity (if polymorphic)
---
## 4. Security Assessment
### 4.1 Authentication
- **JWT validation is correct** — algorithm check (HMAC only), expiry check, sub claim extraction. Using `golang-jwt/v5`.
- **Supabase Auth on frontend** — proper cookie-based session with server-side verification in middleware.
- **No refresh token rotation** — the API client uses `getSession()` which may serve stale tokens.
### 4.2 Authorization
- **Critical: Tenant isolation bypass** (see 1.1)
- **No role-based access control** — `user_tenants` has a `role` column but it's never checked. Any member can do anything.
- **No resource-level permissions** — any user in a tenant can delete any case, document, etc.
### 4.3 Input Validation
- **SQL injection: Protected** — all queries use parameterized placeholders.
- **XSS: Partially protected** — React auto-escapes, but the API returns raw strings that could contain HTML. The `Content-Disposition` header is vulnerable (see 2.6).
- **File upload: Partially protected** — `MaxBytesReader` limits to 50MB, but no file type validation (could upload .exe, .html with scripts, etc.).
- **Rate limiting: AI endpoints only** — the rest of the API has no rate limiting. Login/register go through Supabase (which has its own limits), but all CRUD endpoints are unlimited.
### 4.4 Secrets
- **No hardcoded secrets** — all via environment variables. Good.
- **CalDAV credentials in plaintext** — see 1.3.
- **Supabase service key in backend** — necessary for storage, but this key has full DB access. Should be scoped.
---
## 5. Testing Assessment
### 5.1 Backend Tests (15 files)
- **Integration test** — sets up real DB connection, creates JWT, tests full HTTP flow. Excellent pattern but requires DATABASE_URL (skips otherwise).
- **Handler tests** — mock-based unit tests for most handlers. Test JSON parsing, error responses, basic happy paths.
- **Service tests** — deadline calculator has solid date arithmetic tests. Holiday service tested. CalDAV service tested with mocks. AI service tested with mocked HTTP.
- **Middleware tests** — rate limiter tested.
- **Auth tests** — tenant resolver tested.
### 5.2 Frontend Tests (4 files)
- `api.test.ts` — tests the API client
- `DeadlineTrafficLights.test.tsx` — component test
- `CaseOverviewGrid.test.tsx` — component test
- `LoginPage.test.tsx` — auth page test
### 5.3 What's Missing
- **No E2E tests** — no Playwright/Cypress. Critical for a law firm app where correctness matters.
- **No contract tests** — frontend and backend are tested independently. A schema change could break the frontend without any test catching it.
- **Deadline calculation edge cases** — needs tests for year boundaries, leap years, holidays falling on weekends, multiple consecutive holidays.
- **Multi-tenant security tests** — no test verifying that User A can't access Tenant B's data. This is the most important test to add.
- **Frontend test coverage is thin** — 4 tests for ~30 components. The dashboard, all forms, navigation, error states are untested.
- **No load testing** — unknown how the system behaves under concurrent users.
---
## 6. UX Assessment
### 6.1 What Works
- **Dashboard is strong** — traffic light deadline indicators, upcoming timeline, case overview, quick actions. A lawyer can see what matters at a glance.
- **German localization** — UI is in German with proper legal terminology (Akten, Fristen, Termine, Parteien).
- **Mobile responsive** — sidebar collapses to hamburger menu, layout uses responsive grids.
- **Loading states** — skeleton screens on dashboard, not just spinners.
- **Breadcrumbs** — navigation trail on all pages.
- **Deadline calculator** — unique feature that provides real value for UPC litigation.
### 6.2 What a Lawyer Would Stumble On
1. **No onboarding flow** — after registration, user has no tenant, no cases. The app shows empty states but doesn't guide the user to create a tenant or import data.
2. **No search** — there's no global search. A lawyer with 100+ cases needs to find things fast.
3. **No keyboard shortcuts** — power users (lawyers are keyboard-heavy) have no shortcuts.
4. **Sidebar mixes languages** — "Akten" (German) vs "AI Analyse" (English). Should be consistent.
5. **No notifications** — overdue deadlines don't trigger any alert beyond the dashboard color. No email alerts, no push notifications.
6. **No print view** — lawyers need to print deadline lists, case summaries. No print stylesheet.
7. **No bulk operations** — can't mark multiple deadlines as complete, can't bulk-assign parties.
8. **Document upload has no preview** — uploaded PDFs can't be viewed inline.
9. **AI features require manual trigger** — AI summary and deadline extraction are manual. Should auto-trigger on document upload.
10. **No activity log per user** — no audit trail of who changed what. Critical for law firm compliance.
---
## 7. Deployment Assessment
### 7.1 Docker Setup
- **Multi-stage builds** — both Dockerfiles use builder pattern. Good.
- **Backend is minimal** — Alpine + static binary + ca-certificates. ~15MB image.
- **Frontend** — Bun for deps/build, Node for runtime (standalone output). Reasonable.
- **Missing:** go.sum not copied in backend Dockerfile (see 2.9).
- **Missing:** No docker-compose.yml for local development.
- **Missing:** No health check in Dockerfile (`HEALTHCHECK` instruction).
### 7.2 Environment Handling
- **Config validates required vars** — `DATABASE_URL` and `SUPABASE_JWT_SECRET` are checked at startup.
- **Supabase URL/keys not validated** — if missing, features silently fail or crash at runtime.
- **No .env.example** — new developers don't know what env vars are needed.
### 7.3 Reliability
- **No graceful shutdown** (see 2.7)
- **No readiness/liveness probes** — `/health` exists but only checks DB connectivity. No readiness distinction.
- **CalDAV sync runs in-process** — if the sync goroutine panics, it takes down the API server.
- **No structured error recovery** — panics in handlers will crash the process (no recovery middleware).
---
## 8. Competitive Analysis
### 8.1 The Market
German Kanzleisoftware is a mature, crowded market:
| Tool | Type | Price | Key Strength |
|------|------|-------|-------------|
| **RA-MICRO** | Desktop + Cloud | ~100-200 EUR/user/mo | Market leader, 30+ years, full beA integration |
| **ADVOWARE** | Desktop + Cloud | from 20 EUR/mo | Budget-friendly, strong for small firms |
| **AnNoText** (Wolters Kluwer) | Desktop + Cloud | Custom pricing | Enterprise, AI document analysis, DictNow |
| **Actaport** | Cloud-native | from 79.80 EUR/mo | Modern UI, Mandantenportal, integrated Office |
| **Haufe Advolux** | Cloud | Custom | User-friendly, full-featured |
| **Renostar Legal Cloud** | Cloud | Custom | Browser-based, no installation |
### 8.2 Table-Stakes Features KanzlAI is Missing
These are **mandatory** for any German Kanzleisoftware to be taken seriously:
1. **beA Integration** — since 2022, German lawyers must use the electronic court mailbox (besonderes elektronisches Anwaltspostfach). No Kanzleisoftware sells without it. This is a **massive** implementation effort (KSW-Schnittstelle from BRAK).
2. **RVG Billing (Gebührenrechner)** — automated fee calculation per RVG (Rechtsanwaltsvergütungsgesetz). Every competitor has this built-in. Without it, lawyers can't bill clients.
3. **Document Generation** — templates for Schriftsätze, Klageschriften, Mahnbescheide with auto-populated case data. Usually integrated with Word.
4. **Accounting (FiBu)** — client trust accounts (Fremdgeld), DATEV export, tax-relevant bookkeeping. Legal requirement.
5. **Conflict Check (Kollisionsprüfung)** — check if the firm has a conflict of interest before taking a case. Legally required (§ 43a BRAO).
6. **Dictation System** — voice-to-text for lawyers. RA-MICRO has DictaNet, AnNoText has DictNow.
### 8.3 Where KanzlAI Could Differentiate
Despite the feature gap, KanzlAI has some advantages:
1. **AI-native** — competitors are bolting AI onto 20-year-old software. KanzlAI has Claude API integration from day one. The deadline extraction from PDFs is genuinely useful.
2. **UPC specialization** — the deadline calculator with UPC Rules of Procedure knowledge is unique. No competitor has deep UPC litigation support.
3. **CalDAV sync** — bidirectional sync with external calendars is not common in German Kanzleisoftware.
4. **Modern tech stack** — React + Go + Supabase vs. the .NET/Java/Desktop world of RA-MICRO et al.
5. **Multi-tenant from day 1** — designed for SaaS, not converted from desktop software.
### 8.4 Strategic Recommendation
**Don't compete head-on with RA-MICRO.** The feature gap is 10+ person-years of work. Instead:
**Option A: UPC Niche Tool** — Pivot back to UPC patent litigation. Build the best deadline calculator, case tracker, and AI-powered brief analysis tool for UPC practitioners. There are ~1000 UPC practitioners in Europe who need specialized tooling that RA-MICRO doesn't provide. Charge 200-500 EUR/mo.
**Option B: AI-First Legal Assistant** — Don't call it "Kanzleimanagement." Position as an AI assistant that reads court documents, extracts deadlines, and syncs to the lawyer's existing Kanzleisoftware via CalDAV/iCal. This sidesteps the feature gap entirely.
**Option C: Full Kanzleisoftware** — If you pursue this, beA integration is the first priority, then RVG billing. Without these two, no German lawyer will switch.
---
## 9. Strengths (What's Good, Keep Doing It)
1. **Architecture is solid** — the Go + Next.js + Supabase stack is well-chosen. Clean separation of concerns.
2. **SQL is safe** — parameterized queries throughout. No injection vectors.
3. **Multi-tenant design** — tenant_id scoping with RLS is the right approach.
4. **CalDAV implementation** — genuinely impressive for an MVP. Bidirectional sync with conflict resolution.
5. **Deadline calculator** — ported from youpc.org with holiday awareness. Real domain value.
6. **AI integration** — Claude API with tool use for structured extraction. Clean implementation.
7. **Dashboard UX** — traffic lights, timeline, quick actions. Lawyers will get this immediately.
8. **German-first** — proper legal terminology, German date formats, localized UI.
9. **Test foundation** — 15 backend test files with integration tests. Good starting point.
10. **Docker builds are lean** — multi-stage, Alpine-based, standalone Next.js output.
---
## 10. Priority Roadmap
### P0 — This Week
- [ ] Fix tenant isolation bypass in TenantResolver (1.1)
- [ ] Consolidate tenant resolution logic (1.2)
- [ ] Encrypt CalDAV credentials at rest (1.3)
- [ ] Add CORS middleware + security headers (1.4)
- [ ] Stop leaking internal errors to clients (1.5)
- [ ] Add mutex to HolidayService cache (1.6)
- [ ] Fix rate limiter X-Forwarded-For bypass (1.7)
- [ ] Fix Dockerfile go.sum copy (2.9)
### P1 — Before Demo/Beta
- [ ] Add input validation (length limits, allowed values) (2.1)
- [ ] Add pagination to all list endpoints (2.2)
- [ ] Fix `search_path` connection pool issue (2.8)
- [ ] Add graceful shutdown with signal handling (2.7)
- [ ] Sanitize Content-Disposition filename (2.6)
- [ ] Fix German umlaut typos throughout frontend (2.10)
- [ ] Handle createEvent errors instead of swallowing (2.11)
- [ ] Add React error boundaries (2.12)
- [ ] Implement RLS policies on all tenant-scoped tables (2.13)
- [ ] Add multi-tenant security tests
- [ ] Add database migrations system
- [ ] Add `.env.example` file
- [ ] Add onboarding flow for new users
### P2 — Next Iteration
- [ ] Role-based access control (admin/member/readonly)
- [ ] Global search
- [ ] Email notifications for overdue deadlines
- [ ] Audit trail / activity log per user
- [ ] Auto-trigger AI extraction on document upload
- [ ] Print-friendly views
- [ ] E2E tests with Playwright
- [ ] CI/CD pipeline
### P3 — Strategic
- [ ] Decide market positioning (UPC niche vs. AI assistant vs. full Kanzleisoftware)
- [ ] If Kanzleisoftware: begin beA integration research
- [ ] If Kanzleisoftware: RVG Gebührenrechner
- [ ] If UPC niche: integrate lex-research case law database
---
*This audit was conducted by reading every source file in the repository, running all tests, analyzing the database schema via seed data, and comparing against established German Kanzleisoftware competitors.*

View File

@@ -11,6 +11,7 @@ type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -30,3 +31,12 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}

View File

@@ -36,15 +36,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
ctx := ContextWithUserID(r.Context(), userID)
// Resolve tenant from user_tenants
var tenantID uuid.UUID
err = m.db.GetContext(r.Context(), &tenantID,
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
// Resolve tenant and role from user_tenants
var membership struct {
TenantID uuid.UUID `db:"tenant_id"`
Role string `db:"role"`
}
err = m.db.GetContext(r.Context(), &membership,
"SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, tenantID)
ctx = ContextWithTenantID(ctx, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@@ -0,0 +1,213 @@
package auth
import (
"context"
"net/http"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// Valid roles ordered by privilege level (highest first).
var ValidRoles = []string{"owner", "partner", "associate", "paralegal", "secretary"}
// IsValidRole checks if a role string is one of the defined roles.
func IsValidRole(role string) bool {
for _, r := range ValidRoles {
if r == role {
return true
}
}
return false
}
// Permission represents an action that can be checked against roles.
type Permission int
const (
PermManageTeam Permission = iota
PermManageBilling
PermCreateCase
PermEditAllCases
PermEditAssignedCase
PermViewAllCases
PermManageDeadlines
PermManageAppointments
PermUploadDocuments
PermDeleteDocuments
PermDeleteOwnDocuments
PermViewAuditLog
PermManageSettings
PermAIExtraction
)
// rolePermissions maps each role to its set of permissions.
var rolePermissions = map[string]map[Permission]bool{
"owner": {
PermManageTeam: true,
PermManageBilling: true,
PermCreateCase: true,
PermEditAllCases: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteDocuments: true,
PermDeleteOwnDocuments: true,
PermViewAuditLog: true,
PermManageSettings: true,
PermAIExtraction: true,
},
"partner": {
PermManageTeam: true,
PermManageBilling: true,
PermCreateCase: true,
PermEditAllCases: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteDocuments: true,
PermDeleteOwnDocuments: true,
PermViewAuditLog: true,
PermManageSettings: true,
PermAIExtraction: true,
},
"associate": {
PermCreateCase: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteOwnDocuments: true,
PermAIExtraction: true,
},
"paralegal": {
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
},
"secretary": {
PermViewAllCases: true,
PermManageAppointments: true,
PermUploadDocuments: true,
},
}
// HasPermission checks if the given role has the specified permission.
func HasPermission(role string, perm Permission) bool {
perms, ok := rolePermissions[role]
if !ok {
return false
}
return perms[perm]
}
// RequirePermission returns middleware that checks if the user's role has the given permission.
func RequirePermission(perm Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := UserRoleFromContext(r.Context())
if role == "" || !HasPermission(role, perm) {
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireRole returns middleware that checks if the user has one of the specified roles.
func RequireRole(roles ...string) func(http.Handler) http.Handler {
allowed := make(map[string]bool, len(roles))
for _, r := range roles {
allowed[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := UserRoleFromContext(r.Context())
if !allowed[role] {
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// IsAssignedToCase checks if a user is assigned to a specific case.
func IsAssignedToCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID) (bool, error) {
var exists bool
err := db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM case_assignments WHERE user_id = $1 AND case_id = $2)`,
userID, caseID)
return exists, err
}
// CanEditCase checks if a user can edit a specific case based on role and assignment.
func CanEditCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID, role string) (bool, error) {
// Owner and partner can edit all cases
if HasPermission(role, PermEditAllCases) {
return true, nil
}
// Others need to be assigned
if !HasPermission(role, PermEditAssignedCase) {
return false, nil
}
return IsAssignedToCase(ctx, db, userID, caseID)
}
// CanDeleteDocument checks if a user can delete a specific document.
func CanDeleteDocument(role string, docUploaderID, userID uuid.UUID) bool {
if HasPermission(role, PermDeleteDocuments) {
return true
}
if HasPermission(role, PermDeleteOwnDocuments) {
return docUploaderID == userID
}
return false
}
// permissionNames maps Permission constants to their string names for frontend use.
var permissionNames = map[Permission]string{
PermManageTeam: "manage_team",
PermManageBilling: "manage_billing",
PermCreateCase: "create_case",
PermEditAllCases: "edit_all_cases",
PermEditAssignedCase: "edit_assigned_case",
PermViewAllCases: "view_all_cases",
PermManageDeadlines: "manage_deadlines",
PermManageAppointments: "manage_appointments",
PermUploadDocuments: "upload_documents",
PermDeleteDocuments: "delete_documents",
PermDeleteOwnDocuments: "delete_own_documents",
PermViewAuditLog: "view_audit_log",
PermManageSettings: "manage_settings",
PermAIExtraction: "ai_extraction",
}
// GetRolePermissions returns a list of permission name strings for the given role.
func GetRolePermissions(role string) []string {
perms, ok := rolePermissions[role]
if !ok {
return nil
}
var names []string
for p := range perms {
if name, ok := permissionNames[p]; ok {
names = append(names, name)
}
}
return names
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write([]byte(`{"error":"` + msg + `"}`))
}

View File

@@ -12,6 +12,7 @@ import (
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
@@ -40,9 +41,21 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
http.Error(w, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest)
return
}
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
http.Error(w, "error checking tenant access", http.StatusInternalServerError)
return
}
if role == "" {
http.Error(w, "no access to this tenant", http.StatusForbidden)
return
}
tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else {
// Default to user's first tenant
// Default to user's first tenant (role already set by middleware)
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil {
http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError)

View File

@@ -11,6 +11,7 @@ import (
type mockTenantLookup struct {
tenantID *uuid.UUID
role string
err error
}
@@ -18,9 +19,16 @@ func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.U
return m.tenantID, m.err
}
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.role != "" {
return m.role, m.err
}
return "associate", m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{})
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,119 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type CaseAssignmentHandler struct {
svc *services.CaseAssignmentService
}
func NewCaseAssignmentHandler(svc *services.CaseAssignmentService) *CaseAssignmentHandler {
return &CaseAssignmentHandler{svc: svc}
}
// List handles GET /api/cases/{id}/assignments
func (h *CaseAssignmentHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
assignments, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"assignments": assignments,
"total": len(assignments),
})
}
// Assign handles POST /api/cases/{id}/assignments
func (h *CaseAssignmentHandler) Assign(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var req struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
if req.Role == "" {
req.Role = "team"
}
if req.Role != "lead" && req.Role != "team" && req.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be lead, team, or viewer")
return
}
assignment, err := h.svc.Assign(r.Context(), tenantID, caseID, userID, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, assignment)
}
// Unassign handles DELETE /api/cases/{id}/assignments/{uid}
func (h *CaseAssignmentHandler) Unassign(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
userID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user ID")
return
}
if err := h.svc.Unassign(r.Context(), tenantID, caseID, userID); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}

View File

@@ -167,6 +167,7 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
userID, _ := auth.UserFromContext(r.Context())
role := auth.UserRoleFromContext(r.Context())
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
@@ -174,6 +175,26 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
// Check permission: owner/partner can delete any, associate can delete own
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if doc == nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
uploaderID := uuid.Nil
if doc.UploadedBy != nil {
uploaderID = *doc.UploadedBy
}
if !auth.CanDeleteDocument(role, uploaderID, userID) {
writeError(w, http.StatusForbidden, "insufficient permissions to delete this document")
return
}
if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return

View File

@@ -117,14 +117,14 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return
}
// Only owners and admins can invite
// Only owners and partners can invite
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" {
jsonError(w, "only owners and admins can invite users", http.StatusForbidden)
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can invite users", http.StatusForbidden)
return
}
@@ -141,10 +141,15 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return
}
if req.Role == "" {
req.Role = "member"
req.Role = "associate"
}
if req.Role != "member" && req.Role != "admin" {
jsonError(w, "role must be member or admin", http.StatusBadRequest)
if !auth.IsValidRole(req.Role) {
jsonError(w, "invalid role", http.StatusBadRequest)
return
}
// Non-owners cannot invite as owner
if role != "owner" && req.Role == "owner" {
jsonError(w, "only owners can invite as owner", http.StatusForbidden)
return
}
@@ -177,13 +182,13 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
return
}
// Only owners and admins can remove members (or user removing themselves)
// Only owners and partners can remove members (or user removing themselves)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" && userID != memberID {
if role != "owner" && role != "partner" && userID != memberID {
jsonError(w, "insufficient permissions", http.StatusForbidden)
return
}
@@ -210,14 +215,14 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
// Only owners and admins can update settings
// Only owners and partners can update settings
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" {
jsonError(w, "only owners and admins can update settings", http.StatusForbidden)
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can update settings", http.StatusForbidden)
return
}
@@ -270,6 +275,85 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, members, http.StatusOK)
}
// UpdateMemberRole handles PUT /api/tenants/{id}/members/{uid}/role
func (h *TenantHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
memberID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
jsonError(w, "invalid member ID", http.StatusBadRequest)
return
}
// Only owners and partners can change roles
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can change roles", http.StatusForbidden)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if !auth.IsValidRole(req.Role) {
jsonError(w, "invalid role", http.StatusBadRequest)
return
}
// Non-owners cannot promote to owner
if role != "owner" && req.Role == "owner" {
jsonError(w, "only owners can promote to owner", http.StatusForbidden)
return
}
if err := h.svc.UpdateMemberRole(r.Context(), tenantID, memberID, req.Role); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK)
}
// GetMe handles GET /api/me — returns the current user's ID and role in the active tenant.
func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
role := auth.UserRoleFromContext(r.Context())
tenantID, _ := auth.TenantFromContext(r.Context())
// Get user's permissions for frontend UI
perms := auth.GetRolePermissions(role)
jsonResponse(w, map[string]any{
"user_id": userID,
"tenant_id": tenantID,
"role": role,
"permissions": perms,
}, http.StatusOK)
}
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
"github.com/google/uuid"
)
type CaseAssignment struct {
ID uuid.UUID `db:"id" json:"id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Role string `db:"role" json:"role"`
AssignedAt time.Time `db:"assigned_at" json:"assigned_at"`
}

View File

@@ -29,6 +29,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli)
assignmentSvc := services.NewCaseAssignmentService(db)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
@@ -55,6 +56,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
@@ -70,77 +72,100 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
api.HandleFunc("PUT /api/tenants/{id}/members/{uid}/role", tenantH.UpdateMemberRole)
// Permission-wrapping helper: wraps a HandlerFunc with a permission check
perm := func(p auth.Permission, fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role := auth.UserRoleFromContext(r.Context())
if !auth.HasPermission(role, p) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"insufficient permissions"}`))
return
}
fn(w, r)
}
}
// Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux()
// Cases
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", caseH.Create)
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
// Current user info (role, permissions) — all authenticated users
scoped.HandleFunc("GET /api/me", tenantH.GetMe)
// Parties
// Cases — all can view, create needs PermCreateCase, archive needs PermCreateCase
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create))
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler
scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete))
// Parties — same access as case editing
scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Deadlines
// Deadlines — manage needs PermManageDeadlines, view is open
scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get)
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", perm(auth.PermManageDeadlines, deadlineH.Create))
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Update))
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", perm(auth.PermManageDeadlines, deadlineH.Complete))
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Delete))
// Deadline rules (reference data)
// Deadline rules (reference data) — all can read
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes)
// Deadline calculator
// Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments
// Appointments — all can manage (PermManageAppointments granted to all)
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", apptH.Create)
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create))
scoped.HandleFunc("PUT /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Update))
scoped.HandleFunc("DELETE /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Delete))
// Case events
// Case assignments — manage team required for assign/unassign
scoped.HandleFunc("GET /api/cases/{id}/assignments", assignmentH.List)
scoped.HandleFunc("POST /api/cases/{id}/assignments", perm(auth.PermManageTeam, assignmentH.Assign))
scoped.HandleFunc("DELETE /api/cases/{id}/assignments/{uid}", perm(auth.PermManageTeam, assignmentH.Unassign))
// Case events — all can view
scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
// Notes
// Notes — all can manage
scoped.HandleFunc("GET /api/notes", noteH.List)
scoped.HandleFunc("POST /api/notes", noteH.Create)
scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update)
scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete)
// Dashboard
// Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Documents
// Documents — all can upload, delete checked in handler (own vs all)
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload))
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil {
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines))
scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase))
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
}
// CalDAV sync endpoints
// CalDAV sync endpoints — settings permission required
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", calDAVH.TriggerSync)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
}

View File

@@ -0,0 +1,92 @@
package services
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type CaseAssignmentService struct {
db *sqlx.DB
}
func NewCaseAssignmentService(db *sqlx.DB) *CaseAssignmentService {
return &CaseAssignmentService{db: db}
}
// ListByCase returns all assignments for a case.
func (s *CaseAssignmentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.CaseAssignment, error) {
var assignments []models.CaseAssignment
err := s.db.SelectContext(ctx, &assignments,
`SELECT ca.id, ca.case_id, ca.user_id, ca.role, ca.assigned_at
FROM case_assignments ca
JOIN cases c ON c.id = ca.case_id
WHERE ca.case_id = $1 AND c.tenant_id = $2
ORDER BY ca.assigned_at`,
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("list case assignments: %w", err)
}
return assignments, nil
}
// Assign adds a user to a case with the given role.
func (s *CaseAssignmentService) Assign(ctx context.Context, tenantID, caseID, userID uuid.UUID, role string) (*models.CaseAssignment, error) {
// Verify user is a member of this tenant
var memberExists bool
err := s.db.GetContext(ctx, &memberExists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenantID)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if !memberExists {
return nil, fmt.Errorf("user is not a member of this tenant")
}
// Verify case belongs to tenant
var caseExists bool
err = s.db.GetContext(ctx, &caseExists,
`SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)`,
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("check case: %w", err)
}
if !caseExists {
return nil, fmt.Errorf("case not found")
}
var assignment models.CaseAssignment
err = s.db.QueryRowxContext(ctx,
`INSERT INTO case_assignments (case_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (case_id, user_id) DO UPDATE SET role = EXCLUDED.role
RETURNING id, case_id, user_id, role, assigned_at`,
caseID, userID, role,
).StructScan(&assignment)
if err != nil {
return nil, fmt.Errorf("assign user to case: %w", err)
}
return &assignment, nil
}
// Unassign removes a user from a case.
func (s *CaseAssignmentService) Unassign(ctx context.Context, tenantID, caseID, userID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
`DELETE FROM case_assignments ca
USING cases c
WHERE ca.case_id = c.id AND ca.case_id = $1 AND ca.user_id = $2 AND c.tenant_id = $3`,
caseID, userID, tenantID)
if err != nil {
return fmt.Errorf("unassign: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("assignment not found")
}
return nil
}

View File

@@ -189,6 +189,40 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
return &tenant, nil
}
// UpdateMemberRole changes a member's role in a tenant.
func (s *TenantService) UpdateMemberRole(ctx context.Context, tenantID, userID uuid.UUID, newRole string) error {
// Get current role
currentRole, err := s.GetUserRole(ctx, userID, tenantID)
if err != nil {
return fmt.Errorf("get current role: %w", err)
}
if currentRole == "" {
return fmt.Errorf("user is not a member of this tenant")
}
// If demoting the last owner, block it
if currentRole == "owner" && newRole != "owner" {
var ownerCount int
err := s.db.GetContext(ctx, &ownerCount,
`SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`,
tenantID)
if err != nil {
return fmt.Errorf("count owners: %w", err)
}
if ownerCount <= 1 {
return fmt.Errorf("cannot demote the last owner")
}
}
_, err = s.db.ExecContext(ctx,
`UPDATE user_tenants SET role = $1 WHERE user_id = $2 AND tenant_id = $3`,
newRole, userID, tenantID)
if err != nil {
return fmt.Errorf("update role: %w", err)
}
return nil
}
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner

View File

@@ -13,6 +13,7 @@ import {
Clock,
FileText,
Users,
UserCheck,
StickyNote,
AlertTriangle,
} from "lucide-react";
@@ -43,6 +44,7 @@ const TABS = [
{ segment: "fristen", label: "Fristen", icon: Clock },
{ segment: "dokumente", label: "Dokumente", icon: FileText },
{ segment: "parteien", label: "Parteien", icon: Users },
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
] as const;
@@ -51,6 +53,7 @@ const TAB_LABELS: Record<string, string> = {
fristen: "Fristen",
dokumente: "Dokumente",
parteien: "Parteien",
mitarbeiter: "Mitarbeiter",
notizen: "Notizen",
};

View File

@@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import { CaseAssignments } from "@/components/cases/CaseAssignments";
export default function CaseMitarbeiterPage() {
const { id } = useParams<{ id: string }>();
return <CaseAssignments caseId={id} />;
}

View File

@@ -10,6 +10,7 @@ import { Plus, Search, FolderOpen } from "lucide-react";
import { useState } from "react";
import { SkeletonTable } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
const STATUS_OPTIONS = [
{ value: "", label: "Alle Status" },
@@ -49,6 +50,8 @@ const inputClass =
export default function CasesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { can } = usePermissions();
const canCreateCase = can("create_case");
const [search, setSearch] = useState(searchParams.get("search") ?? "");
const [status, setStatus] = useState(searchParams.get("status") ?? "");
@@ -86,13 +89,15 @@ export default function CasesPage() {
{data ? `${data.total} Akten` : "\u00A0"}
</p>
</div>
<Link
href="/cases/new"
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-4 w-4" />
Neue Akte
</Link>
{canCreateCase && (
<Link
href="/cases/new"
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-4 w-4" />
Neue Akte
</Link>
)}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -145,7 +150,7 @@ export default function CasesPage() {
: "Erstellen Sie Ihre erste Akte, um loszulegen."
}
action={
!search && !status && !type ? (
!search && !status && !type && canCreateCase ? (
<Link
href="/cases/new"
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"

View File

@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { UserPlus, Trash2, Users } from "lucide-react";
import { api } from "@/lib/api";
import type { CaseAssignment, UserTenant } from "@/lib/types";
import { CASE_ASSIGNMENT_ROLE_LABELS } from "@/lib/types";
import type { CaseAssignmentRole } from "@/lib/types";
import { Skeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
export function CaseAssignments({ caseId }: { caseId: string }) {
const queryClient = useQueryClient();
const { can } = usePermissions();
const canManage = can("manage_team");
const tenantId =
typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
const [selectedUser, setSelectedUser] = useState("");
const [assignRole, setAssignRole] = useState<CaseAssignmentRole>("team");
const { data, isLoading } = useQuery({
queryKey: ["case-assignments", caseId],
queryFn: () =>
api.get<{ assignments: CaseAssignment[]; total: number }>(
`/cases/${caseId}/assignments`,
),
});
const { data: members } = useQuery({
queryKey: ["tenant-members", tenantId],
queryFn: () =>
api.get<UserTenant[]>(`/tenants/${tenantId}/members`),
enabled: !!tenantId && canManage,
});
const assignMutation = useMutation({
mutationFn: (input: { user_id: string; role: string }) =>
api.post(`/cases/${caseId}/assignments`, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-assignments", caseId] });
setSelectedUser("");
toast.success("Mitarbeiter zugewiesen");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Zuweisen");
},
});
const unassignMutation = useMutation({
mutationFn: (userId: string) =>
api.delete(`/cases/${caseId}/assignments/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-assignments", caseId] });
toast.success("Zuweisung entfernt");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Entfernen");
},
});
const assignments = data?.assignments ?? [];
const assignedUserIds = new Set(assignments.map((a) => a.user_id));
const availableMembers = (members ?? []).filter(
(m) => !assignedUserIds.has(m.user_id),
);
const handleAssign = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
assignMutation.mutate({ user_id: selectedUser, role: assignRole });
};
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-neutral-900">
Zugewiesene Mitarbeiter
</h3>
{/* Assign form — only for owners/partners */}
{canManage && availableMembers.length > 0 && (
<form onSubmit={handleAssign} className="flex flex-col gap-2 sm:flex-row">
<select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
className="flex-1 rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
<option value="">Mitarbeiter auswählen...</option>
{availableMembers.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.user_id.slice(0, 8)}... ({m.role})
</option>
))}
</select>
<select
value={assignRole}
onChange={(e) => setAssignRole(e.target.value as CaseAssignmentRole)}
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
{(Object.keys(CASE_ASSIGNMENT_ROLE_LABELS) as CaseAssignmentRole[]).map(
(r) => (
<option key={r} value={r}>
{CASE_ASSIGNMENT_ROLE_LABELS[r]}
</option>
),
)}
</select>
<button
type="submit"
disabled={assignMutation.isPending || !selectedUser}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
<UserPlus className="h-3.5 w-3.5" />
Zuweisen
</button>
</form>
)}
{/* Assignments list */}
{assignments.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200">
{assignments.map((a, i) => (
<div
key={a.id}
className={`flex items-center justify-between px-4 py-2.5 ${
i < assignments.length - 1 ? "border-b border-neutral-100" : ""
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-100">
<Users className="h-3.5 w-3.5 text-neutral-500" />
</div>
<div>
<p className="text-sm text-neutral-900">
{a.user_id.slice(0, 8)}...
</p>
<p className="text-xs text-neutral-500">
{CASE_ASSIGNMENT_ROLE_LABELS[a.role as CaseAssignmentRole] ??
a.role}
</p>
</div>
</div>
{canManage && (
<button
onClick={() => unassignMutation.mutate(a.user_id)}
disabled={unassignMutation.isPending}
className="rounded-md p-1 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="Zuweisung entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
) : (
<EmptyState
icon={Users}
title="Keine Zuweisungen"
description="Noch keine Mitarbeiter zugewiesen."
/>
)}
</div>
);
}

View File

@@ -13,19 +13,32 @@ import {
X,
} from "lucide-react";
import { useState, useEffect } from "react";
import { usePermissions } from "@/lib/hooks/usePermissions";
const navigation = [
interface NavItem {
name: string;
href: string;
icon: typeof LayoutDashboard;
permission?: string;
}
const allNavigation: NavItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
];
export function Sidebar() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const { can, isLoading: permLoading } = usePermissions();
const navigation = allNavigation.filter(
(item) => !item.permission || permLoading || can(item.permission),
);
// Close on route change
useEffect(() => {

View File

@@ -3,27 +3,36 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react";
import { UserPlus, Trash2, Crown, Scale, Briefcase, FileText, Phone } from "lucide-react";
import { api } from "@/lib/api";
import type { UserTenant } from "@/lib/types";
import type { UserTenant, UserRole } from "@/lib/types";
import { ROLE_LABELS } from "@/lib/types";
import { Skeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
const ROLE_LABELS: Record<string, { label: string; icon: typeof Crown }> = {
owner: { label: "Eigentümer", icon: Crown },
admin: { label: "Administrator", icon: Shield },
member: { label: "Mitglied", icon: User },
const ROLE_CONFIG: Record<UserRole, { label: string; icon: typeof Crown }> = {
owner: { label: ROLE_LABELS.owner, icon: Crown },
partner: { label: ROLE_LABELS.partner, icon: Scale },
associate: { label: ROLE_LABELS.associate, icon: Briefcase },
paralegal: { label: ROLE_LABELS.paralegal, icon: FileText },
secretary: { label: ROLE_LABELS.secretary, icon: Phone },
};
const INVITE_ROLES: UserRole[] = ["partner", "associate", "paralegal", "secretary"];
export function TeamSettings() {
const queryClient = useQueryClient();
const { can, role: myRole } = usePermissions();
const tenantId =
typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [role, setRole] = useState<string>("associate");
const canManageTeam = can("manage_team");
const {
data: members,
@@ -42,7 +51,7 @@ export function TeamSettings() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
setEmail("");
setRole("member");
setRole("associate");
toast.success("Benutzer eingeladen");
},
onError: (err: { error?: string }) => {
@@ -62,6 +71,19 @@ export function TeamSettings() {
},
});
const updateRoleMutation = useMutation({
mutationFn: ({ userId, newRole }: { userId: string; newRole: string }) =>
api.put(`/tenants/${tenantId}/members/${userId}/role`, { role: newRole }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
toast.success("Rolle aktualisiert");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Aktualisieren der Rolle");
},
});
const handleInvite = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
@@ -81,7 +103,7 @@ export function TeamSettings() {
if (error) {
return (
<EmptyState
icon={User}
icon={Briefcase}
title="Fehler beim Laden"
description="Team-Mitglieder konnten nicht geladen werden."
/>
@@ -90,38 +112,44 @@ export function TeamSettings() {
return (
<div className="space-y-6">
{/* Invite Form */}
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</select>
<button
type="submit"
disabled={inviteMutation.isPending || !email.trim()}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
<UserPlus className="h-3.5 w-3.5" />
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
</button>
</form>
{/* Invite Form — only for owners/partners */}
{canManageTeam && (
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
{INVITE_ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
<button
type="submit"
disabled={inviteMutation.isPending || !email.trim()}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
<UserPlus className="h-3.5 w-3.5" />
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
</button>
</form>
)}
{/* Members List */}
{Array.isArray(members) && members.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200">
{members.map((member, i) => {
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
const roleKey = (member.role as UserRole) || "associate";
const roleInfo = ROLE_CONFIG[roleKey] || ROLE_CONFIG.associate;
const RoleIcon = roleInfo.icon;
return (
<div
@@ -141,23 +169,48 @@ export function TeamSettings() {
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
</div>
</div>
{member.role !== "owner" && (
<button
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="Mitglied entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
<div className="flex items-center gap-2">
{/* Role dropdown — only for owners/partners, not for the member's own row if they are owner */}
{canManageTeam && member.role !== "owner" && (
<select
value={member.role}
onChange={(e) =>
updateRoleMutation.mutate({
userId: member.user_id,
newRole: e.target.value,
})
}
disabled={updateRoleMutation.isPending}
className="rounded-md border border-neutral-200 px-2 py-1 text-xs outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
{myRole === "owner" && (
<option value="owner">{ROLE_LABELS.owner}</option>
)}
{INVITE_ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
)}
{canManageTeam && member.role !== "owner" && (
<button
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="Mitglied entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
);
})}
</div>
) : (
<EmptyState
icon={User}
icon={Briefcase}
title="Noch keine Mitglieder"
description="Laden Sie Teammitglieder per E-Mail ein."
/>

View File

@@ -0,0 +1,29 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { UserInfo } from "@/lib/types";
export function usePermissions() {
const { data, isLoading } = useQuery({
queryKey: ["me"],
queryFn: () => api.get<UserInfo>("/me"),
staleTime: 60 * 1000,
});
const role = data?.role ?? null;
const permissions = data?.permissions ?? [];
function can(permission: string): boolean {
return permissions.includes(permission);
}
return {
role,
permissions,
can,
isLoading,
userId: data?.user_id ?? null,
tenantId: data?.tenant_id ?? null,
};
}

View File

@@ -189,6 +189,40 @@ export interface Note {
updated_at: string;
}
export interface CaseAssignment {
id: string;
case_id: string;
user_id: string;
role: string;
assigned_at: string;
}
export interface UserInfo {
user_id: string;
tenant_id: string;
role: UserRole;
permissions: string[];
}
export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";
export const ROLE_LABELS: Record<UserRole, string> = {
owner: "Inhaber",
partner: "Partner",
associate: "Anwalt",
paralegal: "Paralegal",
secretary: "Sekretariat",
};
export const CASE_ASSIGNMENT_ROLES = ["lead", "team", "viewer"] as const;
export type CaseAssignmentRole = (typeof CASE_ASSIGNMENT_ROLES)[number];
export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
lead: "Federführend",
team: "Team",
viewer: "Einsicht",
};
export interface ApiError {
error: string;
status: number;