// 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 userNewProxyJump bool // ProxyJump vs LocalForward client config mode, toggled alongside groups on the same screen // 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) }