Compare commits

..

2 Commits

Author SHA1 Message Date
m
c324a2b5c7 fix: critical security hardening — tenant isolation, CORS, error masking, input validation 2026-03-30 11:02:52 +02:00
m
c15d5b72f2 fix: critical security hardening — tenant isolation, CORS, error leaking, input validation
1. Tenant isolation bypass (CRITICAL): TenantResolver now verifies user
   has access to X-Tenant-ID via user_tenants lookup before setting context.
   Added VerifyAccess method to TenantLookup interface and TenantService.

2. Consolidated tenant resolution: Removed duplicate resolveTenant() from
   helpers.go and tenant resolution from auth middleware. TenantResolver is
   now the single source of truth. Deadlines and AI handlers use
   auth.TenantFromContext() instead of direct DB queries.

3. CalDAV credential masking: tenant settings responses now mask CalDAV
   passwords with "********" via maskSettingsPassword helper. Applied to
   GetTenant, ListTenants, and UpdateSettings responses.

4. CORS + security headers: New middleware/security.go with CORS
   (restricted to FRONTEND_ORIGIN) and security headers (X-Frame-Options,
   X-Content-Type-Options, HSTS, Referrer-Policy, X-XSS-Protection).

5. Internal error leaking: All writeError(w, 500, err.Error()) replaced
   with internalError() that logs via slog and returns generic "internal
   error" to client. Same for jsonError in tenant handler.

6. Input validation: Max length on title (500), description (10000),
   case_number (100), search (200). Pagination clamped to max 100.
   Content-Disposition filename sanitized against header injection.

Regression test added for tenant access denial (403 on unauthorized
X-Tenant-ID). All existing tests pass, go vet clean.
2026-03-30 11:01:14 +02:00
32 changed files with 450 additions and 1220 deletions

View File

@@ -11,7 +11,6 @@ type contextKey string
const ( const (
userIDKey contextKey = "user_id" userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id" tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
) )
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -31,12 +30,3 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID) id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok 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

@@ -24,32 +24,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r) token := extractBearerToken(r)
if token == "" { if token == "" {
http.Error(w, "missing authorization token", http.StatusUnauthorized) http.Error(w, `{"error":"missing authorization token"}`, http.StatusUnauthorized)
return return
} }
userID, err := m.verifyJWT(token) userID, err := m.verifyJWT(token)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized) http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return return
} }
ctx := ContextWithUserID(r.Context(), userID) ctx := ContextWithUserID(r.Context(), userID)
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Resolve tenant and role from user_tenants // Tenant management routes handle their own access control.
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, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@@ -1,213 +0,0 @@
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

@@ -2,21 +2,21 @@ package auth
import ( import (
"context" "context"
"fmt" "log/slog"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
) )
// TenantLookup resolves the default tenant for a user. // TenantLookup resolves and verifies tenant access for a user.
// Defined as an interface to avoid circular dependency with services. // Defined as an interface to avoid circular dependency with services.
type TenantLookup interface { type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
} }
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
// or defaults to the user's first tenant. // or defaults to the user's first tenant. Always verifies user has access.
type TenantResolver struct { type TenantResolver struct {
lookup TenantLookup lookup TenantLookup
} }
@@ -29,7 +29,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserFromContext(r.Context()) userID, ok := UserFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return return
} }
@@ -38,31 +38,33 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
if header := r.Header.Get("X-Tenant-ID"); header != "" { if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header) parsed, err := uuid.Parse(header)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest) http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return return
} }
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) // Verify user has access to this tenant
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
if err != nil { if err != nil {
http.Error(w, "error checking tenant access", http.StatusInternalServerError) slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return return
} }
if role == "" { if !hasAccess {
http.Error(w, "no access to this tenant", http.StatusForbidden) http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return return
} }
tenantID = parsed tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else { } else {
// Default to user's first tenant (role already set by middleware) // Default to user's first tenant
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError) slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return return
} }
if first == nil { if first == nil {
http.Error(w, "no tenant found for user", http.StatusBadRequest) http.Error(w, `{"error":"no tenant found for user"}`, http.StatusBadRequest)
return return
} }
tenantID = *first tenantID = *first

View File

@@ -11,24 +11,22 @@ import (
type mockTenantLookup struct { type mockTenantLookup struct {
tenantID *uuid.UUID tenantID *uuid.UUID
role string
err error err error
hasAccess bool
accessErr error
} }
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err return m.tenantID, m.err
} }
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
if m.role != "" { return m.hasAccess, m.accessErr
return m.role, m.err
}
return "associate", m.err
} }
func TestTenantResolver_FromHeader(t *testing.T) { func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -55,6 +53,26 @@ func TestTenantResolver_FromHeader(t *testing.T) {
} }
} }
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{hasAccess: false})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", tenantID.String())
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestTenantResolver_DefaultsToFirst(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID}) tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})

