From 0a0ec016d8952bccf32219213d00e9d4e416dfff Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 11:04:57 +0200 Subject: [PATCH] feat: role-based permissions (owner/partner/associate/paralegal/secretary) Backend: - auth/permissions.go: full permission matrix with RequirePermission/RequireRole middleware, CanEditCase, CanDeleteDocument helpers - auth/context.go: add user role to request context - auth/middleware.go: resolve role alongside tenant in auth flow - auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID - handlers/case_assignments.go: CRUD for case-level user assignments - handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints - handlers/documents.go: permission-based delete (own vs all) - router/router.go: permission-wrapped routes for all endpoints - services/case_assignment_service.go: assign/unassign with tenant validation - services/tenant_service.go: UpdateMemberRole with owner protection - models/case_assignment.go: CaseAssignment model Database: - user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary) - case_assignments table: case_id, user_id, role (lead/team/viewer) - Migrated existing admin->partner, member->associate Frontend: - usePermissions hook: fetches /api/me, provides can() helper - TeamSettings: 5-role dropdown, role change, permission-gated invite - CaseAssignments: new component for case-level team management - Sidebar: conditionally hides AI/Settings based on permissions - Cases page: hides "Neue Akte" button for non-authorized roles - Case detail: new "Mitarbeiter" tab for assignment management --- backend/internal/auth/context.go | 10 + backend/internal/auth/middleware.go | 14 +- backend/internal/auth/permissions.go | 213 ++++++++++++++++++ backend/internal/auth/tenant_resolver.go | 15 +- backend/internal/auth/tenant_resolver_test.go | 10 +- backend/internal/handlers/case_assignments.go | 119 ++++++++++ backend/internal/handlers/documents.go | 21 ++ backend/internal/handlers/tenant_handler.go | 106 ++++++++- backend/internal/models/case_assignment.go | 15 ++ backend/internal/router/router.go | 81 ++++--- .../services/case_assignment_service.go | 92 ++++++++ backend/internal/services/tenant_service.go | 34 +++ frontend/src/app/(app)/cases/[id]/layout.tsx | 3 + .../app/(app)/cases/[id]/mitarbeiter/page.tsx | 9 + frontend/src/app/(app)/cases/page.tsx | 21 +- .../src/components/cases/CaseAssignments.tsx | 180 +++++++++++++++ frontend/src/components/layout/Sidebar.tsx | 19 +- .../src/components/settings/TeamSettings.tsx | 147 ++++++++---- frontend/src/lib/hooks/usePermissions.ts | 29 +++ frontend/src/lib/types.ts | 34 +++ 20 files changed, 1068 insertions(+), 104 deletions(-) create mode 100644 backend/internal/auth/permissions.go create mode 100644 backend/internal/handlers/case_assignments.go create mode 100644 backend/internal/models/case_assignment.go create mode 100644 backend/internal/services/case_assignment_service.go create mode 100644 frontend/src/app/(app)/cases/[id]/mitarbeiter/page.tsx create mode 100644 frontend/src/components/cases/CaseAssignments.tsx create mode 100644 frontend/src/lib/hooks/usePermissions.ts diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index a42a6c3..c77e099 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -11,6 +11,7 @@ type contextKey string const ( userIDKey contextKey = "user_id" tenantIDKey contextKey = "tenant_id" + userRoleKey contextKey = "user_role" ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -30,3 +31,12 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(tenantIDKey).(uuid.UUID) return id, ok } + +func ContextWithUserRole(ctx context.Context, role string) context.Context { + return context.WithValue(ctx, userRoleKey, role) +} + +func UserRoleFromContext(ctx context.Context) string { + role, _ := ctx.Value(userRoleKey).(string) + return role +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 4f31eb6..059782f 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -36,15 +36,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { ctx := ContextWithUserID(r.Context(), userID) - // Resolve tenant from user_tenants - var tenantID uuid.UUID - err = m.db.GetContext(r.Context(), &tenantID, - "SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) + // Resolve tenant and role from user_tenants + var membership struct { + TenantID uuid.UUID `db:"tenant_id"` + Role string `db:"role"` + } + err = m.db.GetContext(r.Context(), &membership, + "SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) if err != nil { http.Error(w, "no tenant found for user", http.StatusForbidden) return } - ctx = ContextWithTenantID(ctx, tenantID) + ctx = ContextWithTenantID(ctx, membership.TenantID) + ctx = ContextWithUserRole(ctx, membership.Role) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/backend/internal/auth/permissions.go b/backend/internal/auth/permissions.go new file mode 100644 index 0000000..4a51c3e --- /dev/null +++ b/backend/internal/auth/permissions.go @@ -0,0 +1,213 @@ +package auth + +import ( + "context" + "net/http" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// Valid roles ordered by privilege level (highest first). +var ValidRoles = []string{"owner", "partner", "associate", "paralegal", "secretary"} + +// IsValidRole checks if a role string is one of the defined roles. +func IsValidRole(role string) bool { + for _, r := range ValidRoles { + if r == role { + return true + } + } + return false +} + +// Permission represents an action that can be checked against roles. +type Permission int + +const ( + PermManageTeam Permission = iota + PermManageBilling + PermCreateCase + PermEditAllCases + PermEditAssignedCase + PermViewAllCases + PermManageDeadlines + PermManageAppointments + PermUploadDocuments + PermDeleteDocuments + PermDeleteOwnDocuments + PermViewAuditLog + PermManageSettings + PermAIExtraction +) + +// rolePermissions maps each role to its set of permissions. +var rolePermissions = map[string]map[Permission]bool{ + "owner": { + PermManageTeam: true, + PermManageBilling: true, + PermCreateCase: true, + PermEditAllCases: true, + PermEditAssignedCase: true, + PermViewAllCases: true, + PermManageDeadlines: true, + PermManageAppointments: true, + PermUploadDocuments: true, + PermDeleteDocuments: true, + PermDeleteOwnDocuments: true, + PermViewAuditLog: true, + PermManageSettings: true, + PermAIExtraction: true, + }, + "partner": { + PermManageTeam: true, + PermManageBilling: true, + PermCreateCase: true, + PermEditAllCases: true, + PermEditAssignedCase: true, + PermViewAllCases: true, + PermManageDeadlines: true, + PermManageAppointments: true, + PermUploadDocuments: true, + PermDeleteDocuments: true, + PermDeleteOwnDocuments: true, + PermViewAuditLog: true, + PermManageSettings: true, + PermAIExtraction: true, + }, + "associate": { + PermCreateCase: true, + PermEditAssignedCase: true, + PermViewAllCases: true, + PermManageDeadlines: true, + PermManageAppointments: true, + PermUploadDocuments: true, + PermDeleteOwnDocuments: true, + PermAIExtraction: true, + }, + "paralegal": { + PermEditAssignedCase: true, + PermViewAllCases: true, + PermManageDeadlines: true, + PermManageAppointments: true, + PermUploadDocuments: true, + }, + "secretary": { + PermViewAllCases: true, + PermManageAppointments: true, + PermUploadDocuments: true, + }, +} + +// HasPermission checks if the given role has the specified permission. +func HasPermission(role string, perm Permission) bool { + perms, ok := rolePermissions[role] + if !ok { + return false + } + return perms[perm] +} + +// RequirePermission returns middleware that checks if the user's role has the given permission. +func RequirePermission(perm Permission) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role := UserRoleFromContext(r.Context()) + if role == "" || !HasPermission(role, perm) { + writeJSONError(w, "insufficient permissions", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// RequireRole returns middleware that checks if the user has one of the specified roles. +func RequireRole(roles ...string) func(http.Handler) http.Handler { + allowed := make(map[string]bool, len(roles)) + for _, r := range roles { + allowed[r] = true + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role := UserRoleFromContext(r.Context()) + if !allowed[role] { + writeJSONError(w, "insufficient permissions", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// IsAssignedToCase checks if a user is assigned to a specific case. +func IsAssignedToCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID) (bool, error) { + var exists bool + err := db.GetContext(ctx, &exists, + `SELECT EXISTS(SELECT 1 FROM case_assignments WHERE user_id = $1 AND case_id = $2)`, + userID, caseID) + return exists, err +} + +// CanEditCase checks if a user can edit a specific case based on role and assignment. +func CanEditCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID, role string) (bool, error) { + // Owner and partner can edit all cases + if HasPermission(role, PermEditAllCases) { + return true, nil + } + // Others need to be assigned + if !HasPermission(role, PermEditAssignedCase) { + return false, nil + } + return IsAssignedToCase(ctx, db, userID, caseID) +} + +// CanDeleteDocument checks if a user can delete a specific document. +func CanDeleteDocument(role string, docUploaderID, userID uuid.UUID) bool { + if HasPermission(role, PermDeleteDocuments) { + return true + } + if HasPermission(role, PermDeleteOwnDocuments) { + return docUploaderID == userID + } + return false +} + +// permissionNames maps Permission constants to their string names for frontend use. +var permissionNames = map[Permission]string{ + PermManageTeam: "manage_team", + PermManageBilling: "manage_billing", + PermCreateCase: "create_case", + PermEditAllCases: "edit_all_cases", + PermEditAssignedCase: "edit_assigned_case", + PermViewAllCases: "view_all_cases", + PermManageDeadlines: "manage_deadlines", + PermManageAppointments: "manage_appointments", + PermUploadDocuments: "upload_documents", + PermDeleteDocuments: "delete_documents", + PermDeleteOwnDocuments: "delete_own_documents", + PermViewAuditLog: "view_audit_log", + PermManageSettings: "manage_settings", + PermAIExtraction: "ai_extraction", +} + +// GetRolePermissions returns a list of permission name strings for the given role. +func GetRolePermissions(role string) []string { + perms, ok := rolePermissions[role] + if !ok { + return nil + } + var names []string + for p := range perms { + if name, ok := permissionNames[p]; ok { + names = append(names, name) + } + } + return names +} + +func writeJSONError(w http.ResponseWriter, msg string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(`{"error":"` + msg + `"}`)) +} diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go index 6358d4d..d10c2a8 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -12,6 +12,7 @@ import ( // Defined as an interface to avoid circular dependency with services. type TenantLookup interface { FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) + GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) } // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header @@ -40,9 +41,21 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { http.Error(w, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest) return } + // Verify user has access and get their role + role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) + if err != nil { + http.Error(w, "error checking tenant access", http.StatusInternalServerError) + return + } + if role == "" { + http.Error(w, "no access to this tenant", http.StatusForbidden) + return + } tenantID = parsed + // Override the role from middleware with the correct one for this tenant + r = r.WithContext(ContextWithUserRole(r.Context(), role)) } else { - // Default to user's first tenant + // Default to user's first tenant (role already set by middleware) first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) if err != nil { http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError) diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go index dfb8e2d..291cfa6 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -11,6 +11,7 @@ import ( type mockTenantLookup struct { tenantID *uuid.UUID + role string err error } @@ -18,9 +19,16 @@ func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.U return m.tenantID, m.err } +func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { + if m.role != "" { + return m.role, m.err + } + return "associate", m.err +} + func TestTenantResolver_FromHeader(t *testing.T) { tenantID := uuid.New() - tr := NewTenantResolver(&mockTenantLookup{}) + tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) var gotTenantID uuid.UUID next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/handlers/case_assignments.go b/backend/internal/handlers/case_assignments.go new file mode 100644 index 0000000..a58fcee --- /dev/null +++ b/backend/internal/handlers/case_assignments.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +type CaseAssignmentHandler struct { + svc *services.CaseAssignmentService +} + +func NewCaseAssignmentHandler(svc *services.CaseAssignmentService) *CaseAssignmentHandler { + return &CaseAssignmentHandler{svc: svc} +} + +// List handles GET /api/cases/{id}/assignments +func (h *CaseAssignmentHandler) List(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + caseID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + assignments, err := h.svc.ListByCase(r.Context(), tenantID, caseID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "assignments": assignments, + "total": len(assignments), + }) +} + +// Assign handles POST /api/cases/{id}/assignments +func (h *CaseAssignmentHandler) Assign(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + caseID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + var req struct { + UserID string `json:"user_id"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + userID, err := uuid.Parse(req.UserID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user_id") + return + } + + if req.Role == "" { + req.Role = "team" + } + if req.Role != "lead" && req.Role != "team" && req.Role != "viewer" { + writeError(w, http.StatusBadRequest, "role must be lead, team, or viewer") + return + } + + assignment, err := h.svc.Assign(r.Context(), tenantID, caseID, userID, req.Role) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, assignment) +} + +// Unassign handles DELETE /api/cases/{id}/assignments/{uid} +func (h *CaseAssignmentHandler) Unassign(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + caseID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + userID, err := uuid.Parse(r.PathValue("uid")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user ID") + return + } + + if err := h.svc.Unassign(r.Context(), tenantID, caseID, userID); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) +} diff --git a/backend/internal/handlers/documents.go b/backend/internal/handlers/documents.go index c15c0cb..92f3ccf 100644 --- a/backend/internal/handlers/documents.go +++ b/backend/internal/handlers/documents.go @@ -167,6 +167,7 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) { return } userID, _ := auth.UserFromContext(r.Context()) + role := auth.UserRoleFromContext(r.Context()) docID, err := uuid.Parse(r.PathValue("docId")) if err != nil { @@ -174,6 +175,26 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) { return } + // Check permission: owner/partner can delete any, associate can delete own + doc, err := h.svc.GetByID(r.Context(), tenantID, docID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if doc == nil { + writeError(w, http.StatusNotFound, "document not found") + return + } + + uploaderID := uuid.Nil + if doc.UploadedBy != nil { + uploaderID = *doc.UploadedBy + } + if !auth.CanDeleteDocument(role, uploaderID, userID) { + writeError(w, http.StatusForbidden, "insufficient permissions to delete this document") + return + } + if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil { writeError(w, http.StatusNotFound, "document not found") return diff --git a/backend/internal/handlers/tenant_handler.go b/backend/internal/handlers/tenant_handler.go index 1db14f7..4eebaa0 100644 --- a/backend/internal/handlers/tenant_handler.go +++ b/backend/internal/handlers/tenant_handler.go @@ -117,14 +117,14 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) { return } - // Only owners and admins can invite + // Only owners and partners can invite role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) if err != nil { jsonError(w, err.Error(), http.StatusInternalServerError) return } - if role != "owner" && role != "admin" { - jsonError(w, "only owners and admins can invite users", http.StatusForbidden) + if role != "owner" && role != "partner" { + jsonError(w, "only owners and partners can invite users", http.StatusForbidden) return } @@ -141,10 +141,15 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) { return } if req.Role == "" { - req.Role = "member" + req.Role = "associate" } - if req.Role != "member" && req.Role != "admin" { - jsonError(w, "role must be member or admin", http.StatusBadRequest) + if !auth.IsValidRole(req.Role) { + jsonError(w, "invalid role", http.StatusBadRequest) + return + } + // Non-owners cannot invite as owner + if role != "owner" && req.Role == "owner" { + jsonError(w, "only owners can invite as owner", http.StatusForbidden) return } @@ -177,13 +182,13 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { return } - // Only owners and admins can remove members (or user removing themselves) + // Only owners and partners can remove members (or user removing themselves) role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) if err != nil { jsonError(w, err.Error(), http.StatusInternalServerError) return } - if role != "owner" && role != "admin" && userID != memberID { + if role != "owner" && role != "partner" && userID != memberID { jsonError(w, "insufficient permissions", http.StatusForbidden) return } @@ -210,14 +215,14 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) { return } - // Only owners and admins can update settings + // Only owners and partners can update settings role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) if err != nil { jsonError(w, err.Error(), http.StatusInternalServerError) return } - if role != "owner" && role != "admin" { - jsonError(w, "only owners and admins can update settings", http.StatusForbidden) + if role != "owner" && role != "partner" { + jsonError(w, "only owners and partners can update settings", http.StatusForbidden) return } @@ -270,6 +275,85 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) { jsonResponse(w, members, http.StatusOK) } +// UpdateMemberRole handles PUT /api/tenants/{id}/members/{uid}/role +func (h *TenantHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) { + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + tenantID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + jsonError(w, "invalid tenant ID", http.StatusBadRequest) + return + } + + memberID, err := uuid.Parse(r.PathValue("uid")) + if err != nil { + jsonError(w, "invalid member ID", http.StatusBadRequest) + return + } + + // Only owners and partners can change roles + role, err := h.svc.GetUserRole(r.Context(), userID, tenantID) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + if role != "owner" && role != "partner" { + jsonError(w, "only owners and partners can change roles", http.StatusForbidden) + return + } + + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + if !auth.IsValidRole(req.Role) { + jsonError(w, "invalid role", http.StatusBadRequest) + return + } + + // Non-owners cannot promote to owner + if role != "owner" && req.Role == "owner" { + jsonError(w, "only owners can promote to owner", http.StatusForbidden) + return + } + + if err := h.svc.UpdateMemberRole(r.Context(), tenantID, memberID, req.Role); err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + + jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK) +} + +// GetMe handles GET /api/me — returns the current user's ID and role in the active tenant. +func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) { + userID, ok := auth.UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + role := auth.UserRoleFromContext(r.Context()) + tenantID, _ := auth.TenantFromContext(r.Context()) + + // Get user's permissions for frontend UI + perms := auth.GetRolePermissions(role) + + jsonResponse(w, map[string]any{ + "user_id": userID, + "tenant_id": tenantID, + "role": role, + "permissions": perms, + }, http.StatusOK) +} + func jsonResponse(w http.ResponseWriter, data interface{}, status int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/backend/internal/models/case_assignment.go b/backend/internal/models/case_assignment.go new file mode 100644 index 0000000..3d7a9ce --- /dev/null +++ b/backend/internal/models/case_assignment.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type CaseAssignment struct { + ID uuid.UUID `db:"id" json:"id"` + CaseID uuid.UUID `db:"case_id" json:"case_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Role string `db:"role" json:"role"` + AssignedAt time.Time `db:"assigned_at" json:"assigned_at"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3c6f8a5..c1c2ae0 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -29,6 +29,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se calculator := services.NewDeadlineCalculator(holidaySvc) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) documentSvc := services.NewDocumentService(db, storageCli) + assignmentSvc := services.NewCaseAssignmentService(db) // AI service (optional — only if API key is configured) var aiH *handlers.AIHandler @@ -55,6 +56,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se noteH := handlers.NewNoteHandler(noteSvc) eventH := handlers.NewCaseEventHandler(db) docH := handlers.NewDocumentHandler(documentSvc) + assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc) // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -70,77 +72,100 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser) api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember) api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers) + api.HandleFunc("PUT /api/tenants/{id}/members/{uid}/role", tenantH.UpdateMemberRole) + + // Permission-wrapping helper: wraps a HandlerFunc with a permission check + perm := func(p auth.Permission, fn http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + role := auth.UserRoleFromContext(r.Context()) + if !auth.HasPermission(role, p) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"insufficient permissions"}`)) + return + } + fn(w, r) + } + } // Tenant-scoped routes (require tenant context) scoped := http.NewServeMux() - // Cases - scoped.HandleFunc("GET /api/cases", caseH.List) - scoped.HandleFunc("POST /api/cases", caseH.Create) - scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) - scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) - scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete) + // Current user info (role, permissions) — all authenticated users + scoped.HandleFunc("GET /api/me", tenantH.GetMe) - // Parties + // Cases — all can view, create needs PermCreateCase, archive needs PermCreateCase + scoped.HandleFunc("GET /api/cases", caseH.List) + scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create)) + scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) + scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler + scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete)) + + // Parties — same access as case editing scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List) scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create) scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update) scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete) - // Deadlines + // Deadlines — manage needs PermManageDeadlines, view is open scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get) scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll) scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase) - scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create) - scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update) - scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete) - scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete) + scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", perm(auth.PermManageDeadlines, deadlineH.Create)) + scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Update)) + scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", perm(auth.PermManageDeadlines, deadlineH.Complete)) + scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Delete)) - // Deadline rules (reference data) + // Deadline rules (reference data) — all can read scoped.HandleFunc("GET /api/deadline-rules", ruleH.List) scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree) scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes) - // Deadline calculator + // Deadline calculator — all can use scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) - // Appointments + // Appointments — all can manage (PermManageAppointments granted to all) scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get) scoped.HandleFunc("GET /api/appointments", apptH.List) - scoped.HandleFunc("POST /api/appointments", apptH.Create) - scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update) - scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete) + scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create)) + scoped.HandleFunc("PUT /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Update)) + scoped.HandleFunc("DELETE /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Delete)) - // Case events + // Case assignments — manage team required for assign/unassign + scoped.HandleFunc("GET /api/cases/{id}/assignments", assignmentH.List) + scoped.HandleFunc("POST /api/cases/{id}/assignments", perm(auth.PermManageTeam, assignmentH.Assign)) + scoped.HandleFunc("DELETE /api/cases/{id}/assignments/{uid}", perm(auth.PermManageTeam, assignmentH.Unassign)) + + // Case events — all can view scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get) - // Notes + // Notes — all can manage scoped.HandleFunc("GET /api/notes", noteH.List) scoped.HandleFunc("POST /api/notes", noteH.Create) scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update) scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete) - // Dashboard + // Dashboard — all can view scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) - // Documents + // Documents — all can upload, delete checked in handler (own vs all) scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) - scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload) + scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) - scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) + scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler // AI endpoints (rate limited: 5 req/min burst 10 per IP) if aiH != nil { aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10) - scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines)) - scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase)) + scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines))) + scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) } - // CalDAV sync endpoints + // CalDAV sync endpoints — settings permission required if calDAVSvc != nil { calDAVH := handlers.NewCalDAVHandler(calDAVSvc) - scoped.HandleFunc("POST /api/caldav/sync", calDAVH.TriggerSync) + scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus) } diff --git a/backend/internal/services/case_assignment_service.go b/backend/internal/services/case_assignment_service.go new file mode 100644 index 0000000..1fb8545 --- /dev/null +++ b/backend/internal/services/case_assignment_service.go @@ -0,0 +1,92 @@ +package services + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +type CaseAssignmentService struct { + db *sqlx.DB +} + +func NewCaseAssignmentService(db *sqlx.DB) *CaseAssignmentService { + return &CaseAssignmentService{db: db} +} + +// ListByCase returns all assignments for a case. +func (s *CaseAssignmentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.CaseAssignment, error) { + var assignments []models.CaseAssignment + err := s.db.SelectContext(ctx, &assignments, + `SELECT ca.id, ca.case_id, ca.user_id, ca.role, ca.assigned_at + FROM case_assignments ca + JOIN cases c ON c.id = ca.case_id + WHERE ca.case_id = $1 AND c.tenant_id = $2 + ORDER BY ca.assigned_at`, + caseID, tenantID) + if err != nil { + return nil, fmt.Errorf("list case assignments: %w", err) + } + return assignments, nil +} + +// Assign adds a user to a case with the given role. +func (s *CaseAssignmentService) Assign(ctx context.Context, tenantID, caseID, userID uuid.UUID, role string) (*models.CaseAssignment, error) { + // Verify user is a member of this tenant + var memberExists bool + err := s.db.GetContext(ctx, &memberExists, + `SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`, + userID, tenantID) + if err != nil { + return nil, fmt.Errorf("check membership: %w", err) + } + if !memberExists { + return nil, fmt.Errorf("user is not a member of this tenant") + } + + // Verify case belongs to tenant + var caseExists bool + err = s.db.GetContext(ctx, &caseExists, + `SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)`, + caseID, tenantID) + if err != nil { + return nil, fmt.Errorf("check case: %w", err) + } + if !caseExists { + return nil, fmt.Errorf("case not found") + } + + var assignment models.CaseAssignment + err = s.db.QueryRowxContext(ctx, + `INSERT INTO case_assignments (case_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (case_id, user_id) DO UPDATE SET role = EXCLUDED.role + RETURNING id, case_id, user_id, role, assigned_at`, + caseID, userID, role, + ).StructScan(&assignment) + if err != nil { + return nil, fmt.Errorf("assign user to case: %w", err) + } + return &assignment, nil +} + +// Unassign removes a user from a case. +func (s *CaseAssignmentService) Unassign(ctx context.Context, tenantID, caseID, userID uuid.UUID) error { + result, err := s.db.ExecContext(ctx, + `DELETE FROM case_assignments ca + USING cases c + WHERE ca.case_id = c.id AND ca.case_id = $1 AND ca.user_id = $2 AND c.tenant_id = $3`, + caseID, userID, tenantID) + if err != nil { + return fmt.Errorf("unassign: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("assignment not found") + } + return nil +} diff --git a/backend/internal/services/tenant_service.go b/backend/internal/services/tenant_service.go index 7ed5614..ef31f3b 100644 --- a/backend/internal/services/tenant_service.go +++ b/backend/internal/services/tenant_service.go @@ -189,6 +189,40 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID, return &tenant, nil } +// UpdateMemberRole changes a member's role in a tenant. +func (s *TenantService) UpdateMemberRole(ctx context.Context, tenantID, userID uuid.UUID, newRole string) error { + // Get current role + currentRole, err := s.GetUserRole(ctx, userID, tenantID) + if err != nil { + return fmt.Errorf("get current role: %w", err) + } + if currentRole == "" { + return fmt.Errorf("user is not a member of this tenant") + } + + // If demoting the last owner, block it + if currentRole == "owner" && newRole != "owner" { + var ownerCount int + err := s.db.GetContext(ctx, &ownerCount, + `SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`, + tenantID) + if err != nil { + return fmt.Errorf("count owners: %w", err) + } + if ownerCount <= 1 { + return fmt.Errorf("cannot demote the last owner") + } + } + + _, err = s.db.ExecContext(ctx, + `UPDATE user_tenants SET role = $1 WHERE user_id = $2 AND tenant_id = $3`, + newRole, userID, tenantID) + if err != nil { + return fmt.Errorf("update role: %w", err) + } + return nil +} + // RemoveMember removes a user from a tenant. Cannot remove the last owner. func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error { // Check if the user being removed is an owner diff --git a/frontend/src/app/(app)/cases/[id]/layout.tsx b/frontend/src/app/(app)/cases/[id]/layout.tsx index faafb59..665627a 100644 --- a/frontend/src/app/(app)/cases/[id]/layout.tsx +++ b/frontend/src/app/(app)/cases/[id]/layout.tsx @@ -13,6 +13,7 @@ import { Clock, FileText, Users, + UserCheck, StickyNote, AlertTriangle, } from "lucide-react"; @@ -43,6 +44,7 @@ const TABS = [ { segment: "fristen", label: "Fristen", icon: Clock }, { segment: "dokumente", label: "Dokumente", icon: FileText }, { segment: "parteien", label: "Parteien", icon: Users }, + { segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck }, { segment: "notizen", label: "Notizen", icon: StickyNote }, ] as const; @@ -51,6 +53,7 @@ const TAB_LABELS: Record = { fristen: "Fristen", dokumente: "Dokumente", parteien: "Parteien", + mitarbeiter: "Mitarbeiter", notizen: "Notizen", }; diff --git a/frontend/src/app/(app)/cases/[id]/mitarbeiter/page.tsx b/frontend/src/app/(app)/cases/[id]/mitarbeiter/page.tsx new file mode 100644 index 0000000..af95330 --- /dev/null +++ b/frontend/src/app/(app)/cases/[id]/mitarbeiter/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { CaseAssignments } from "@/components/cases/CaseAssignments"; + +export default function CaseMitarbeiterPage() { + const { id } = useParams<{ id: string }>(); + return ; +} diff --git a/frontend/src/app/(app)/cases/page.tsx b/frontend/src/app/(app)/cases/page.tsx index 32efc16..c6203f5 100644 --- a/frontend/src/app/(app)/cases/page.tsx +++ b/frontend/src/app/(app)/cases/page.tsx @@ -10,6 +10,7 @@ import { Plus, Search, FolderOpen } from "lucide-react"; import { useState } from "react"; import { SkeletonTable } from "@/components/ui/Skeleton"; import { EmptyState } from "@/components/ui/EmptyState"; +import { usePermissions } from "@/lib/hooks/usePermissions"; const STATUS_OPTIONS = [ { value: "", label: "Alle Status" }, @@ -49,6 +50,8 @@ const inputClass = export default function CasesPage() { const router = useRouter(); const searchParams = useSearchParams(); + const { can } = usePermissions(); + const canCreateCase = can("create_case"); const [search, setSearch] = useState(searchParams.get("search") ?? ""); const [status, setStatus] = useState(searchParams.get("status") ?? ""); @@ -86,13 +89,15 @@ export default function CasesPage() { {data ? `${data.total} Akten` : "\u00A0"}

- - - Neue Akte - + {canCreateCase && ( + + + Neue Akte + + )}
@@ -145,7 +150,7 @@ export default function CasesPage() { : "Erstellen Sie Ihre erste Akte, um loszulegen." } action={ - !search && !status && !type ? ( + !search && !status && !type && canCreateCase ? ( ("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(`/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 ( +
+ + +
+ ); + } + + return ( +
+

+ Zugewiesene Mitarbeiter +

+ + {/* Assign form — only for owners/partners */} + {canManage && availableMembers.length > 0 && ( +
+ + + +
+ )} + + {/* Assignments list */} + {assignments.length > 0 ? ( +
+ {assignments.map((a, i) => ( +
+
+
+ +
+
+

+ {a.user_id.slice(0, 8)}... +

+

+ {CASE_ASSIGNMENT_ROLE_LABELS[a.role as CaseAssignmentRole] ?? + a.role} +

+
+
+ {canManage && ( + + )} +
+ ))} +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 19b4cfc..8896b17 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -13,19 +13,32 @@ import { X, } from "lucide-react"; import { useState, useEffect } from "react"; +import { usePermissions } from "@/lib/hooks/usePermissions"; -const navigation = [ +interface NavItem { + name: string; + href: string; + icon: typeof LayoutDashboard; + permission?: string; +} + +const allNavigation: NavItem[] = [ { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { name: "Akten", href: "/cases", icon: FolderOpen }, { name: "Fristen", href: "/fristen", icon: Clock }, { name: "Termine", href: "/termine", icon: Calendar }, - { name: "AI Analyse", href: "/ai/extract", icon: Brain }, - { name: "Einstellungen", href: "/einstellungen", icon: Settings }, + { name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" }, + { name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" }, ]; export function Sidebar() { const pathname = usePathname(); const [mobileOpen, setMobileOpen] = useState(false); + const { can, isLoading: permLoading } = usePermissions(); + + const navigation = allNavigation.filter( + (item) => !item.permission || permLoading || can(item.permission), + ); // Close on route change useEffect(() => { diff --git a/frontend/src/components/settings/TeamSettings.tsx b/frontend/src/components/settings/TeamSettings.tsx index 8a1def3..d5fc5ca 100644 --- a/frontend/src/components/settings/TeamSettings.tsx +++ b/frontend/src/components/settings/TeamSettings.tsx @@ -3,27 +3,36 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react"; +import { UserPlus, Trash2, Crown, Scale, Briefcase, FileText, Phone } from "lucide-react"; import { api } from "@/lib/api"; -import type { UserTenant } from "@/lib/types"; +import type { UserTenant, UserRole } from "@/lib/types"; +import { ROLE_LABELS } from "@/lib/types"; import { Skeleton } from "@/components/ui/Skeleton"; import { EmptyState } from "@/components/ui/EmptyState"; +import { usePermissions } from "@/lib/hooks/usePermissions"; -const ROLE_LABELS: Record = { - owner: { label: "Eigentümer", icon: Crown }, - admin: { label: "Administrator", icon: Shield }, - member: { label: "Mitglied", icon: User }, +const ROLE_CONFIG: Record = { + owner: { label: ROLE_LABELS.owner, icon: Crown }, + partner: { label: ROLE_LABELS.partner, icon: Scale }, + associate: { label: ROLE_LABELS.associate, icon: Briefcase }, + paralegal: { label: ROLE_LABELS.paralegal, icon: FileText }, + secretary: { label: ROLE_LABELS.secretary, icon: Phone }, }; +const INVITE_ROLES: UserRole[] = ["partner", "associate", "paralegal", "secretary"]; + export function TeamSettings() { const queryClient = useQueryClient(); + const { can, role: myRole } = usePermissions(); const tenantId = typeof window !== "undefined" ? localStorage.getItem("kanzlai_tenant_id") : null; const [email, setEmail] = useState(""); - const [role, setRole] = useState("member"); + const [role, setRole] = useState("associate"); + + const canManageTeam = can("manage_team"); const { data: members, @@ -42,7 +51,7 @@ export function TeamSettings() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tenant-members"] }); setEmail(""); - setRole("member"); + setRole("associate"); toast.success("Benutzer eingeladen"); }, onError: (err: { error?: string }) => { @@ -62,6 +71,19 @@ export function TeamSettings() { }, }); + const updateRoleMutation = useMutation({ + mutationFn: ({ userId, newRole }: { userId: string; newRole: string }) => + api.put(`/tenants/${tenantId}/members/${userId}/role`, { role: newRole }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-members"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + toast.success("Rolle aktualisiert"); + }, + onError: (err: { error?: string }) => { + toast.error(err.error || "Fehler beim Aktualisieren der Rolle"); + }, + }); + const handleInvite = (e: React.FormEvent) => { e.preventDefault(); if (!email.trim()) return; @@ -81,7 +103,7 @@ export function TeamSettings() { if (error) { return ( @@ -90,38 +112,44 @@ export function TeamSettings() { return (
- {/* Invite Form */} -
- setEmail(e.target.value)} - placeholder="name@example.com" - className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" - /> - - -
+ {/* Invite Form — only for owners/partners */} + {canManageTeam && ( +
+ setEmail(e.target.value)} + placeholder="name@example.com" + className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400" + /> + + +
+ )} {/* Members List */} {Array.isArray(members) && members.length > 0 ? (
{members.map((member, i) => { - const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member; + const roleKey = (member.role as UserRole) || "associate"; + const roleInfo = ROLE_CONFIG[roleKey] || ROLE_CONFIG.associate; const RoleIcon = roleInfo.icon; return (
{roleInfo.label}

- {member.role !== "owner" && ( - - )} +
+ {/* Role dropdown — only for owners/partners, not for the member's own row if they are owner */} + {canManageTeam && member.role !== "owner" && ( + + )} + {canManageTeam && member.role !== "owner" && ( + + )} +
); })}
) : ( diff --git a/frontend/src/lib/hooks/usePermissions.ts b/frontend/src/lib/hooks/usePermissions.ts new file mode 100644 index 0000000..57e43fe --- /dev/null +++ b/frontend/src/lib/hooks/usePermissions.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { UserInfo } from "@/lib/types"; + +export function usePermissions() { + const { data, isLoading } = useQuery({ + queryKey: ["me"], + queryFn: () => api.get("/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, + }; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 458cc5b..a842be4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -189,6 +189,40 @@ export interface Note { updated_at: string; } +export interface CaseAssignment { + id: string; + case_id: string; + user_id: string; + role: string; + assigned_at: string; +} + +export interface UserInfo { + user_id: string; + tenant_id: string; + role: UserRole; + permissions: string[]; +} + +export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary"; + +export const ROLE_LABELS: Record = { + 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 = { + lead: "Federführend", + team: "Team", + viewer: "Einsicht", +}; + export interface ApiError { error: string; status: number;