This commit is contained in:
2026-08-13 23:40:39 +02:00
parent 260fe28ac0
commit 322c70e5cf
4 changed files with 160 additions and 39 deletions
+105 -19
View File
@@ -1,22 +1,16 @@
// Command seed-platform-admin upserts the first (or additional) platform admin
// for greenfield / production deploys where no legacy admin_users cutover ran
// and seed-demo must not be used.
// Command seed-platform-admin upserts a platform admin with a tenant company
// (membership admin + Free plan), matching self-serve register. Without a
// company, login leaves session company empty and every /api/* tenant call
// returns 400 "company not selected".
//
// Usage:
//
// cd apps/api
// go run ./cmd/seed-platform-admin \
// -postgres "$env:DATABASE_URL" \
// -email you@example.com \
// -password 'choose-a-strong-password' \
// -confirm
// npm run seed:platform-admin -- --email you@example.com --password '…'
// npm run seed:platform-admin -- --email you@example.com --promote-only
// npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops'
//
// Env aliases: none for credentials — pass -email / -password (or use
// `npm run seed:platform-admin -- --email … --password …`). DATABASE_URL may
// come from the shell or monorepo-root .env via config.LoadDotEnv.
//
// -promote-only grants admin on an existing user without changing the password
// (-password is ignored). Always requires -confirm.
// Credentials are CLI-only (never from .env). DATABASE_URL may come from the
// shell or monorepo-root .env via config.LoadDotEnv. Always requires -confirm.
package main
import (
@@ -30,6 +24,7 @@ import (
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -43,11 +38,13 @@ func main() {
email := flag.String("email", "", "Platform admin email (required; do not put in .env)")
password := flag.String("password", "", "Password (min 8; ignored with -promote-only; do not put in .env)")
name := flag.String("name", "", "Display name (defaults to email local-part)")
company := flag.String("company", "", "Company/workspace name (default: \"<name> workspace\")")
noCompany := flag.Bool("no-company", false, "Skip company creation (staff-only; dashboard tenant APIs will 400)")
promoteOnly := flag.Bool("promote-only", false, "Grant platform admin on an existing user; do not set password")
confirm := flag.Bool("confirm", false, "Required: acknowledge this writes is_platform_admin + staff_role=admin")
confirm := flag.Bool("confirm", false, "Required: acknowledge privilege + optional company writes")
flag.Parse()
opts, err := parseOptions(*postgresURL, *email, *password, *name, *promoteOnly, *confirm, os.Getenv("APP_ENV"))
opts, err := parseOptions(*postgresURL, *email, *password, *name, *company, *promoteOnly, *noCompany, *confirm, os.Getenv("APP_ENV"))
if err != nil {
log.Fatal(err)
}
@@ -80,8 +77,30 @@ func main() {
} else {
fmt.Println(" password: set (argon2id)")
}
if opts.NoCompany {
fmt.Println(" company: skipped (-no-company)")
fmt.Println()
fmt.Println("WARNING: without a company, dashboard /api/* calls return 400 company not selected.")
return
}
companyID, companyName, companyCreated, err := ensureAdminCompany(ctx, pg, userID, opts.CompanyName)
if err != nil {
log.Fatal(err)
}
billingSvc := &billing.Service{Pool: pg}
if err := billingSvc.ProvisionFreePlan(ctx, companyID); err != nil {
log.Fatalf("provision Free plan: %v", err)
}
companyAction := "existing"
if companyCreated {
companyAction = "created"
}
fmt.Printf(" company: %s (%s) [%s]\n", companyName, companyID, companyAction)
fmt.Println(" plan: Free (provisioned)")
fmt.Println()
fmt.Println("Sign in on the web app, then open /admin.")
fmt.Println("Sign in on the web app (company is selected on login). Platform panel: /admin.")
}
type options struct {
@@ -89,10 +108,12 @@ type options struct {
Email string
Password string
Name string
CompanyName string
PromoteOnly bool
NoCompany bool
}
func parseOptions(postgresURL, email, password, name string, promoteOnly, confirm bool, appEnv string) (options, error) {
func parseOptions(postgresURL, email, password, name, company string, promoteOnly, noCompany, confirm bool, appEnv string) (options, error) {
if !confirm {
return options{}, fmt.Errorf("-confirm is required (refuses silent privilege grants)")
}
@@ -116,11 +137,18 @@ func parseOptions(postgresURL, email, password, name string, promoteOnly, confir
}
}
companyName := strings.TrimSpace(company)
if !noCompany && companyName == "" {
companyName = display + " workspace"
}
opts := options{
PostgresURL: pg,
Email: emailNorm,
Name: display,
CompanyName: companyName,
PromoteOnly: promoteOnly,
NoCompany: noCompany,
}
if promoteOnly {
return opts, nil
@@ -225,3 +253,61 @@ func upsertPlatformAdmin(ctx context.Context, pg *pgxpool.Pool, opts options) (u
}
return userID, !existed, nil
}
// ensureAdminCompany attaches the user to a company as membership admin.
// If they already have any active membership, reuses the first company (idempotent repair).
func ensureAdminCompany(ctx context.Context, pg *pgxpool.Pool, userID uuid.UUID, companyName string) (uuid.UUID, string, bool, error) {
var existingID uuid.UUID
var existingName string
err := pg.QueryRow(ctx, `
SELECT c.id, c.name
FROM memberships m
JOIN companies c ON c.id = m.company_id
WHERE m.user_id = $1 AND m.status = 'active'
ORDER BY m.created_at NULLS LAST, c.name
LIMIT 1`, userID).Scan(&existingID, &existingName)
if err == nil {
_, _ = pg.Exec(ctx, `
UPDATE memberships
SET role = 'admin', status = 'active', updated_at = now()
WHERE user_id = $1 AND company_id = $2`, userID, existingID)
return existingID, existingName, false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, "", false, fmt.Errorf("lookup memberships: %w", err)
}
tx, err := pg.Begin(ctx)
if err != nil {
return uuid.Nil, "", false, fmt.Errorf("begin company: %w", err)
}
defer tx.Rollback(ctx)
var companyID uuid.UUID
err = tx.QueryRow(ctx, `INSERT INTO companies (name) VALUES ($1) RETURNING id`, companyName).Scan(&companyID)
if err != nil {
return uuid.Nil, "", false, fmt.Errorf("insert company: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
if err != nil {
return uuid.Nil, "", false, fmt.Errorf("insert membership: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO company_settings (company_id) VALUES ($1)
ON CONFLICT DO NOTHING`, companyID)
if err != nil {
return uuid.Nil, "", false, fmt.Errorf("company_settings: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO credit_balances (company_id) VALUES ($1)
ON CONFLICT DO NOTHING`, companyID)
if err != nil {
return uuid.Nil, "", false, fmt.Errorf("credit_balances: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return uuid.Nil, "", false, fmt.Errorf("commit company: %w", err)
}
return companyID, companyName, true, nil
}