feat: Einladungslink für die Mitarbeiter-Selbstanmeldung
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>
This commit is contained in:
@@ -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"})
|
||||
|
||||
140
internal/web/einladung_handlers.go
Normal file
140
internal/web/einladung_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
144
internal/web/einladung_handlers_test.go
Normal file
144
internal/web/einladung_handlers_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
22
internal/web/templates/einladung.html
Normal file
22
internal/web/templates/einladung.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{define "einladung"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Einladung annehmen</h1>
|
||||
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<button type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
12
internal/web/templates/einladung_ungueltig.html
Normal file
12
internal/web/templates/einladung_ungueltig.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{{define "einladung-ungueltig"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Einladung</h1>
|
||||
<p class="fehler">Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen aktuellen Link anfragen.</p>
|
||||
<p><a href="/login">Zur Anmeldung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
20
internal/web/templates/einladung_verwaltung.html
Normal file
20
internal/web/templates/einladung_verwaltung.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{define "einladung-verwaltung"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Einladungslink</h1>
|
||||
<p class="hinweis">
|
||||
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.
|
||||
</p>
|
||||
<p><input type="text" readonly value="{{.Link}}" onclick="this.select()"></p>
|
||||
<form method="post" action="/verwaltung/einladung/erneuern" onsubmit="return confirm('Neuen Link erzeugen? Der alte Link wird dann sofort ungültig.')">
|
||||
<button type="submit" class="entfernen">Link erneuern</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -28,6 +28,7 @@
|
||||
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user