`/admin` was 404 — only `/admin/team` existed. Add a browseable index so
the admin area has a root, with the existing Team-Verwaltung tile alongside
greyed-out roadmap placeholders (Departments, Audit-Log, Email-Templates,
Feature-Flags) so admins see what's coming.
- internal/handlers/admin_users.go: handleAdminIndexPage serves
dist/admin.html. Same RequireAdminFunc gate as /admin/team — non-admins
get the standard 302 to /dashboard?forbidden=admin.
- internal/handlers/handlers.go: register GET /admin under the existing
admin-conditional block.
- frontend/src/admin.tsx + client/admin.ts: card grid built from the
shared .grid + .card landing-page pattern. .admin-card-soon dims the
placeholders + adds a "Kommt bald" badge so they read as roadmap, not
broken links.
- frontend/src/components/Sidebar.tsx: add Admin-Bereich (/admin) above
Team-Verwaltung in the existing admin group. Both items live in the
same display:none group that sidebar.ts reveals after /api/me confirms
global_role='global_admin'.
- frontend/src/client/i18n.ts: nav.admin.bereich + admin.title /
.heading / .subtitle / .section.{available,planned} / .coming_soon
plus per-card title+desc, DE+EN.
- frontend/src/styles/global.css: .admin-section-planned spacing,
.admin-card-soon dimming, .admin-soon-badge pill.
- frontend/build.ts: register the renderAdmin entrypoint and admin.ts
client bundle.
164 lines
5.7 KiB
Go
164 lines
5.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/services"
|
|
)
|
|
|
|
// admin_users.go — backing endpoints for the /admin/team page (t-paliad-050).
|
|
// All four routes are registered behind RequireAdminFunc in handlers.go, so
|
|
// the in-handler logic can assume the caller already passed the admin gate
|
|
// and only the operation itself needs validation.
|
|
|
|
// GET /api/admin/users — full unredacted list of every paliad.users row.
|
|
func handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
users, err := dbSvc.users.List(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, users)
|
|
}
|
|
|
|
// GET /api/admin/users/unonboarded — auth.users entries without a paliad.users
|
|
// row. Feeds the "direct add" dropdown so an admin can onboard a colleague
|
|
// who logged in but never finished the form.
|
|
func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
rows, err := dbSvc.users.ListUnonboardedAuthUsers(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// POST /api/admin/users — direct-create a paliad.users row for an existing
|
|
// auth.users entry. The recipient email's domain must already match the
|
|
// allowed-email policy (Supabase wouldn't have let them sign up otherwise),
|
|
// but we re-check here so a stale auth.users row from before the policy
|
|
// existed can't sneak through.
|
|
func handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
var input services.AdminCreateInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
if !isAllowedEmailDomain(input.Email) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"error": "email domain not on the HLC allow-list",
|
|
})
|
|
return
|
|
}
|
|
u, err := dbSvc.users.AdminCreateUser(r.Context(), input)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, services.ErrUserAlreadyOnboarded):
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": "user already onboarded",
|
|
})
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
// AdminCreateUser uses ErrInvalidInput for both bad-shape inputs
|
|
// and the "no auth.users row for this email" case. Surfacing the
|
|
// raw message keeps the form's error display useful without a
|
|
// separate error type for each.
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, u)
|
|
}
|
|
|
|
// PATCH /api/admin/users/{id} — mutate any paliad.users row.
|
|
func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var input services.AdminUpdateInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
u, err := dbSvc.users.AdminUpdateUser(r.Context(), id, input)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, services.ErrUserNotOnboarded):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
case errors.Is(err, services.ErrLastGlobalAdmin):
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": "cannot demote the last remaining global admin",
|
|
})
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, u)
|
|
}
|
|
|
|
// DELETE /api/admin/users/{id} — remove a paliad.users row + cascade clean-up
|
|
// of project_teams / department_members. auth.users is left intact so the
|
|
// user can re-onboard later if needed.
|
|
func handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := dbSvc.users.AdminDeleteUser(r.Context(), id); err != nil {
|
|
switch {
|
|
case errors.Is(err, services.ErrUserNotOnboarded):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
case errors.Is(err, services.ErrLastGlobalAdmin):
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": "cannot delete the last remaining global admin",
|
|
})
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
}
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleAdminTeamPage serves the SPA shell for /admin/team.
|
|
func handleAdminTeamPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/admin-team.html")
|
|
}
|
|
|
|
// handleAdminIndexPage serves the SPA shell for /admin — the admin landing
|
|
// page that lists current and planned admin sub-pages so the area is
|
|
// browseable. Like /admin/team, the route is gated through RequireAdminFunc
|
|
// at registration in handlers.go; non-admins get the standard 302 to
|
|
// /dashboard?forbidden=admin from rejectAdmin.
|
|
func handleAdminIndexPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/admin.html")
|
|
}
|