602 lines
13 KiB
Go
602 lines
13 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"`
|
|
Online bool `json:"onlineStatus"`
|
|
PresenceStatus string `json:"presenceStatus"`
|
|
LastSeenAt *time.Time `json:"lastSeenAt"`
|
|
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 ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) error {
|
|
return ensureOptionalDeviceLookupValue(ctx, tx, "user_units", unit)
|
|
}
|
|
|
|
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,
|
|
`+presenceStatusSQL+`,
|
|
last_seen_at,
|
|
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{}
|
|
userIndexByID := map[string]int{}
|
|
|
|
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.PresenceStatus,
|
|
&user.LastSeenAt,
|
|
&user.CreatedAt,
|
|
&user.UpdatedAt,
|
|
); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden")
|
|
return
|
|
}
|
|
|
|
user.Teams = []AdminTeam{}
|
|
user.Online = user.PresenceStatus == "online"
|
|
|
|
users = append(users, user)
|
|
userIndexByID[user.ID] = len(users) - 1
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig gelesen werden")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
userIndex, ok := userIndexByID[userID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
users[userIndex].Teams = append(users[userIndex].Teams, team)
|
|
}
|
|
|
|
if err := teamRows.Err(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht vollständig gelesen werden")
|
|
return
|
|
}
|
|
|
|
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())
|
|
|
|
if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if createdUser, err := s.getUserByID(r.Context(), userID); err == nil {
|
|
s.publishUserProfileEvent("user.created", createdUser)
|
|
}
|
|
|
|
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 err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if updatedUser, err := s.getUserByID(r.Context(), userID); err == nil {
|
|
s.publishUserProfileEvent("user.updated", updatedUser)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|