From eb5875a699b2c3b99e284e7478f5acb92372e167 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 30 Jun 2026 15:40:24 +0900 Subject: [PATCH] feat(felis): add Operator break-glass op behind an operation menu When a staff account already exists, the break-glass console now opens on a thin top-level menu (menuModel) where account operations are peers rather than tails of one wizard: provision/reset the Owner, or add an Operator. A fresh machine with no Owner skips the menu and goes straight to Owner bootstrap, since minting an Operator first would create a staff account the login gate rejects. The Operator path reuses ownerModel via a bgOperation discriminator. It is insert-only (performAddOperator -> InsertOperator), wraps a duplicate username as api.ErrConflict and routes back to the provision form for a retry rather than tearing down, and deliberately never flips the global local_auth toggle the way the Owner thread does. The post-exit summary and audit trail distinguish the two outcomes (isOperator); only the Owner provision claims local-password login was enabled. Tests cover the operator-model defaults, path selection (insert vs upsert and the local-auth gate), conflict-retry versus generic teardown, isOperator propagation, and the root menu routing for both fresh and admin-present machines. --- cmd/felis/breakglass.go | 38 ++++-- cmd/felis/tui_menu.go | 103 +++++++++++++++ cmd/felis/tui_menu_test.go | 252 +++++++++++++++++++++++++++++++++++++ cmd/felis/tui_owner.go | 104 +++++++++++++-- cmd/felis/tui_root.go | 31 ++++- cmd/felis/tui_root_test.go | 13 +- 6 files changed, 513 insertions(+), 28 deletions(-) create mode 100644 cmd/felis/tui_menu.go create mode 100644 cmd/felis/tui_menu_test.go diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index b93fb6a..a99d9ae 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -39,16 +39,25 @@ import ( // this is root and can edit Postgres directly — but it produces an honest trail // for an honest operator, which is the point. // -// The console opens on a thin top-level router (stepMenu) so that operations -// are peers, not tails of one wizard. Two are wired today: (1) provision/reset -// the Owner — the thin thread above — and (2) an OPTIONAL Cloudflare Tunnel + -// Access edge (internal/cfsetup), kept "锦上添花": it is reachable WITHOUT touching -// the Owner credential, supported but never required, and gated entirely on the -// operator's own Cloudflare account. The remaining ops (halt, sync, S3) land in a -// later phase as further menu peers. The edge flow's verifiable logic lives in -// cfsetup (fail-closed policy, ingress, gating, all unit-tested); what this file -// adds for it is the untested bubbletea shell plus a `tea.ExecProcess` suspension -// for the interactive `cloudflared tunnel login` browser consent. +// When a staff account already exists the console opens on a thin top-level menu +// (menuModel) so that operations are peers, not tails of one wizard. Two account +// operations are wired today: (1) provision/reset the Owner — the thin thread above, +// which also re-enables local-password login — and (2) add an Operator: an +// insert-only mint of an additional staff admin (provisionOperator) that +// deliberately never touches the global local_auth toggle. On a fresh machine (no +// Owner yet) the menu is skipped: bootstrapping the first Owner is the only sensible +// operation, and adding an Operator first would mint a staff account the login gate +// still rejects. The remaining ops (halt, sync, S3) land in a later phase as further +// menu peers. +// +// An OPTIONAL Cloudflare Tunnel + Access edge (internal/cfsetup) is offered by the +// first-run SETUP flow (runSetupTUI / the connection chooser), not by this +// break-glass menu, though its helpers live in this file. It is kept "锦上添花": +// reachable WITHOUT touching the Owner credential, supported but never required, and +// gated entirely on the operator's own Cloudflare account. The edge flow's verifiable +// logic lives in cfsetup (fail-closed policy, ingress, gating, all unit-tested); what +// this file adds for it is the untested bubbletea shell plus a `tea.ExecProcess` +// suspension for the interactive `cloudflared tunnel login` browser consent. // breakGlassOverrideToken is the literal an operator must type to proceed when no // admin credential could be verified. Requiring an explicit, deliberate word (not a @@ -147,7 +156,13 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { // outcome — and any generated one-time password — survives in scrollback long // enough for the operator to log in. if res.provisioned { - fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username) + if res.isOperator { + // Adding an Operator does NOT flip local_auth_enabled (performAddOperator), + // so the summary must not claim it did — only the Owner thread enables login. + fmt.Fprintf(stdout, "\nfelis breakGlass: Operator account %q provisioned.\n", res.username) + } else { + fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username) + } fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser) if res.displayPassword != "" { // A one-time password was generated (recovery / root override). It is shown, @@ -500,6 +515,7 @@ func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error // post-exit summary. provisioned is false on cancel. type breakGlassResult struct { provisioned bool + isOperator bool // an Operator was added rather than the Owner provisioned mode string accountable string osUser string diff --git a/cmd/felis/tui_menu.go b/cmd/felis/tui_menu.go new file mode 100644 index 0000000..8b38c42 --- /dev/null +++ b/cmd/felis/tui_menu.go @@ -0,0 +1,103 @@ +package main + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +// bgOperation is the account operation the break-glass menu dispatches to. Its +// zero value is bgProvisionOwner so any ownerModel built without an explicit +// operation keeps the original "provision the Owner" behaviour — the menu and the +// operator path are strictly additive. +type bgOperation int + +const ( + bgProvisionOwner bgOperation = iota + bgAddOperator +) + +// menuChoiceMsg is emitted to the root once the operator picks an operation. The +// menu screen has no database handle of its own, so it reports the choice upward +// and lets the root (which holds ctx/store) build the next screen. +type menuChoiceMsg struct{ op bgOperation } + +// menuModel is the thin top-level router the break-glass console opens on when a +// staff account already exists, making the account operations peers rather than +// tails of one wizard. It is shown only in recovery (adminExists): on a fresh +// machine bootstrapping the first Owner is the only sensible operation, and adding +// an Operator first would mint a staff account the login gate still rejects, so the +// console skips straight to Owner provisioning there. +type menuModel struct { + form *huh.Form + choice bgOperation + width, height int +} + +func newMenuModel() *menuModel { + m := &menuModel{} + m.form = m.build() + return m +} + +func (m *menuModel) build() *huh.Form { + return m.sized(newFelisForm(huh.NewGroup( + huh.NewSelect[bgOperation](). + Title("Break-glass console"). + Description("Local root recovery. Choose an operation."). + Value(&m.choice). + Options( + // Owner-provision is first so it is the default: it is the common + // recovery flow, and landing on it keeps that path a single Enter. + huh.NewOption("Provision or reset the Owner account", bgProvisionOwner), + huh.NewOption("Add an Operator account", bgAddOperator), + ), + // A dim footnote spelling out the one behavioural difference that matters: + // Owner-reset re-enables local-password login, operator-add never touches the + // global auth toggle. + huh.NewNote().Description( + "Owner reset re-enables local-password login. Adding an Operator mints an "+ + "additional staff admin and leaves the global auth toggle untouched."), + ))) +} + +func (m *menuModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *menuModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + +func (m *menuModel) Init() tea.Cmd { return m.form.Init() } + +func (m *menuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "ctrl+c", "esc": + // Backing out of the top-level menu cancels the whole console — no account + // is created and the durable summary reports "no changes made". + return m, tea.Quit + } + } + + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + op := m.choice + return m, func() tea.Msg { return menuChoiceMsg{op: op} } + case huh.StateAborted: + return m, tea.Quit + } + return m, cmd +} + +func (m *menuModel) View() string { return m.form.View() } diff --git a/cmd/felis/tui_menu_test.go b/cmd/felis/tui_menu_test.go new file mode 100644 index 0000000..23b8126 --- /dev/null +++ b/cmd/felis/tui_menu_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "errors" + "fmt" + "testing" + + "felis.lolicon.best/internal/api" + + tea "github.com/charmbracelet/bubbletea" +) + +// These tests cover the break-glass operator path that the menu added: the new +// menuModel, the operation discriminator on ownerModel, and the root routing that +// turns a menu choice into the right screen. Like tui_root_test.go they inject the +// inter-screen messages directly and run the screens' outgoing commands by hand, so +// no database, terminal, or huh key-replay is involved — only the wiring is under +// test (the core provisioning logic is covered in breakglass_test.go). + +func TestNewOperatorModelDefaults(t *testing.T) { + m := newOperatorModel(context.Background(), &fakeOwnerStore{}, "root") + if m.operation != bgAddOperator { + t.Errorf("operation = %v, want bgAddOperator", m.operation) + } + if !m.adminExists { + t.Error("adminExists = false, want true — adding an Operator presupposes an existing admin") + } + if m.step != owAuth { + t.Errorf("step = %v, want owAuth — the operator path always authenticates first", m.step) + } + // The username must NOT default to "owner" the way the Owner flow does: the + // operator path is insert-only, so a defaulted name would hit ErrConflict on the + // happy path every single time. + if m.ownerUser != "" { + t.Errorf("ownerUser = %q, want empty (no default for the insert-only operator path)", m.ownerUser) + } + if m.subject() != "Operator" { + t.Errorf("subject() = %q, want Operator", m.subject()) + } + + // The Owner flow is the zero value and is unchanged by the discriminator. + ow := newOwnerModel(context.Background(), &fakeOwnerStore{}, "root", true) + if ow.operation != bgProvisionOwner { + t.Errorf("owner operation = %v, want bgProvisionOwner", ow.operation) + } + if ow.ownerUser != "owner" || ow.subject() != "Owner" { + t.Errorf("owner defaults changed: ownerUser=%q subject=%q", ow.ownerUser, ow.subject()) + } +} + +func TestProvisionCmdSelectsPathByOperation(t *testing.T) { + ctx := context.Background() + + t.Run("operator path inserts and leaves local auth untouched", func(t *testing.T) { + f := &fakeOwnerStore{} + m := newOperatorModel(ctx, f, "root") + m.mode, m.accountable, m.username, m.attempt = "recovery", "root", "ops-new", "root" + + out := m.provisionCmd()() + msg, ok := out.(owProvisionMsg) + if !ok { + t.Fatalf("provisionCmd produced %T, want owProvisionMsg", out) + } + if msg.err != nil { + t.Fatalf("operator provision: %v", msg.err) + } + // performAddOperator is insert-only and never uses the Owner upsert path. + if len(f.inserts) != 1 { + t.Fatalf("want 1 insert (performAddOperator), got %d", len(f.inserts)) + } + if len(f.upserts) != 0 { + t.Errorf("want 0 owner upserts, got %d — the operator path must not use UpsertOwner", len(f.upserts)) + } + // The operator path must NOT flip the global local-auth gate (Owner-only). + if _, ok := f.settings[api.LocalAuthEnabledKey]; ok { + t.Error("the operator path flipped local auth — only the Owner thread may") + } + // No password was typed, so a one-time credential is surfaced to hand off. + if msg.outcome.displayPassword == "" { + t.Error("want a generated one-time password to hand to the new operator") + } + }) + + t.Run("owner path upserts and enables local auth", func(t *testing.T) { + f := &fakeOwnerStore{} + m := newOwnerModel(ctx, f, "root", true) + m.mode, m.accountable, m.username = "recovery", "root", "owner" + + out := m.provisionCmd()() + msg, ok := out.(owProvisionMsg) + if !ok { + t.Fatalf("provisionCmd produced %T, want owProvisionMsg", out) + } + if msg.err != nil { + t.Fatalf("owner provision: %v", msg.err) + } + // performBreakGlass upserts the single Owner and enables local-password login. + if len(f.upserts) != 1 || len(f.inserts) != 0 { + t.Fatalf("want 1 upsert and 0 inserts (performBreakGlass), got upserts=%d inserts=%d", len(f.upserts), len(f.inserts)) + } + if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok { + t.Error("the owner path did not enable local auth — the thin thread requires it") + } + }) +} + +func TestOwnerModelProvisionErrorRouting(t *testing.T) { + ctx := context.Background() + // A wrapped conflict, exactly as provisionOperator surfaces it. + conflict := fmt.Errorf("operator %q already exists: %w", "owner", api.ErrConflict) + + t.Run("an operator name clash returns to the provision form with a prompt", func(t *testing.T) { + m := newOperatorModel(ctx, &fakeOwnerStore{}, "root") + m.mode, m.accountable = "recovery", "root" + + next, _ := m.Update(owProvisionMsg{err: conflict}) + om, ok := next.(*ownerModel) + if !ok { + t.Fatalf("Update returned %T, want *ownerModel", next) + } + if om.step != owProvision { + t.Fatalf("step = %v, want owProvision — a taken name is recoverable, not fatal", om.step) + } + // provisionErr is what buildProvisionForm keys the "already taken — choose + // another name" prompt off of, so a still-matchable conflict here is what makes + // the retry legible to the operator. + if !errors.Is(om.provisionErr, api.ErrConflict) { + t.Errorf("provisionErr = %v, want it to wrap api.ErrConflict so the form can prompt for another name", om.provisionErr) + } + }) + + t.Run("a non-conflict error tears the console down", func(t *testing.T) { + m := newOperatorModel(ctx, &fakeOwnerStore{}, "root") + + next, cmd := m.Update(owProvisionMsg{err: errors.New("boom")}) + om := next.(*ownerModel) + if om.step == owProvision { + t.Error("a generic store fault must not be treated as a retryable name clash") + } + if cmd == nil { + t.Fatal("a fatal provision error should return a failure command") + } + res, ok := cmd().(ownerResultMsg) + if !ok { + t.Fatalf("failure cmd produced %T, want ownerResultMsg", cmd()) + } + if res.err == nil { + t.Error("ownerResultMsg.err = nil, want the surfaced failure") + } + }) + + t.Run("a conflict on the Owner path is not a retry", func(t *testing.T) { + // Defensive: the Owner upserts and so never conflicts, but were one ever to + // surface it must end the session rather than loop the form — only the + // insert-only operator path is retryable. + m := newOwnerModel(ctx, &fakeOwnerStore{}, "root", true) + + next, cmd := m.Update(owProvisionMsg{err: conflict}) + om := next.(*ownerModel) + if om.provisionErr != nil { + t.Error("the Owner path recorded a retryable conflict; only the operator path retries") + } + if res, ok := cmd().(ownerResultMsg); !ok || res.err == nil { + t.Error("an Owner-path conflict should tear down via an error result") + } + }) +} + +func TestOwnerResultCmdCarriesIsOperator(t *testing.T) { + ctx := context.Background() + + op := newOperatorModel(ctx, &fakeOwnerStore{}, "root") + op.username, op.displayPassword, op.mode, op.accountable = "ops", "pw", "recovery", "root" + if res := op.ownerResultCmd()().(ownerResultMsg); !res.isOperator { + t.Error("operator result.isOperator = false, want true") + } + + ow := newOwnerModel(ctx, &fakeOwnerStore{}, "root", true) + ow.username, ow.mode, ow.accountable = "owner", "recovery", "root" + if res := ow.ownerResultCmd()().(ownerResultMsg); res.isOperator { + t.Error("owner result.isOperator = true, want false") + } +} + +func TestRootBreakGlassOpensMenuWhenAdminExists(t *testing.T) { + m := newTestRoot(true, consoleModeBreakGlass, "") + if m.stage != stageMenu { + t.Fatalf("stage = %v, want stageMenu", m.stage) + } + if _, ok := m.screen.(*menuModel); !ok { + t.Fatalf("screen = %T, want *menuModel", m.screen) + } + + // Picking "add Operator" adopts an ownerModel wired for the operator path. + m = drive(t, m, menuChoiceMsg{op: bgAddOperator}) + if m.stage != stageOwner { + t.Fatalf("after the menu choice, stage = %v, want stageOwner", m.stage) + } + om, ok := m.screen.(*ownerModel) + if !ok { + t.Fatalf("screen = %T, want *ownerModel", m.screen) + } + if om.operation != bgAddOperator { + t.Errorf("operation = %v, want bgAddOperator", om.operation) + } +} + +func TestRootBreakGlassMenuOwnerChoice(t *testing.T) { + m := newTestRoot(true, consoleModeBreakGlass, "") + m = drive(t, m, menuChoiceMsg{op: bgProvisionOwner}) + om, ok := m.screen.(*ownerModel) + if !ok { + t.Fatalf("screen = %T, want *ownerModel", m.screen) + } + if om.operation != bgProvisionOwner { + t.Errorf("operation = %v, want bgProvisionOwner", om.operation) + } +} + +func TestRootBreakGlassSkipsMenuOnFreshMachine(t *testing.T) { + // No admin yet: bootstrapping the first Owner is the only sensible op, so the menu + // is skipped and the console opens straight on the Owner screen. + m := newTestRoot(false, consoleModeBreakGlass, "") + if m.stage != stageOwner { + t.Fatalf("stage = %v, want stageOwner (no menu on a fresh machine)", m.stage) + } + om, ok := m.screen.(*ownerModel) + if !ok { + t.Fatalf("screen = %T, want *ownerModel", m.screen) + } + if om.operation != bgProvisionOwner { + t.Errorf("operation = %v, want bgProvisionOwner", om.operation) + } +} + +func TestMenuModelCancelQuits(t *testing.T) { + if m := newMenuModel(); m.choice != bgProvisionOwner { + t.Errorf("default choice = %v, want bgProvisionOwner (Owner reset is the common path)", m.choice) + } + // Backing out of the top-level menu cancels the whole console. + for _, kt := range []tea.KeyType{tea.KeyEsc, tea.KeyCtrlC} { + m := newMenuModel() + _, cmd := m.Update(key(kt)) + if cmd == nil { + t.Fatalf("%v on the menu should return a command (tea.Quit)", kt) + } + if msg := cmd(); !isQuit(msg) { + t.Errorf("%v on the menu = %T, want tea.Quit", kt, msg) + } + } +} diff --git a/cmd/felis/tui_owner.go b/cmd/felis/tui_owner.go index 01949ab..4f1ea11 100644 --- a/cmd/felis/tui_owner.go +++ b/cmd/felis/tui_owner.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "felis.lolicon.best/internal/api" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" @@ -37,19 +39,28 @@ const ( // override, owner details) are huh forms; the async phases (verifying, // provisioning) show a spinner; the done phase shows the credential card. The // outward contract is unchanged: it emits an ownerResultMsg when finished. +// +// The same model serves the Add-Operator break-glass operation: the Owner and +// Operator flows are identical in shape (authenticate or override → collect a +// username → provision → show a one-time credential), so an operation discriminator +// switches the few differences (which provision function runs, the on-screen +// labels, whether a username is defaulted) rather than forking a near-duplicate +// model. The zero value, bgProvisionOwner, is the original Owner behaviour. type ownerModel struct { ctx context.Context store ownerStore osUser string adminExists bool + operation bgOperation mode string // "bootstrap", "recovery", "root_override" accountable string attempt string - step owStep - form *huh.Form - sp spinner.Model - working string + step owStep + form *huh.Form + sp spinner.Model + working string + provisionErr error // last provision failure routed back to the form (operator name clash) width, height int @@ -92,8 +103,41 @@ func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminEx return m } +// newOperatorModel builds the model for the Add-Operator break-glass operation. It +// always starts at admin authentication: adding an Operator presupposes an existing +// admin (that is why the menu only offers it when one exists), so there is no +// bootstrap branch and the password is always generated. The username is left empty +// on purpose — defaulting it to "owner" (as the Owner flow does) would make the +// happy path insert a duplicate and hit ErrConflict on every attempt. +func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *ownerModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + + m := &ownerModel{ + ctx: ctx, + store: store, + osUser: osUser, + adminExists: true, + operation: bgAddOperator, + sp: sp, + } + m.step = owAuth + m.form = m.buildAuthForm() + return m +} + func (m *ownerModel) Init() tea.Cmd { return m.form.Init() } +// subject is the human label for the account being provisioned, branching every +// on-screen string and the durable summary between the two operations. +func (m *ownerModel) subject() string { + if m.operation == bgAddOperator { + return "Operator" + } + return "Owner" +} + func (m *ownerModel) setSize(w, h int) { m.width, m.height = w, h if m.form != nil { @@ -133,6 +177,17 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case owProvisionMsg: if msg.err != nil { + // A taken Operator username is the expected, recoverable outcome of the + // insert-only operator path (refusing the clash is the whole reason it is + // insert-only, not an upsert). Route back to the form with a note so the + // operator can pick another name, rather than tearing down the console — + // any other error is a genuine fault and still ends the session. + if m.operation == bgAddOperator && errors.Is(msg.err, api.ErrConflict) { + m.provisionErr = msg.err + m.step = owProvision + m.form = m.sized(m.buildProvisionForm()) + return m, m.form.Init() + } return m, m.failCmd(msg.err) } m.step = owDone @@ -215,7 +270,7 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) { case owProvision: m.username = strings.TrimSpace(m.ownerUser) m.step = owWorking - m.working = "Provisioning Owner account…" + m.working = "Provisioning " + m.subject() + " account…" return m, tea.Batch(m.sp.Tick, m.provisionCmd()) } return m, nil @@ -235,8 +290,16 @@ func (m *ownerModel) provisionCmd() tea.Cmd { ownerPassword: password, attemptedAdmin: m.attempt, } + // performAddOperator and performBreakGlass share a signature; the operation + // discriminator selects which one runs. The operator path is insert-only and + // never flips local auth (see performAddOperator); the Owner path upserts and + // enables local-password login. + perform := performBreakGlass + if m.operation == bgAddOperator { + perform = performAddOperator + } return func() tea.Msg { - out, err := performBreakGlass(m.ctx, m.store, op) + out, err := perform(m.ctx, m.store, op) return owProvisionMsg{outcome: out, err: err} } } @@ -253,6 +316,7 @@ func (m *ownerModel) ownerResultCmd() tea.Cmd { mode: m.mode, accountable: m.accountable, auditWarning: m.auditWarning, + isOperator: m.operation == bgAddOperator, } } } @@ -294,6 +358,9 @@ func (m *ownerModel) buildOverrideForm() *huh.Form { } func (m *ownerModel) buildProvisionForm() *huh.Form { + subject := m.subject() // "Owner" | "Operator" + lower := strings.ToLower(subject) + desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser) switch m.mode { case "recovery": @@ -301,15 +368,30 @@ func (m *ownerModel) buildProvisionForm() *huh.Form { case "root_override": desc = "Root override — a one-time password will be generated." } + if m.operation == bgAddOperator { + // Operator-add never bootstraps (an admin is already present to authorize it), + // so it is always one of the generated-password modes. + switch m.mode { + case "recovery": + desc = fmt.Sprintf("Add an Operator — authenticated as %q; a one-time password will be generated.", m.accountable) + case "root_override": + desc = "Add an Operator (root override) — a one-time password will be generated." + } + } + if m.provisionErr != nil { + // The only error routed back to this form is a username clash on the insert-only + // operator path; show a concrete prompt to choose another name. + desc = "That username is already taken — choose a different one.\n\n" + desc + } fields := []huh.Field{ - huh.NewNote().Title("Owner account").Description(desc), + huh.NewNote().Title(subject + " account").Description(desc), huh.NewInput(). - Title("Owner username"). + Title(subject + " username"). Value(&m.ownerUser). - Validate(requiredField("owner username")), + Validate(requiredField(lower + " username")), huh.NewInput(). - Title("Owner email"). + Title(subject + " email"). Description("optional"). Placeholder("you@example.com"). Value(&m.ownerEmail), @@ -368,7 +450,7 @@ func (m *ownerModel) View() string { func (m *ownerModel) doneView() string { var b strings.Builder - b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n") + b.WriteString(tuiSuccessBanner(m.subject()+" account is ready.") + "\n\n") var box strings.Builder box.WriteString(tuiLabel.Render("username ") + m.username + "\n") diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 8a98bc3..4cc3706 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -57,6 +57,7 @@ type ownerResultMsg struct { mode string accountable string auditWarning string + isOperator bool // true when an Operator was added rather than the Owner provisioned err error } @@ -89,6 +90,10 @@ const ( stageOwner stageConnect stageSummary + // stageMenu is the break-glass operation menu. It is appended last so the + // setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed + // in break-glass mode, so this stage never reaches it. + stageMenu ) // setupRailSteps is the one progress rail shared by the whole first-run flow, @@ -145,8 +150,18 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi }, } if mode == consoleModeBreakGlass { - rm.stage = stageOwner - rm.screen = newOwnerModel(ctx, store, osUser, adminExists) + if adminExists { + // A staff account exists, so account operations are peers: open on the menu + // (provision/reset Owner, or add Operator). + rm.stage = stageMenu + rm.screen = newMenuModel() + } else { + // Fresh machine: bootstrapping the first Owner is the only sensible op, so skip + // the menu and go straight to it (offering "add Operator" here would mint a + // staff account the login gate still rejects). + rm.stage = stageOwner + rm.screen = newOwnerModel(ctx, store, osUser, adminExists) + } } else { rm.stage = stagePreflight rm.screen = newPreflightModel(dbURL, rootDomain) @@ -182,12 +197,24 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stage = stageOwner return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false)) + case menuChoiceMsg: + // The break-glass menu picked an account operation; build its screen. Both reuse + // stageOwner (the rail is suppressed in break-glass, so the stage is only a label). + m.stage = stageOwner + switch msg.op { + case bgAddOperator: + return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser)) + default: + return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists)) + } + case ownerResultMsg: if msg.err != nil { m.err = msg.err return m, tea.Quit } m.result.provisioned = true + m.result.isOperator = msg.isOperator m.result.username = msg.username m.result.displayPassword = msg.displayPassword m.result.mode = msg.mode diff --git a/cmd/felis/tui_root_test.go b/cmd/felis/tui_root_test.go index 6e51954..70345bf 100644 --- a/cmd/felis/tui_root_test.go +++ b/cmd/felis/tui_root_test.go @@ -149,14 +149,19 @@ func TestRootRerunLandsOnStatus(t *testing.T) { } func TestRootBreakGlassQuitsAfterOwner(t *testing.T) { - // Break-glass starts at owner and must quit on owner completion without - // entering the connection chooser. + // Break-glass with a staff account present opens on the operation menu; choosing + // "provision/reset the Owner" lands on the owner screen, which must quit on + // completion without entering the connection chooser (that step is setup-only). m := newTestRoot(true, consoleModeBreakGlass, "") + if m.stage != stageMenu { + t.Fatalf("break-glass initial stage = %v, want stageMenu", m.stage) + } + m = drive(t, m, menuChoiceMsg{op: bgProvisionOwner}) if m.stage != stageOwner { - t.Fatalf("break-glass initial stage = %v, want stageOwner", m.stage) + t.Fatalf("after the menu choice, stage = %v, want stageOwner", m.stage) } if _, ok := m.screen.(*ownerModel); !ok { - t.Fatalf("break-glass initial screen = %T, want *ownerModel", m.screen) + t.Fatalf("after the menu choice, screen = %T, want *ownerModel", m.screen) } next, cmd := m.Update(ownerResultMsg{username: "owner", displayPassword: "pw", mode: "recovery"})