Remove password authentication everywhere; the only session doors are passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and op-login vouching. Remediates the 33-finding cross-check review across backend, CLI, panel, plugins, and docs. Backend/CLI: - Drop password routes and fields from account/user/onboard/auth handlers; align tests (new account subtests, naming reserves "console", op-login/onboard/qr-login test updates). - Add migrations 0016_op_login.sql and 0017_drop_password.sql. - Thread panel/admin hostnames from hostcfg through api.go, setup_panel.go, tui_root.go and tui_preflight.go instead of hardcoding; bootstrap.sh writes panel-hostname/admin-hostname into felis.toml. - Reword breakglass and TUI copy for passwordless flows. Panel: - Delete the ChangePassword page and all password UI; align login/auth/api/types with the passwordless contract; add the migration and op-login approval flows. - i18n: convert ImageBuildPage durations/status badges and ServerLuckPerms strings to translation keys; drop 72 orphan keys per locale; unify the title as "Felis - Console". Plugins (all six rebuilt): - Velocity waiting router returns 503 at_capacity during wake; MOTD/control-channel copy and config comments. - Paper zh menu title; Limbo bind-code TTL 600s with panel_url preference; unified /link lines in fabric/forge/neoforge; shared link-client javadoc contract fixes. Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and plugins/README.md aligned with the implementation. BREAKING CHANGE: migration 0017 irreversibly drops users.password_hash and users.must_change_password; password login cannot be restored after migrating.
108 lines
3.4 KiB
Go
108 lines
3.4 KiB
Go
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
|
|
bgHaltServer
|
|
bgSyncBackup
|
|
)
|
|
|
|
// 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),
|
|
huh.NewOption("Halt a running server", bgHaltServer),
|
|
huh.NewOption("Back up a world now (Sync)", bgSyncBackup),
|
|
),
|
|
// A dim footnote spelling out the one behavioural difference that matters:
|
|
// Owner-reset re-enables local session sign-in, operator-add never touches
|
|
// the global auth toggle.
|
|
huh.NewNote().Description(
|
|
"Owner reset re-enables local session sign-in. 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() }
|