View File

@@ -13,6 +13,7 @@ type Config struct {
SupabaseServiceKey string SupabaseServiceKey string
SupabaseJWTSecret string SupabaseJWTSecret string
AnthropicAPIKey string AnthropicAPIKey string
FrontendOrigin string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -24,6 +25,7 @@ func Load() (*Config, error) {
SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"), SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"),
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {

View File

@@ -5,18 +5,16 @@ import (
"io" "io"
"net/http" "net/http"
"github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
) )
type AIHandler struct { type AIHandler struct {
ai *services.AIService ai *services.AIService
db *sqlx.DB
} }
func NewAIHandler(ai *services.AIService, db *sqlx.DB) *AIHandler { func NewAIHandler(ai *services.AIService) *AIHandler {
return &AIHandler{ai: ai, db: db} return &AIHandler{ai: ai}
} }
// ExtractDeadlines handles POST /api/ai/extract-deadlines // ExtractDeadlines handles POST /api/ai/extract-deadlines
@@ -61,10 +59,14 @@ func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "provide either a PDF file or text") writeError(w, http.StatusBadRequest, "provide either a PDF file or text")
return return
} }
if len(text) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "text exceeds maximum length")
return
}
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text) deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "AI extraction failed: "+err.Error()) internalError(w, "AI deadline extraction failed", err)
return return
} }
@@ -77,9 +79,9 @@ func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
// SummarizeCase handles POST /api/ai/summarize-case // SummarizeCase handles POST /api/ai/summarize-case
// Accepts JSON {"case_id": "uuid"}. // Accepts JSON {"case_id": "uuid"}.
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) { func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -104,7 +106,7 @@ func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID) summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "AI summarization failed: "+err.Error()) internalError(w, "AI case summarization failed", err)
return return
} }

View File

@@ -42,7 +42,7 @@ func TestAIExtractDeadlines_InvalidJSON(t *testing.T) {
} }
} }
func TestAISummarizeCase_MissingCaseID(t *testing.T) { func TestAISummarizeCase_MissingTenant(t *testing.T) {
h := &AIHandler{} h := &AIHandler{}
body := `{"case_id":""}` body := `{"case_id":""}`
@@ -52,9 +52,9 @@ func TestAISummarizeCase_MissingCaseID(t *testing.T) {
h.SummarizeCase(w, r) h.SummarizeCase(w, r)
// Without auth context, the resolveTenant will fail first // Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusUnauthorized { if w.Code != http.StatusForbidden {
t.Errorf("expected 401, got %d", w.Code) t.Errorf("expected 403, got %d", w.Code)
} }
} }
@@ -67,8 +67,8 @@ func TestAISummarizeCase_InvalidJSON(t *testing.T) {
h.SummarizeCase(w, r) h.SummarizeCase(w, r)
// Without auth context, the resolveTenant will fail first // Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusUnauthorized { if w.Code != http.StatusForbidden {
t.Errorf("expected 401, got %d", w.Code) t.Errorf("expected 403, got %d", w.Code)
} }
} }

View File

@@ -121,6 +121,10 @@ func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "title is required") writeError(w, http.StatusBadRequest, "title is required")
return return
} }
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() { if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required") writeError(w, http.StatusBadRequest, "start_at is required")
return return
@@ -188,6 +192,10 @@ func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "title is required") writeError(w, http.StatusBadRequest, "title is required")
return return
} }
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() { if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required") writeError(w, http.StatusBadRequest, "start_at is required")
return return

View File

@@ -27,7 +27,7 @@ func (h *CalDAVHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
cfg, err := h.svc.LoadTenantConfig(tenantID) cfg, err := h.svc.LoadTenantConfig(tenantID)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, "CalDAV not configured for this tenant")
return return
} }

View File

@@ -1,119 +0,0 @@
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

