Files
shorebird-server/internal/api/handlers/admin.go
T
Tony 5f72264b06 feat: add organization management features and password reset functionality
- Introduced a new section for managing organizations, including creating organizations and adding users to them.
- Added a password reset modal and functionality to request a password reset link.
- Updated the settings page to include personal settings for changing passwords and admin settings for configuring registration options.
- Enhanced the app detail view with tabs for releases, insights, collaborators, tracks, and settings.
- Improved user management with admin capabilities to verify user emails and change user roles.
- Updated navigation and UI elements to accommodate new features and improve user experience.
2026-06-12 20:03:58 +08:00

235 lines
6.6 KiB
Go

package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/shorebird-server/internal/api/middleware"
"github.com/shorebird-server/internal/db"
)
// AdminHandler handles admin/management API endpoints.
type AdminHandler struct {
DB db.Store
}
// AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices
// Restricts a patch to only be delivered to specific devices (by client_id).
func (h *AdminHandler) AddTargetDevice(w http.ResponseWriter, r *http.Request) {
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
return
}
var req struct {
ClientID string `json:"client_id"`
}
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
return
}
if err := h.DB.AddPatchTargetDevice(r.Context(), patchID, req.ClientID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to add target device", strPtr(err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
// RemoveTargetDevice handles DELETE /api/v1/admin/patches/{patchId}/target-devices/{clientId}
func (h *AdminHandler) RemoveTargetDevice(w http.ResponseWriter, r *http.Request) {
// For simplicity, target device removal is handled by the database cascade
// In a full implementation, add a RemovePatchTargetDevice DB method
respondError(w, http.StatusNotImplemented, "Not yet implemented", nil)
}
// GetTargetDevices handles GET /api/v1/admin/patches/{patchId}/target-devices
func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request) {
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
return
}
devices, err := h.DB.GetPatchTargetDevices(r.Context(), patchID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to fetch target devices", nil)
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"patch_id": patchID,
"client_ids": devices,
})
}
// GetPatchEvents handles GET /api/v1/admin/apps/{appId}/events
// Returns patch events for analytics.
func (h *AdminHandler) GetPatchEvents(w http.ResponseWriter, r *http.Request) {
// This would query patch_events table for the app
// For now, return a stub
respondJSON(w, http.StatusOK, map[string]interface{}{
"events": []map[string]interface{}{},
})
}
// ListUsers handles GET /api/v1/admin/users (admin convenience endpoint)
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
users, err := h.DB.ListAllUsers(r.Context())
if err != nil {
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Admin endpoints available",
"users": []interface{}{},
})
return
}
type userEntry struct {
ID int `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
EmailVerified bool `json:"email_verified"`
IsAdmin bool `json:"is_admin"`
}
result := make([]userEntry, 0, len(users))
for _, u := range users {
role := u.Role
if role == "" {
role = "member"
}
result = append(result, userEntry{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Role: role,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
EmailVerified: u.EmailVerified,
IsAdmin: u.IsAdmin,
})
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"users": result,
})
}
func (h *AdminHandler) VerifyUserEmail(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid user ID", nil)
return
}
if err := h.DB.MarkUserEmailVerified(r.Context(), userID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to verify user email", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *AdminHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid user ID", nil)
return
}
var req struct {
IsAdmin bool `json:"is_admin"`
}
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
return
}
if err := h.DB.SetUserAdmin(r.Context(), userID, req.IsAdmin); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update admin status", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *AdminHandler) GetSettings(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
settings, err := h.DB.ListSettings(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load settings", nil)
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{"settings": redactedSettings(settings)})
}
func (h *AdminHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
var req map[string]string
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
return
}
allowed := map[string]bool{
"registration_enabled": true,
"sso_registration_enabled": true,
"sso_only_registration": true,
"casdoor_endpoint": true,
"casdoor_client_id": true,
"casdoor_client_secret": true,
"casdoor_organization": true,
}
for key, value := range req {
if !allowed[key] {
continue
}
if key == "casdoor_client_secret" && value == "" {
continue
}
if err := h.DB.SetSetting(r.Context(), key, value); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update settings", nil)
return
}
}
h.GetSettings(w, r)
}
func (h *AdminHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
claims := middleware.GetClaims(r)
if claims == nil {
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
return false
}
user, err := h.DB.GetUserByID(r.Context(), claims.UserID)
if err != nil || !user.IsAdmin {
respondError(w, http.StatusForbidden, "Admin access required", nil)
return false
}
return true
}
func redactedSettings(settings map[string]string) map[string]string {
out := map[string]string{}
for k, v := range settings {
if k == "casdoor_client_secret" && v != "" {
out[k] = "********"
continue
}
out[k] = v
}
return out
}