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
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:
1 parent
db5c5cde9b
commit
af77b9e809
24 files changed
+1077
-142
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user