339 lines
10 KiB
Go
339 lines
10 KiB
Go
// Package ui implements the interactive terminal UI: a single bubbletea
|
|
// Model with a small screen stack, backed by the real system/store/keymgr
|
|
// operations — every action taken here (create user, lock, edit group
|
|
// hosts, export) performs the actual privileged operation immediately, then
|
|
// records it to the audit log and (for state changes) commits it to the
|
|
// git-backed state store.
|
|
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"ssh-access-tui/internal/audit"
|
|
"ssh-access-tui/internal/config"
|
|
"ssh-access-tui/internal/keymgr"
|
|
"ssh-access-tui/internal/model"
|
|
"ssh-access-tui/internal/store"
|
|
)
|
|
|
|
type screen int
|
|
|
|
const (
|
|
scrMenu screen = iota
|
|
scrUsers
|
|
scrUserNew
|
|
scrUserNewGroups
|
|
scrCatalogHosts
|
|
scrHostForm
|
|
scrCatalogServices
|
|
scrServiceForm
|
|
scrGroups
|
|
scrGroupForm
|
|
scrGroupHostsPick
|
|
scrExportSelect
|
|
scrExportResult
|
|
scrConfigUpdateSelect
|
|
scrConfigUpdateResult
|
|
scrAudit
|
|
scrQuitConfirm
|
|
scrConfirmAction
|
|
)
|
|
|
|
// confirmKind identifies which destructive action scrConfirmAction is
|
|
// guarding, so a single generic confirm screen can back every one of them
|
|
// instead of duplicating the "are you sure" plumbing per action.
|
|
type confirmKind int
|
|
|
|
const (
|
|
confirmNone confirmKind = iota
|
|
confirmLockUser
|
|
confirmDeleteUser
|
|
confirmDeleteHost
|
|
confirmDeleteService
|
|
confirmDeleteGroup
|
|
confirmExportRotate
|
|
)
|
|
|
|
// Colors are deliberately ANSI-native (0-15) rather than fixed 256-color
|
|
// codes: those map to whatever palette the user's own terminal is themed
|
|
// with, so they stay legible in both light and dark terminals. The bubbles
|
|
// list widget's default title style paints a fixed 256-color background
|
|
// ("62", blue-purple) with near-white text, which reads badly in many
|
|
// terminal themes — newList below overrides it with a plain bold
|
|
// foreground and no background fill for the same reason.
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("5")).Padding(0, 1)
|
|
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
|
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
|
|
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
|
|
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8")).Padding(1, 2)
|
|
|
|
listTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")).Padding(0, 1)
|
|
)
|
|
|
|
// newList builds a list.Model with the corrected (readable) title style
|
|
// applied, so every screen gets it instead of bubbles' low-contrast default.
|
|
func newList(items []list.Item, title string, w, h int) list.Model {
|
|
l := list.New(items, list.NewDefaultDelegate(), w, h)
|
|
l.Title = title
|
|
l.Styles.Title = listTitleStyle
|
|
l.SetShowStatusBar(false)
|
|
// bubbles/list binds "q" (and ForceQuit) to return tea.Quit directly,
|
|
// bypassing whatever screen it's on. Every screen except the main menu
|
|
// falls through unhandled keys straight into the list's own Update, so
|
|
// without this, pressing "q" on the Users/Groups/Catalog screens would
|
|
// quit the whole program immediately — skipping attemptQuit's warnings
|
|
// about unexported or stale-config users entirely. The menu handles "q"
|
|
// itself before reaching the list, so disabling it here is harmless
|
|
// there and load-bearing everywhere else.
|
|
l.DisableQuitKeybindings()
|
|
return l
|
|
}
|
|
|
|
// Model is the root bubbletea model for the whole application.
|
|
type Model struct {
|
|
cfg config.Config
|
|
st *store.Store
|
|
|
|
screen screen
|
|
err string
|
|
status string
|
|
|
|
menu list.Model
|
|
|
|
usersList list.Model
|
|
|
|
userNewName textinput.Model
|
|
userNewComment textinput.Model
|
|
userNewFocus int
|
|
userNewGroupsSel map[string]bool
|
|
userNewGroupsOrder []string
|
|
userNewCursor int
|
|
userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user
|
|
|
|
// catalog: hosts and their services, shared across groups
|
|
catalogHostsList list.Model
|
|
hostForm [3]textinput.Model // name, address, description
|
|
hostFormFocus int
|
|
hostFormEditTarget string // "" = creating a new host
|
|
selectedHost string // catalog host currently open in scrCatalogServices/scrServiceForm
|
|
catalogServicesList list.Model
|
|
serviceForm [3]textinput.Model // name, port, description
|
|
serviceFormFocus int
|
|
serviceFormEditTarget string // "" = creating a new service
|
|
|
|
// groups: named policies that reference individual catalog services,
|
|
// picked via a host -> services tree (see groups.go)
|
|
groupsList list.Model
|
|
groupForm [2]textinput.Model // name, description
|
|
groupFormFocus int
|
|
groupFormEditTarget string // "" = creating a new group
|
|
selectedGroup string
|
|
groupPickRows []pickRow
|
|
groupPickServices map[string][]string // host -> its service names, snapshot at screen-entry
|
|
groupPickSel map[string]bool // key "host/service" -> selected
|
|
groupPickCursor int
|
|
|
|
exportSel map[string]bool
|
|
exportOrder []string
|
|
exportCursor int
|
|
exportChosen []string // selection pending confirmation (if any rotation) or already run
|
|
exportRes []keymgr.BuildResult
|
|
exportPass string
|
|
exportErr string
|
|
|
|
configUpdateSel map[string]bool
|
|
configUpdateOrder []string
|
|
configUpdateCursor int
|
|
configUpdateRes []keymgr.ConfigOnlyResult
|
|
configUpdatePass string
|
|
configUpdateErr string
|
|
|
|
onlineSessions []audit.OnlineSession
|
|
recentEvents []audit.Event
|
|
|
|
quitReturnTo screen // screen to go back to if quit is cancelled
|
|
quitPending []string // usernames with no key issued yet, shown on scrQuitConfirm
|
|
quitStale []string // usernames whose config is out of date, shown on scrQuitConfirm
|
|
|
|
// generic confirmation for destructive actions (lock/delete user,
|
|
// delete host/service/group, export key rotation) — see confirm.go.
|
|
// Confirming requires typing back a freshly generated token, not just
|
|
// pressing enter/y, so a destructive action can never fire from a
|
|
// reflexive keypress.
|
|
pendingConfirm confirmKind
|
|
confirmTarget string
|
|
confirmMessage string
|
|
confirmReturnTo screen
|
|
confirmToken string
|
|
confirmInput textinput.Model
|
|
confirmError string
|
|
|
|
width, height int
|
|
}
|
|
|
|
// New builds the initial Model rooted at the given config and store.
|
|
//
|
|
// All list.Model fields are initialized here via list.New (not left as the
|
|
// zero value), even for screens the user hasn't visited yet: bubbletea
|
|
// delivers a WindowSizeMsg immediately on startup, which fans out to
|
|
// SetSize on every list in Update — and list.Model.SetSize on an
|
|
// uninitialized zero-value list panics (nil pagination model).
|
|
func New(cfg config.Config, st *store.Store) Model {
|
|
m := Model{cfg: cfg, st: st}
|
|
m.menu = newMenuList()
|
|
m.usersList = newList(nil, "Пользователи", 0, 0)
|
|
m.catalogHostsList = newList(nil, "Хосты и сервисы", 0, 0)
|
|
m.catalogServicesList = newList(nil, "Сервисы", 0, 0)
|
|
m.groupsList = newList(nil, "Группы", 0, 0)
|
|
return m
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd { return nil }
|
|
|
|
func (m *Model) loadState() *model.State {
|
|
st, err := m.st.Load()
|
|
if err != nil {
|
|
m.err = err.Error()
|
|
return &model.State{}
|
|
}
|
|
return st
|
|
}
|
|
|
|
func (m *Model) mutate(commitMsg string, fn func(st *model.State) error) {
|
|
err := m.st.WithLock(func() error {
|
|
st, err := m.st.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fn(st); err != nil {
|
|
return err
|
|
}
|
|
return m.st.Save(st, commitMsg)
|
|
})
|
|
if err != nil {
|
|
m.err = err.Error()
|
|
} else {
|
|
m.err = ""
|
|
m.status = commitMsg
|
|
}
|
|
}
|
|
|
|
func (m *Model) logAudit(user, action, detail string) {
|
|
_ = audit.Append(m.cfg.AuditLog, audit.Event{Time: time.Now(), User: user, Action: action, Detail: detail})
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width, m.height = msg.Width, msg.Height
|
|
listW, listH := msg.Width-4, msg.Height-8
|
|
m.menu.SetSize(listW, listH)
|
|
m.usersList.SetSize(listW, listH)
|
|
m.catalogHostsList.SetSize(listW, listH)
|
|
m.catalogServicesList.SetSize(listW, listH)
|
|
m.groupsList.SetSize(listW, listH)
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
switch m.screen {
|
|
case scrMenu:
|
|
return m.updateMenu(msg)
|
|
case scrUsers:
|
|
return m.updateUsers(msg)
|
|
case scrUserNew:
|
|
return m.updateUserNew(msg)
|
|
case scrUserNewGroups:
|
|
return m.updateUserNewGroups(msg)
|
|
case scrCatalogHosts:
|
|
return m.updateCatalogHosts(msg)
|
|
case scrHostForm:
|
|
return m.updateHostForm(msg)
|
|
case scrCatalogServices:
|
|
return m.updateCatalogServices(msg)
|
|
case scrServiceForm:
|
|
return m.updateServiceForm(msg)
|
|
case scrGroups:
|
|
return m.updateGroups(msg)
|
|
case scrGroupForm:
|
|
return m.updateGroupForm(msg)
|
|
case scrGroupHostsPick:
|
|
return m.updateGroupHostsPick(msg)
|
|
case scrExportSelect:
|
|
return m.updateExportSelect(msg)
|
|
case scrExportResult:
|
|
return m.updateExportResult(msg)
|
|
case scrConfigUpdateSelect:
|
|
return m.updateConfigUpdateSelect(msg)
|
|
case scrConfigUpdateResult:
|
|
return m.updateConfigUpdateResult(msg)
|
|
case scrAudit:
|
|
return m.updateAudit(msg)
|
|
case scrQuitConfirm:
|
|
return m.updateQuitConfirm(msg)
|
|
case scrConfirmAction:
|
|
return m.updateConfirmAction(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
var body string
|
|
switch m.screen {
|
|
case scrMenu:
|
|
body = m.viewMenu()
|
|
case scrUsers:
|
|
body = m.viewUsers()
|
|
case scrUserNew:
|
|
body = m.viewUserNew()
|
|
case scrUserNewGroups:
|
|
body = m.viewUserNewGroups()
|
|
case scrCatalogHosts:
|
|
body = m.viewCatalogHosts()
|
|
case scrHostForm:
|
|
body = m.viewHostForm()
|
|
case scrCatalogServices:
|
|
body = m.viewCatalogServices()
|
|
case scrServiceForm:
|
|
body = m.viewServiceForm()
|
|
case scrGroups:
|
|
body = m.viewGroups()
|
|
case scrGroupForm:
|
|
body = m.viewGroupForm()
|
|
case scrGroupHostsPick:
|
|
body = m.viewGroupHostsPick()
|
|
case scrExportSelect:
|
|
body = m.viewExportSelect()
|
|
case scrExportResult:
|
|
body = m.viewExportResult()
|
|
case scrConfigUpdateSelect:
|
|
body = m.viewConfigUpdateSelect()
|
|
case scrConfigUpdateResult:
|
|
body = m.viewConfigUpdateResult()
|
|
case scrAudit:
|
|
body = m.viewAudit()
|
|
case scrQuitConfirm:
|
|
body = m.viewQuitConfirm()
|
|
case scrConfirmAction:
|
|
body = m.viewConfirmAction()
|
|
}
|
|
|
|
footer := ""
|
|
if m.err != "" {
|
|
footer = errStyle.Render("ошибка: " + m.err)
|
|
} else if m.status != "" {
|
|
footer = okStyle.Render(m.status)
|
|
}
|
|
return fmt.Sprintf("%s\n\n%s\n", body, footer)
|
|
}
|