The setup URL carries a 43-char token and overruns 80 columns; the TUI renderer clipped it. Break it at the query '=' boundary (token on its own line) with a shared wrapDisplayURL helper used by both the Owner wizard and the mc-bind wizard; unit test pins the no-loss concatenation.
437 lines
12 KiB
Go
437 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"felis.lolicon.best/internal/api"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/huh"
|
|
)
|
|
|
|
type owAuthMsg struct {
|
|
matched string
|
|
ok bool
|
|
err error
|
|
}
|
|
|
|
type owProvisionMsg struct {
|
|
outcome breakGlassOutcome
|
|
err error
|
|
}
|
|
|
|
type owStep int
|
|
|
|
const (
|
|
owAuth owStep = iota
|
|
owOverride
|
|
owProvision
|
|
owWorking
|
|
owDone
|
|
owError
|
|
)
|
|
|
|
// ownerModel collects the owner account. The input phases (admin auth, root
|
|
// 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
|
|
provisionErr error // last provision failure routed back to the form (operator name clash)
|
|
|
|
width, height int
|
|
|
|
// huh-bound form values
|
|
authUser string
|
|
overrideTok string
|
|
ownerUser string
|
|
ownerEmail string
|
|
|
|
username string
|
|
setupTokenURL string
|
|
auditWarning string
|
|
}
|
|
|
|
func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel {
|
|
sp := spinner.New()
|
|
sp.Spinner = spinner.Dot
|
|
sp.Style = tuiLabel
|
|
|
|
m := &ownerModel{
|
|
ctx: ctx,
|
|
store: store,
|
|
osUser: osUser,
|
|
adminExists: adminExists,
|
|
sp: sp,
|
|
ownerUser: "owner",
|
|
}
|
|
if adminExists {
|
|
m.step = owAuth
|
|
m.form = m.buildAuthForm()
|
|
} else {
|
|
m.mode = "bootstrap"
|
|
m.accountable = osUser
|
|
m.step = owProvision
|
|
m.form = m.buildProvisionForm()
|
|
}
|
|
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. 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 {
|
|
m.form = m.form.WithWidth(w).WithHeight(h)
|
|
}
|
|
}
|
|
|
|
// sized applies the current terminal area to a freshly built form so phase
|
|
// transitions don't reset back to huh's default 80-column layout.
|
|
func (m *ownerModel) sized(f *huh.Form) *huh.Form {
|
|
if m.width > 0 {
|
|
return f.WithWidth(m.width).WithHeight(m.height)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (m *ownerModel) isFormStep() bool {
|
|
return m.step == owAuth || m.step == owOverride || m.step == owProvision
|
|
}
|
|
|
|
func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case owAuthMsg:
|
|
if msg.err != nil {
|
|
return m, m.failCmd(msg.err)
|
|
}
|
|
if msg.ok {
|
|
m.mode = "recovery"
|
|
m.accountable = msg.matched
|
|
m.step = owProvision
|
|
m.form = m.sized(m.buildProvisionForm())
|
|
return m, m.form.Init()
|
|
}
|
|
m.step = owOverride
|
|
m.form = m.sized(m.buildOverrideForm())
|
|
return m, m.form.Init()
|
|
|
|
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
|
|
m.setupTokenURL = msg.outcome.setupTokenURL
|
|
if msg.outcome.auditErr != nil {
|
|
m.auditWarning = msg.outcome.auditErr.Error()
|
|
}
|
|
return m, nil
|
|
|
|
case spinner.TickMsg:
|
|
if m.step == owWorking {
|
|
var cmd tea.Cmd
|
|
m.sp, cmd = m.sp.Update(msg)
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch m.step {
|
|
case owDone:
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc", "enter":
|
|
return m, m.ownerResultCmd()
|
|
}
|
|
return m, nil
|
|
case owWorking:
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
default: // form steps — intercept cancel/back, let huh handle the rest
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
case "esc":
|
|
if m.step == owOverride {
|
|
m.step = owAuth
|
|
m.form = m.sized(m.buildAuthForm())
|
|
return m, m.form.Init()
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drive the active form.
|
|
if m.isFormStep() && m.form != nil {
|
|
form, cmd := m.form.Update(msg)
|
|
if f, ok := form.(*huh.Form); ok {
|
|
m.form = f
|
|
}
|
|
switch m.form.State {
|
|
case huh.StateCompleted:
|
|
return m.onFormComplete()
|
|
case huh.StateAborted:
|
|
return m, tea.Quit
|
|
}
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
|
|
switch m.step {
|
|
case owAuth:
|
|
m.attempt = strings.TrimSpace(m.authUser)
|
|
m.step = owWorking
|
|
m.working = "Verifying admin…"
|
|
user := m.authUser
|
|
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
|
matched, ok, err := authenticateAdmin(m.ctx, m.store, user)
|
|
return owAuthMsg{matched: matched, ok: ok, err: err}
|
|
})
|
|
case owOverride:
|
|
m.mode = "root_override"
|
|
m.accountable = m.osUser
|
|
m.step = owProvision
|
|
m.form = m.sized(m.buildProvisionForm())
|
|
return m, m.form.Init()
|
|
case owProvision:
|
|
m.username = strings.TrimSpace(m.ownerUser)
|
|
m.step = owWorking
|
|
m.working = "Provisioning " + m.subject() + " account…"
|
|
return m, tea.Batch(m.sp.Tick, m.provisionCmd())
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *ownerModel) provisionCmd() tea.Cmd {
|
|
op := breakGlassOp{
|
|
mode: m.mode,
|
|
accountable: m.accountable,
|
|
osUser: m.osUser,
|
|
ownerUsername: m.username,
|
|
ownerEmail: m.ownerEmail,
|
|
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 session sign-in.
|
|
perform := performBreakGlass
|
|
if m.operation == bgAddOperator {
|
|
perform = performAddOperator
|
|
}
|
|
return func() tea.Msg {
|
|
out, err := perform(m.ctx, m.store, op)
|
|
return owProvisionMsg{outcome: out, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *ownerModel) failCmd(err error) tea.Cmd {
|
|
return func() tea.Msg { return ownerResultMsg{err: err} }
|
|
}
|
|
|
|
func (m *ownerModel) ownerResultCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
return ownerResultMsg{
|
|
username: m.username,
|
|
setupTokenURL: m.setupTokenURL,
|
|
mode: m.mode,
|
|
accountable: m.accountable,
|
|
auditWarning: m.auditWarning,
|
|
isOperator: m.operation == bgAddOperator,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- form builders ----
|
|
|
|
func (m *ownerModel) buildAuthForm() *huh.Form {
|
|
return m.sized(newFelisForm(huh.NewGroup(
|
|
huh.NewNote().
|
|
Title("Admin authentication").
|
|
Description("A staff account already exists. Identify yourself to continue."),
|
|
huh.NewInput().
|
|
Title("Admin username").
|
|
Value(&m.authUser).
|
|
Validate(requiredField("admin username")),
|
|
)))
|
|
}
|
|
|
|
func (m *ownerModel) buildOverrideForm() *huh.Form {
|
|
return m.sized(newFelisForm(huh.NewGroup(
|
|
huh.NewNote().
|
|
Title("Root override").
|
|
Description(fmt.Sprintf("That credential did not match. Proceed as OS user %q with root authority by typing the confirmation token.", m.osUser)),
|
|
huh.NewInput().
|
|
Title("Type "+breakGlassOverrideToken+" to confirm").
|
|
Value(&m.overrideTok).
|
|
Validate(func(s string) error {
|
|
if s != breakGlassOverrideToken {
|
|
return errors.New("type " + breakGlassOverrideToken + " exactly to proceed")
|
|
}
|
|
return nil
|
|
}),
|
|
)))
|
|
}
|
|
|
|
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":
|
|
desc = fmt.Sprintf("Authenticated as %q.", m.accountable)
|
|
case "root_override":
|
|
desc = "Root override — the Owner will be reset."
|
|
}
|
|
if m.operation == bgAddOperator {
|
|
switch m.mode {
|
|
case "recovery":
|
|
desc = fmt.Sprintf("Add an Operator — authenticated as %q.", m.accountable)
|
|
case "root_override":
|
|
desc = "Add an Operator (root override)."
|
|
}
|
|
}
|
|
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(subject + " account").Description(desc),
|
|
huh.NewInput().
|
|
Title(subject + " username").
|
|
Value(&m.ownerUser).
|
|
Validate(requiredField(lower + " username")),
|
|
huh.NewInput().
|
|
Title(subject + " email").
|
|
Description("optional").
|
|
Placeholder("[email protected]").
|
|
Value(&m.ownerEmail),
|
|
}
|
|
return m.sized(newFelisForm(huh.NewGroup(fields...)))
|
|
}
|
|
|
|
func requiredField(name string) func(string) error {
|
|
return func(s string) error {
|
|
if strings.TrimSpace(s) == "" {
|
|
return errors.New(name + " is required")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ---- views ----
|
|
|
|
func (m *ownerModel) View() string {
|
|
switch m.step {
|
|
case owWorking:
|
|
msg := m.working
|
|
if msg == "" {
|
|
msg = "Working…"
|
|
}
|
|
return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n"
|
|
case owDone:
|
|
return m.doneView()
|
|
default:
|
|
if m.form == nil {
|
|
return ""
|
|
}
|
|
return m.form.View()
|
|
}
|
|
}
|
|
|
|
func (m *ownerModel) doneView() string {
|
|
var b strings.Builder
|
|
b.WriteString(tuiSuccessBanner(m.subject()+" account is ready.") + "\n\n")
|
|
|
|
var box strings.Builder
|
|
box.WriteString(tuiLabel.Render("username ") + m.username + "\n")
|
|
if m.setupTokenURL != "" {
|
|
box.WriteString("\n" + tuiLabel.Render("setup URL ") + "\n")
|
|
for _, line := range wrapDisplayURL(m.setupTokenURL, 70) {
|
|
box.WriteString(tuiPassword.Render(line) + "\n")
|
|
}
|
|
box.WriteString("\n")
|
|
box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup. It is shown only once."))
|
|
}
|
|
if m.auditWarning != "" {
|
|
box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning))
|
|
}
|
|
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
|
|
b.WriteString(tuiAction("enter", "continue"))
|
|
return b.String()
|
|
}
|