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

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