Files
ssh-access-tui/internal/ui/app.go
T
2026-07-22 15:14:44 +03:00

290 lines
7.6 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
scrGroups
scrGroupHosts
scrHostNew
scrHostServices
scrServiceNew
scrExportSelect
scrExportConfirm
scrExportResult
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
)
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
userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user
groupsList list.Model
groupNewName textinput.Model
awaitingGroup bool
selectedGroup string
hostsList list.Model
hostForm [2]textinput.Model // name, address
hostFormFocus int
selectedHost string
servicesList list.Model
serviceForm [3]textinput.Model // name, port, description
serviceFormFocus int
exportSel map[string]bool
exportOrder []string
exportCursor int
exportChosen []string // confirmed selection, pending scrExportConfirm -> runExport
exportRes []keymgr.BuildResult
exportPass string
exportErr 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
// generic confirmation for destructive actions (lock/delete user,
// delete host/service) — see confirm.go. confirmTarget holds the
// username/host/service name being acted on; for host/service deletion
// the group (and host, for a service) context is the already-present
// selectedGroup/selectedHost fields above.
pendingConfirm confirmKind
confirmTarget string
confirmMessage string
confirmReturnTo screen
configUpdateUser string
configUpdateArchive string
configUpdatePass 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 = emptyList("Пользователи")
m.groupsList = emptyList("Группы доступа")
m.hostsList = emptyList("Хосты")
m.servicesList = 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)
m.servicesList.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 scrHostServices:
return m.updateHostServices(msg)
case scrServiceNew:
return m.updateServiceNew(msg)
case scrExportSelect:
return m.updateExportSelect(msg)
case scrExportConfirm:
return m.updateExportConfirm(msg)
case scrExportResult:
return m.updateExportResult(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 scrGroups:
body = m.viewGroups()
case scrGroupHosts:
body = m.viewGroupHosts()
case scrHostNew:
body = m.viewHostNew()
case scrHostServices:
body = m.viewHostServices()
case scrServiceNew:
body = m.viewServiceNew()
case scrExportSelect:
body = m.viewExportSelect()
case scrExportConfirm:
body = m.viewExportConfirm()
case scrExportResult:
body = m.viewExportResult()
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)
}