feat(auth): invite links for several people, invite by email, registration with approval or email domains
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s

- Invites: link /?invite=CODE, copy link, send by email, max uses with counter
- Registration modes closed | approval | domain | domain+approval | open, waiting list with approve/reject
- REGISTRATION_MODE / REGISTRATION_DOMAINS lock the admin setting; ALLOW_REGISTRATION=true is honoured

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 13:43:54 +01:00
1 parent db5c5cde9b
commit af77b9e809
24 files changed
+1077 -142

No files matched your search

+32 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- ORGANIZATIONS & TEAMS ---
@@ -93,6 +94,10 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pending := s.store.PendingRegistrationIDs()
for i := range users {
users[i].PendingApproval = pending[users[i].ID]
}
writeJSON(w, http.StatusOK, users)
}
@@ -191,6 +196,9 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
if req.IsActive != nil && !*req.IsActive {
s.wsHub.DisconnectUser(targetUserID)
}
if req.IsActive != nil && *req.IsActive {
s.store.ClearPendingRegistration(targetUserID) // activating by hand counts as approval
}
writeJSON(w, http.StatusOK, u)
}
@@ -247,6 +255,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
s.removeUserBackups(targetUserID)
s.store.ClearPendingRegistration(targetUserID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
@@ -270,6 +279,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
Role string `json:"role"`
OrganizationID string `json:"organizationId"`
ValidDays int `json:"validDays"`
MaxUses int `json:"maxUses"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -278,7 +288,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays, req.MaxUses)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -312,6 +322,16 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
out.SMTPPassSet = out.SMTPPass != ""
out.SMTPPass = ""
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
// What the environment fixes is shown as in effect and locked in the form
if s.cfg.RegistrationMode != "" {
out.RegistrationMode = s.cfg.RegistrationMode
out.AllowRegistration = out.RegistrationMode == storage.RegistrationOpen
out.RegistrationConfiguredViaEnv = true
}
if s.cfg.RegistrationDomains != "" {
out.RegistrationDomains = strings.Join(storage.ParseEmailDomains(s.cfg.RegistrationDomains), ", ")
out.RegistrationConfiguredViaEnv = true
}
return &out
}
@@ -327,6 +347,17 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
settings.SMTPPass = cur.SMTPPass
}
}
// Registration fixed by the environment cannot be changed here (the stored value stays as it was)
if s.cfg.RegistrationMode != "" || s.cfg.RegistrationDomains != "" {
if cur, err := s.store.GetSystemSettings(); err == nil {
if s.cfg.RegistrationMode != "" {
settings.RegistrationMode, settings.AllowRegistration = cur.RegistrationMode, cur.AllowRegistration
}
if s.cfg.RegistrationDomains != "" {
settings.RegistrationDomains = cur.RegistrationDomains
}
}
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+48 -11
View File
@@ -29,21 +29,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
count, _ := s.store.CountUsers()
sys, _ := s.store.GetSystemSettings()
allowReg := false
policy := s.registrationPolicy()
instanceName := "MindSift"
if sys != nil {
allowReg = sys.AllowRegistration
if sys.InstanceName != "" {
instanceName = sys.InstanceName
}
}
status := map[string]interface{}{
"needsSetup": count == 0,
"allowRegistration": allowReg,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
"needsSetup": count == 0,
// Registering without an invite: allowed at all, how (closed | approval | domain | domain+approval |
// open) and for which e-mail domains (shown as a hint on the sign-up form)
"allowRegistration": policy.Mode != storage.RegistrationClosed,
"registrationMode": policy.Mode,
"registrationDomains": policy.Domains,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds},
// Public so the login screen can link Impressum and Datenschutz
@@ -131,6 +134,10 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
sys.InstanceName = strings.TrimSpace(req.InstanceName)
}
sys.AllowRegistration = req.AllowRegistration
sys.RegistrationMode = storage.RegistrationClosed
if req.AllowRegistration {
sys.RegistrationMode = storage.RegistrationOpen
}
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
if err := s.store.UpdateSystemSettings(sys); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error())
@@ -191,6 +198,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
count, _ := s.store.CountUsers()
role := "member"
var validInvite *domain.UserInvite
pending := false // registered without invite while approval is needed
if strings.TrimSpace(req.InviteCode) != "" {
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
@@ -207,9 +215,14 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
} else if count == 0 {
role = "admin"
} else {
sysSettings, _ := s.store.GetSystemSettings()
if sysSettings == nil || !sysSettings.AllowRegistration {
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
var err error
pending, err = s.registrationPolicy().registrationOutcome(req.Email)
if err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
if pending && len(s.store.PendingRegistrationIDs()) >= storage.MaxPendingRegistrations {
writeError(w, http.StatusTooManyRequests, "Gerade warten zu viele Registrierungen auf Freigabe – bitte später erneut versuchen")
return
}
}
@@ -248,6 +261,26 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
}
// Waiting for approval: the account exists but stays locked, no session yet
if pending {
inactive := false
if _, err := s.store.UpdateUser(user.ID, nil, nil, nil, &inactive, nil, nil); err != nil {
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
return
}
if err := s.store.AddPendingRegistration(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
return
}
slog.Info("Registrierung wartet auf Freigabe", "user_id", user.ID, "email", user.Email)
go s.notifyAdminsOfRegistration(user)
writeJSON(w, http.StatusAccepted, map[string]interface{}{
"pending": true,
"message": "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst.",
})
return
}
token, err := s.startSession(r, user)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
@@ -310,6 +343,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.loginGuard.recordSuccess(account, ip)
if !user.IsActive && s.store.IsPendingRegistration(user.ID) {
writeError(w, http.StatusForbidden, "Dein Konto wartet noch auf die Freigabe durch einen Administrator")
return
}
if !user.IsActive {
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
inv, err := store.CreateUserInvite("", "", "member", "", 7)
inv, err := store.CreateUserInvite("", "", "member", "", 7, 1)
if err != nil {
t.Fatal(err)
}
@@ -46,7 +46,7 @@ func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
func TestInviteReleasedWhenAccountFails(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
inv, _ := store.CreateUserInvite("", "", "member", "", 7)
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
_, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member")
body := func(email string) string {
return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}`
+209
View File
@@ -0,0 +1,209 @@
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// Registration without an invite (mode and e-mail domains), approval of waiting accounts, and sending
// invite links by mail. See Doku/plans/registrierung-und-login.md.
type registrationPolicy struct {
Mode string
Domains []string
ViaEnv bool
}
// registrationPolicy: the environment (REGISTRATION_MODE / REGISTRATION_DOMAINS) wins over the admin
// settings, so an operator can lock it down for good.
func (s *Server) registrationPolicy() registrationPolicy {
p := registrationPolicy{Mode: storage.RegistrationClosed}
if sys, _ := s.store.GetSystemSettings(); sys != nil {
p.Mode = sys.RegistrationMode
p.Domains = storage.ParseEmailDomains(sys.RegistrationDomains)
}
if s.cfg.RegistrationMode != "" {
p.Mode, p.ViaEnv = s.cfg.RegistrationMode, true
}
if s.cfg.RegistrationDomains != "" {
p.Domains, p.ViaEnv = storage.ParseEmailDomains(s.cfg.RegistrationDomains), true
}
return p
}
// registrationOutcome decides for a sign-up without invite: allowed at all, and active at once or
// waiting for approval. The error is shown to the person.
func (p registrationPolicy) registrationOutcome(email string) (pending bool, err error) {
inDomains := storage.EmailInDomains(email, p.Domains)
switch p.Mode {
case storage.RegistrationOpen:
return false, nil
case storage.RegistrationApproval:
return true, nil
case storage.RegistrationDomain:
if !inDomains {
return false, errors.New("Registrierung nur mit E-Mail-Adressen von " + domainList(p.Domains))
}
return false, nil
case storage.RegistrationDomainApproval:
return !inDomains, nil
}
return false, errors.New("Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
}
func domainList(domains []string) string {
if len(domains) == 0 {
return "freigegebenen Domains"
}
out := make([]string, len(domains))
for i, d := range domains {
out[i] = "@" + strings.TrimPrefix(d, "*.")
}
return strings.Join(out, ", ")
}
// appLink is the address of this instance for links in mails: BASE_URL, otherwise the address the
// admin is using right now (only for mails an admin triggers, never for anonymous requests).
func (s *Server) appLink(r *http.Request) string {
if s.cfg.BaseURLSet {
return strings.TrimRight(s.cfg.BaseURL, "/") + "/"
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
return scheme + "://" + host + "/"
}
// notifyAdminsOfRegistration mails every active admin that someone waits (only with SMTP).
func (s *Server) notifyAdminsOfRegistration(user *domain.User) {
if _, err := s.smtpSettings(); err != nil {
return
}
users, err := s.store.GetAllUsers()
if err != nil {
return
}
for _, a := range users {
if a.Role != "admin" || !a.IsActive || a.Email == "" {
continue
}
c := mailContent{
Preheader: user.DisplayName + " wartet auf Freigabe",
Greeting: mailGreeting(time.Now(), a.DisplayName),
Intro: "Jemand hat sich bei MindSift registriert und wartet auf deine Freigabe:",
Sections: []mailSection{{Items: []mailItem{{Title: user.DisplayName, Meta: user.Email}}}},
Outro: "Freigeben oder ablehnen kannst du in den Administrator-Einstellungen unter „Benutzer & Einladungen“.",
Footer: "Du bekommst diese E-Mail als Administrator dieser MindSift-Instanz.",
}
if link := s.notifyLink(); link != "" {
c.Button = &mailButton{Label: "Benutzerverwaltung öffnen", URL: link}
}
if err := s.sendMailContent(a.Email, "MindSift: Neue Registrierung wartet auf Freigabe", c); err != nil {
slog.Warn("Hinweis auf neue Registrierung nicht verschickt", "admin", a.ID, "error", err)
}
}
}
// handleAdminApproveUser: POST /admin/users/{id}/approve – a waiting account becomes active.
func (s *Server) handleAdminApproveUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
ok, err := s.store.ApprovePendingRegistration(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusConflict, "Dieses Konto wartet nicht auf Freigabe")
return
}
user.IsActive = true
link := s.appLink(r)
go func() {
_ = s.sendMailContent(user.Email, "MindSift: Dein Konto ist freigeschaltet", mailContent{
Preheader: "Du kannst dich jetzt anmelden",
Greeting: mailGreeting(time.Now(), user.DisplayName),
Intro: "Dein MindSift-Konto ist freigeschaltet. Du kannst dich jetzt mit deiner E-Mail-Adresse und deinem Passwort anmelden.",
Button: &mailButton{Label: "Zu MindSift", URL: link},
Footer: mailFooterAccount,
})
}()
writeJSON(w, http.StatusOK, user)
}
// handleAdminSendInvite: POST /admin/invites/{id}/send {"email"} – mails the invite link. A single-use
// invite is bound to that address on the way.
func (s *Server) handleAdminSendInvite(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !strings.Contains(req.Email, "@") {
writeError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if _, err := s.smtpSettings(); err != nil {
writeError(w, http.StatusBadRequest, "Für Einladungen per E-Mail muss ein Mail-Server eingerichtet sein")
return
}
inv, err := s.store.GetUserInviteByID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if inv.MaxUses == 1 {
if inv.Email != "" && !strings.EqualFold(inv.Email, email) {
writeError(w, http.StatusConflict, "Diese Einladung ist an "+inv.Email+" gebunden")
return
}
if inv.Email == "" {
if err := s.store.BindUserInviteEmail(inv.ID, email); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
inv.Email = email
}
}
inviter := "Jemand"
if me, _ := s.store.GetUserByID(auth.GetUserID(r.Context())); me != nil && me.DisplayName != "" {
inviter = me.DisplayName
}
link := s.appLink(r) + "?invite=" + url.QueryEscape(inv.Code)
until := inv.ExpiresAt
if t, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil {
until = t.Local().Format("02.01.2006")
}
err = s.sendMailContent(email, inviter+" lädt dich zu MindSift ein", mailContent{
Preheader: inviter + " lädt dich zu MindSift ein",
Greeting: "Hallo",
Intro: inviter + " lädt dich zu MindSift ein – Gedanken abladen, Aufgaben, Notizen und gemeinsame Listen an einem Ort.",
Button: &mailButton{Label: "Konto anlegen", URL: link},
Note: "Die Einladung gilt bis " + until + ". Falls der Knopf nicht geht: " + link + "\nEinladungscode: " + inv.Code,
Footer: "Du bekommst diese E-Mail, weil dich jemand zu MindSift eingeladen hat.",
})
if err != nil {
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
return
}
writeJSON(w, http.StatusOK, inv)
}
+157
View File
@@ -0,0 +1,157 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
func setRegistration(t *testing.T, store *storage.Store, mode, domains string) {
t.Helper()
sys, _ := store.GetSystemSettings()
sys.RegistrationMode, sys.RegistrationDomains = mode, domains
if err := store.UpdateSystemSettings(sys); err != nil {
t.Fatal(err)
}
}
func register(srv *Server, email, invite string) (int, map[string]interface{}) {
body := `{"email":"` + email + `","password":"password123","displayName":"` + strings.Split(email, "@")[0] + `","inviteCode":"` + invite + `"}`
rec := sessionCall(srv.Router(), "POST", "/api/v1/auth/register", "", body, "")
var out map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
func TestRegistrationModes(t *testing.T) {
srv, store, _ := setupTestServer(t)
setRegistration(t, store, storage.RegistrationClosed, "")
if code, _ := register(srv, "a@example.com", ""); code != http.StatusForbidden {
t.Fatalf("closed: want 403, got %d", code)
}
setRegistration(t, store, storage.RegistrationOpen, "")
if code, out := register(srv, "b@example.com", ""); code != http.StatusCreated || out["token"] == nil {
t.Fatalf("open: want 201 with token, got %d %v", code, out)
}
setRegistration(t, store, storage.RegistrationDomain, "firma.de, @familie.de")
if code, _ := register(srv, "c@fremd.de", ""); code != http.StatusForbidden {
t.Fatalf("domain: foreign address must be refused, got %d", code)
}
if code, _ := register(srv, "c@Familie.DE", ""); code != http.StatusCreated {
t.Fatalf("domain: listed domain (any case) must be active at once, got %d", code)
}
setRegistration(t, store, storage.RegistrationDomainApproval, "firma.de")
if code, _ := register(srv, "d@firma.de", ""); code != http.StatusCreated {
t.Fatalf("domain+approval: listed domain active at once, got %d", code)
}
if code, out := register(srv, "e@fremd.de", ""); code != http.StatusAccepted || out["pending"] != true || out["token"] != nil {
t.Fatalf("domain+approval: others wait without session, got %d %v", code, out)
}
// An invite skips mode and domain check
setRegistration(t, store, storage.RegistrationClosed, "")
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
if code, _ := register(srv, "f@fremd.de", inv.Code); code != http.StatusCreated {
t.Fatalf("invite must work in closed mode, got %d", code)
}
}
func TestApprovalFlow(t *testing.T) {
srv, store, adminToken := setupTestServer(t)
router := srv.Router()
setRegistration(t, store, storage.RegistrationApproval, "")
if code, _ := register(srv, "neu@example.com", ""); code != http.StatusAccepted {
t.Fatalf("approval: want 202, got %d", code)
}
login := func() *http.Response {
rec := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "")
return rec.Result()
}
res := login()
var errBody map[string]string
_ = json.NewDecoder(res.Body).Decode(&errBody)
if res.StatusCode != http.StatusForbidden || !strings.Contains(errBody["error"], "Freigabe") {
t.Fatalf("login before approval: want 403 mentioning Freigabe, got %d %v", res.StatusCode, errBody)
}
// The admin sees the account as waiting and approves it
var users []domain.User
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/users", adminToken, "", "").Body.Bytes(), &users)
var waiting *domain.User
for i := range users {
if users[i].Email == "neu@example.com" {
waiting = &users[i]
}
}
if waiting == nil || !waiting.PendingApproval || waiting.IsActive {
t.Fatalf("admin list must show the waiting account, got %+v", waiting)
}
_, memberToken := newTestUser(t, store, "member@example.com")
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", memberToken, "", ""); rec.Code != http.StatusForbidden {
t.Fatalf("members must not approve, got %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusOK {
t.Fatalf("approve: %d %s", rec.Code, rec.Body.String())
}
if res := login(); res.StatusCode != http.StatusOK {
t.Fatalf("login after approval: want 200, got %d", res.StatusCode)
}
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusConflict {
t.Fatalf("approving twice: want 409, got %d", rec.Code)
}
}
func TestInviteLinkForSeveralPeople(t *testing.T) {
srv, store, _ := setupTestServer(t)
if _, err := store.CreateUserInvite("", "x@example.com", "member", "", 7, 3); err == nil {
t.Fatal("a link for several people must not be bound to one address")
}
inv, err := store.CreateUserInvite("", "", "member", "", 7, 2)
if err != nil {
t.Fatal(err)
}
for _, email := range []string{"eins@example.com", "zwei@example.com"} {
if code, out := register(srv, email, inv.Code); code != http.StatusCreated {
t.Fatalf("%s: want 201, got %d %v", email, code, out)
}
}
if code, out := register(srv, "drei@example.com", inv.Code); code != http.StatusForbidden || !strings.Contains(out["error"].(string), "ausgeschöpft") {
t.Fatalf("third use: want 403 'ausgeschöpft', got %d %v", code, out)
}
invites, _ := store.GetUserInvites()
if len(invites) != 1 || invites[0].UseCount != 2 || invites[0].MaxUses != 2 {
t.Fatalf("use count wrong: %+v", invites)
}
}
func TestRegistrationFixedByEnvironment(t *testing.T) {
srv, store, adminToken := setupTestServer(t)
router := srv.Router()
srv.cfg.RegistrationMode = storage.RegistrationClosed
// The admin form shows the fixed value as locked and cannot change it
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", adminToken, `{"registrationMode":"open","allowRegistration":true,"instanceName":"MindSift"}`, "")
if rec.Code != http.StatusOK {
t.Fatalf("save settings: %d %s", rec.Code, rec.Body.String())
}
var sys domain.SystemSettings
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/settings", adminToken, "", "").Body.Bytes(), &sys)
if sys.RegistrationMode != storage.RegistrationClosed || !sys.RegistrationConfiguredViaEnv {
t.Fatalf("environment must win and be marked, got %+v", sys)
}
if code, _ := register(srv, "x@example.com", ""); code != http.StatusForbidden {
t.Fatalf("registration must stay closed, got %d", code)
}
if stored, _ := store.GetSystemSettings(); stored.RegistrationMode == storage.RegistrationOpen {
t.Fatalf("the locked value must not be overwritten in the database")
}
}
+2
View File
@@ -290,10 +290,12 @@ func (s *Server) Router() http.Handler {
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser))
r.Post("/api/v1/admin/users/{id}/approve", s.notInDemo(s.handleAdminApproveUser))
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
r.Post("/api/v1/admin/invites/{id}/send", s.notInDemo(s.handleAdminSendInvite))
})
// Server-Admin Strictly Protected Routes