feat: Support-Login — Betreiber kann sich als Kunden-Nutzer anmelden
Ebene 5 (Betreiber) kann sich jetzt auf der Account-Detailseite über
einen Button je Nutzer als dieser Kunden-Login anmelden, ohne dessen
Passwort zu kennen — für Support-Fälle, in denen der Betreiber
nachvollziehen muss, was ein Kunde sieht. Nicht für deaktivierte
Nutzer möglich. Neue Spalte session.impersonated_by_user_id (Migration
0014, store.CreateImpersonatedSession) hält fest, wer die Sitzung
ausgelöst hat — die Nav zeigt während der gesamten Sitzung einen
auffälligen Banner ("Support-Zugriff durch ..."), damit nie unklar
ist, im Kontext eines fremden Kontos zu handeln. Jede Nutzung erzeugt
einen audit_log-Eintrag. Die neue Sitzung ersetzt die eigene
Betreiber-Sitzung (kein Sitzungs-Stack) — nach der Nutzung meldet sich
der Betreiber mit den eigenen Zugangsdaten neu an.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
29
CLAUDE.md
29
CLAUDE.md
@@ -115,12 +115,29 @@ reiner Anzeige-Screen reicht nicht):
|
|||||||
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
||||||
erreichbar (404).
|
erreichbar (404).
|
||||||
|
|
||||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
|
**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
|
||||||
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
|
`GET /betreiber/accounts/{id}` kann sich der Betreiber über
|
||||||
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
|
`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
|
||||||
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
|
anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
|
||||||
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
|
`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
|
||||||
wurde beim Produktwechsel sogar entfernt).
|
0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt
|
||||||
|
die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum
|
||||||
|
Betreiber" — der Betreiber meldet sich danach mit den eigenen
|
||||||
|
Zugangsdaten neu an). Jede Nutzung erzeugt einen `audit_log`-Eintrag
|
||||||
|
(Action `betreiber_login_als_nutzer`); während der gesamten Sitzung
|
||||||
|
zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
|
||||||
|
siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist,
|
||||||
|
im Kontext eines fremden Kontos zu handeln.
|
||||||
|
|
||||||
|
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes
|
||||||
|
Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am
|
||||||
|
2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme
|
||||||
|
10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die
|
||||||
|
eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus-
|
||||||
|
Zugangsdaten), Account-Verwaltung durch den Betreiber jenseits des
|
||||||
|
Support-Logins (Bearbeiten/Sperren eines Accounts selbst hängt weiter
|
||||||
|
an der Abrechnungs-/Freischaltungs-Architektur, siehe Offene Punkte:
|
||||||
|
`account.verified` wurde beim Produktwechsel sogar entfernt).
|
||||||
|
|
||||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||||
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ func TestSessionCRUD(t *testing.T) {
|
|||||||
if got.UserID != user.ID {
|
if got.UserID != user.ID {
|
||||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||||
}
|
}
|
||||||
|
if got.ImpersonatedByUserID != nil {
|
||||||
|
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||||
t.Fatalf("DeleteSession: %v", err)
|
t.Fatalf("DeleteSession: %v", err)
|
||||||
@@ -207,3 +210,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateImpersonatedSession(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
kundenAcc := testAccountID(t, s)
|
||||||
|
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||||
|
}
|
||||||
|
betreiberAcc := testAccountID(t, s)
|
||||||
|
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||||
|
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||||
|
}
|
||||||
|
if sess.UserID != kundenNutzer.ID {
|
||||||
|
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||||
|
}
|
||||||
|
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSession(ctx, sess.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||||
|
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||||
|
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||||
|
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||||
|
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||||
|
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||||
|
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||||
|
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||||
@@ -12,38 +12,70 @@ import (
|
|||||||
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||||
|
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||||
|
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||||
|
// statt durch den regulären Login des Nutzers selbst.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string
|
Token string
|
||||||
UserID string
|
UserID string
|
||||||
ExpiresAt time.Time
|
ImpersonatedByUserID *string
|
||||||
CreatedAt time.Time
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||||
|
|
||||||
|
func scanSession(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Session, error) {
|
||||||
|
var sess Session
|
||||||
|
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||||
|
return sess, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
var sess Session
|
row := s.Pool.QueryRow(ctx, `
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO session (token, user_id, expires_at)
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING token, user_id, expires_at, created_at
|
RETURNING `+sessionColumns,
|
||||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
token, userID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||||
}
|
}
|
||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||||
|
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||||
|
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||||
|
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||||
|
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||||
|
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+sessionColumns,
|
||||||
|
token, userID, impersonatedByUserID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
var sess Session
|
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
sess, err := scanSession(row)
|
||||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
|
||||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||||
|
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||||
|
// (Support-Login).
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
@@ -149,5 +157,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type betreiberDashboardData struct {
|
type betreiberDashboardData struct {
|
||||||
@@ -73,13 +76,16 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
type betreiberUserView struct {
|
type betreiberUserView struct {
|
||||||
Email string
|
ID string
|
||||||
Role string
|
Email string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type betreiberAccountDetailData struct {
|
type betreiberAccountDetailData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
|
AccountID string
|
||||||
Name string
|
Name string
|
||||||
Users []betreiberUserView
|
Users []betreiberUserView
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
@@ -100,11 +106,11 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := betreiberAccountDetailData{
|
data := betreiberAccountDetailData{
|
||||||
Title: "Account", Nav: navFor(r), Name: acc.Name,
|
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
|
||||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
}
|
}
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
@@ -143,3 +149,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request)
|
|||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||||
|
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||||
|
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||||
|
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||||
|
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||||
|
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||||
|
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||||
|
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||||
|
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||||
|
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||||
|
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||||
|
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||||
|
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||||
|
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||||
|
betreiber := currentUser(r)
|
||||||
|
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||||
|
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ziel.Active {
|
||||||
|
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||||
|
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
120
internal/web/impersonation_test.go
Normal file
120
internal/web/impersonation_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("expected a new session cookie")
|
||||||
|
}
|
||||||
|
neueCookie := cookies[0]
|
||||||
|
|
||||||
|
// Die neue Sitzung ist tatsächlich der Kunden-Login.
|
||||||
|
protected := getWithCookie(t, s, neueCookie, "/antraege")
|
||||||
|
if protected.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
neueCookie := loginResp.Result().Cookies()[0]
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, neueCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
|
||||||
|
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
|
||||||
|
var gefunden bool
|
||||||
|
for _, e := range fs.auditLog {
|
||||||
|
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
|
||||||
|
gefunden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotLoginAls(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,44 +12,67 @@ const sessionCookieName = "deklarix_session"
|
|||||||
|
|
||||||
type contextKey int
|
type contextKey int
|
||||||
|
|
||||||
const userContextKey contextKey = iota
|
const (
|
||||||
|
userContextKey contextKey = iota
|
||||||
|
impersonatorContextKey
|
||||||
|
)
|
||||||
|
|
||||||
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
|
||||||
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||||
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
|
||||||
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
|
||||||
|
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
|
||||||
|
// sonst leer.
|
||||||
|
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
if time.Now().After(sess.ExpiresAt) {
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||||
if err != nil || !user.Active {
|
if err != nil || !user.Active {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
return user, true
|
|
||||||
|
impersonator := ""
|
||||||
|
if sess.ImpersonatedByUserID != nil {
|
||||||
|
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
|
||||||
|
impersonator = betreiber.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, impersonator, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
|
||||||
|
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
|
||||||
|
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
if impersonator != "" {
|
||||||
|
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||||
|
}
|
||||||
|
return r.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +82,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||||
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +99,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||||
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -85,7 +108,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +129,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// nicht erfährt, dass es die Seite überhaupt gibt.
|
// nicht erfährt, dass es die Seite überhaupt gibt.
|
||||||
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -115,7 +138,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +147,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -133,7 +156,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +165,10 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||||
// Plattform-Link gezeigt werden soll.
|
// Plattform-Link gezeigt werden soll.
|
||||||
type navData struct {
|
type navData struct {
|
||||||
IsBetreiber bool
|
IsBetreiber bool
|
||||||
IsFachebene bool
|
IsFachebene bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
|
||||||
}
|
}
|
||||||
|
|
||||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||||
@@ -155,8 +179,9 @@ func navFor(r *http.Request) navData {
|
|||||||
return navData{
|
return navData{
|
||||||
IsBetreiber: role == "betreiber",
|
IsBetreiber: role == "betreiber",
|
||||||
// admin sieht die Fachebene mit, siehe requireFachebene.
|
// admin sieht die Fachebene mit, siehe requireFachebene.
|
||||||
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||||
IsAdmin: role == "admin",
|
IsAdmin: role == "admin",
|
||||||
|
Impersonator: currentImpersonator(r),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,3 +196,10 @@ func currentUser(r *http.Request) store.User {
|
|||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentImpersonator liefert die E-Mail des Betreibers, falls die
|
||||||
|
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
|
||||||
|
func currentImpersonator(r *http.Request) string {
|
||||||
|
v, _ := r.Context().Value(impersonatorContextKey).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Store interface {
|
|||||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||||
SetUserActive(ctx context.Context, id string, active bool) error
|
SetUserActive(ctx context.Context, id string, active bool) error
|
||||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||||
|
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||||
DeleteSession(ctx context.Context, token string) error
|
DeleteSession(ctx context.Context, token string) error
|
||||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
@@ -152,6 +153,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
||||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||||
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||||
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
|
|||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||||
|
f.sessions[token] = sess
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|||||||
@@ -275,6 +275,38 @@ nav button {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
|
||||||
|
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
|
||||||
|
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
|
||||||
|
.impersonation-banner {
|
||||||
|
background: var(--color-mittel-bg);
|
||||||
|
color: var(--color-mittel);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-mittel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.impersonation-banner {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fehler {
|
.fehler {
|
||||||
background: var(--color-hoch-bg);
|
background: var(--color-hoch-bg);
|
||||||
color: var(--color-hoch);
|
color: var(--color-hoch);
|
||||||
|
|||||||
@@ -12,7 +12,15 @@
|
|||||||
<ul class="beteiligte">
|
<ul class="beteiligte">
|
||||||
{{range .Users}}
|
{{range .Users}}
|
||||||
<li class="beteiligter">
|
<li class="beteiligter">
|
||||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
<div class="beteiligter-kopf">
|
||||||
|
{{.Email}} <span class="rolle">({{.Role}})</span>
|
||||||
|
{{if not .Active}}<span class="status">deaktiviert</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Active}}
|
||||||
|
<form method="post" action="/betreiber/accounts/{{$.AccountID}}/nutzer/{{.ID}}/anmelden-als" onsubmit="return confirm('Als {{.Email}} anmelden? Ihre eigene Sitzung wird dabei ersetzt.')">
|
||||||
|
<button type="submit">Als Nutzer anmelden</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -37,6 +37,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
{{if .Impersonator}}
|
||||||
|
<div class="impersonation-banner">
|
||||||
|
Support-Zugriff durch <strong>{{.Impersonator}}</strong> — dies ist nicht Ihre eigene Sitzung.
|
||||||
|
<form method="post" action="/logout" style="display:inline">
|
||||||
|
<button type="submit">Sitzung beenden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||||
|
|||||||
Reference in New Issue
Block a user