teg/backend/admin.go
2026-05-19 18:07:22 +02:00

558 lines
12 KiB
Go

// backend/admin.go
package main
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"golang.org/x/crypto/bcrypt"
)
type AdminTeam struct {
ID string `json:"id"`
Name string `json:"name"`
Initial string `json:"initial"`
Href string `json:"href"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type AdminUser struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Unit string `json:"unit"`
Group string `json:"group"`
Rights []string `json:"rights"`
Teams []AdminTeam `json:"teams"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type AdminUserRequest struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Password string `json:"password"`
Avatar string `json:"avatar"`
Unit string `json:"unit"`
Group string `json:"group"`
Rights []string `json:"rights"`
TeamIDs []string `json:"teamIds"`
}
type AdminTeamRequest struct {
Name string `json:"name"`
Initial string `json:"initial"`
Href string `json:"href"`
}
func normalizeAdminUserInput(input *AdminUserRequest) {
input.Username = strings.TrimSpace(input.Username)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Email = strings.TrimSpace(input.Email)
input.Password = strings.TrimSpace(input.Password)
input.Avatar = strings.TrimSpace(input.Avatar)
input.Unit = strings.TrimSpace(input.Unit)
input.Group = strings.TrimSpace(input.Group)
if input.Group == "" {
input.Group = "user"
}
input.Rights = cleanStringList(input.Rights)
input.TeamIDs = cleanStringList(input.TeamIDs)
}
func normalizeAdminTeamInput(input *AdminTeamRequest) {
input.Name = strings.TrimSpace(input.Name)
input.Initial = strings.TrimSpace(input.Initial)
input.Href = strings.TrimSpace(input.Href)
if input.Initial == "" && input.Name != "" {
input.Initial = strings.ToUpper(string([]rune(input.Name)[0]))
}
if input.Href == "" {
input.Href = "#"
}
}
func cleanStringList(values []string) []string {
result := []string{}
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
id::TEXT,
COALESCE(username, ''),
COALESCE(display_name, ''),
email,
COALESCE(avatar, ''),
COALESCE(unit, ''),
COALESCE(user_group, ''),
rights,
created_at,
updated_at
FROM users
ORDER BY display_name ASC, email ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden")
return
}
defer rows.Close()
users := []AdminUser{}
userByID := map[string]*AdminUser{}
for rows.Next() {
var user AdminUser
if err := rows.Scan(
&user.ID,
&user.Username,
&user.DisplayName,
&user.Email,
&user.Avatar,
&user.Unit,
&user.Group,
&user.Rights,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden")
return
}
user.Teams = []AdminTeam{}
users = append(users, user)
userByID[user.ID] = &users[len(users)-1]
}
teamRows, err := s.db.Query(
r.Context(),
`
SELECT
ut.user_id::TEXT,
t.id::TEXT,
t.name,
t.initial,
t.href,
t.created_at,
t.updated_at
FROM user_teams ut
JOIN teams t ON t.id = ut.team_id
ORDER BY t.name ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht geladen werden")
return
}
defer teamRows.Close()
for teamRows.Next() {
var userID string
var team AdminTeam
if err := teamRows.Scan(
&userID,
&team.ID,
&team.Name,
&team.Initial,
&team.Href,
&team.CreatedAt,
&team.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht gelesen werden")
return
}
if user := userByID[userID]; user != nil {
user.Teams = append(user.Teams, team)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
})
}
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
var input AdminUserRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeAdminUserInput(&input)
if input.Email == "" {
writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich")
return
}
if input.Password == "" {
writeError(w, http.StatusBadRequest, "Passwort ist erforderlich")
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden")
return
}
defer tx.Rollback(r.Context())
var userID string
err = tx.QueryRow(
r.Context(),
`
INSERT INTO users (
username,
display_name,
email,
password_hash,
avatar,
unit,
user_group,
rights
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id::TEXT
`,
input.Username,
input.DisplayName,
input.Email,
string(passwordHash),
input.Avatar,
input.Unit,
input.Group,
input.Rights,
).Scan(&userID)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "Benutzername oder E-Mail existiert bereits")
return
}
writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden")
return
}
if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil {
writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"ok": true,
})
}
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("id"))
if userID == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID fehlt")
return
}
var input AdminUserRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeAdminUserInput(&input)
if input.Email == "" {
writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
return
}
defer tx.Rollback(r.Context())
if input.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
return
}
_, err = tx.Exec(
r.Context(),
`
UPDATE users
SET
username = $2,
display_name = $3,
email = $4,
password_hash = $5,
avatar = $6,
unit = $7,
user_group = $8,
rights = $9,
updated_at = now()
WHERE id = $1
`,
userID,
input.Username,
input.DisplayName,
input.Email,
string(passwordHash),
input.Avatar,
input.Unit,
input.Group,
input.Rights,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
return
}
} else {
_, err = tx.Exec(
r.Context(),
`
UPDATE users
SET
username = $2,
display_name = $3,
email = $4,
avatar = $5,
unit = $6,
user_group = $7,
rights = $8,
updated_at = now()
WHERE id = $1
`,
userID,
input.Username,
input.DisplayName,
input.Email,
input.Avatar,
input.Unit,
input.Group,
input.Rights,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
return
}
}
if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil {
writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func replaceUserTeams(ctx context.Context, tx pgx.Tx, userID string, teamIDs []string) error {
if _, err := tx.Exec(ctx, `DELETE FROM user_teams WHERE user_id = $1`, userID); err != nil {
return err
}
for _, teamID := range teamIDs {
if _, err := tx.Exec(
ctx,
`
INSERT INTO user_teams (user_id, team_id)
VALUES ($1, $2)
ON CONFLICT (user_id, team_id) DO NOTHING
`,
userID,
teamID,
); err != nil {
return err
}
}
return nil
}
func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT id::TEXT, name, initial, href, created_at, updated_at
FROM teams
ORDER BY name ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Teams konnten nicht geladen werden")
return
}
defer rows.Close()
teams := []AdminTeam{}
for rows.Next() {
var team AdminTeam
if err := rows.Scan(
&team.ID,
&team.Name,
&team.Initial,
&team.Href,
&team.CreatedAt,
&team.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Teams konnten nicht gelesen werden")
return
}
teams = append(teams, team)
}
writeJSON(w, http.StatusOK, map[string]any{
"teams": teams,
})
}
func (s *Server) handleAdminCreateTeam(w http.ResponseWriter, r *http.Request) {
var input AdminTeamRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeAdminTeamInput(&input)
if input.Name == "" {
writeError(w, http.StatusBadRequest, "Teamname ist erforderlich")
return
}
_, err := s.db.Exec(
r.Context(),
`
INSERT INTO teams (name, initial, href)
VALUES ($1, $2, $3)
`,
input.Name,
input.Initial,
input.Href,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"ok": true,
})
}
func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) {
teamID := strings.TrimSpace(r.PathValue("id"))
if teamID == "" {
writeError(w, http.StatusBadRequest, "Team-ID fehlt")
return
}
var input AdminTeamRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeAdminTeamInput(&input)
if input.Name == "" {
writeError(w, http.StatusBadRequest, "Teamname ist erforderlich")
return
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE teams
SET name = $2,
initial = $3,
href = $4,
updated_at = now()
WHERE id = $1
`,
teamID,
input.Name,
input.Initial,
input.Href,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}