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,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
|
||||
}
|
||||
Reference in new issue
Block a user