From b91a58ccbaf5efc8559ee208457ddf087e058311 Mon Sep 17 00:00:00 2001 From: noroot Date: Sat, 29 Aug 2026 17:41:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Einladungslink=20f=C3=BCr=20die=20Mitar?= =?UTF-8?q?beiter-Selbstanmeldung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schließt den ersten Punkt der geplanten Onboarding-Reihenfolge (siehe CLAUDE.md): ein Admin sieht unter GET /verwaltung/einladung einen Sammellink (account.einladung_token, Migration 0013), den beliebig viele Mitarbeitende nutzen können, um sich selbst mit eigenem Passwort anzumelden (GET/POST /einladung/{token}, öffentlich, Rolle mitarbeiter). Kein Ablaufdatum, aber per Admin jederzeit erneuerbar — das macht den alten Link sofort ungültig, falls er versehentlich außerhalb des Unternehmens geteilt wurde. Keine E-Mail-Bestätigung (bewusst konsistent mit dem Rest des Produkts, das noch keine E-Mail-Infrastruktur hat). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 19 ++- internal/store/account.go | 77 +++++++--- internal/store/auth_test.go | 50 ++++++ .../0013_account_einladung_token.down.sql | 1 + .../0013_account_einladung_token.up.sql | 8 + internal/web/auth_handlers.go | 5 +- internal/web/einladung_handlers.go | 140 +++++++++++++++++ internal/web/einladung_handlers_test.go | 144 ++++++++++++++++++ internal/web/server.go | 6 + internal/web/server_test.go | 26 +++- internal/web/templates/einladung.html | 22 +++ .../web/templates/einladung_ungueltig.html | 12 ++ .../web/templates/einladung_verwaltung.html | 20 +++ internal/web/templates/layout.html | 1 + 14 files changed, 504 insertions(+), 27 deletions(-) create mode 100644 internal/store/migrations/0013_account_einladung_token.down.sql create mode 100644 internal/store/migrations/0013_account_einladung_token.up.sql create mode 100644 internal/web/einladung_handlers.go create mode 100644 internal/web/einladung_handlers_test.go create mode 100644 internal/web/templates/einladung.html create mode 100644 internal/web/templates/einladung_ungueltig.html create mode 100644 internal/web/templates/einladung_verwaltung.html diff --git a/CLAUDE.md b/CLAUDE.md index d97074f..f69a7ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,12 +325,19 @@ leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt (noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis Ebene 5/Betreiber-Pflege gebaut ist). -**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink -(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV- -Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne -vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto -entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma- -Registrierung. +**Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~ — +**erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`, +Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro +Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung` +jederzeit erneuerbar [macht den alten Link sofort ungültig]. +`GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login +mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an, +ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung +wählt die Person weiterhin erst beim ersten Antrag, nicht bei der +Einladung.), (2) CSV-Import, (3) SSO (Entra ID/Google Workspace), +(4) Antrag ganz ohne vorheriges Konto über Mandantenlink + E-Mail- +Bestätigungscode (Konto entsteht nebenbei). (2)-(4) sind weiterhin +nicht gebaut. **Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht** nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende diff --git a/internal/store/account.go b/internal/store/account.go index 728e23d..7a4a89b 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -10,20 +10,35 @@ import ( ) // Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung -// nutzt). Jeder Antrag gehört genau einem Account. +// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist +// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, +// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar. type Account struct { - ID string - Name string - CreatedAt time.Time + ID string + Name string + EinladungToken string + CreatedAt time.Time } -// CreateAccount legt einen neuen Mandanten an. -func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) { +const accountColumns = `id, name, einladung_token, created_at` + +func scanAccount(row interface { + Scan(dest ...any) error +}) (Account, error) { var a Account - err := s.Pool.QueryRow(ctx, ` + err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt) + return a, err +} + +// CreateAccount legt einen neuen Mandanten an. einladung_token wird von +// der Datenbank per DEFAULT erzeugt (siehe Migration 0013). +func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) { + row := s.Pool.QueryRow(ctx, ` INSERT INTO account (name) VALUES ($1) - RETURNING id, name, created_at - `, name).Scan(&a.ID, &a.Name, &a.CreatedAt) + RETURNING `+accountColumns, + name, + ) + a, err := scanAccount(row) if err != nil { return Account{}, fmt.Errorf("store: create account: %w", err) } @@ -32,10 +47,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) // GetAccount liest einen Mandanten anhand seiner ID. func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { - var a Account - err := s.Pool.QueryRow(ctx, ` - SELECT id, name, created_at FROM account WHERE id = $1 - `, id).Scan(&a.ID, &a.Name, &a.CreatedAt) + row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id) + a, err := scanAccount(row) if errors.Is(err, pgx.ErrNoRows) { return Account{}, ErrNotFound } @@ -45,12 +58,40 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { return a, nil } +// GetAccountByEinladungToken liest den Mandanten zu einem +// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung +// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem +// oder bereits erneuertem (damit ungültig gewordenem) Token. +func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) { + row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token) + a, err := scanAccount(row) + if errors.Is(err, pgx.ErrNoRows) { + return Account{}, ErrNotFound + } + if err != nil { + return Account{}, fmt.Errorf("store: get account by einladung token: %w", err) + } + return a, nil +} + +// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten +// durch einen neuen — der alte Link wird damit sofort ungültig (z. B. +// wenn er versehentlich außerhalb des Unternehmens geteilt wurde). +func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error { + tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken) + if err != nil { + return fmt.Errorf("store: regenerate einladung token: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + // ListAccounts liefert alle Mandanten, neueste zuerst — für den // Admin-Bereich (Accounts-Verwaltung). func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { - rows, err := s.Pool.Query(ctx, ` - SELECT id, name, created_at FROM account ORDER BY created_at DESC - `) + rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`) if err != nil { return nil, fmt.Errorf("store: list accounts: %w", err) } @@ -58,8 +99,8 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { var out []Account for rows.Next() { - var a Account - if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil { + a, err := scanAccount(rows) + if err != nil { return nil, fmt.Errorf("store: scan account: %w", err) } out = append(out, a) diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go index 9020ff4..e32b476 100644 --- a/internal/store/auth_test.go +++ b/internal/store/auth_test.go @@ -25,6 +25,56 @@ func TestAccountCRUD(t *testing.T) { if got.Name != "Beispiel Agentur GmbH" { t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name) } + if got.EinladungToken == "" { + t.Error("expected a newly created account to have a non-empty EinladungToken") + } +} + +func TestGetAccountByEinladungToken(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken) + if err != nil { + t.Fatalf("GetAccountByEinladungToken: %v", err) + } + if got.ID != acc.ID { + t.Fatalf("GetAccountByEinladungToken returned a different account") + } + + _, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token") + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err) + } +} + +func TestRegenerateEinladungToken(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + alterToken := acc.EinladungToken + + if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil { + t.Fatalf("RegenerateEinladungToken: %v", err) + } + + if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("expected the old token to be invalid, got err = %v", err) + } + got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token") + if err != nil { + t.Fatalf("GetAccountByEinladungToken (neu): %v", err) + } + if got.ID != acc.ID { + t.Fatalf("GetAccountByEinladungToken (neu) returned a different account") + } } func TestUserCRUD(t *testing.T) { diff --git a/internal/store/migrations/0013_account_einladung_token.down.sql b/internal/store/migrations/0013_account_einladung_token.down.sql new file mode 100644 index 0000000..5c4f0f5 --- /dev/null +++ b/internal/store/migrations/0013_account_einladung_token.down.sql @@ -0,0 +1 @@ +ALTER TABLE account DROP COLUMN einladung_token; diff --git a/internal/store/migrations/0013_account_einladung_token.up.sql b/internal/store/migrations/0013_account_einladung_token.up.sql new file mode 100644 index 0000000..b933ca5 --- /dev/null +++ b/internal/store/migrations/0013_account_einladung_token.up.sql @@ -0,0 +1,8 @@ +-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung +-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink, +-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro +-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten +-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende +-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne +-- dass store.CreateAccount etwas ändern muss. +ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text; diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index 8060baf..e476823 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -31,8 +31,9 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { // Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb // wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows). // Es gibt bewusst KEINE offene Selbstregistrierung für einzelne -// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink -// (spätere Ausbaustufe), nicht über dieses Formular. +// Mitarbeiter über dieses Formular — die kommen über den +// Einladungslink (siehe einladung_handlers.go) oder künftig CSV- +// Import/Mandantenlink (weiterhin nicht gebaut). func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"}) diff --git a/internal/web/einladung_handlers.go b/internal/web/einladung_handlers.go new file mode 100644 index 0000000..a49a2be --- /dev/null +++ b/internal/web/einladung_handlers.go @@ -0,0 +1,140 @@ +// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den +// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md, +// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung +// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter +// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung +// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen +// Abschnitt A), nicht hier. +package web + +import ( + "net/http" + + "github.com/netcell-it/deklarix/internal/auth" +) + +type einladungFormData struct { + Title string + Firmenname string + Error string +} + +// handleEinladungForm zeigt das Selbstanmelde-Formular für einen +// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit +// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein +// abgelaufener Link — es wird nicht unterschieden, ob das Token nie +// existiert hat oder nur nicht mehr gültig ist. +func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) { + acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token")) + if err != nil { + s.renderEinladungError(w) + return + } + s.renderEinladungForm(w, acc.Name, "") +} + +func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) { + data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg} + if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +func (s *Server) renderEinladungError(w http.ResponseWriter) { + if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleEinladungAnnehmen legt einen neuen Login mit Rolle +// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die +// Person direkt an — wie bei der Firma-Registrierung gibt es keine +// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding). +func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) { + acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token")) + if err != nil { + s.renderEinladungError(w) + return + } + if err := r.ParseForm(); err != nil { + s.renderEinladungForm(w, acc.Name, "ungültiges Formular") + return + } + + email := r.FormValue("email") + password := r.FormValue("password") + if email == "" || password == "" { + s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht") + return + } + + passwordHash, err := auth.HashPassword(password) + if err != nil { + s.renderEinladungForm(w, acc.Name, err.Error()) + return + } + + user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter") + if err != nil { + s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben") + return + } + + if err := s.startSession(w, r, user.ID); err != nil { + s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden") + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +type einladungVerwaltungData struct { + Title string + Nav navData + Link string +} + +// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der +// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter +// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt +// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich +// X-Forwarded-Proto berücksichtigen. +func einladungBasisURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return scheme + "://" + r.Host +} + +// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen +// Mandanten zum Kopieren. +func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) { + acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := einladungVerwaltungData{ + Title: "Einladungslink", Nav: navFor(r), + Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken, + } + if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten +// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich +// außerhalb des Unternehmens geteilt wurde). +func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) { + newToken, err := auth.NewSessionToken() + if err != nil { + http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError) + return + } + if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil { + http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther) +} diff --git a/internal/web/einladung_handlers_test.go b/internal/web/einladung_handlers_test.go new file mode 100644 index 0000000..839eeda --- /dev/null +++ b/internal/web/einladung_handlers_test.go @@ -0,0 +1,144 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestEinladungFormZeigtFirmenname(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "Beispiel GmbH") { + t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String()) + } +} + +func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + + resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "ungültig") { + t.Errorf("expected an invalid-link message, got: %s", resp.Body.String()) + } +} + +func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{ + "email": {"neu@example.com"}, "password": {"ein-langes-passwort"}, + }) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) + } + if len(resp.Result().Cookies()) == 0 { + t.Fatal("expected a session cookie to be set") + } + + user, err := fs.GetUserByEmail(context.Background(), "neu@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if user.Role != "mitarbeiter" || user.AccountID != acc.ID { + t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID) + } + + cookie := resp.Result().Cookies()[0] + protected := getWithCookie(t, s, cookie, "/antraege") + if protected.Code != http.StatusOK { + t.Fatalf("expected the new session to work, status = %d", protected.Code) + } +} + +func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + + resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{ + "email": {"neu@example.com"}, "password": {"ein-langes-passwort"}, + }) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "ungültig") { + t.Errorf("expected an invalid-link message, got: %s", resp.Body.String()) + } + if len(fs.users) != 0 { + t.Error("expected no user to be created for an invalid token") + } +} + +func TestAdminSiehtEinladungslink(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "/einladung/") { + t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String()) + } +} + +func TestAdminKannEinladungslinkErneuern(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + altesToken := fs.accounts[admin.AccountID].EinladungToken + + resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + neuesToken := fs.accounts[admin.AccountID].EinladungToken + if neuesToken == altesToken { + t.Fatal("expected the token to change") + } + + oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken) + if !strings.Contains(oldLinkResp.Body.String(), "ungültig") { + t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String()) + } + newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken) + if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") { + t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String()) + } +} + +func TestMitarbeiterCannotAccessEinladungsverwaltung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter") + + resp := getWithCookie(t, s, cookie, "/verwaltung/einladung") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 3052b40..d2fd190 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -30,6 +30,8 @@ var staticFS embed.FS type Store interface { CreateAccount(ctx context.Context, name string) (store.Account, error) GetAccount(ctx context.Context, id string) (store.Account, error) + GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) + RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error ListAccounts(ctx context.Context) ([]store.Account, error) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) GetUserByEmail(ctx context.Context, email string) (store.User, error) @@ -110,6 +112,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /register", s.handleRegisterForm) mux.HandleFunc("POST /register", s.handleRegister) + mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm) + mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen) mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /logout", s.handleLogout) @@ -130,6 +134,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren)) mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren)) + mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung)) + mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern)) mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe)) mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate)) mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 6363c51..f8147e6 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -64,11 +64,35 @@ func (f *fakeStore) newID() string { func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) { f.mu.Lock() defer f.mu.Unlock() - acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()} + id := f.newID() + acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()} f.accounts[acc.ID] = acc return acc, nil } +func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, acc := range f.accounts { + if acc.EinladungToken == token { + return acc, nil + } + } + return store.Account{}, store.ErrNotFound +} + +func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error { + f.mu.Lock() + defer f.mu.Unlock() + acc, ok := f.accounts[accountID] + if !ok { + return store.ErrNotFound + } + acc.EinladungToken = newToken + f.accounts[accountID] = acc + return nil +} + func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/web/templates/einladung.html b/internal/web/templates/einladung.html new file mode 100644 index 0000000..44c44e2 --- /dev/null +++ b/internal/web/templates/einladung.html @@ -0,0 +1,22 @@ +{{define "einladung"}} + +{{template "head" .}} + +
+

