// 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 scrGroups scrGroupHosts scrHostNew scrExportSelect scrExportResult scrAudit ) var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Padding(0, 1) errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) ) // 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 groupsList list.Model groupNewName textinput.Model awaitingGroup bool selectedGroup string hostsList list.Model hostForm [4]textinput.Model // name, address, port, description hostFormFocus int exportSel map[string]bool exportOrder []string exportCursor int exportRes []keymgr.BuildResult exportPass string exportErr string onlineSessions []audit.OnlineSession recentEvents []audit.Event 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 = emptyList("Пользователи") m.groupsList = emptyList("Группы доступа") m.hostsList = emptyList("Хосты") return m } func emptyList(title string) list.Model { l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l.Title = title l.SetShowStatusBar(false) return l } 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.groupsList.SetSize(listW, listH) m.hostsList.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 scrGroups: return m.updateGroups(msg) case scrGroupHosts: return m.updateGroupHosts(msg) case scrHostNew: return m.updateHostNew(msg) case scrExportSelect: return m.updateExportSelect(msg) case scrExportResult: return m.updateExportResult(msg) case scrAudit: return m.updateAudit(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 scrGroups: body = m.viewGroups() case scrGroupHosts: body = m.viewGroupHosts() case scrHostNew: body = m.viewHostNew() case scrExportSelect: body = m.viewExportSelect() case scrExportResult: body = m.viewExportResult() case scrAudit: body = m.viewAudit() } 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) }