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

+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")