Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user