fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739)

On a fresh instance, `pad init` / `pad auth setup` created the admin
account in the browser and dropped the operator on the console, then
printed a SECOND "authorize the CLI" URL back in the terminal that a
user who'd moved to the browser never saw — forcing a ctrl-C + re-run.

Collapse it into a single browser tab: the CLI mints the pending CLI
auth session up front and hands /setup a validated `next=/auth/cli/<code>`
target, so account creation flows straight into the approval page where
the just-bootstrapped admin approves in one click and the CLI connects.

- internal/cli/bootstrap.go: thread `next` into the /setup URL (query
  before the #token fragment); raise bootstrapPollTimeout to 20m to
  match the setup session TTL.
- cmd/pad/main.go: extract pollAndSaveCLIAuth; runBrowserSetup pre-creates
  the session and polls it; `pad workspace init` drives local setup inline.
- cmd/pad/init.go: `pad init` routes through the unified handoff.
- internal/store + internal/server: grant a setup-specific 20m CLI auth
  session TTL when UserCount==0 so the combined create-account + approve
  window can't expire mid-flow; normal logins keep the 5m default.
- web/src/routes/setup: honor a validated local `next` redirect (open-
  redirect guarded), preserved across the token-fragment scrub.

Reviewed via Codex loop (3 rounds → clean).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
This commit is contained in:
xarmian
2026-06-20 23:51:43 -04:00
committed by GitHub
parent 62fafc49c6
commit 22d901c823
7 changed files with 225 additions and 66 deletions
+11 -17
View File
@@ -171,27 +171,21 @@ Examples:
fmt.Printf("Admin account created — logged in as %s (%s)\n", resp.User.Name, resp.User.Email)
fmt.Println()
} else {
// Default browser path. RunBrowserBootstrap returns once
// the server reports setup_required: false but the CLI
// has no credentials — the browser owns the session
// cookie. Chain doBrowserLogin afterwards so the rest
// of pad init (workspace creation, etc.) can hit
// authenticated endpoints.
// Default browser path: a SINGLE handoff that creates the
// admin account AND authorizes this CLI in the same
// browser tab. runBrowserSetup mints the pending CLI auth
// session first, hands /setup a next= target pointing at
// its approval page, then polls until approved — so the
// operator never has to return to the terminal to copy a
// second URL. BUG-1843.
//
// SIGINT is already handled by installInitCancelHandler
// at the top of this RunE — it short-circuits the whole
// process via os.Exit(130), so the helper doesn't need
// its own signal-aware context. Background suffices.
if err := cli.RunBrowserBootstrap(context.Background(), client, cfg); err != nil {
// at the top of this RunE (os.Exit(130)); runBrowserSetup
// installs a redundant handler of its own, which is
// harmless.
if err := runBrowserSetup(context.Background(), cfg, client); err != nil {
return err
}
green.Print("✓ ")
fmt.Println("First admin account created")
fmt.Println()
fmt.Println(" Authenticating the CLI…")
if err := doBrowserLogin(client, cfg); err != nil {
return fmt.Errorf("login: %w", err)
}
fmt.Println()
}
+60 -20
View File
@@ -1167,7 +1167,11 @@ legacy in-terminal email/name/password prompts.`,
if cliPrompt {
return runCLISetup(cfg, client)
}
return runBrowserSetup(cmd.Context(), cfg, client)
if err := runBrowserSetup(cmd.Context(), cfg, client); err != nil {
return err
}
printPostSetupNextStepsHint()
return nil
},
}
// --cli-prompt is the deliberate hedge from IDEA-1179 / TASK-1216: we
@@ -1181,14 +1185,18 @@ legacy in-terminal email/name/password prompts.`,
return cmd
}
// runBrowserSetup drives the browser-based first-admin bootstrap and then
// chains a CLI auth-session login so the user ends up authenticated on
// the CLI — mirroring the post-condition of the legacy --cli-prompt path
// (Bootstrap returns a token and we save credentials in one shot). Two
// browser approvals — one to create the admin, one to authorize the CLI
// — but each is a single click in a browser the operator already has
// open, and the alternative (telling them to manually run `pad auth
// login` afterwards) is a worse UX.
// runBrowserSetup drives the browser-based first-admin bootstrap and the
// CLI authorization in a SINGLE browser handoff. Before printing anything
// it creates a pending CLI auth session, then hands /setup a next= target
// pointing at that session's approval page. The operator opens one URL,
// creates the admin account, and the browser auto-navigates to the
// "Authorize CLI" page in the same tab — where, already authenticated by
// the bootstrap they just completed, a single click finishes login. The
// CLI polls that pre-created session and saves credentials on approval.
//
// This closes BUG-1843: the pre-fix flow created the admin, dropped the
// operator on the console, and only THEN printed a second auth URL back in
// the terminal — which a user who'd moved to the browser never saw.
func runBrowserSetup(ctx context.Context, cfg *config.Config, client *cli.Client) error {
if ctx == nil {
ctx = context.Background()
@@ -1206,7 +1214,17 @@ func runBrowserSetup(ctx context.Context, cfg *config.Config, client *cli.Client
}
}()
if err := cli.RunBrowserBootstrap(bootstrapCtx, client, cfg); err != nil {
// Create the CLI auth session FIRST so we know the approval-page path
// to hand /setup as its post-bootstrap redirect target. CreateCLIAuthSession
// is unauthenticated (it mints a pending request), so it works on a
// fresh instance with no users yet.
sess, err := client.CreateCLIAuthSession()
if err != nil {
return fmt.Errorf("failed to start login session: %w", err)
}
next := "/auth/cli/" + sess.SessionCode
if err := cli.RunBrowserBootstrap(bootstrapCtx, client, cfg, next); err != nil {
// Map ctx cancellation to the canonical errCancelled sentinel so
// the deferred isCancellation() check in the parent RunE routes
// us through "Cancelled." + exit 130 instead of cobra's generic
@@ -1220,12 +1238,8 @@ func runBrowserSetup(ctx context.Context, cfg *config.Config, client *cli.Client
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf(" %s First admin account created\n", green("✓"))
fmt.Println()
fmt.Println(" Authenticating the CLI…")
if err := doBrowserLogin(client, cfg); err != nil {
return err
}
printPostSetupNextStepsHint()
return nil
fmt.Println(" Authorizing the CLI… approve the request in the browser tab that just opened.")
return pollAndSaveCLIAuth(bootstrapCtx, client, cfg, sess)
}
// runCLISetup is the legacy in-terminal admin bootstrap, reachable via
@@ -1366,6 +1380,18 @@ func doBrowserLogin(client *cli.Client, cfg *config.Config) error {
cancel()
}()
return pollAndSaveCLIAuth(ctx, client, cfg, sess)
}
// pollAndSaveCLIAuth polls a pending CLI auth session until it is approved,
// then persists the issued token as credentials for cfg.BaseURL(). It is the
// shared tail of every browser auth flow: doBrowserLogin (which prints its
// own /auth/cli URL) and the first-run setup handoff (where the browser
// auto-navigates to that same approval page from /setup, so no URL is
// printed — BUG-1843). The caller owns ctx and its SIGINT wiring; on
// cancellation this returns errCancelled so the standard "Cancelled." +
// exit-130 path fires.
func pollAndSaveCLIAuth(ctx context.Context, client *cli.Client, cfg *config.Config, sess *cli.CLIAuthSessionResponse) error {
// Poll until approved, expired, or cancelled
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
@@ -2228,10 +2254,24 @@ a workspace in one step.`,
return fmt.Errorf("failed to check auth status: %w", err)
}
if session.SetupRequired {
printSetupRequiredHint(cfg)
dim := color.New(color.Faint)
fmt.Println(dim.Sprint("\nTip: Run 'pad init' to set up everything at once."))
return fmt.Errorf("this Pad instance has not been initialized yet")
// Remote/cloud instances can only be bootstrapped from the
// server host — a client machine can't create the first
// admin. Keep the pointing-at-the-host hint for those.
switch cfg.Mode {
case config.ModeRemote, config.ModeCloud:
printSetupRequiredHint(cfg)
return fmt.Errorf("this Pad instance has not been initialized yet")
}
// Fresh local instance: drive the full first-run setup
// (create the first admin + authorize this CLI) inline so
// `pad init` is a genuine one-shot rather than bouncing the
// user to `pad auth setup` and back. BUG-1843.
fmt.Println("This Pad instance hasn't been set up yet — let's create your admin account.")
if err := runBrowserSetup(cmd.Context(), cfg, client); err != nil {
return err
}
fmt.Println()
client = cli.NewClientFromURL(cfg.BaseURL())
} else if !session.Authenticated {
fmt.Println("Log in to continue.")
fmt.Println()
+34 -11
View File
@@ -23,6 +23,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@@ -50,12 +51,17 @@ const bootstrapTokenFilename = ".bootstrap-token"
var bootstrapPollInterval = 2 * time.Second
// bootstrapPollTimeout caps how long RunBrowserBootstrap waits for the
// browser side to finish. 5 minutes is generous for an interactive admin
// form and short enough that an abandoned terminal doesn't sit waiting
// indefinitely. The caller's SIGINT path can cut this short via ctx.
// var (not const) so tests can exercise the timeout branch in
// browser side to finish. It MUST stay >= the server's setup-session TTL
// (cliAuthSetupSessionTTL, 20m in internal/store/cli_auth_sessions.go):
// the unified setup handoff (BUG-1843) pre-creates a 20-minute CLI auth
// session, so the terminal must keep polling for setup at least that long
// — otherwise a user who takes >5m on the admin form would have the CLI
// give up here while the browser session is still valid, exactly the
// half-fixed expiry class this aligns away. Still finite so an abandoned
// terminal doesn't wait forever; the caller's SIGINT path cuts it short
// via ctx. var (not const) so tests can exercise the timeout branch in
// milliseconds.
var bootstrapPollTimeout = 5 * time.Minute
var bootstrapPollTimeout = 20 * time.Minute
// RunBrowserBootstrap walks the operator through the browser-based first-
// admin bootstrap. Returns nil on success (server has flipped to
@@ -72,11 +78,18 @@ var bootstrapPollTimeout = 5 * time.Minute
// want the CLI to be authenticated afterwards should chain a CLI-auth-
// session login (see doBrowserLogin in cmd/pad/main.go) once this returns.
//
// next, when non-empty, is a local path the /setup page navigates to after
// the admin account is created (instead of dropping the operator at the
// console). Callers pass "/auth/cli/<code>" for a pre-created CLI auth
// session so account creation flows straight into the CLI-authorize step
// in the SAME browser tab — no second URL to copy back in the terminal
// (BUG-1843).
//
// The helper is idempotent: if the server already reports
// setup_required: false on entry, it returns nil immediately without
// touching the token file or printing anything. That matters for any
// caller invoking it against a server where setup is already done.
func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config) error {
func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config, next string) error {
if client == nil {
return errors.New("RunBrowserBootstrap: nil client")
}
@@ -93,7 +106,7 @@ func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config
return nil
}
url, err := buildBootstrapURL(cfg, session.SetupMethod)
setupURL, err := buildBootstrapURL(cfg, session.SetupMethod, next)
if err != nil {
return err
}
@@ -102,7 +115,7 @@ func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config
fmt.Println()
fmt.Println(" Open this URL in your browser to finish setup:")
fmt.Println()
fmt.Printf(" %s\n", bold(url))
fmt.Printf(" %s\n", bold(setupURL))
fmt.Println()
fmt.Println(" Waiting for setup to complete (Ctrl+C to cancel)...")
@@ -137,19 +150,29 @@ func RunBrowserBootstrap(ctx context.Context, client *Client, cfg *config.Config
//
// - anything else — newer server speaking a method this CLI doesn't know.
// Bail loudly with the same --cli-prompt fallback hint.
func buildBootstrapURL(cfg *config.Config, setupMethod string) (string, error) {
func buildBootstrapURL(cfg *config.Config, setupMethod, next string) (string, error) {
base := cfg.BrowserURL()
// The next= handoff target rides as a query param, which MUST sit
// before any #token fragment (a query after the fragment would be
// parsed as part of the fragment and never reach the page's
// searchParams). url.QueryEscape keeps the leading slash and any
// nested path safe to round-trip through the address bar.
query := ""
if next != "" {
query = "?next=" + url.QueryEscape(next)
}
switch setupMethod {
case "logs_token":
token, err := readBootstrapToken(cfg.DataDir)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/setup#token=%s", base, token), nil
return fmt.Sprintf("%s/setup%s#token=%s", base, query, token), nil
case "open":
return base + "/setup", nil
return base + "/setup" + query, nil
case "", "local_cli":
return "", fmt.Errorf("server has no bootstrap token configured (setup_method=%q); re-run with --cli-prompt to use the legacy TTY flow", setupMethod)
+45 -10
View File
@@ -70,7 +70,7 @@ func TestRunBrowserBootstrap_Idempotent(t *testing.T) {
// Deliberately do NOT write a token file. If the helper short-circuits
// correctly on setup_required: false, it should never need to read it.
if err := RunBrowserBootstrap(context.Background(), client, cfg); err != nil {
if err := RunBrowserBootstrap(context.Background(), client, cfg, ""); err != nil {
t.Fatalf("RunBrowserBootstrap returned %v on already-bootstrapped server, want nil", err)
}
}
@@ -99,7 +99,7 @@ func TestRunBrowserBootstrap_LogsTokenSuccess(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := RunBrowserBootstrap(ctx, client, cfg)
err := RunBrowserBootstrap(ctx, client, cfg, "")
if err != nil {
t.Fatalf("RunBrowserBootstrap: %v", err)
}
@@ -131,7 +131,7 @@ func TestRunBrowserBootstrap_OpenMode(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := RunBrowserBootstrap(ctx, client, cfg); err != nil {
if err := RunBrowserBootstrap(ctx, client, cfg, ""); err != nil {
t.Fatalf("RunBrowserBootstrap (open mode): %v", err)
}
}
@@ -147,7 +147,7 @@ func TestRunBrowserBootstrap_TokenMissing(t *testing.T) {
// No writeBootstrapToken — file is absent.
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with missing token, want error")
}
@@ -172,7 +172,7 @@ func TestRunBrowserBootstrap_TokenEmpty(t *testing.T) {
writeBootstrapToken(t, cfg.DataDir, " ")
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with empty token, want error")
}
@@ -192,7 +192,7 @@ func TestRunBrowserBootstrap_LocalCLIMethod(t *testing.T) {
cfg := newTestConfig(t, srv.URL)
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with setup_method=local_cli, want error")
}
@@ -211,7 +211,7 @@ func TestRunBrowserBootstrap_UnknownMethod(t *testing.T) {
cfg := newTestConfig(t, srv.URL)
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil with unknown setup_method, want error")
}
@@ -253,7 +253,7 @@ func TestRunBrowserBootstrap_TimeoutFires(t *testing.T) {
writeBootstrapToken(t, cfg.DataDir, "tok")
client := NewClientFromURL(srv.URL)
err := RunBrowserBootstrap(context.Background(), client, cfg)
err := RunBrowserBootstrap(context.Background(), client, cfg, "")
if err == nil {
t.Fatal("RunBrowserBootstrap returned nil after internal timeout, want error")
}
@@ -292,7 +292,7 @@ func TestRunBrowserBootstrap_ContextCancelled(t *testing.T) {
defer cancel()
start := time.Now()
err := RunBrowserBootstrap(ctx, client, cfg)
err := RunBrowserBootstrap(ctx, client, cfg, "")
elapsed := time.Since(start)
if err == nil {
@@ -319,7 +319,7 @@ func TestBuildBootstrapURL_LogsTokenFragment(t *testing.T) {
cfg := newTestConfig(t, "http://example.test:7777")
writeBootstrapToken(t, cfg.DataDir, "abc123")
got, err := buildBootstrapURL(cfg, "logs_token")
got, err := buildBootstrapURL(cfg, "logs_token", "")
if err != nil {
t.Fatalf("buildBootstrapURL: %v", err)
}
@@ -329,6 +329,41 @@ func TestBuildBootstrapURL_LogsTokenFragment(t *testing.T) {
}
}
// TestBuildBootstrapURL_NextBeforeFragment locks down the BUG-1843 handoff
// shape: the next= query param MUST sit before the #token fragment.
// A query placed after the fragment would be swallowed by it and never
// reach the /setup page's searchParams, breaking the post-bootstrap
// redirect to the CLI-authorize page. The path is URL-escaped so the
// leading slash round-trips intact.
func TestBuildBootstrapURL_NextBeforeFragment(t *testing.T) {
cfg := newTestConfig(t, "http://example.test:7777")
writeBootstrapToken(t, cfg.DataDir, "abc123")
got, err := buildBootstrapURL(cfg, "logs_token", "/auth/cli/XYZ")
if err != nil {
t.Fatalf("buildBootstrapURL: %v", err)
}
want := "http://example.test:7777/setup?next=%2Fauth%2Fcli%2FXYZ#token=abc123"
if got != want {
t.Errorf("buildBootstrapURL = %q, want %q", got, want)
}
}
// TestBuildBootstrapURL_OpenModeNext — in open mode (no token) the next=
// query still attaches, with no fragment.
func TestBuildBootstrapURL_OpenModeNext(t *testing.T) {
cfg := newTestConfig(t, "http://example.test:7777")
got, err := buildBootstrapURL(cfg, "open", "/auth/cli/XYZ")
if err != nil {
t.Fatalf("buildBootstrapURL: %v", err)
}
want := "http://example.test:7777/setup?next=%2Fauth%2Fcli%2FXYZ"
if got != want {
t.Errorf("buildBootstrapURL = %q, want %q", got, want)
}
}
// TestReadBootstrapToken_TrimsTrailingNewline — EnsureBootstrapToken
// writes "<token>\n", so a faithful read must strip the trailing newline
// (and any incidental whitespace). Otherwise the URL would carry a stray
+13 -1
View File
@@ -14,7 +14,19 @@ import (
// The CLI calls this, then presents the auth URL to the user.
// POST /api/v1/auth/cli/sessions
func (s *Server) handleCreateCLIAuthSession(w http.ResponseWriter, r *http.Request) {
sess, err := s.store.CreateCLIAuthSession()
// On a fresh instance (no users yet) this session is part of the
// first-run setup handoff: the CLI mints it BEFORE the operator creates
// the admin account in the browser, so /setup can redirect straight to
// the approval page (BUG-1843). Grant the longer setup TTL so the
// combined account-creation + approval window doesn't expire mid-flow.
// Once users exist (normal `pad auth login`), use the shorter default.
// A failed count falls back to the default TTL rather than blocking login.
create := s.store.CreateCLIAuthSession
if count, err := s.store.UserCount(); err == nil && count == 0 {
create = s.store.CreateCLIAuthSessionForSetup
}
sess, err := create()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create CLI auth session")
return
+25 -3
View File
@@ -10,6 +10,16 @@ import (
const cliAuthSessionTTL = 5 * time.Minute
// cliAuthSetupSessionTTL is the longer window granted to a CLI auth session
// minted during first-run setup (no users exist yet). The setup handoff
// creates the session BEFORE the operator fills out the admin-account form
// in the browser (so /setup can redirect straight to the approval page —
// BUG-1843), so its clock must cover account creation AND the approval
// click, not just the click. 5 minutes was tight for that combined window;
// 20 gives comfortable headroom. Normal `pad auth login` (users already
// exist) keeps the shorter default.
const cliAuthSetupSessionTTL = 20 * time.Minute
// CLIAuthSession represents a pending or approved CLI login session.
type CLIAuthSession struct {
Code string `json:"code"`
@@ -21,9 +31,21 @@ type CLIAuthSession struct {
}
// CreateCLIAuthSession generates a new pending CLI auth session with a
// cryptographically random code. Returns the session including the code
// the CLI should present to the user.
// cryptographically random code and the default TTL. Returns the session
// including the code the CLI should present to the user.
func (s *Store) CreateCLIAuthSession() (*CLIAuthSession, error) {
return s.createCLIAuthSession(cliAuthSessionTTL)
}
// CreateCLIAuthSessionForSetup is CreateCLIAuthSession with the longer
// first-run window. The setup handoff mints the session before the admin
// account is even created (BUG-1843), so its clock must cover account
// creation as well as the approval click.
func (s *Store) CreateCLIAuthSessionForSetup() (*CLIAuthSession, error) {
return s.createCLIAuthSession(cliAuthSetupSessionTTL)
}
func (s *Store) createCLIAuthSession(ttl time.Duration) (*CLIAuthSession, error) {
// Clean up expired sessions opportunistically
_, _ = s.db.Exec(s.q(`
DELETE FROM cli_auth_sessions WHERE expires_at < ?
@@ -37,7 +59,7 @@ func (s *Store) CreateCLIAuthSession() (*CLIAuthSession, error) {
code := hex.EncodeToString(raw)
ts := now()
expiresAt := time.Now().UTC().Add(cliAuthSessionTTL).Format(time.RFC3339)
expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339)
_, err := s.db.Exec(s.q(`
INSERT INTO cli_auth_sessions (code, status, created_at, expires_at)
+37 -4
View File
@@ -37,6 +37,25 @@
let token = $state('');
let pastedToken = $state('');
// nextPath is the local path to navigate to after the admin account is
// created, passed by the CLI as ?next=… (BUG-1843). The CLI hands us
// "/auth/cli/<code>" so account creation flows straight into the
// "Authorize CLI" page in the same tab instead of dumping the operator
// on the console while a second auth URL waits unseen in the terminal.
// Empty unless a SAFE local path is supplied — see isSafeLocalPath.
// Plain (non-reactive) let: it's resolved once during synchronous init
// and only read later inside onMount/handleSubmit closures, never in the
// template, so it doesn't need to be a $state rune.
let nextPath = '';
// isSafeLocalPath gates the next= target to same-origin paths so the
// redirect can't be turned into an open redirect (//evil.com, /\evil,
// or an absolute http(s):// URL). Must be a single leading slash
// followed by a non-slash, non-backslash char.
function isSafeLocalPath(p: string): boolean {
return /^\/[^/\\]/.test(p);
}
// openMode is true when the server has been started with
// PAD_BYPASS_SETUP_TOKEN=true on a self-host deployment with no users
// yet. The /setup form works directly — no paste-token step, no
@@ -47,6 +66,13 @@
let openMode = $state(false);
if (typeof window !== 'undefined') {
// Capture next= BEFORE any fragment scrub below — the scrub rewrites
// the URL and would otherwise drop the query string with it.
const rawNext = new URLSearchParams(window.location.search).get('next');
if (rawNext && isSafeLocalPath(rawNext)) {
nextPath = rawNext;
}
const hash = window.location.hash;
if (hash.startsWith('#token=')) {
const raw = hash.slice('#token='.length);
@@ -62,8 +88,10 @@
// Scrub the fragment from the URL bar before paint so the
// secret doesn't survive in browser history, screen recordings,
// or screenshots (F10). replaceState keeps the navigation
// entry — we just rewrite its URL.
history.replaceState({}, '', '/setup');
// entry — we just rewrite its URL. Preserve next= so a reload
// (or the post-bootstrap redirect) still has its handoff target.
const scrubbed = nextPath ? `/setup?next=${encodeURIComponent(nextPath)}` : '/setup';
history.replaceState({}, '', scrubbed);
}
}
@@ -84,7 +112,10 @@
try {
const session = await authStore.ensureLoaded();
if (session?.authenticated) {
await goto('/');
// Already signed in — honor the CLI's next= handoff (e.g. a
// reload after the admin was created) so a pending CLI auth
// session still lands on its approval page. BUG-1843.
await goto(nextPath || '/');
return;
}
// Self-host operators who set PAD_BYPASS_SETUP_TOKEN=true get
@@ -137,7 +168,9 @@
// Bootstrap success — server has set the session cookie. Reload
// the auth store so subsequent navigations see the new session.
await authStore.load();
await goto('/');
// Hand off to the CLI's next= target when present (the "Authorize
// CLI" page), otherwise land on the console. BUG-1843.
await goto(nextPath || '/');
} catch (err: unknown) {
// 403: token rejected (expired / already used / wrong). Clear
// the in-memory token, drop back to the paste prompt, and