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
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 + `"}`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -20,9 +20,15 @@ type Config struct {
|
||||
// from it, never from the request's Host header.
|
||||
BaseURLSet bool
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
// RegistrationMode / RegistrationDomains: REGISTRATION_MODE (closed | approval | domain |
|
||||
// domain+approval | open) and REGISTRATION_DOMAINS. When set they win over the admin settings,
|
||||
// which then cannot change them. ALLOW_REGISTRATION=true counts as REGISTRATION_MODE=open;
|
||||
// ALLOW_REGISTRATION=false (the template default) leaves the choice to the admin settings.
|
||||
RegistrationMode string
|
||||
RegistrationDomains string
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
|
||||
DemoResetMinutes int
|
||||
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
|
||||
@@ -234,6 +240,8 @@ func Load() *Config {
|
||||
BaseURL: baseURL,
|
||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||
AllowRegistration: allowReg,
|
||||
RegistrationMode: registrationMode(allowReg),
|
||||
RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")),
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
@@ -326,3 +334,18 @@ func demoResetMinutes() int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// registrationMode reads REGISTRATION_MODE; unknown values are ignored ("" = admin settings decide).
|
||||
func registrationMode(allowReg bool) string {
|
||||
switch m := strings.ToLower(strings.TrimSpace(os.Getenv("REGISTRATION_MODE"))); m {
|
||||
case "closed", "approval", "domain", "domain+approval", "open":
|
||||
return m
|
||||
case "":
|
||||
default:
|
||||
slog.Warn("REGISTRATION_MODE unbekannt, wird ignoriert", "value", m)
|
||||
}
|
||||
if allowReg {
|
||||
return "open"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -135,6 +135,8 @@ type User struct {
|
||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// Registered while registration needs approval and not approved yet (admin user list only)
|
||||
PendingApproval bool `json:"pendingApproval,omitempty"`
|
||||
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||
}
|
||||
@@ -216,7 +218,10 @@ type UserInvite struct {
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
UsedBy string `json:"usedBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// A link for several people: how often it may be used and how often it was
|
||||
MaxUses int `json:"maxUses"`
|
||||
UseCount int `json:"useCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ThoughtStatus string
|
||||
@@ -350,12 +355,19 @@ type TeamMember struct {
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
SharingMode string `json:"sharingMode"` // private | organizations | open
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
SharingMode string `json:"sharingMode"` // private | organizations | open
|
||||
// AllowRegistration mirrors RegistrationMode == "open" (older clients and the setup wizard)
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
// Who may register without an invite: closed | approval | domain | domain+approval | open
|
||||
RegistrationMode string `json:"registrationMode"`
|
||||
// E-mail domains for the domain modes, comma separated ("firma.de, familie.de")
|
||||
RegistrationDomains string `json:"registrationDomains"`
|
||||
// Set by REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true): not editable here
|
||||
RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
|
||||
// writing it empty keeps the stored password
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
|
||||
@@ -30,6 +30,7 @@ var migrations = []migration{
|
||||
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
||||
{8, "Journal", (*Store).migrateJournal},
|
||||
{9, "Web-Push", (*Store).migratePush},
|
||||
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registration without an invite: who may register (mode), from which e-mail domains, and accounts
|
||||
// that wait for an admin's approval (they exist with is_active = 0 until approved).
|
||||
|
||||
const (
|
||||
RegistrationClosed = "closed" // only with an invite
|
||||
RegistrationApproval = "approval" // anyone, active after approval
|
||||
RegistrationDomain = "domain" // only listed e-mail domains, active at once
|
||||
RegistrationDomainApproval = "domain+approval" // listed domains at once, others after approval
|
||||
RegistrationOpen = "open" // anyone, active at once
|
||||
|
||||
// MaxPendingRegistrations stops a flood of sign-ups while approval is needed
|
||||
MaxPendingRegistrations = 50
|
||||
)
|
||||
|
||||
// NormalizeRegistrationMode returns a known mode, "" for anything else.
|
||||
func NormalizeRegistrationMode(mode string) string {
|
||||
switch m := strings.ToLower(strings.TrimSpace(mode)); m {
|
||||
case RegistrationClosed, RegistrationApproval, RegistrationDomain, RegistrationDomainApproval, RegistrationOpen:
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseEmailDomains turns "firma.de, @Familie.de" into ["firma.de", "familie.de"].
|
||||
func ParseEmailDomains(raw string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' }) {
|
||||
d := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(part), "@"))
|
||||
if d == "" || !strings.Contains(d, ".") || seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EmailInDomains: the part after "@" equals one of the domains; "*.firma.de" also allows subdomains.
|
||||
func EmailInDomains(email string, domains []string) bool {
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(email[at+1:]))
|
||||
for _, d := range domains {
|
||||
if strings.HasPrefix(d, "*.") {
|
||||
if base := d[2:]; host == base || strings.HasSuffix(host, "."+base) {
|
||||
return true
|
||||
}
|
||||
} else if host == d {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) migrateRegistration() error {
|
||||
for _, q := range []string{
|
||||
`ALTER TABLE user_invites ADD COLUMN max_uses INTEGER NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE user_invites ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0`,
|
||||
`UPDATE user_invites SET use_count = 1 WHERE used_by != '' AND use_count = 0`,
|
||||
`CREATE TABLE IF NOT EXISTS pending_registrations (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
} {
|
||||
if err := s.execSchemaStatement(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AddPendingRegistration(userID string) error {
|
||||
_, err := s.db.Exec(`INSERT OR IGNORE INTO pending_registrations (user_id, created_at) VALUES (?, ?)`,
|
||||
userID, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearPendingRegistration(userID string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM pending_registrations WHERE user_id = ?`, userID)
|
||||
}
|
||||
|
||||
func (s *Store) IsPendingRegistration(userID string) bool {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM pending_registrations WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// PendingRegistrationIDs: accounts that still wait for approval (only existing users).
|
||||
func (s *Store) PendingRegistrationIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
rows, err := s.db.Query(`SELECT p.user_id FROM pending_registrations p JOIN users u ON u.id = p.user_id`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApprovePendingRegistration activates a waiting account; false when it was not waiting.
|
||||
func (s *Store) ApprovePendingRegistration(userID string) (bool, error) {
|
||||
if !s.IsPendingRegistration(userID) {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.execInTx(
|
||||
stmt(`UPDATE users SET is_active = 1 WHERE id = ?`, userID),
|
||||
stmt(`DELETE FROM pending_registrations WHERE user_id = ?`, userID),
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -138,6 +138,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
settings.SharingMode = v
|
||||
case "allow_registration":
|
||||
settings.AllowRegistration = (v == "true" || v == "1")
|
||||
case "registration_mode":
|
||||
settings.RegistrationMode = NormalizeRegistrationMode(v)
|
||||
case "registration_domains":
|
||||
settings.RegistrationDomains = v
|
||||
case "instance_name":
|
||||
settings.InstanceName = v
|
||||
case "smtp_host":
|
||||
@@ -173,10 +177,28 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Older installations only knew the open/closed switch
|
||||
if settings.RegistrationMode == "" {
|
||||
settings.RegistrationMode = RegistrationClosed
|
||||
if settings.AllowRegistration {
|
||||
settings.RegistrationMode = RegistrationOpen
|
||||
}
|
||||
}
|
||||
settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
// Clients that only send the old switch keep working
|
||||
settings.RegistrationMode = NormalizeRegistrationMode(settings.RegistrationMode)
|
||||
if settings.RegistrationMode == "" {
|
||||
settings.RegistrationMode = RegistrationClosed
|
||||
if settings.AllowRegistration {
|
||||
settings.RegistrationMode = RegistrationOpen
|
||||
}
|
||||
}
|
||||
settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen
|
||||
settings.RegistrationDomains = strings.Join(ParseEmailDomains(settings.RegistrationDomains), ", ")
|
||||
regStr := "false"
|
||||
if settings.AllowRegistration {
|
||||
regStr = "true"
|
||||
@@ -221,7 +243,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
('allow_private_webhooks', ?),
|
||||
('backup_enabled', ?),
|
||||
('backup_time', ?),
|
||||
('backup_retention_days', ?)
|
||||
('backup_retention_days', ?),
|
||||
('registration_mode', ?),
|
||||
('registration_domains', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
settings.SharingMode,
|
||||
regStr,
|
||||
@@ -238,6 +262,8 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
strconv.FormatBool(settings.BackupEnabled),
|
||||
settings.BackupTime,
|
||||
strconv.Itoa(settings.BackupRetentionDays),
|
||||
settings.RegistrationMode,
|
||||
settings.RegistrationDomains,
|
||||
)
|
||||
return err
|
||||
}
|
||||
+53
-22
@@ -327,13 +327,25 @@ func (s *Store) RevokeAllUserTokens(userID string) error {
|
||||
|
||||
// --- USER INVITES ---
|
||||
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
|
||||
// MaxInviteUses caps how many accounts one invite link can create.
|
||||
const MaxInviteUses = 1000
|
||||
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays, maxUses int) (*domain.UserInvite, error) {
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
if validDays <= 0 || validDays > 365 {
|
||||
validDays = 7
|
||||
}
|
||||
if maxUses < 1 {
|
||||
maxUses = 1
|
||||
}
|
||||
if maxUses > MaxInviteUses {
|
||||
maxUses = MaxInviteUses
|
||||
}
|
||||
if maxUses > 1 && strings.TrimSpace(email) != "" {
|
||||
return nil, errors.New("Ein Einladungslink für mehrere Personen kann nicht an eine E-Mail-Adresse gebunden werden")
|
||||
}
|
||||
code := newInviteCode()
|
||||
now := time.Now().UTC()
|
||||
inv := &domain.UserInvite{
|
||||
@@ -345,11 +357,12 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
|
||||
CreatedBy: createdBy,
|
||||
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
MaxUses: maxUses,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
||||
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, 0)`,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt, inv.MaxUses,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -359,7 +372,8 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
|
||||
|
||||
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
|
||||
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at,
|
||||
i.max_uses, i.use_count
|
||||
FROM user_invites i
|
||||
LEFT JOIN organizations o ON o.id = i.organization_id
|
||||
ORDER BY i.created_at DESC`,
|
||||
@@ -372,7 +386,7 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
invites := make([]domain.UserInvite, 0)
|
||||
for rows.Next() {
|
||||
var inv domain.UserInvite
|
||||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
|
||||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt, &inv.MaxUses, &inv.UseCount); err == nil {
|
||||
invites = append(invites, inv)
|
||||
}
|
||||
}
|
||||
@@ -389,17 +403,28 @@ func newInviteCode() string {
|
||||
}
|
||||
|
||||
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
return s.getUserInvite(`UPPER(code) = ?`, strings.ToUpper(strings.TrimSpace(code)))
|
||||
}
|
||||
|
||||
// GetUserInviteByID: a still usable invite by its id (e.g. to send it by mail).
|
||||
func (s *Store) GetUserInviteByID(id string) (*domain.UserInvite, error) {
|
||||
return s.getUserInvite(`id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *Store) getUserInvite(where string, arg string) (*domain.UserInvite, error) {
|
||||
var inv domain.UserInvite
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
|
||||
FROM user_invites WHERE UPPER(code) = ?`,
|
||||
code,
|
||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
||||
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count
|
||||
FROM user_invites WHERE `+where,
|
||||
arg,
|
||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt, &inv.MaxUses, &inv.UseCount)
|
||||
if err != nil {
|
||||
return nil, errors.New("Ungültiger Einladungscode")
|
||||
}
|
||||
if inv.UsedBy != "" {
|
||||
if inv.UseCount >= inv.MaxUses {
|
||||
if inv.MaxUses > 1 {
|
||||
return nil, errors.New("Dieser Einladungslink ist bereits voll ausgeschöpft")
|
||||
}
|
||||
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
||||
}
|
||||
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
|
||||
@@ -408,30 +433,36 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// ClaimUserInvite reserves an unused invite before the account is created, so two registrations with
|
||||
// the same code cannot both succeed (FLAW-40). It returns the claim marker for FinishUserInvite or
|
||||
// ReleaseUserInvite.
|
||||
// ClaimUserInvite reserves one use of an invite before the account is created, so registrations with
|
||||
// the same code can never use it more often than allowed, even at the same moment (FLAW-40). The
|
||||
// returned marker goes to FinishUserInvite or ReleaseUserInvite.
|
||||
func (s *Store) ClaimUserInvite(inviteID string) (string, error) {
|
||||
claim := domain.NewID("claim")
|
||||
res, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ''`, claim, inviteID)
|
||||
res, err := s.db.Exec(`UPDATE user_invites SET use_count = use_count + 1 WHERE id = ? AND use_count < max_uses`, inviteID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return "", errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
||||
}
|
||||
return claim, nil
|
||||
return domain.NewID("claim"), nil
|
||||
}
|
||||
|
||||
// FinishUserInvite records the new account on a claimed invite.
|
||||
// FinishUserInvite records the new account on a single-use invite (links for several people only count).
|
||||
func (s *Store) FinishUserInvite(inviteID, claim, userID string) error {
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ?`, userID, inviteID, claim)
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND max_uses = 1`, userID, inviteID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReleaseUserInvite frees a claimed invite again when the account could not be created.
|
||||
// ReleaseUserInvite gives the reserved use back when the account could not be created.
|
||||
func (s *Store) ReleaseUserInvite(inviteID, claim string) {
|
||||
_, _ = s.db.Exec(`UPDATE user_invites SET used_by = '' WHERE id = ? AND used_by = ?`, inviteID, claim)
|
||||
_, _ = s.db.Exec(`UPDATE user_invites SET use_count = use_count - 1 WHERE id = ? AND use_count > 0`, inviteID)
|
||||
}
|
||||
|
||||
// BindUserInviteEmail ties a single-use invite to the address it is sent to.
|
||||
func (s *Store) BindUserInviteEmail(inviteID, email string) error {
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET email = ? WHERE id = ? AND max_uses = 1 AND email = ''`,
|
||||
strings.ToLower(strings.TrimSpace(email)), inviteID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserInvite(inviteID string) error {
|
||||
|
||||
Reference in new issue
Block a user