feat: role-based permissions — owner/partner/associate/paralegal/secretary (P0)
This commit is contained in:
119
backend/internal/handlers/case_assignments.go
Normal file
119
backend/internal/handlers/case_assignments.go
Normal 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"})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user