563 lines
16 KiB
Go
563 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/api"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/huh"
|
|
)
|
|
|
|
type owAuthMsg struct {
|
|
start recoveryStart
|
|
err error
|
|
}
|
|
|
|
type owProvisionMsg struct {
|
|
outcome breakGlassOutcome
|
|
err error
|
|
}
|
|
|
|
type owStep int
|
|
|
|
const (
|
|
owAuth owStep = iota
|
|
owOverride
|
|
owCode // typing the recovery code mailed to the named admin
|
|
owProvision
|
|
owWorking
|
|
owDone
|
|
owError
|
|
)
|
|
|
|
// ownerModel collects the owner account. The input phases (admin name, recovery
|
|
// code, 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
|
|
recovery recoveryConfig
|
|
|
|
// Recovery proof: the admin the typed name resolved to, the code mailed to it,
|
|
// and once settled either how it was proven or why the run fell back to the
|
|
// override.
|
|
admin *api.StaffUser
|
|
code *recoveryCode
|
|
codeNote string // "wrong code" line shown above a rebuilt code form
|
|
verifiedBy string
|
|
codeSentTo string
|
|
skip string // otpSkip*
|
|
skipDetail 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
|
|
codeInput 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
|
|
}
|
|
|
|
// withRecovery hands the model the relay its recovery codes go through.
|
|
func (m *ownerModel) withRecovery(r recoveryConfig) *ownerModel {
|
|
m.recovery = r
|
|
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 == owCode || 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)
|
|
}
|
|
m.admin = msg.start.admin
|
|
if msg.start.code != nil {
|
|
m.code = msg.start.code
|
|
m.codeNote = ""
|
|
m.step = owCode
|
|
m.form = m.sized(m.buildCodeForm())
|
|
return m, m.form.Init()
|
|
}
|
|
return m.toOverride(msg.start.skip, msg.start.detail)
|
|
|
|
case owProvisionMsg:
|
|
if msg.err != nil {
|
|
// api.ErrConflict marks the two recoverable refusals: a taken Operator
|
|
// username (insert-only clash) and an Owner reset naming anything but the
|
|
// occupied seat (ownerSeatTakenError Is ErrConflict). Route back to the
|
|
// form with a note so the operator can retype, rather than tearing down
|
|
// the console — any other error is a genuine fault and still ends the
|
|
// session.
|
|
if 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 == owCode {
|
|
// Start over: a code mailed for the old attempt dies with it.
|
|
m.admin, m.code, m.skip, m.skipDetail = nil, nil, "", ""
|
|
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 = "Sending a recovery code…"
|
|
user, rc, osUser, op := m.authUser, m.recovery, m.osUser, m.operation
|
|
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
|
start, err := beginRecovery(m.ctx, m.store, rc, user, osUser, op)
|
|
return owAuthMsg{start: start, err: err}
|
|
})
|
|
case owCode:
|
|
typed := strings.TrimSpace(m.codeInput)
|
|
m.codeInput = ""
|
|
if typed == breakGlassOverrideToken {
|
|
m.skip, m.skipDetail = otpSkipByOperator, ""
|
|
m.code = nil
|
|
return m.proceedAsRoot()
|
|
}
|
|
switch m.code.check(typed, m.recovery.clock()) {
|
|
case codeAccepted:
|
|
m.mode = "recovery"
|
|
m.accountable = m.admin.Username
|
|
m.verifiedBy = verifiedByEmailOTP
|
|
m.codeSentTo = m.admin.Email
|
|
m.code = nil
|
|
m.step = owProvision
|
|
m.form = m.sized(m.buildProvisionForm())
|
|
return m, m.form.Init()
|
|
case codeWrong:
|
|
m.codeNote = fmt.Sprintf("That code is wrong. %d attempts left.", m.code.attemptsLeft())
|
|
m.form = m.sized(m.buildCodeForm())
|
|
return m, m.form.Init()
|
|
case codeExpired:
|
|
return m.toOverride(otpSkipCodeExpired, "")
|
|
default:
|
|
return m.toOverride(otpSkipCodeRejected, fmt.Sprintf("%d wrong codes", recoveryCodeAttempts))
|
|
}
|
|
case owOverride:
|
|
return m.proceedAsRoot()
|
|
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
|
|
}
|
|
|
|
// toOverride records why no code proved an admin and asks for the typed OVERRIDE.
|
|
func (m *ownerModel) toOverride(skip, detail string) (tea.Model, tea.Cmd) {
|
|
m.skip, m.skipDetail = skip, detail
|
|
m.code = nil
|
|
m.step = owOverride
|
|
m.form = m.sized(m.buildOverrideForm())
|
|
return m, m.form.Init()
|
|
}
|
|
|
|
// proceedAsRoot is the typed OVERRIDE: the run goes on as the OS user, unverified.
|
|
func (m *ownerModel) proceedAsRoot() (tea.Model, tea.Cmd) {
|
|
m.mode = "root_override"
|
|
m.accountable = m.osUser
|
|
m.step = owProvision
|
|
m.form = m.sized(m.buildProvisionForm())
|
|
return m, m.form.Init()
|
|
}
|
|
|
|
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,
|
|
verifiedBy: m.verifiedBy,
|
|
codeSentTo: m.codeSentTo,
|
|
otpSkipped: m.skip,
|
|
otpSkipDetail: m.skipDetail,
|
|
}
|
|
// 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. Name yours: a one-time code goes to its verified email address."),
|
|
huh.NewInput().
|
|
Title("Admin username").
|
|
Value(&m.authUser).
|
|
Validate(requiredField("admin username")),
|
|
)))
|
|
}
|
|
|
|
func (m *ownerModel) buildCodeForm() *huh.Form {
|
|
desc := fmt.Sprintf("A recovery code went to %s, the verified address of %q. It works for %d minutes.\n\n"+
|
|
"No mail? Type %s to go on as OS user %q with root authority; the audit log records that as an unverified override. Esc starts over.",
|
|
maskEmail(m.admin.Email), m.admin.Username, int(recoveryCodeTTL/time.Minute), breakGlassOverrideToken, m.osUser)
|
|
if m.codeNote != "" {
|
|
desc = m.codeNote + "\n\n" + desc
|
|
}
|
|
return m.sized(newFelisForm(huh.NewGroup(
|
|
huh.NewNote().Title("Email verification").Description(desc),
|
|
huh.NewInput().
|
|
Title("Recovery code").
|
|
Value(&m.codeInput).
|
|
Validate(func(s string) error {
|
|
s = strings.TrimSpace(s)
|
|
if s == breakGlassOverrideToken || isRecoveryCodeShape(s) {
|
|
return nil
|
|
}
|
|
return errors.New("enter the 6-digit code, or " + breakGlassOverrideToken)
|
|
}),
|
|
)))
|
|
}
|
|
|
|
func isRecoveryCodeShape(s string) bool {
|
|
if len(s) != 6 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// overrideReason says why no code proved an admin, first line of the override form.
|
|
func (m *ownerModel) overrideReason() string {
|
|
switch m.skip {
|
|
case otpSkipUnknownAdmin:
|
|
return fmt.Sprintf("No staff account is named %q.", m.attempt)
|
|
case otpSkipNoVerifiedEmail:
|
|
return fmt.Sprintf("%q has no verified email address, so no recovery code can reach it.", m.admin.Username)
|
|
case otpSkipNoRelay:
|
|
return "No mail relay can send a recovery code: " + m.skipDetail + "."
|
|
case otpSkipSendFailed:
|
|
return "The recovery code could not be sent: " + m.skipDetail + "."
|
|
case otpSkipCodeExpired:
|
|
return "The recovery code expired."
|
|
case otpSkipCodeRejected:
|
|
return fmt.Sprintf("%d wrong codes; that code no longer works.", recoveryCodeAttempts)
|
|
}
|
|
return "No admin was verified."
|
|
}
|
|
|
|
func (m *ownerModel) buildOverrideForm() *huh.Form {
|
|
return m.sized(newFelisForm(huh.NewGroup(
|
|
huh.NewNote().
|
|
Title("Root override").
|
|
Description(m.overrideReason()+"\n\n"+fmt.Sprintf("Proceed as OS user %q with root authority by typing the confirmation token. The audit log records this run as an unverified root override and the reason above. Esc starts over.", 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("Verified as %q by an email code.", 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 — verified as %q by an email code.", m.accountable)
|
|
case "root_override":
|
|
desc = "Add an Operator (root override)."
|
|
}
|
|
}
|
|
if m.provisionErr != nil {
|
|
// Recoverable refusals routed back here: the seat refusal already names the
|
|
// username to enter, so show it verbatim; the operator-name clash gets the
|
|
// generic retry prompt.
|
|
note := "That username is already taken — choose a different one."
|
|
var seatErr *ownerSeatTakenError
|
|
if errors.As(m.provisionErr, &seatErr) {
|
|
note = seatErr.Error()
|
|
}
|
|
desc = note + "\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()
|
|
}
|