package admin import ( "errors" "fmt" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/config" "github.com/ultisuite/ulti-backend/internal/mail/hosted" migr "github.com/ultisuite/ulti-backend/internal/migration" "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/permission" "github.com/ultisuite/ulti-backend/internal/securityaudit" platformusers "github.com/ultisuite/ulti-backend/internal/users" ) type Handler struct { svc *Service logger *slog.Logger } func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger, cfg *config.Config, nc *nextcloud.Client) *Handler { return &Handler{ svc: NewService(db, audit, cfg, nc), logger: slog.Default().With("component", "admin-api"), } } func (h *Handler) SetHostedService(svc *hosted.Service) { h.svc.SetHostedService(svc) } func (h *Handler) SetMigrationService(svc *migr.Service) { h.svc.SetMigrationService(svc) } func (h *Handler) Routes() chi.Router { r := chi.NewRouter() read := middleware.RequireAdminScope(permission.AdminScopeRead) write := middleware.RequireAdminScope(permission.AdminScopeWrite) r.With(read).Get("/users", h.ListUsers) r.With(read).Get("/users/{userID}", h.GetUser) r.With(write).Post("/users", h.CreateUser) r.With(write).Post("/users/invite", h.InviteUser) r.With(write).Put("/users/{userID}", h.UpdateUser) r.With(write).Post("/users/{userID}/disable", h.DisableUser) r.With(write).Post("/users/{userID}/reactivate", h.ReactivateUser) r.With(write).Put("/users/{userID}/quota", h.SetQuota) r.With(write).Put("/users/{userID}/role", h.SetUserRole) r.With(write).Delete("/users/{userID}", h.DeleteUser) r.With(write).Post("/users/bulk", h.BulkUsersAction) r.With(read).Get("/user-groups", h.ListUserGroups) r.With(read).Get("/user-groups/{groupID}", h.GetUserGroup) r.With(write).Post("/user-groups", h.CreateUserGroup) r.With(write).Put("/user-groups/{groupID}", h.UpdateUserGroup) r.With(write).Delete("/user-groups/{groupID}", h.DeleteUserGroup) r.With(write).Put("/user-groups/{groupID}/members", h.SetUserGroupMembers) r.With(read).Get("/audit", h.ListAuditLogs) r.With(read).Get("/audit/export", h.ExportAuditLogs) r.With(read).Get("/stats", h.GetStats) r.With(read).Get("/public-shares", h.ListPublicShares) r.With(write).Delete("/public-shares/{shareID}", h.RevokePublicShare) r.With(read).Get("/org/settings", h.GetOrgSettings) r.With(write).Put("/org/settings", h.PutOrgSettings) r.With(read).Post("/org/llm/discover-models", h.DiscoverOrgLLMModels) r.With(read).Get("/ai/usage", h.GetAIUsage) r.With(read).Get("/ai/usage/users/{userID}", h.GetUserAIUsage) r.With(read).Get("/ai/pricing", h.GetAIPricing) r.With(write).Put("/ai/pricing", h.PutAIPricing) r.With(read).Get("/ai/policies", h.GetAICostPolicies) r.With(write).Put("/users/{userID}/ai-policy", h.PutUserAICostPolicy) r.With(write).Put("/user-groups/{groupID}/ai-policy", h.PutGroupAICostPolicy) r.With(read).Get("/org/identity-providers/redirect-uri/{slug}", h.GetIdentityProviderRedirectURI) r.With(write).Post("/org/identity-providers/{providerID}/test", h.TestIdentityProvider) r.With(write).Post("/org/identity-providers/{providerID}/sync", h.SyncIdentityProvider) h.registerDriveAdminRoutes(r, read, write) h.registerMailAdminRoutes(r, read, write) return r } func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } status, verr := validateStatus(r.URL.Query().Get("status")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } role, verr := validateAccountRoleFilter(r.URL.Query().Get("role")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } groupID, verr := validateGroupIDFilter(r.URL.Query().Get("group_id")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } result, err := h.svc.ListUsers(r.Context(), params, UserFilter{ Status: status, Role: role, GroupID: groupID, Q: strings.TrimSpace(params.Q), }) if err != nil { h.logger.Error("list users", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } user, err := h.svc.GetUser(r.Context(), userID) if err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("get user", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, user) } func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req createUserRequest if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil { return } if verr := validateCreateUser(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } user, err := h.svc.CreateUser(r.Context(), claims.Sub, req) if err != nil { h.logger.Error("create user", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, user) } func (h *Handler) InviteUser(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req inviteUserRequest if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil { return } if verr := validateInviteUser(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } user, err := h.svc.InviteUser(r.Context(), claims.Sub, req) if err != nil { h.logger.Error("invite user", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusCreated, user) } func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } claims := middleware.ClaimsFromContext(r.Context()) var req updateUserRequest if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil { return } if verr := validateUpdateUser(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } user, err := h.svc.UpdateUser(r.Context(), claims.Sub, userID, req) if err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("update user", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, user) } func (h *Handler) SetUserRole(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } claims := middleware.ClaimsFromContext(r.Context()) var req setUserRoleRequest if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil { return } if verr := validateSetUserRole(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } user, err := h.svc.SetUserRole(r.Context(), claims.Sub, userID, req.Role) if err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } if errors.Is(err, platformusers.ErrLastPlatformAdmin) { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "role", Message: "cannot remove the last platform admin"}, )) return } h.logger.Error("set user role", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, user) } func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } claims := middleware.ClaimsFromContext(r.Context()) var req setQuotaRequest if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil { return } if verr := validateSetQuota(&req); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } if err := h.svc.SetQuota(r.Context(), claims.Sub, userID, req); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("set quota", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) DisableUser(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } claims := middleware.ClaimsFromContext(r.Context()) if err := h.svc.DisableUser(r.Context(), claims.Sub, userID); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("disable user", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) ReactivateUser(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } claims := middleware.ClaimsFromContext(r.Context()) if err := h.svc.ReactivateUser(r.Context(), claims.Sub, userID); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("reactivate user", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } claims := middleware.ClaimsFromContext(r.Context()) if err := h.svc.DeleteUser(r.Context(), claims.Sub, userID); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("delete user", "error", err) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) ListAuditLogs(w http.ResponseWriter, r *http.Request) { params, err := query.ParseListRequest(r) if err != nil { apivalidate.WriteQueryError(w, r, err) return } result, err := h.svc.ListAuditLogs(r.Context(), params) if err != nil { h.logger.Error("list audit logs", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) ExportAuditLogs(w http.ResponseWriter, r *http.Request) { format, verr := validateExportFormat(r.URL.Query().Get("format")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } limit, verr := validateExportLimit(r.URL.Query().Get("limit")) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } payload, err := h.svc.ExportAuditLogs(r.Context(), format, limit) if err != nil { h.logger.Error("export audit logs", "error", err) apivalidate.WriteInternal(w, r) return } w.Header().Set("Content-Type", payload.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, payload.FileName)) w.WriteHeader(http.StatusOK) _, _ = w.Write(payload.Content) } func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { stats, err := h.svc.GetStats(r.Context()) if err != nil { h.logger.Error("get stats", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, stats) } func (h *Handler) GetOrgSettings(w http.ResponseWriter, r *http.Request) { payload, err := h.svc.GetOrgSettings(r.Context()) if err != nil { h.logger.Error("get org settings", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, payload) } func (h *Handler) PutOrgSettings(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var req map[string]any if err := apivalidate.DecodeJSON(w, r, 1<<20, &req); err != nil { return } patch, ok := req["policy"].(map[string]any) if !ok || len(patch) == 0 { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "policy", Message: "required"}, )) return } payload, err := h.svc.PutOrgSettings(r.Context(), claims.Sub, patch) if err != nil { h.logger.Error("put org settings", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, payload) } type discoverOrgLLMModelsRequest struct { ProviderID string `json:"provider_id"` } func (h *Handler) DiscoverOrgLLMModels(w http.ResponseWriter, r *http.Request) { var req discoverOrgLLMModelsRequest if err := apivalidate.DecodeJSON(w, r, 1<<20, &req); err != nil { return } if strings.TrimSpace(req.ProviderID) == "" { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "provider_id", Message: "required"}, )) return } models, err := h.svc.DiscoverOrgLLMModels(r.Context(), req.ProviderID) if err != nil { h.logger.Error("discover org llm models", "error", err, "provider_id", req.ProviderID) apiresponse.WriteError(w, r, http.StatusBadGateway, "llm_models_unavailable", err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"models": models}) }