package main import ( "context" "fmt" "log" "net/url" "os" "path/filepath" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "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 { return webOrigin() + "/accept-invite?token=" + url.QueryEscape(token) } // 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 }