@@ -28,18 +28,25 @@ func (h *CaseHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, offset = clampPagination(limit, offset)
search := r.URL.Query().Get("search")
if msg := validateStringLength("search", search, maxSearchLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
filter := services.CaseFilter{ filter := services.CaseFilter{
Status: r.URL.Query().Get("status"), Status: r.URL.Query().Get("status"),
Type: r.URL.Query().Get("type"), Type: r.URL.Query().Get("type"),
Search: r.URL.Query().Get("search"), Search: search,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
} }
cases, total, err := h.svc.List(r.Context(), tenantID, filter) cases, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to list cases", err)
return return
} }
@@ -66,10 +73,18 @@ func (h *CaseHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "case_number and title are required") writeError(w, http.StatusBadRequest, "case_number and title are required")
return return
} }
if msg := validateStringLength("case_number", input.CaseNumber, maxCaseNumberLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
c, err := h.svc.Create(r.Context(), tenantID, userID, input) c, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to create case", err)
return return
} }
@@ -91,7 +106,7 @@ func (h *CaseHandler) Get(w http.ResponseWriter, r *http.Request) {
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID) detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to get case", err)
return return
} }
if detail == nil { if detail == nil {
@@ -121,10 +136,22 @@ func (h *CaseHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON body") writeError(w, http.StatusBadRequest, "invalid JSON body")
return return
} }
if input.Title != nil {
if msg := validateStringLength("title", *input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
if input.CaseNumber != nil {
if msg := validateStringLength("case_number", *input.CaseNumber, maxCaseNumberLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input) updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to update case", err)
return return
} }
if updated == nil { if updated == nil {

View File

@@ -24,7 +24,7 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
data, err := h.svc.Get(r.Context(), tenantID) data, err := h.svc.Get(r.Context(), tenantID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to load dashboard", err)
return return
} }

View File

@@ -4,27 +4,25 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
) )
// DeadlineHandlers holds handlers for deadline CRUD endpoints // DeadlineHandlers holds handlers for deadline CRUD endpoints
type DeadlineHandlers struct { type DeadlineHandlers struct {
deadlines *services.DeadlineService deadlines *services.DeadlineService
db *sqlx.DB
} }
// NewDeadlineHandlers creates deadline handlers // NewDeadlineHandlers creates deadline handlers
func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHandlers { func NewDeadlineHandlers(ds *services.DeadlineService) *DeadlineHandlers {
return &DeadlineHandlers{deadlines: ds, db: db} return &DeadlineHandlers{deadlines: ds}
} }
// Get handles GET /api/deadlines/{deadlineID} // Get handles GET /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -36,7 +34,7 @@ func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
deadline, err := h.deadlines.GetByID(tenantID, deadlineID) deadline, err := h.deadlines.GetByID(tenantID, deadlineID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch deadline") internalError(w, "failed to fetch deadline", err)
return return
} }
if deadline == nil { if deadline == nil {
@@ -49,15 +47,15 @@ func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
// ListAll handles GET /api/deadlines // ListAll handles GET /api/deadlines
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
deadlines, err := h.deadlines.ListAll(tenantID) deadlines, err := h.deadlines.ListAll(tenantID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadlines") internalError(w, "failed to list deadlines", err)
return return
} }
@@ -66,9 +64,9 @@ func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
// ListForCase handles GET /api/cases/{caseID}/deadlines // ListForCase handles GET /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -80,7 +78,7 @@ func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
deadlines, err := h.deadlines.ListForCase(tenantID, caseID) deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadlines") internalError(w, "failed to list deadlines for case", err)
return return
} }
@@ -89,9 +87,9 @@ func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
// Create handles POST /api/cases/{caseID}/deadlines // Create handles POST /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -112,10 +110,14 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "title and due_date are required") writeError(w, http.StatusBadRequest, "title and due_date are required")
return return
} }
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
deadline, err := h.deadlines.Create(tenantID, input) deadline, err := h.deadlines.Create(tenantID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create deadline") internalError(w, "failed to create deadline", err)
return return
} }
@@ -124,9 +126,9 @@ func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
// Update handles PUT /api/deadlines/{deadlineID} // Update handles PUT /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -144,7 +146,7 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
deadline, err := h.deadlines.Update(tenantID, deadlineID, input) deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update deadline") internalError(w, "failed to update deadline", err)
return return
} }
if deadline == nil { if deadline == nil {
@@ -157,9 +159,9 @@ func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
// Complete handles PATCH /api/deadlines/{deadlineID}/complete // Complete handles PATCH /api/deadlines/{deadlineID}/complete
func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -171,7 +173,7 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
deadline, err := h.deadlines.Complete(tenantID, deadlineID) deadline, err := h.deadlines.Complete(tenantID, deadlineID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete deadline") internalError(w, "failed to complete deadline", err)
return return
} }
if deadline == nil { if deadline == nil {
@@ -184,9 +186,9 @@ func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
// Delete handles DELETE /api/deadlines/{deadlineID} // Delete handles DELETE /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db) tenantID, ok := auth.TenantFromContext(r.Context())
if err != nil { if !ok {
handleTenantError(w, err) writeError(w, http.StatusForbidden, "missing tenant")
return return
} }
@@ -196,9 +198,8 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
err = h.deadlines.Delete(tenantID, deadlineID) if err := h.deadlines.Delete(tenantID, deadlineID); err != nil {
if err != nil { writeError(w, http.StatusNotFound, "deadline not found")
writeError(w, http.StatusNotFound, err.Error())
return return
} }

View File

@@ -36,7 +36,7 @@ func (h *DocumentHandler) ListByCase(w http.ResponseWriter, r *http.Request) {
docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID) docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to list documents", err)
return return
} }
@@ -98,7 +98,7 @@ func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "case not found") writeError(w, http.StatusNotFound, "case not found")
return return
} }
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to upload document", err)
return return
} }
@@ -121,16 +121,16 @@ func (h *DocumentHandler) Download(w http.ResponseWriter, r *http.Request) {
body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID) body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID)
if err != nil { if err != nil {
if err.Error() == "document not found" || err.Error() == "document has no file" { if err.Error() == "document not found" || err.Error() == "document has no file" {
writeError(w, http.StatusNotFound, err.Error()) writeError(w, http.StatusNotFound, "document not found")
return return
} }
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to download document", err)
return return
} }
defer body.Close() defer body.Close()
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title)) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, sanitizeFilename(title)))
io.Copy(w, body) io.Copy(w, body)
} }
@@ -149,7 +149,7 @@ func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) {
doc, err := h.svc.GetByID(r.Context(), tenantID, docID) doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to get document metadata", err)
return return
} }
if doc == nil { if doc == nil {
@@ -167,7 +167,6 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
userID, _ := auth.UserFromContext(r.Context()) userID, _ := auth.UserFromContext(r.Context())
role := auth.UserRoleFromContext(r.Context())
docID, err := uuid.Parse(r.PathValue("docId")) docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil { if err != nil {
@@ -175,26 +174,6 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return 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 { if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil {
writeError(w, http.StatusNotFound, "document not found") writeError(w, http.StatusNotFound, "document not found")
return return

View File

@@ -2,12 +2,12 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"strings"
"unicode/utf8"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
) )
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -20,62 +20,9 @@ func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg}) writeJSON(w, status, map[string]string{"error": msg})
} }
// resolveTenant gets the tenant ID for the authenticated user. // internalError logs the real error and returns a generic message to the client.
// Checks X-Tenant-ID header first, then falls back to user's first tenant. func internalError(w http.ResponseWriter, msg string, err error) {
func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) { slog.Error(msg, "error", err)
userID, ok := auth.UserFromContext(r.Context())
if !ok {
return uuid.Nil, errUnauthorized
}
// Check header first
if headerVal := r.Header.Get("X-Tenant-ID"); headerVal != "" {
tenantID, err := uuid.Parse(headerVal)
if err != nil {
return uuid.Nil, errInvalidTenant
}
// Verify user has access to this tenant
var count int
err = db.Get(&count,
`SELECT COUNT(*) FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil || count == 0 {
return uuid.Nil, errTenantAccess
}
return tenantID, nil
}
// Fall back to user's first tenant
var tenantID uuid.UUID
err := db.Get(&tenantID,
`SELECT tenant_id FROM user_tenants WHERE user_id = $1 ORDER BY created_at LIMIT 1`,
userID)
if err != nil {
return uuid.Nil, errNoTenant
}
return tenantID, nil
}
type apiError struct {
msg string
status int
}
func (e *apiError) Error() string { return e.msg }
var (
errUnauthorized = &apiError{msg: "unauthorized", status: http.StatusUnauthorized}
errInvalidTenant = &apiError{msg: "invalid tenant ID", status: http.StatusBadRequest}
errTenantAccess = &apiError{msg: "no access to tenant", status: http.StatusForbidden}
errNoTenant = &apiError{msg: "no tenant found for user", status: http.StatusBadRequest}
)
// handleTenantError writes the appropriate error response for tenant resolution errors
func handleTenantError(w http.ResponseWriter, err error) {
if ae, ok := err.(*apiError); ok {
writeError(w, ae.status, ae.msg)
return
}
writeError(w, http.StatusInternalServerError, "internal error") writeError(w, http.StatusInternalServerError, "internal error")
} }
@@ -88,3 +35,74 @@ func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
func parseUUID(s string) (uuid.UUID, error) { func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s) return uuid.Parse(s)
} }
// --- Input validation helpers ---
const (
maxTitleLen = 500
maxDescriptionLen = 10000
maxCaseNumberLen = 100
maxSearchLen = 200
maxPaginationLimit = 100
)
// validateStringLength checks if a string exceeds the given max length.
func validateStringLength(field, value string, maxLen int) string {
if utf8.RuneCountInString(value) > maxLen {
return field + " exceeds maximum length"
}
return ""
}
// clampPagination enforces sane pagination defaults and limits.
func clampPagination(limit, offset int) (int, int) {
if limit <= 0 {
limit = 20
}
if limit > maxPaginationLimit {
limit = maxPaginationLimit
}
if offset < 0 {
offset = 0
}
return limit, offset
}
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
func sanitizeFilename(name string) string {
// Remove control characters, quotes, and backslashes
var b strings.Builder
for _, r := range name {
if r < 32 || r == '"' || r == '\\' || r == '/' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// maskSettingsPassword masks the CalDAV password in tenant settings JSON before returning to clients.
func maskSettingsPassword(settings json.RawMessage) json.RawMessage {
if len(settings) == 0 {
return settings
}
var m map[string]json.RawMessage
if err := json.Unmarshal(settings, &m); err != nil {
return settings
}
caldavRaw, ok := m["caldav"]
if !ok {
return settings
}
var caldav map[string]json.RawMessage
if err := json.Unmarshal(caldavRaw, &caldav); err != nil {
return settings
}
if _, ok := caldav["password"]; ok {
caldav["password"], _ = json.Marshal("********")
}
m["caldav"], _ = json.Marshal(caldav)
result, _ := json.Marshal(m)
return result
}

View File

@@ -60,6 +60,10 @@ func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "content is required") writeError(w, http.StatusBadRequest, "content is required")
return return
} }
if msg := validateStringLength("content", input.Content, maxDescriptionLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
var createdBy *uuid.UUID var createdBy *uuid.UUID
if userID != uuid.Nil { if userID != uuid.Nil {
@@ -100,6 +104,10 @@ func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "content is required") writeError(w, http.StatusBadRequest, "content is required")
return return
} }
if msg := validateStringLength("content", req.Content, maxDescriptionLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content) note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content)
if err != nil { if err != nil {

View File

@@ -34,7 +34,7 @@ func (h *PartyHandler) List(w http.ResponseWriter, r *http.Request) {
parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID) parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to list parties", err)
return return
} }
@@ -67,13 +67,18 @@ func (h *PartyHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
if msg := validateStringLength("name", input.Name, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input) party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "case not found") writeError(w, http.StatusNotFound, "case not found")
return return
} }
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to create party", err)
return return
} }
@@ -101,7 +106,7 @@ func (h *PartyHandler) Update(w http.ResponseWriter, r *http.Request) {
updated, err := h.svc.Update(r.Context(), tenantID, partyID, input) updated, err := h.svc.Update(r.Context(), tenantID, partyID, input)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) internalError(w, "failed to update party", err)
return return
} }
if updated == nil { if updated == nil {

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
@@ -41,7 +42,8 @@ func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug) tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to create tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
@@ -58,10 +60,16 @@ func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := h.svc.ListForUser(r.Context(), userID) tenants, err := h.svc.ListForUser(r.Context(), userID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to list tenants", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
// Mask CalDAV passwords in tenant settings
for i := range tenants {
tenants[i].Settings = maskSettingsPassword(tenants[i].Settings)
}
jsonResponse(w, tenants, http.StatusOK) jsonResponse(w, tenants, http.StatusOK)
} }
@@ -82,7 +90,8 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
// Verify user has access to this tenant // Verify user has access to this tenant
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role == "" { if role == "" {
@@ -92,7 +101,8 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
tenant, err := h.svc.GetByID(r.Context(), tenantID) tenant, err := h.svc.GetByID(r.Context(), tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if tenant == nil { if tenant == nil {
@@ -100,6 +110,9 @@ func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
return return
} }
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
jsonResponse(w, tenant, http.StatusOK) jsonResponse(w, tenant, http.StatusOK)
} }
@@ -117,14 +130,15 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return return
} }
// Only owners and partners can invite // Only owners and admins can invite
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role != "owner" && role != "partner" { if role != "owner" && role != "admin" {
jsonError(w, "only owners and partners can invite users", http.StatusForbidden) jsonError(w, "only owners and admins can invite users", http.StatusForbidden)
return return
} }
@@ -141,21 +155,17 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Role == "" { if req.Role == "" {
req.Role = "associate" req.Role = "member"
} }
if !auth.IsValidRole(req.Role) { if req.Role != "member" && req.Role != "admin" {
jsonError(w, "invalid role", http.StatusBadRequest) jsonError(w, "role must be member or admin", 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 return
} }
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role) ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest) // These are user-facing validation errors (user not found, already member)
jsonError(w, "failed to invite user", http.StatusBadRequest)
return return
} }
@@ -182,19 +192,21 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
return return
} }
// Only owners and partners can remove members (or user removing themselves) // Only owners and admins can remove members (or user removing themselves)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role != "owner" && role != "partner" && userID != memberID { if role != "owner" && role != "admin" && userID != memberID {
jsonError(w, "insufficient permissions", http.StatusForbidden) jsonError(w, "insufficient permissions", http.StatusForbidden)
return return
} }
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil { if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest) // These are user-facing validation errors (not a member, last owner, etc.)
jsonError(w, "failed to remove member", http.StatusBadRequest)
return return
} }
@@ -215,14 +227,15 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
// Only owners and partners can update settings // Only owners and admins can update settings
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role != "owner" && role != "partner" { if role != "owner" && role != "admin" {
jsonError(w, "only owners and partners can update settings", http.StatusForbidden) jsonError(w, "only owners and admins can update settings", http.StatusForbidden)
return return
} }
@@ -234,10 +247,14 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings) tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to update settings", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
jsonResponse(w, tenant, http.StatusOK) jsonResponse(w, tenant, http.StatusOK)
} }
@@ -258,7 +275,8 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
// Verify user has access // Verify user has access
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
if role == "" { if role == "" {
@@ -268,92 +286,14 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members, err := h.svc.ListMembers(r.Context(), tenantID) members, err := h.svc.ListMembers(r.Context(), tenantID)
if err != nil { if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError) slog.Error("failed to list members", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return return
} }
jsonResponse(w, members, http.StatusOK) 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) { func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)

View File

@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"strings"
)
// SecurityHeaders adds standard security headers to all responses.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// CORS returns middleware that restricts cross-origin requests to the given origin.
// If allowedOrigin is empty, CORS headers are not set (same-origin only).
func CORS(allowedOrigin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigin != "" && origin != "" && matchOrigin(origin, allowedOrigin) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Tenant-ID")
w.Header().Set("Access-Control-Max-Age", "86400")
w.Header().Set("Vary", "Origin")
}
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// matchOrigin checks if the request origin matches the allowed origin.
func matchOrigin(origin, allowed string) bool {
return strings.EqualFold(strings.TrimRight(origin, "/"), strings.TrimRight(allowed, "/"))
}

View File

@@ -1,15 +0,0 @@
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,13 +29,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
calculator := services.NewDeadlineCalculator(holidaySvc) calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli) documentSvc := services.NewDocumentService(db, storageCli)
assignmentSvc := services.NewCaseAssignmentService(db)
// AI service (optional — only if API key is configured) // AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler var aiH *handlers.AIHandler
if cfg.AnthropicAPIKey != "" { if cfg.AnthropicAPIKey != "" {
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db) aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
aiH = handlers.NewAIHandler(aiSvc, db) aiH = handlers.NewAIHandler(aiSvc)
} }
// Middleware // Middleware
@@ -49,14 +48,13 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
caseH := handlers.NewCaseHandler(caseSvc) caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc) partyH := handlers.NewPartyHandler(partySvc)
apptH := handlers.NewAppointmentHandler(appointmentSvc) apptH := handlers.NewAppointmentHandler(appointmentSvc)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db) deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc) dashboardH := handlers.NewDashboardHandler(dashboardSvc)
noteH := handlers.NewNoteHandler(noteSvc) noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db) eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc) docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
// Public routes // Public routes
mux.HandleFunc("GET /health", handleHealth(db)) mux.HandleFunc("GET /health", handleHealth(db))
@@ -72,100 +70,77 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser) api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember) api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers) 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) // Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux() scoped := http.NewServeMux()
// Current user info (role, permissions) — all authenticated users // Cases
scoped.HandleFunc("GET /api/me", tenantH.GetMe)
// Cases — all can view, create needs PermCreateCase, archive needs PermCreateCase
scoped.HandleFunc("GET /api/cases", caseH.List) scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create)) scoped.HandleFunc("POST /api/cases", caseH.Create)
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete)) scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
// Parties — same access as case editing // Parties
scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List) scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create) scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update) scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete) scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Deadlines — manage needs PermManageDeadlines, view is open // Deadlines
scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get) scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get)
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll) scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase) scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", perm(auth.PermManageDeadlines, deadlineH.Create)) scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Update)) scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", perm(auth.PermManageDeadlines, deadlineH.Complete)) scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Delete)) scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
// Deadline rules (reference data) — all can read // Deadline rules (reference data)
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List) scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree) scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes) scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes)
// Deadline calculator — all can use // Deadline calculator
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments — all can manage (PermManageAppointments granted to all) // Appointments
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get) scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List) scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create)) scoped.HandleFunc("POST /api/appointments", apptH.Create)
scoped.HandleFunc("PUT /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Update)) scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
scoped.HandleFunc("DELETE /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Delete)) scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
// Case assignments — manage team required for assign/unassign // Case events
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) scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
// Notes — all can manage // Notes
scoped.HandleFunc("GET /api/notes", noteH.List) scoped.HandleFunc("GET /api/notes", noteH.List)
scoped.HandleFunc("POST /api/notes", noteH.Create) scoped.HandleFunc("POST /api/notes", noteH.Create)
scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update) scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update)
scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete) scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete)
// Dashboard — all can view // Dashboard
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Documents — all can upload, delete checked in handler (own vs all) // Documents
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
// AI endpoints (rate limited: 5 req/min burst 10 per IP) // AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil { if aiH != nil {
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10) aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines))) scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines))
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase))
} }
// CalDAV sync endpoints — settings permission required // CalDAV sync endpoints
if calDAVSvc != nil { if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc) calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) scoped.HandleFunc("POST /api/caldav/sync", calDAVH.TriggerSync)
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus) scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
} }
@@ -174,14 +149,20 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
mux.Handle("/api/", authMW.RequireAuth(api)) mux.Handle("/api/", authMW.RequireAuth(api))
return requestLogger(mux) // Apply security middleware stack: CORS -> Security Headers -> Request Logger -> Routes
var handler http.Handler = mux
handler = requestLogger(handler)
handler = middleware.SecurityHeaders(handler)
handler = middleware.CORS(cfg.FrontendOrigin)(handler)
return handler
} }
func handleHealth(db *sqlx.DB) http.HandlerFunc { func handleHealth(db *sqlx.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "error", "error": err.Error()}) json.NewEncoder(w).Encode(map[string]string{"status": "error"})
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -219,4 +200,3 @@ func requestLogger(next http.Handler) http.Handler {
) )
}) })
} }

View File

@@ -1,92 +0,0 @@
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

@@ -101,6 +101,19 @@ func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.U
return role, nil return role, nil
} }
// VerifyAccess checks if a user has access to a given tenant.
func (s *TenantService) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
var exists bool
err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenantID,
)
if err != nil {
return false, fmt.Errorf("verify tenant access: %w", err)
}
return exists, nil
}
// FirstTenantForUser returns the user's first tenant (by name), used as default. // FirstTenantForUser returns the user's first tenant (by name), used as default.
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
var tenantID uuid.UUID var tenantID uuid.UUID
@@ -189,40 +202,6 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
return &tenant, nil 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. // RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error { func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner // Check if the user being removed is an owner

View File

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

View File

@@ -1,9 +0,0 @@
"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,7 +10,6 @@ import { Plus, Search, FolderOpen } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { SkeletonTable } from "@/components/ui/Skeleton"; import { SkeletonTable } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: "", label: "Alle Status" }, { value: "", label: "Alle Status" },
@@ -50,8 +49,6 @@ const inputClass =
export default function CasesPage() { export default function CasesPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { can } = usePermissions();
const canCreateCase = can("create_case");
const [search, setSearch] = useState(searchParams.get("search") ?? ""); const [search, setSearch] = useState(searchParams.get("search") ?? "");
const [status, setStatus] = useState(searchParams.get("status") ?? ""); const [status, setStatus] = useState(searchParams.get("status") ?? "");
@@ -89,7 +86,6 @@ export default function CasesPage() {
{data ? `${data.total} Akten` : "\u00A0"} {data ? `${data.total} Akten` : "\u00A0"}
</p> </p>
</div> </div>
{canCreateCase && (
<Link <Link
href="/cases/new" 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" 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"
@@ -97,7 +93,6 @@ export default function CasesPage() {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Neue Akte Neue Akte
</Link> </Link>
)}
</div> </div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -150,7 +145,7 @@ export default function CasesPage() {
: "Erstellen Sie Ihre erste Akte, um loszulegen." : "Erstellen Sie Ihre erste Akte, um loszulegen."
} }
action={ action={
!search && !status && !type && canCreateCase ? ( !search && !status && !type ? (
<Link <Link
href="/cases/new" 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" 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

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

View File

@@ -3,36 +3,27 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { UserPlus, Trash2, Crown, Scale, Briefcase, FileText, Phone } from "lucide-react"; import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { UserTenant, UserRole } from "@/lib/types"; import type { UserTenant } from "@/lib/types";
import { ROLE_LABELS } from "@/lib/types";
import { Skeleton } from "@/components/ui/Skeleton"; import { Skeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { usePermissions } from "@/lib/hooks/usePermissions";
const ROLE_CONFIG: Record<UserRole, { label: string; icon: typeof Crown }> = { const ROLE_LABELS: Record<string, { label: string; icon: typeof Crown }> = {
owner: { label: ROLE_LABELS.owner, icon: Crown }, owner: { label: "Eigentümer", icon: Crown },
partner: { label: ROLE_LABELS.partner, icon: Scale }, admin: { label: "Administrator", icon: Shield },
associate: { label: ROLE_LABELS.associate, icon: Briefcase }, member: { label: "Mitglied", icon: User },
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() { export function TeamSettings() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { can, role: myRole } = usePermissions();
const tenantId = const tenantId =
typeof window !== "undefined" typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id") ? localStorage.getItem("kanzlai_tenant_id")
: null; : null;
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [role, setRole] = useState<string>("associate"); const [role, setRole] = useState("member");
const canManageTeam = can("manage_team");
const { const {
data: members, data: members,
@@ -51,7 +42,7 @@ export function TeamSettings() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] }); queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
setEmail(""); setEmail("");
setRole("associate"); setRole("member");
toast.success("Benutzer eingeladen"); toast.success("Benutzer eingeladen");
}, },
onError: (err: { error?: string }) => { onError: (err: { error?: string }) => {
@@ -71,19 +62,6 @@ 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) => { const handleInvite = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!email.trim()) return; if (!email.trim()) return;
@@ -103,7 +81,7 @@ export function TeamSettings() {
if (error) { if (error) {
return ( return (
<EmptyState <EmptyState
icon={Briefcase} icon={User}
title="Fehler beim Laden" title="Fehler beim Laden"
description="Team-Mitglieder konnten nicht geladen werden." description="Team-Mitglieder konnten nicht geladen werden."
/> />
@@ -112,8 +90,7 @@ export function TeamSettings() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Invite Form — only for owners/partners */} {/* Invite Form */}
{canManageTeam && (
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row"> <form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<input <input
type="email" type="email"
@@ -127,11 +104,8 @@ export function TeamSettings() {
onChange={(e) => setRole(e.target.value)} 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" 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 value="member">Mitglied</option>
<option key={r} value={r}> <option value="admin">Administrator</option>
{ROLE_LABELS[r]}
</option>
))}
</select> </select>
<button <button
type="submit" type="submit"
@@ -142,14 +116,12 @@ export function TeamSettings() {
{inviteMutation.isPending ? "Einladen..." : "Einladen"} {inviteMutation.isPending ? "Einladen..." : "Einladen"}
</button> </button>
</form> </form>
)}
{/* Members List */} {/* Members List */}
{Array.isArray(members) && members.length > 0 ? ( {Array.isArray(members) && members.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200"> <div className="overflow-hidden rounded-md border border-neutral-200">
{members.map((member, i) => { {members.map((member, i) => {
const roleKey = (member.role as UserRole) || "associate"; const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
const roleInfo = ROLE_CONFIG[roleKey] || ROLE_CONFIG.associate;
const RoleIcon = roleInfo.icon; const RoleIcon = roleInfo.icon;
return ( return (
<div <div
@@ -169,31 +141,7 @@ export function TeamSettings() {
<p className="text-xs text-neutral-500">{roleInfo.label}</p> <p className="text-xs text-neutral-500">{roleInfo.label}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> {member.role !== "owner" && (
{/* 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 <button
onClick={() => removeMutation.mutate(member.user_id)} onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending} disabled={removeMutation.isPending}
@@ -204,13 +152,12 @@ export function TeamSettings() {
</button> </button>
)} )}
</div> </div>
</div>
); );
})} })}
</div> </div>
) : ( ) : (
<EmptyState <EmptyState
icon={Briefcase} icon={User}
title="Noch keine Mitglieder" title="Noch keine Mitglieder"
description="Laden Sie Teammitglieder per E-Mail ein." description="Laden Sie Teammitglieder per E-Mail ein."
/> />

View File

@@ -1,29 +0,0 @@
"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,40 +189,6 @@ export interface Note {
updated_at: string; 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 { export interface ApiError {
error: string; error: string;
status: number; status: number;