feat: role-based permissions — owner/partner/associate/paralegal/secretary (P0)

This commit is contained in:
m
2026-03-30 11:09:05 +02:00
20 changed files with 1112 additions and 123 deletions

View File

@@ -9,10 +9,19 @@ import (
type contextKey string type contextKey string
const ( const (
<<<<<<< HEAD
userIDKey contextKey = "user_id" userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id" tenantIDKey contextKey = "tenant_id"
ipKey contextKey = "ip_address" ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent" 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 { 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) id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok return id, ok
} }
<<<<<<< HEAD
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip) ctx = context.WithValue(ctx, ipKey, ip)
@@ -52,3 +62,15 @@ func UserAgentFromContext(ctx context.Context) *string {
} }
return nil 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

View File

@@ -40,15 +40,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
// Tenant management routes handle their own access control. // Tenant management routes handle their own access control.
||||||| 82878df ||||||| 82878df
// Resolve tenant from user_tenants // Resolve tenant and role from user_tenants
var tenantID uuid.UUID var membership struct {
err = m.db.GetContext(r.Context(), &tenantID, TenantID uuid.UUID `db:"tenant_id"`
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) 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 { if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden) http.Error(w, "no tenant found for user", http.StatusForbidden)
return return
} }
ctx = ContextWithTenantID(ctx, tenantID) ctx = ContextWithTenantID(ctx, membership.TenantID)
ctx = ContextWithUserRole(ctx, membership.Role)
======= =======

View File

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

View File

@@ -12,7 +12,12 @@ import (
// Defined as an interface to avoid circular dependency with services. // Defined as an interface to avoid circular dependency with services.
type TenantLookup interface { type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
<<<<<<< HEAD
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) 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 // 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) http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return return
} }
<<<<<<< HEAD
// Verify user has access to this tenant // Verify user has access to this tenant
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed) hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
@@ -54,9 +60,24 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return 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 tenantID = parsed
// Override the role from middleware with the correct one for this tenant
r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else { } else {
// Default to user's first tenant // Default to user's first tenant (role already set by middleware)
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil { if err != nil {
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID) slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)

View File

@@ -10,23 +10,49 @@ import (
) )
type mockTenantLookup struct { type mockTenantLookup struct {
<<<<<<< HEAD
tenantID *uuid.UUID tenantID *uuid.UUID
err error err error
hasAccess bool hasAccess bool
accessErr error 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) { func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err return m.tenantID, m.err
} }
<<<<<<< HEAD
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) { func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr 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) { func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New() tenantID := uuid.New()
<<<<<<< HEAD
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true}) tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
||||||| 82878df
tr := NewTenantResolver(&mockTenantLookup{})
=======
tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
>>>>>>> mai/pike/p0-role-based
var gotTenantID uuid.UUID var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,14 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineRuleSvc := services.NewDeadlineRuleService(db) deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc) calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
<<<<<<< HEAD
documentSvc := services.NewDocumentService(db, storageCli, auditSvc) 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) // AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler 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) noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db) eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc) docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
// Public routes // Public routes
mux.HandleFunc("GET /health", handleHealth(db)) mux.HandleFunc("GET /health", handleHealth(db))
@@ -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("POST /api/tenants/{id}/invite", tenantH.InviteUser)
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember) api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers) api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
api.HandleFunc("PUT /api/tenants/{id}/members/{uid}/role", tenantH.UpdateMemberRole)
// Permission-wrapping helper: wraps a HandlerFunc with a permission check
perm := func(p auth.Permission, fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role := auth.UserRoleFromContext(r.Context())
if !auth.HasPermission(role, p) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"insufficient permissions"}`))
return
}
fn(w, r)
}
}
// Tenant-scoped routes (require tenant context) // Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux() scoped := http.NewServeMux()
// Cases // Current user info (role, permissions) — all authenticated users
scoped.HandleFunc("GET /api/cases", caseH.List) scoped.HandleFunc("GET /api/me", tenantH.GetMe)
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)
// 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("GET /api/cases/{id}/parties", partyH.List)
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create) scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update) scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete) scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Deadlines // Deadlines — manage needs PermManageDeadlines, view is open
scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get) scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get)
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll) scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase) scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create) scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", perm(auth.PermManageDeadlines, deadlineH.Create))
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update) scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Update))
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete) scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", perm(auth.PermManageDeadlines, deadlineH.Complete))
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete) 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", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree) scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes) scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes)
// Deadline calculator // Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate) 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/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List) scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", apptH.Create) scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create))
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update) scoped.HandleFunc("PUT /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Update))
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete) 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) scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
// Notes // Notes — all can manage
scoped.HandleFunc("GET /api/notes", noteH.List) scoped.HandleFunc("GET /api/notes", noteH.List)
scoped.HandleFunc("POST /api/notes", noteH.Create) scoped.HandleFunc("POST /api/notes", noteH.Create)
scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update) scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update)
scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete) scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete)
// Dashboard // Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
<<<<<<< HEAD
// Audit log // Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List) scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents // 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("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}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler
// AI endpoints (rate limited: 5 req/min burst 10 per IP) // AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil { if aiH != nil {
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10) aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines)) scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase)) scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
} }
<<<<<<< HEAD
// Notifications // Notifications
if notifH != nil { if notifH != nil {
scoped.HandleFunc("GET /api/notifications", notifH.List) 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 // CalDAV sync endpoints
||||||| 82878df
// CalDAV sync endpoints
=======
// CalDAV sync endpoints — settings permission required
>>>>>>> mai/pike/p0-role-based
if calDAVSvc != nil { if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc) 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) scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -189,36 +189,39 @@ export interface Note {
updated_at: string; updated_at: string;
} }
// Notifications export interface CaseAssignment {
export interface Notification {
id: string; id: string;
tenant_id: string; case_id: string;
user_id: string; user_id: string;
type: "deadline_reminder" | "deadline_overdue" | "case_update" | "assignment"; role: string;
entity_type?: "deadline" | "appointment" | "case"; assigned_at: string;
entity_id?: string;
title: string;
body?: string;
sent_at?: string;
read_at?: string;
created_at: string;
} }
export interface NotificationPreferences { export interface UserInfo {
user_id: string; user_id: string;
tenant_id: string; tenant_id: string;
deadline_reminder_days: number[]; role: UserRole;
email_enabled: boolean; permissions: string[];
daily_digest: boolean;
created_at?: string;
updated_at?: string;
} }
export interface NotificationListResponse { export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";
data: Notification[];
total: number; export const ROLE_LABELS: Record<UserRole, string> = {
} owner: "Inhaber",
partner: "Partner",
associate: "Anwalt",
paralegal: "Paralegal",
secretary: "Sekretariat",
};
export const CASE_ASSIGNMENT_ROLES = ["lead", "team", "viewer"] as const;
export type CaseAssignmentRole = (typeof CASE_ASSIGNMENT_ROLES)[number];
export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
lead: "Federführend",
team: "Team",
viewer: "Einsicht",
};
export interface ApiError { export interface ApiError {
error: string; error: string;