Files
ssh-access-tui/internal/ui/menu.go
T
2026-07-22 17:13:35 +03:00

145 lines
5.4 KiB
Go

package ui
import (
"fmt"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
func newMenuList() list.Model {
items := []list.Item{
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
simpleItem{id: "catalog", title: "Хосты и сервисы", desc: "Общий каталог: адреса и порты, один раз на всех, группы просто выбирают из него"},
simpleItem{id: "groups", title: "Группы", desc: "Какие сервисы из каталога входят в каждую группу"},
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
simpleItem{id: "configupdate", title: "Обновить конфиги", desc: "Перевыпустить только config (без ключа) для тех, у кого изменился доступ"},
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
simpleItem{id: "quit", title: "Выход", desc: ""},
}
return newList(items, "ssh-access-tui", 0, 0)
}
func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "enter":
sel, ok := m.menu.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.err, m.status = "", ""
switch sel.id {
case "users":
m.screen = scrUsers
m.refreshUsersList()
case "catalog":
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
case "groups":
m.screen = scrGroups
m.refreshGroupsList()
case "export":
m.screen = scrExportSelect
m.initExportSelect()
case "configupdate":
m.screen = scrConfigUpdateSelect
m.initConfigUpdateSelect()
case "audit":
m.screen = scrAudit
m.refreshAudit()
case "quit":
return m.attemptQuit()
}
return m, nil
case "q":
return m.attemptQuit()
}
}
var cmd tea.Cmd
m.menu, cmd = m.menu.Update(msg)
return m, cmd
}
// viewMenu appends a live summary of pending follow-up work (users with no
// key yet, users whose config no longer matches their real access) below
// the menu, recomputed on every render — so it can never go stale itself.
func (m Model) viewMenu() string {
body := m.menu.View()
neverExported, stale := m.quitWarnings()
if len(neverExported) == 0 && len(stale) == 0 {
return borderStyle.Render(body)
}
notice := ""
if len(neverExported) > 0 {
notice += warnStyle.Render(fmt.Sprintf("Без ключа: %d (см. «Экспорт архивов»)", len(neverExported))) + "\n"
}
if len(stale) > 0 {
notice += warnStyle.Render(fmt.Sprintf("Устаревший конфиг: %d (см. «Обновить конфиги»)", len(stale))) + "\n"
}
return borderStyle.Render(body + "\n" + notice)
}
// quitWarnings splits active users into the two categories worth warning
// about before quitting: those who never got a key at all, and those whose
// access changed since their last config (full export or config-only
// update) — the latter are running with an out-of-date client config that
// won't reach everything they're now allowed to.
func (m *Model) quitWarnings() (neverExported, stale []string) {
st := m.loadState()
return st.NeverExportedUsernames(), st.StaleConfigUsernames()
}
// attemptQuit is the only path that actually quits from the menu ("q" or
// selecting "Выход"): if there are users created but never exported, or
// users whose config fell out of date, it warns instead of quitting
// straight away — both are easy to lose track of mid-session. ctrl+c
// bypasses this deliberately, as an always-available hard exit.
func (m Model) attemptQuit() (tea.Model, tea.Cmd) {
neverExported, stale := m.quitWarnings()
if len(neverExported) == 0 && len(stale) == 0 {
return m, tea.Quit
}
m.quitPending = neverExported
m.quitStale = stale
m.quitReturnTo = scrMenu
m.screen = scrQuitConfirm
return m, nil
}
func (m Model) updateQuitConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "enter", "y":
return m, tea.Quit
case "esc", "n":
m.screen = m.quitReturnTo
return m, nil
}
}
return m, nil
}
func (m Model) viewQuitConfirm() string {
body := ""
if len(m.quitPending) > 0 {
body += warnStyle.Render("Без выданного ключа (не были в «Экспорт архивов»):") + "\n"
for _, u := range m.quitPending {
body += " - " + u + "\n"
}
body += "\n"
}
if len(m.quitStale) > 0 {
body += warnStyle.Render("Доступ изменился, но конфиг у пользователя старый (см. «Обновить конфиги»):") + "\n"
for _, u := range m.quitStale {
body += " - " + u + "\n"
}
body += "\n"
}
body += "Без этого у части пользователей либо нет доступа вообще, либо их текущий\n"
body += "config не соответствует реальным правам (не хватает новых сервисов, или\n"
body += "остались лишние, которых уже нет).\n"
body += "\n[enter/y] выйти всё равно [esc/n] вернуться"
return borderStyle.Render(body)
}