2026-08-09 22:47:43 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
2026-08-17 21:20:45 +02:00
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
2026-08-09 22:47:43 +02:00
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// SetPasswordHook is a one-time accept-invite style token for a migrated user.
|
|
|
|
|
// SMTP delivery is owned by mailhooks / WS7 — this only prepares durable invite rows + a local artifact.
|
|
|
|
|
type SetPasswordHook struct {
|
|
|
|
|
UserID uuid.UUID `json:"user_id"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
CompanyID uuid.UUID `json:"company_id"`
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
|
|
|
InviteID uuid.UUID `json:"invite_id,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func webOrigin() string {
|
|
|
|
|
o := strings.TrimSpace(os.Getenv("WEB_ORIGIN"))
|
|
|
|
|
if o == "" {
|
|
|
|
|
o = "http://localhost:5174"
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimRight(o, "/")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setPasswordInviteURL(token string) string {
|
2026-08-17 21:20:45 +02:00
|
|
|
return mail.AcceptInviteURL(webOrigin(), token)
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// prepareSetPasswordHooks creates invites for active users with must_set_password=true.
|
|
|
|
|
// Tokens are returned once for the artifact (do not commit). AcceptInvite sets password
|
|
|
|
|
// only when must_set_password is still true (existing accounts with a password must verify it).
|
|
|
|
|
func prepareSetPasswordHooks(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
pg *pgxpool.Pool,
|
|
|
|
|
ttl time.Duration,
|
|
|
|
|
dryRun bool,
|
|
|
|
|
report map[string]int,
|
|
|
|
|
) ([]SetPasswordHook, error) {
|
|
|
|
|
if dryRun {
|
|
|
|
|
report["set_password_hooks_skipped_dry_run"]++
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
if ttl <= 0 {
|
|
|
|
|
ttl = 7 * 24 * time.Hour
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rows, err := pg.Query(ctx, `
|
|
|
|
|
SELECT u.id, u.email, m.company_id, m.role
|
|
|
|
|
FROM users u
|
|
|
|
|
JOIN memberships m ON m.user_id = u.id AND m.status = 'active'
|
|
|
|
|
WHERE u.must_set_password = true AND u.is_active = true
|
|
|
|
|
ORDER BY u.email, m.created_at
|
|
|
|
|
`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list must_set_password users: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
seen := map[uuid.UUID]bool{}
|
|
|
|
|
var hooks []SetPasswordHook
|
|
|
|
|
expires := time.Now().UTC().Add(ttl)
|
|
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var h SetPasswordHook
|
|
|
|
|
if err := rows.Scan(&h.UserID, &h.Email, &h.CompanyID, &h.Role); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if seen[h.UserID] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[h.UserID] = true
|
|
|
|
|
if auth.IsSyntheticLegacyEmail(h.Email) {
|
|
|
|
|
report["set_password_hooks_skipped_synthetic"]++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if h.Role == "" {
|
|
|
|
|
h.Role = "member"
|
|
|
|
|
}
|
|
|
|
|
token, err := auth.RandomToken(24)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
h.Token = token
|
|
|
|
|
h.ExpiresAt = expires
|
|
|
|
|
h.URL = setPasswordInviteURL(h.Token)
|
|
|
|
|
|
|
|
|
|
// Expire prior unaccepted invites for this email+company so re-issue is safe.
|
|
|
|
|
_, _ = pg.Exec(ctx, `
|
|
|
|
|
UPDATE invites
|
|
|
|
|
SET expires_at = least(expires_at, now())
|
|
|
|
|
WHERE company_id = $1 AND lower(email) = lower($2) AND accepted_at IS NULL`,
|
|
|
|
|
h.CompanyID, h.Email)
|
|
|
|
|
|
|
|
|
|
err = pg.QueryRow(ctx, `
|
|
|
|
|
INSERT INTO invites (company_id, email, role, token, expires_at)
|
|
|
|
|
VALUES ($1, lower($2), $3, $4, $5)
|
|
|
|
|
RETURNING id`,
|
|
|
|
|
h.CompanyID, h.Email, h.Role, auth.HashInviteToken(h.Token), h.ExpiresAt,
|
|
|
|
|
).Scan(&h.InviteID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("set-password invite for user_id=%s: %v", h.UserID, err)
|
|
|
|
|
report["set_password_hooks_skipped"]++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
hooks = append(hooks, h)
|
|
|
|
|
report["set_password_hooks"]++
|
|
|
|
|
}
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return hooks, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeSetPasswordArtifacts(mapsDir string, hooks []SetPasswordHook) error {
|
|
|
|
|
if len(hooks) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(mapsDir, 0o755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
// Canonical mailhooks path + operator-friendly alias with URLs.
|
|
|
|
|
hookPath := filepath.Join(mapsDir, "set-password-hooks.json")
|
|
|
|
|
invitePath := filepath.Join(mapsDir, "password_invites.json")
|
|
|
|
|
if err := writeJSON(hookPath, hooks); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := writeJSON(invitePath, hooks); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf("wrote %d set-password invites to %s and %s (do not commit)\n", len(hooks), invitePath, hookPath)
|
|
|
|
|
fmt.Println("=== Set-password invite URLs ===")
|
|
|
|
|
for _, h := range hooks {
|
|
|
|
|
fmt.Printf("%s\t%s\n", h.Email, h.URL)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// setPasswordByEmail is a local/dev bootstrap: force a password for one migrated user.
|
|
|
|
|
func setPasswordByEmail(ctx context.Context, pg *pgxpool.Pool, emailPass string) error {
|
|
|
|
|
parts := strings.SplitN(emailPass, ":", 2)
|
|
|
|
|
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || parts[1] == "" {
|
|
|
|
|
return fmt.Errorf("-set-password expects email:password")
|
|
|
|
|
}
|
|
|
|
|
email := strings.ToLower(strings.TrimSpace(parts[0]))
|
|
|
|
|
password := parts[1]
|
|
|
|
|
hash, err := auth.HashPassword(password)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
ct, err := pg.Exec(ctx, `
|
|
|
|
|
UPDATE users
|
|
|
|
|
SET password_hash = $2, must_set_password = false, updated_at = now()
|
|
|
|
|
WHERE lower(email) = $1`, email, hash)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if ct.RowsAffected() == 0 {
|
|
|
|
|
return fmt.Errorf("no user with email %s", email)
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf("set password for %s (must_set_password=false)\n", email)
|
|
|
|
|
return nil
|
|
|
|
|
}
|