diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index abab9cf..c2aeaef 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -9,10 +9,19 @@ import ( type contextKey string const ( +<<<<<<< HEAD userIDKey contextKey = "user_id" tenantIDKey contextKey = "tenant_id" ipKey contextKey = "ip_address" userAgentKey contextKey = "user_agent" +||||||| 82878df + userIDKey contextKey = "user_id" + tenantIDKey contextKey = "tenant_id" +======= + userIDKey contextKey = "user_id" + tenantIDKey contextKey = "tenant_id" + userRoleKey contextKey = "user_role" +>>>>>>> mai/pike/p0-role-based ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -32,6 +41,7 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(tenantIDKey).(uuid.UUID) return id, ok } +<<<<<<< HEAD func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { ctx = context.WithValue(ctx, ipKey, ip) @@ -52,3 +62,15 @@ func UserAgentFromContext(ctx context.Context) *string { } return nil } +||||||| 82878df +======= + +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 +} +>>>>>>> mai/pike/p0-role-based diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 8ec7228..02428c6 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -40,15 +40,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { // Tenant management routes handle their own access control. ||||||| 82878df - // 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) ======= 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 24688d0..0dab57f 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -12,7 +12,12 @@ import ( // Defined as an interface to avoid circular dependency with services. type TenantLookup interface { FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) +<<<<<<< HEAD VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) +||||||| 82878df +======= + GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) +>>>>>>> mai/pike/p0-role-based } // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header @@ -41,6 +46,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) return } +<<<<<<< HEAD // Verify user has access to this tenant hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed) @@ -54,9 +60,24 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { return } +||||||| 82878df +======= + // 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 + } +>>>>>>> mai/pike/p0-role-based 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 { slog.Error("failed to resolve default tenant", "error", err, "user_id", userID) diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go index a542bdf..d0300c2 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -10,23 +10,49 @@ import ( ) type mockTenantLookup struct { +<<<<<<< HEAD tenantID *uuid.UUID err error hasAccess bool accessErr error +||||||| 82878df + tenantID *uuid.UUID + err error +======= + tenantID *uuid.UUID + role string + err error +>>>>>>> mai/pike/p0-role-based } func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { return m.tenantID, m.err } +<<<<<<< HEAD func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) { return m.hasAccess, m.accessErr } +||||||| 82878df +======= +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 +} + +>>>>>>> mai/pike/p0-role-based func TestTenantResolver_FromHeader(t *testing.T) { tenantID := uuid.New() +<<<<<<< HEAD tr := NewTenantResolver(&mockTenantLookup{hasAccess: true}) +||||||| 82878df + tr := NewTenantResolver(&mockTenantLookup{}) +======= + tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) +>>>>>>> mai/pike/p0-role-based 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 3a9098c..7dec882 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 52bb29f..6341b1d 100644 --- a/backend/internal/handlers/tenant_handler.go +++ b/backend/internal/handlers/tenant_handler.go @@ -130,15 +130,15 @@ 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 { slog.Error("failed to get user role", "error", err) jsonError(w, "internal 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 } @@ -155,10 +155,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 } @@ -192,14 +197,14 @@ 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 { slog.Error("failed to get user role", "error", err) jsonError(w, "internal 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 } @@ -227,15 +232,15 @@ 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 { slog.Error("failed to get user role", "error", err) jsonError(w, "internal 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 } @@ -294,6 +299,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 156d1dc..84b7f8e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -29,7 +29,14 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) +<<<<<<< HEAD documentSvc := services.NewDocumentService(db, storageCli, auditSvc) +||||||| 82878df + documentSvc := services.NewDocumentService(db, storageCli) +======= + documentSvc := services.NewDocumentService(db, storageCli) + assignmentSvc := services.NewCaseAssignmentService(db) +>>>>>>> mai/pike/p0-role-based // AI service (optional — only if API key is configured) var aiH *handlers.AIHandler @@ -63,6 +70,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)) @@ -78,76 +86,106 @@ 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) +<<<<<<< HEAD // Audit log scoped.HandleFunc("GET /api/audit-log", auditH.List) // Documents +||||||| 82878df + // Documents +======= + // Documents — all can upload, delete checked in handler (own vs all) +>>>>>>> mai/pike/p0-role-based 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))) } +<<<<<<< HEAD // Notifications if notifH != nil { scoped.HandleFunc("GET /api/notifications", notifH.List) @@ -159,9 +197,14 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se } // CalDAV sync endpoints +||||||| 82878df + // CalDAV sync endpoints +======= + // CalDAV sync endpoints — settings permission required +>>>>>>> mai/pike/p0-role-based 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 5e47a80..e0f5cea 100644 --- a/backend/internal/services/tenant_service.go +++ b/backend/internal/services/tenant_service.go @@ -206,6 +206,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 a8ea73f..857f570 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, ScrollText, @@ -44,6 +45,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 }, { segment: "protokoll", label: "Protokoll", icon: ScrollText }, ] as const; @@ -53,6 +55,7 @@ const TAB_LABELS: Record = { fristen: "Fristen", dokumente: "Dokumente", parteien: "Parteien", + mitarbeiter: "Mitarbeiter", notizen: "Notizen", protokoll: "Protokoll", }; 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 80e61c6..a842be4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -189,36 +189,39 @@ export interface Note { updated_at: string; } -// Notifications - -export interface Notification { +export interface CaseAssignment { id: string; - tenant_id: string; + case_id: string; user_id: string; - type: "deadline_reminder" | "deadline_overdue" | "case_update" | "assignment"; - entity_type?: "deadline" | "appointment" | "case"; - entity_id?: string; - title: string; - body?: string; - sent_at?: string; - read_at?: string; - created_at: string; + role: string; + assigned_at: string; } -export interface NotificationPreferences { +export interface UserInfo { user_id: string; tenant_id: string; - deadline_reminder_days: number[]; - email_enabled: boolean; - daily_digest: boolean; - created_at?: string; - updated_at?: string; + role: UserRole; + permissions: string[]; } -export interface NotificationListResponse { - data: Notification[]; - total: number; -} +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;