Einladung annehmen

+

Sie treten dem Konto von {{.Firmenname}} bei.

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + +
+

Schon ein Konto? Anmelden

+
+ + +{{end}} diff --git a/internal/web/templates/einladung_ungueltig.html b/internal/web/templates/einladung_ungueltig.html new file mode 100644 index 0000000..b56a076 --- /dev/null +++ b/internal/web/templates/einladung_ungueltig.html @@ -0,0 +1,12 @@ +{{define "einladung-ungueltig"}} + +{{template "head" .}} + +
+

Einladung

+

Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen aktuellen Link anfragen.

+

Zur Anmeldung

+
+ + +{{end}} diff --git a/internal/web/templates/einladung_verwaltung.html b/internal/web/templates/einladung_verwaltung.html new file mode 100644 index 0000000..87f340b --- /dev/null +++ b/internal/web/templates/einladung_verwaltung.html @@ -0,0 +1,20 @@ +{{define "einladung-verwaltung"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Einladungslink

+

+ Ein Sammellink für alle Mitarbeitenden — jede Person, die den Link + öffnet, kann sich selbst mit eigenem Passwort anmelden (Rolle + "mitarbeiter"). Kein Ablaufdatum, aber jederzeit erneuerbar. +

+

+
+ +
+
+ + +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 3d0cef6..d6d33bd 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -28,6 +28,7 @@ {{if .IsFachebene}}Register{{end}} {{if .IsFachebene}}Wiedervorlage{{end}} {{if .IsAdmin}}Nutzerverwaltung{{end}} + {{if .IsAdmin}}Einladungslink{{end}} {{if .IsAdmin}}Abteilungen{{end}} {{if .IsAdmin}}Werkzeugkatalog{{end}} {{if .IsBetreiber}}Plattform{{end}}