Files
MindSift/internal/api/registration.go
T
sandrodachandClaude Opus 5.5 af77b9e809
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
feat(auth): invite links for several people, invite by email, registration with approval or email domains
- 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>
2026-10-31 13:43:54 +01:00

210 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}