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

+1
View File
@@ -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 {
+127
View File
@@ -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
}
+27 -1
View File
@@ -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
View File
@@ -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 {