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 <noreply@anthropic.com>
141 lines
5.1 KiB
Go
141 lines
5.1 KiB
Go
// 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)
|
|
}
|