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
+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