392 lines
11 KiB
Go
392 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
|
)
|
|
|
|
type TenantHandler struct {
|
|
svc *services.TenantService
|
|
}
|
|
|
|
func NewTenantHandler(svc *services.TenantService) *TenantHandler {
|
|
return &TenantHandler{svc: svc}
|
|
}
|
|
|
|
// CreateTenant handles POST /api/tenants
|
|
func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Name == "" || req.Slug == "" {
|
|
jsonError(w, "name and slug are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
|
|
if err != nil {
|
|
slog.Error("failed to create tenant", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, tenant, http.StatusCreated)
|
|
}
|
|
|
|
// ListTenants handles GET /api/tenants
|
|
func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tenants, err := h.svc.ListForUser(r.Context(), userID)
|
|
if err != nil {
|
|
slog.Error("failed to list tenants", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Mask CalDAV passwords in tenant settings
|
|
for i := range tenants {
|
|
tenants[i].Settings = maskSettingsPassword(tenants[i].Settings)
|
|
}
|
|
|
|
jsonResponse(w, tenants, http.StatusOK)
|
|
}
|
|
|
|
// GetTenant handles GET /api/tenants/{id}
|
|
func (h *TenantHandler) GetTenant(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
|
|
}
|
|
|
|
// Verify user has access to this tenant
|
|
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 == "" {
|
|
jsonError(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
tenant, err := h.svc.GetByID(r.Context(), tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to get tenant", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tenant == nil {
|
|
jsonError(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Mask CalDAV password before returning
|
|
tenant.Settings = maskSettingsPassword(tenant.Settings)
|
|
|
|
jsonResponse(w, tenant, http.StatusOK)
|
|
}
|
|
|
|
// InviteUser handles POST /api/tenants/{id}/invite
|
|
func (h *TenantHandler) InviteUser(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
|
|
}
|
|
|
|
// 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 != "partner" {
|
|
jsonError(w, "only owners and partners can invite users", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Email == "" {
|
|
jsonError(w, "email is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Role == "" {
|
|
req.Role = "associate"
|
|
}
|
|
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
|
|
}
|
|
|
|
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
|
|
if err != nil {
|
|
// These are user-facing validation errors (user not found, already member)
|
|
jsonError(w, "failed to invite user", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, ut, http.StatusCreated)
|
|
}
|
|
|
|
// RemoveMember handles DELETE /api/tenants/{id}/members/{uid}
|
|
func (h *TenantHandler) RemoveMember(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 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 != "partner" && userID != memberID {
|
|
jsonError(w, "insufficient permissions", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
|
|
// These are user-facing validation errors (not a member, last owner, etc.)
|
|
jsonError(w, "failed to remove member", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, map[string]string{"status": "removed"}, http.StatusOK)
|
|
}
|
|
|
|
// UpdateSettings handles PUT /api/tenants/{id}/settings
|
|
func (h *TenantHandler) UpdateSettings(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
|
|
}
|
|
|
|
// 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 != "partner" {
|
|
jsonError(w, "only owners and partners can update settings", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var settings json.RawMessage
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
|
|
if err != nil {
|
|
slog.Error("failed to update settings", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Mask CalDAV password before returning
|
|
tenant.Settings = maskSettingsPassword(tenant.Settings)
|
|
|
|
jsonResponse(w, tenant, http.StatusOK)
|
|
}
|
|
|
|
// ListMembers handles GET /api/tenants/{id}/members
|
|
func (h *TenantHandler) ListMembers(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
|
|
}
|
|
|
|
// Verify user has access
|
|
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 == "" {
|
|
jsonError(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
members, err := h.svc.ListMembers(r.Context(), tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to list members", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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)
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func jsonError(w http.ResponseWriter, msg string, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|