add edit and all in one menu for hosts and services
This commit is contained in:
1 parent
8c1b93c14e
commit
e39ad275b5
15 files changed
+1136
-675
No files matched your search
+89
-60
@@ -29,13 +29,14 @@ const (
|
||||
scrUsers
|
||||
scrUserNew
|
||||
scrUserNewGroups
|
||||
scrCatalogHosts
|
||||
scrHostForm
|
||||
scrCatalogServices
|
||||
scrServiceForm
|
||||
scrGroups
|
||||
scrGroupHosts
|
||||
scrHostNew
|
||||
scrHostServices
|
||||
scrServiceNew
|
||||
scrGroupForm
|
||||
scrGroupHostsPick
|
||||
scrExportSelect
|
||||
scrExportConfirm
|
||||
scrExportResult
|
||||
scrConfigUpdateResult
|
||||
scrAudit
|
||||
@@ -54,17 +55,37 @@ const (
|
||||
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("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)
|
||||
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)
|
||||
return l
|
||||
}
|
||||
|
||||
// Model is the root bubbletea model for the whole application.
|
||||
type Model struct {
|
||||
cfg config.Config
|
||||
@@ -86,23 +107,31 @@ type Model struct {
|
||||
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
|
||||
// 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
|
||||
|
||||
selectedHost string
|
||||
servicesList list.Model
|
||||
serviceForm [3]textinput.Model // name, port, description
|
||||
serviceFormFocus int
|
||||
// groups: named policies that reference a subset of catalog hosts
|
||||
groupsList list.Model
|
||||
groupForm [2]textinput.Model // name, description
|
||||
groupFormFocus int
|
||||
groupFormEditTarget string // "" = creating a new group
|
||||
selectedGroup string
|
||||
groupHostsPickSel map[string]bool
|
||||
groupHostsPickOrder []string
|
||||
groupHostsPickCursor int
|
||||
|
||||
exportSel map[string]bool
|
||||
exportOrder []string
|
||||
exportCursor int
|
||||
exportChosen []string // confirmed selection, pending scrExportConfirm -> runExport
|
||||
exportChosen []string // selection pending confirmation (if any rotation) or already run
|
||||
exportRes []keymgr.BuildResult
|
||||
exportPass string
|
||||
exportErr string
|
||||
@@ -114,14 +143,17 @@ type Model struct {
|
||||
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.
|
||||
// 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
|
||||
|
||||
configUpdateUser string
|
||||
configUpdateArchive string
|
||||
@@ -140,20 +172,13 @@ type Model struct {
|
||||
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("Сервисы")
|
||||
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 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 {
|
||||
@@ -195,9 +220,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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)
|
||||
m.hostsList.SetSize(listW, listH)
|
||||
m.servicesList.SetSize(listW, listH)
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
@@ -214,20 +239,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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 scrGroupHosts:
|
||||
return m.updateGroupHosts(msg)
|
||||
case scrHostNew:
|
||||
return m.updateHostNew(msg)
|
||||
case scrHostServices:
|
||||
return m.updateHostServices(msg)
|
||||
case scrServiceNew:
|
||||
return m.updateServiceNew(msg)
|
||||
case scrGroupForm:
|
||||
return m.updateGroupForm(msg)
|
||||
case scrGroupHostsPick:
|
||||
return m.updateGroupHostsPick(msg)
|
||||
case scrExportSelect:
|
||||
return m.updateExportSelect(msg)
|
||||
case scrExportConfirm:
|
||||
return m.updateExportConfirm(msg)
|
||||
case scrExportResult:
|
||||
return m.updateExportResult(msg)
|
||||
case scrConfigUpdateResult:
|
||||
@@ -253,20 +280,22 @@ func (m Model) View() string {
|
||||
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 scrGroupHosts:
|
||||
body = m.viewGroupHosts()
|
||||
case scrHostNew:
|
||||
body = m.viewHostNew()
|
||||
case scrHostServices:
|
||||
body = m.viewHostServices()
|
||||
case scrServiceNew:
|
||||
body = m.viewServiceNew()
|
||||
case scrGroupForm:
|
||||
body = m.viewGroupForm()
|
||||
case scrGroupHostsPick:
|
||||
body = m.viewGroupHostsPick()
|
||||
case scrExportSelect:
|
||||
body = m.viewExportSelect()
|
||||
case scrExportConfirm:
|
||||
body = m.viewExportConfirm()
|
||||
case scrExportResult:
|
||||
body = m.viewExportResult()
|
||||
case scrConfigUpdateResult:
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// validNameErr renders a consistent error for group/host/service names that
|
||||
// fail model.ValidName — these names end up as ssh_config/authorized_keys
|
||||
// tokens, where spaces or "#" would corrupt the generated file.
|
||||
func validNameErr(kind, name string) string {
|
||||
return fmt.Sprintf("%s %q: имя не должно содержать пробелов и спецсимволов (разрешены латинские буквы, цифры, точка, подчёркивание, дефис)", kind, name)
|
||||
}
|
||||
|
||||
// --- catalog: hosts ---
|
||||
|
||||
func (m *Model) refreshCatalogHostsList() {
|
||||
st := m.loadState()
|
||||
items := make([]list.Item, 0, len(st.Hosts))
|
||||
for _, h := range st.Hosts {
|
||||
desc := fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services))
|
||||
if h.Description != "" {
|
||||
desc += " (" + h.Description + ")"
|
||||
}
|
||||
items = append(items, simpleItem{id: h.Name, title: h.Name, desc: desc})
|
||||
}
|
||||
m.catalogHostsList = newList(items,
|
||||
"Хосты и сервисы [n]новый [e]изменить [enter]сервисы [d]удалить [esc]назад",
|
||||
m.catalogHostsList.Width(), m.catalogHostsList.Height())
|
||||
}
|
||||
|
||||
func (m Model) updateCatalogHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrMenu
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "n":
|
||||
m.hostFormEditTarget = ""
|
||||
for i := range m.hostForm {
|
||||
m.hostForm[i] = textinput.New()
|
||||
}
|
||||
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1 (без пробелов)"
|
||||
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
|
||||
m.hostForm[2].Placeholder = "описание (необязательно)"
|
||||
m.hostFormFocus = 0
|
||||
m.hostForm[0].Focus()
|
||||
m.err = ""
|
||||
m.screen = scrHostForm
|
||||
return m, nil
|
||||
case "e":
|
||||
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
h := st.FindHost(sel.id)
|
||||
if h == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.hostFormEditTarget = h.Name
|
||||
for i := range m.hostForm {
|
||||
m.hostForm[i] = textinput.New()
|
||||
}
|
||||
m.hostForm[0].SetValue(h.Name)
|
||||
m.hostForm[1].SetValue(h.Address)
|
||||
m.hostForm[2].SetValue(h.Description)
|
||||
m.hostFormFocus = 0
|
||||
m.hostForm[0].Focus()
|
||||
m.err = ""
|
||||
m.screen = scrHostForm
|
||||
return m, nil
|
||||
case "enter":
|
||||
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedHost = sel.id
|
||||
m.screen = scrCatalogServices
|
||||
m.refreshCatalogServicesList()
|
||||
return m, nil
|
||||
case "d":
|
||||
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
affected := st.UsernamesUsingHost(sel.id)
|
||||
groups := st.GroupNamesReferencingHost(sel.id)
|
||||
msg := fmt.Sprintf(
|
||||
"Удалить хост %q из каталога?\n\n"+
|
||||
"Он используется в группах: %v.\n"+
|
||||
"Это затронет пользователей: %d — они немедленно потеряют доступ ко всем\n"+
|
||||
"сервисам этого хоста.",
|
||||
sel.id, groups, len(affected))
|
||||
m = m.startConfirm(confirmDeleteHost, sel.id, msg, scrCatalogHosts)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.catalogHostsList, cmd = m.catalogHostsList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewCatalogHosts() string {
|
||||
return borderStyle.Render(m.catalogHostsList.View())
|
||||
}
|
||||
|
||||
func (m Model) updateHostForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrCatalogHosts
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.hostForm[m.hostFormFocus].Blur()
|
||||
m.hostFormFocus = (m.hostFormFocus + 1) % len(m.hostForm)
|
||||
m.hostForm[m.hostFormFocus].Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.hostForm[0].Value()
|
||||
addr := m.hostForm[1].Value()
|
||||
desc := m.hostForm[2].Value()
|
||||
if name == "" || addr == "" {
|
||||
m.err = "имя и адрес обязательны"
|
||||
return m, nil
|
||||
}
|
||||
if !model.ValidName(name) {
|
||||
m.err = validNameErr("хост", name)
|
||||
return m, nil
|
||||
}
|
||||
target := m.hostFormEditTarget
|
||||
m.err = ""
|
||||
if target == "" {
|
||||
m.mutate("добавлен хост "+name, func(st *model.State) error {
|
||||
if st.FindHost(name) != nil {
|
||||
return fmt.Errorf("хост %q уже существует", name)
|
||||
}
|
||||
st.Hosts = append(st.Hosts, model.Host{Name: name, Address: addr, Description: desc})
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
m.mutate("изменён хост "+target, func(st *model.State) error {
|
||||
if name != target && st.FindHost(name) != nil {
|
||||
return fmt.Errorf("хост %q уже существует", name)
|
||||
}
|
||||
if name != target {
|
||||
st.RenameHost(target, name)
|
||||
}
|
||||
h := st.FindHost(name)
|
||||
if h == nil {
|
||||
return fmt.Errorf("host %s not found", name)
|
||||
}
|
||||
h.Address = addr
|
||||
h.Description = desc
|
||||
return resyncHostUsers(st, name)
|
||||
})
|
||||
}
|
||||
m.selectedHost = name
|
||||
m.screen = scrCatalogHosts
|
||||
m.refreshCatalogHostsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewHostForm() string {
|
||||
title := "Новый хост в каталоге"
|
||||
if m.hostFormEditTarget != "" {
|
||||
title = fmt.Sprintf("Изменить хост %q", m.hostFormEditTarget)
|
||||
}
|
||||
body := fmt.Sprintf("%s\n\nИмя: %s\nАдрес: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
|
||||
title, m.hostForm[0].View(), m.hostForm[1].View(), m.hostForm[2].View())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
// --- catalog: services within the selected host ---
|
||||
|
||||
func (m *Model) refreshCatalogServicesList() {
|
||||
st := m.loadState()
|
||||
h := st.FindHost(m.selectedHost)
|
||||
var items []list.Item
|
||||
if h != nil {
|
||||
for _, svc := range h.Services {
|
||||
items = append(items, simpleItem{
|
||||
id: svc.Name,
|
||||
title: fmt.Sprintf("%s (порт %d)", svc.Name, svc.Port),
|
||||
desc: svc.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
m.catalogServicesList = newList(items,
|
||||
fmt.Sprintf("Сервисы хоста %q [n]добавить [e]изменить [d]удалить [esc]назад", m.selectedHost),
|
||||
m.catalogServicesList.Width(), m.catalogServicesList.Height())
|
||||
}
|
||||
|
||||
func (m Model) updateCatalogServices(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrCatalogHosts
|
||||
m.refreshCatalogHostsList()
|
||||
return m, nil
|
||||
case "n":
|
||||
m.serviceFormEditTarget = ""
|
||||
for i := range m.serviceForm {
|
||||
m.serviceForm[i] = textinput.New()
|
||||
}
|
||||
m.serviceForm[0].Placeholder = "имя сервиса, например ssh или web-api (без пробелов)"
|
||||
m.serviceForm[1].Placeholder = "порт на хосте, например 22 или 7888"
|
||||
m.serviceForm[2].Placeholder = "описание (необязательно)"
|
||||
m.serviceFormFocus = 0
|
||||
m.serviceForm[0].Focus()
|
||||
m.err = ""
|
||||
m.screen = scrServiceForm
|
||||
return m, nil
|
||||
case "e":
|
||||
sel, ok := m.catalogServicesList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
h := st.FindHost(m.selectedHost)
|
||||
if h == nil {
|
||||
return m, nil
|
||||
}
|
||||
var target *model.Service
|
||||
for i := range h.Services {
|
||||
if h.Services[i].Name == sel.id {
|
||||
target = &h.Services[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.serviceFormEditTarget = target.Name
|
||||
for i := range m.serviceForm {
|
||||
m.serviceForm[i] = textinput.New()
|
||||
}
|
||||
m.serviceForm[0].SetValue(target.Name)
|
||||
m.serviceForm[1].SetValue(strconv.Itoa(target.Port))
|
||||
m.serviceForm[2].SetValue(target.Description)
|
||||
m.serviceFormFocus = 0
|
||||
m.serviceForm[0].Focus()
|
||||
m.err = ""
|
||||
m.screen = scrServiceForm
|
||||
return m, nil
|
||||
case "d":
|
||||
sel, ok := m.catalogServicesList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
affected := st.UsernamesUsingHost(m.selectedHost)
|
||||
msg := fmt.Sprintf(
|
||||
"Удалить сервис %q у хоста %q?\n\n"+
|
||||
"Это затронет пользователей: %d — они немедленно потеряют доступ к этому\n"+
|
||||
"сервису.",
|
||||
sel.id, m.selectedHost, len(affected))
|
||||
m = m.startConfirm(confirmDeleteService, sel.id, msg, scrCatalogServices)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.catalogServicesList, cmd = m.catalogServicesList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewCatalogServices() string {
|
||||
return borderStyle.Render(m.catalogServicesList.View())
|
||||
}
|
||||
|
||||
func (m Model) updateServiceForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrCatalogServices
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.serviceForm[m.serviceFormFocus].Blur()
|
||||
m.serviceFormFocus = (m.serviceFormFocus + 1) % len(m.serviceForm)
|
||||
m.serviceForm[m.serviceFormFocus].Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.serviceForm[0].Value()
|
||||
portStr := m.serviceForm[1].Value()
|
||||
desc := m.serviceForm[2].Value()
|
||||
if name == "" || portStr == "" {
|
||||
m.err = "имя и порт обязательны"
|
||||
return m, nil
|
||||
}
|
||||
if !model.ValidName(name) {
|
||||
m.err = validNameErr("сервис", name)
|
||||
return m, nil
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
m.err = "порт должен быть числом от 1 до 65535"
|
||||
return m, nil
|
||||
}
|
||||
hostName := m.selectedHost
|
||||
target := m.serviceFormEditTarget
|
||||
m.err = ""
|
||||
m.mutate(fmt.Sprintf("сохранён сервис %s хоста %s", name, hostName), func(st *model.State) error {
|
||||
h := st.FindHost(hostName)
|
||||
if h == nil {
|
||||
return fmt.Errorf("host %s not found", hostName)
|
||||
}
|
||||
if target == "" {
|
||||
for _, svc := range h.Services {
|
||||
if svc.Name == name {
|
||||
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
|
||||
}
|
||||
}
|
||||
h.Services = append(h.Services, model.Service{Name: name, Port: port, Description: desc})
|
||||
} else {
|
||||
if name != target {
|
||||
for _, svc := range h.Services {
|
||||
if svc.Name == name {
|
||||
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range h.Services {
|
||||
if h.Services[i].Name == target {
|
||||
h.Services[i].Name = name
|
||||
h.Services[i].Port = port
|
||||
h.Services[i].Description = desc
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return resyncHostUsers(st, hostName)
|
||||
})
|
||||
m.screen = scrCatalogServices
|
||||
m.refreshCatalogServicesList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewServiceForm() string {
|
||||
title := fmt.Sprintf("Новый сервис на хосте %q", m.selectedHost)
|
||||
if m.serviceFormEditTarget != "" {
|
||||
title = fmt.Sprintf("Изменить сервис %q на хосте %q", m.serviceFormEditTarget, m.selectedHost)
|
||||
}
|
||||
body := fmt.Sprintf("%s\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
|
||||
title, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
+106
-45
@@ -1,37 +1,96 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
// updateConfirmAction drives the single generic "are you sure" screen that
|
||||
// backs every destructive action in the tool (lock/delete a user, delete a
|
||||
// host or a service). Nothing the action does runs until this confirms it;
|
||||
// esc/n cancels with zero effect.
|
||||
const (
|
||||
confirmTokenLetters = "abcdefghijklmnopqrstuvwxyz"
|
||||
confirmTokenDigits = "0123456789"
|
||||
)
|
||||
|
||||
// generateConfirmToken returns a short, freshly random string the admin
|
||||
// must type back exactly to carry out a destructive action — e.g.
|
||||
// "kqzvm-482". Confirming with a fixed keypress (enter/y) can fire from a
|
||||
// reflexive/habitual keypress; typing back a token that's different every
|
||||
// time cannot.
|
||||
func generateConfirmToken() string {
|
||||
letters := make([]byte, 5)
|
||||
for i := range letters {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(confirmTokenLetters))))
|
||||
letters[i] = confirmTokenLetters[n.Int64()]
|
||||
}
|
||||
digits := make([]byte, 3)
|
||||
for i := range digits {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(confirmTokenDigits))))
|
||||
digits[i] = confirmTokenDigits[n.Int64()]
|
||||
}
|
||||
return string(letters) + "-" + string(digits)
|
||||
}
|
||||
|
||||
// startConfirm switches to the generic confirmation screen: kind identifies
|
||||
// what executeConfirmedAction should do once the token is typed back
|
||||
// correctly, target is the entity being acted on (username/host/service/
|
||||
// group name — unused for confirmExportRotate, which instead reads
|
||||
// m.exportChosen), message explains the consequences, and returnTo is where
|
||||
// esc/cancel goes back to.
|
||||
func (m Model) startConfirm(kind confirmKind, target, message string, returnTo screen) Model {
|
||||
m.pendingConfirm = kind
|
||||
m.confirmTarget = target
|
||||
m.confirmMessage = message
|
||||
m.confirmReturnTo = returnTo
|
||||
m.confirmToken = generateConfirmToken()
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "введите строку выше"
|
||||
ti.CharLimit = 32
|
||||
ti.Focus()
|
||||
m.confirmInput = ti
|
||||
m.confirmError = ""
|
||||
m.screen = scrConfirmAction
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) updateConfirmAction(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc", "n":
|
||||
case "esc":
|
||||
m.pendingConfirm = confirmNone
|
||||
m.screen = m.confirmReturnTo
|
||||
return m, nil
|
||||
case "enter", "y":
|
||||
case "enter":
|
||||
if strings.TrimSpace(m.confirmInput.Value()) != m.confirmToken {
|
||||
m.confirmError = "не совпадает со строкой выше — попробуйте ещё раз (или esc для отмены)"
|
||||
m.confirmInput.SetValue("")
|
||||
return m, nil
|
||||
}
|
||||
return m.executeConfirmedAction()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
var cmd tea.Cmd
|
||||
m.confirmInput, cmd = m.confirmInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewConfirmAction() string {
|
||||
body := warnStyle.Render("ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ") + "\n\n"
|
||||
body += m.confirmMessage + "\n\n"
|
||||
body += "[enter/y] подтвердить [esc/n] отмена, ничего не изменится"
|
||||
body += "Чтобы подтвердить, наберите ровно эту строку:\n\n"
|
||||
body += " " + titleStyle.Render(m.confirmToken) + "\n\n"
|
||||
body += m.confirmInput.View() + "\n"
|
||||
if m.confirmError != "" {
|
||||
body += "\n" + errStyle.Render(m.confirmError) + "\n"
|
||||
}
|
||||
body += "\n[enter] проверить [esc] отмена, ничего не изменится"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
@@ -39,7 +98,6 @@ func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
|
||||
kind := m.pendingConfirm
|
||||
target := m.confirmTarget
|
||||
m.pendingConfirm = confirmNone
|
||||
m.err, m.status = "", ""
|
||||
|
||||
switch kind {
|
||||
case confirmLockUser:
|
||||
@@ -50,6 +108,10 @@ func (m Model) executeConfirmedAction() (tea.Model, tea.Cmd) {
|
||||
return m.doDeleteHost(target)
|
||||
case confirmDeleteService:
|
||||
return m.doDeleteService(target)
|
||||
case confirmDeleteGroup:
|
||||
return m.doDeleteGroup(target)
|
||||
case confirmExportRotate:
|
||||
return m.runExport(m.exportChosen)
|
||||
}
|
||||
m.screen = m.confirmReturnTo
|
||||
return m, nil
|
||||
@@ -99,52 +161,51 @@ func (m Model) doDeleteUser(username string) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// doDeleteHost removes a host (and all its services) from m.selectedGroup,
|
||||
// then resyncs authorized_keys for every affected user.
|
||||
// doDeleteHost removes a catalog host (and its services) entirely, dropping
|
||||
// it from every group that referenced it, and resyncs authorized_keys for
|
||||
// every user that lost access as a result.
|
||||
func (m Model) doDeleteHost(hostName string) (tea.Model, tea.Cmd) {
|
||||
groupName := m.selectedGroup
|
||||
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", hostName, groupName), func(st *model.State) error {
|
||||
g := st.FindGroup(groupName)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", groupName)
|
||||
}
|
||||
for i := range g.Hosts {
|
||||
if g.Hosts[i].Name == hostName {
|
||||
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return resyncGroupUsers(st, groupName)
|
||||
m.mutate("удалён хост "+hostName+" из каталога", func(st *model.State) error {
|
||||
affected := st.UsernamesUsingHost(hostName)
|
||||
st.DeleteHost(hostName)
|
||||
return resyncUsernames(st, affected)
|
||||
})
|
||||
m.screen = scrGroupHosts
|
||||
m.refreshHostsList()
|
||||
m.screen = scrCatalogHosts
|
||||
m.refreshCatalogHostsList()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// doDeleteService removes a service from m.selectedHost within
|
||||
// m.selectedGroup, then resyncs authorized_keys for every affected user.
|
||||
// doDeleteService removes a service from m.selectedHost, then resyncs
|
||||
// authorized_keys for every user that lost access as a result.
|
||||
func (m Model) doDeleteService(serviceName string) (tea.Model, tea.Cmd) {
|
||||
groupName, hostName := m.selectedGroup, m.selectedHost
|
||||
hostName := m.selectedHost
|
||||
m.mutate(fmt.Sprintf("удалён сервис %s у хоста %s", serviceName, hostName), func(st *model.State) error {
|
||||
g := st.FindGroup(groupName)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", groupName)
|
||||
h := st.FindHost(hostName)
|
||||
if h == nil {
|
||||
return fmt.Errorf("host %s not found", hostName)
|
||||
}
|
||||
for hi := range g.Hosts {
|
||||
if g.Hosts[hi].Name != hostName {
|
||||
continue
|
||||
}
|
||||
svcs := g.Hosts[hi].Services
|
||||
for si := range svcs {
|
||||
if svcs[si].Name == serviceName {
|
||||
g.Hosts[hi].Services = append(svcs[:si], svcs[si+1:]...)
|
||||
break
|
||||
}
|
||||
for i := range h.Services {
|
||||
if h.Services[i].Name == serviceName {
|
||||
h.Services = append(h.Services[:i], h.Services[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return resyncGroupUsers(st, groupName)
|
||||
return resyncUsernames(st, st.UsernamesUsingHost(hostName))
|
||||
})
|
||||
m.screen = scrHostServices
|
||||
m.refreshServicesList()
|
||||
m.screen = scrCatalogServices
|
||||
m.refreshCatalogServicesList()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// doDeleteGroup removes a group and its membership from every user who had
|
||||
// it, then resyncs authorized_keys for everyone who lost access.
|
||||
func (m Model) doDeleteGroup(groupName string) (tea.Model, tea.Cmd) {
|
||||
m.mutate("удалена группа "+groupName, func(st *model.State) error {
|
||||
affected := st.UsernamesInGroup(groupName)
|
||||
st.DeleteGroup(groupName)
|
||||
return resyncUsernames(st, affected)
|
||||
})
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
}
|
||||
+25
-41
@@ -63,53 +63,37 @@ func (m Model) updateExportSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.exportChosen = chosen
|
||||
m.screen = scrExportConfirm
|
||||
m.err = ""
|
||||
|
||||
// Issuing a brand new key is not destructive — nothing existing
|
||||
// breaks — so it runs immediately. Re-exporting someone who
|
||||
// already has a key rotates it and invalidates their current
|
||||
// archive, which does need the typed-token confirmation.
|
||||
st := m.loadState()
|
||||
var rotating []string
|
||||
for _, uname := range chosen {
|
||||
if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
|
||||
rotating = append(rotating, uname)
|
||||
}
|
||||
}
|
||||
if len(rotating) == 0 {
|
||||
return m.runExport(chosen)
|
||||
}
|
||||
confirmMsg := "Будут перевыпущены ключи (старые архивы перестанут работать):\n\n"
|
||||
for _, uname := range chosen {
|
||||
if u := st.FindUser(uname); u != nil && u.PublicKey != "" {
|
||||
confirmMsg += fmt.Sprintf(" %-20s ПЕРЕВЫПУСК\n", uname)
|
||||
} else {
|
||||
confirmMsg += fmt.Sprintf(" %-20s новый ключ\n", uname)
|
||||
}
|
||||
}
|
||||
m = m.startConfirm(confirmExportRotate, "", confirmMsg, scrExportSelect)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// updateExportConfirm guards against the accidental case this screen exists
|
||||
// for: selecting users who already have a key issued re-exports them, which
|
||||
// generates a brand new keypair and immediately invalidates whatever archive
|
||||
// they were already given (their old private key stops matching
|
||||
// authorized_keys). Nothing is generated or written until this is confirmed.
|
||||
func (m Model) updateExportConfirm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc", "n":
|
||||
m.screen = scrExportSelect
|
||||
return m, nil
|
||||
case "enter", "y":
|
||||
return m.runExport(m.exportChosen)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewExportConfirm() string {
|
||||
st := m.loadState()
|
||||
body := "Подтвердите экспорт\n\n"
|
||||
anyRotate := false
|
||||
for _, username := range m.exportChosen {
|
||||
u := st.FindUser(username)
|
||||
if u != nil && u.PublicKey != "" {
|
||||
body += fmt.Sprintf(" %-20s %s\n", username, warnStyle.Render("ключ уже выдан — будет ПЕРЕВЫПУЩЕН, старый архив перестанет работать"))
|
||||
anyRotate = true
|
||||
} else {
|
||||
body += fmt.Sprintf(" %-20s новый ключ\n", username)
|
||||
}
|
||||
}
|
||||
if anyRotate {
|
||||
body += "\n" + warnStyle.Render("Внимание: для отмеченных выше пользователей текущий ключ будет заменён.") + "\n"
|
||||
body += warnStyle.Render("Все, у кого сейчас работает старый архив, потеряют доступ, пока им не выдадут новый.") + "\n"
|
||||
}
|
||||
body += "\n[enter/y] подтвердить и сгенерировать [esc/n] отмена, ничего не создано"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
// runExport performs the actual batch export: for each chosen user it
|
||||
// (re)generates a keypair, rewrites authorized_keys with that user's
|
||||
// currently-allowed hosts, builds the encrypted archive, and records the new
|
||||
@@ -245,7 +229,7 @@ func (m Model) viewExportSelect() string {
|
||||
}
|
||||
body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status)
|
||||
}
|
||||
body += "\n[space] отметить [a] отметить всех [enter] далее (спросит подтверждение) [esc] назад"
|
||||
body += "\n[space] отметить [a] отметить всех [enter] сгенерировать (перевыпуск попросит подтверждение) [esc] назад"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
|
||||
+173
-338
@@ -2,74 +2,30 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
// validNameErr renders a consistent error for group/host/service names that
|
||||
// fail model.ValidName — these names end up as ssh_config/authorized_keys
|
||||
// tokens, where spaces or "#" would corrupt the generated file.
|
||||
func validNameErr(kind, name string) string {
|
||||
return fmt.Sprintf("%s %q: имя не должно содержать пробелов и спецсимволов (разрешены латинские буквы, цифры, точка, подчёркивание, дефис)", kind, name)
|
||||
}
|
||||
|
||||
func (m *Model) refreshGroupsList() {
|
||||
st := m.loadState()
|
||||
items := make([]list.Item, 0, len(st.Groups))
|
||||
for _, g := range st.Groups {
|
||||
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: fmt.Sprintf("%d хостов", len(g.Hosts))})
|
||||
desc := fmt.Sprintf("%d хостов", len(g.Hosts))
|
||||
if g.Description != "" {
|
||||
desc += " — " + g.Description
|
||||
}
|
||||
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc})
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.groupsList.Width(), m.groupsList.Height())
|
||||
l.Title = "Группы доступа [n]новая группа [enter]хосты группы [esc]назад"
|
||||
l.SetShowStatusBar(false)
|
||||
m.groupsList = l
|
||||
m.awaitingGroup = false
|
||||
m.groupsList = newList(items,
|
||||
"Группы [n]новая [e]изменить [enter]хосты группы [d]удалить [esc]назад",
|
||||
m.groupsList.Width(), m.groupsList.Height())
|
||||
}
|
||||
|
||||
// contains a small state flag reused from Model: awaitingGroup + groupNewName input
|
||||
|
||||
func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.awaitingGroup {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.awaitingGroup = false
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.groupNewName.Value()
|
||||
if name == "" {
|
||||
m.err = "имя группы не может быть пустым"
|
||||
return m, nil
|
||||
}
|
||||
if !model.ValidName(name) {
|
||||
m.err = validNameErr("группа", name)
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
if st.FindGroup(name) != nil {
|
||||
m.err = fmt.Sprintf("группа %q уже существует", name)
|
||||
return m, nil
|
||||
}
|
||||
m.mutate("создана группа "+name, func(st *model.State) error {
|
||||
st.Groups = append(st.Groups, model.Group{Name: name})
|
||||
return nil
|
||||
})
|
||||
m.awaitingGroup = false
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.groupNewName, cmd = m.groupNewName.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
@@ -77,11 +33,37 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "n":
|
||||
m.awaitingGroup = true
|
||||
m.groupNewName = textinput.New()
|
||||
m.groupNewName.Placeholder = "имя группы, например team-billing"
|
||||
m.groupNewName.Focus()
|
||||
m.groupFormEditTarget = ""
|
||||
for i := range m.groupForm {
|
||||
m.groupForm[i] = textinput.New()
|
||||
}
|
||||
m.groupForm[0].Placeholder = "имя группы, например team-billing"
|
||||
m.groupForm[1].Placeholder = "описание (необязательно)"
|
||||
m.groupFormFocus = 0
|
||||
m.groupForm[0].Focus()
|
||||
m.err = ""
|
||||
m.screen = scrGroupForm
|
||||
return m, nil
|
||||
case "e":
|
||||
sel, ok := m.groupsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
g := st.FindGroup(sel.id)
|
||||
if g == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.groupFormEditTarget = g.Name
|
||||
for i := range m.groupForm {
|
||||
m.groupForm[i] = textinput.New()
|
||||
}
|
||||
m.groupForm[0].SetValue(g.Name)
|
||||
m.groupForm[1].SetValue(g.Description)
|
||||
m.groupFormFocus = 0
|
||||
m.groupForm[0].Focus()
|
||||
m.err = ""
|
||||
m.screen = scrGroupForm
|
||||
return m, nil
|
||||
case "enter":
|
||||
sel, ok := m.groupsList.SelectedItem().(simpleItem)
|
||||
@@ -89,8 +71,35 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedGroup = sel.id
|
||||
m.screen = scrGroupHosts
|
||||
m.refreshHostsList()
|
||||
st := m.loadState()
|
||||
g := st.FindGroup(sel.id)
|
||||
m.groupHostsPickSel = map[string]bool{}
|
||||
if g != nil {
|
||||
for _, hn := range g.Hosts {
|
||||
m.groupHostsPickSel[hn] = true
|
||||
}
|
||||
}
|
||||
m.groupHostsPickOrder = nil
|
||||
for _, h := range st.Hosts {
|
||||
m.groupHostsPickOrder = append(m.groupHostsPickOrder, h.Name)
|
||||
}
|
||||
m.groupHostsPickCursor = 0
|
||||
m.screen = scrGroupHostsPick
|
||||
return m, nil
|
||||
case "d":
|
||||
sel, ok := m.groupsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
affected := st.UsernamesInGroup(sel.id)
|
||||
msg := fmt.Sprintf(
|
||||
"Удалить группу %q?\n\n"+
|
||||
"Это затронет пользователей: %d — группа будет снята с них, и они\n"+
|
||||
"немедленно потеряют доступ ко всем сервисам, которые давала только эта\n"+
|
||||
"группа.",
|
||||
sel.id, len(affected))
|
||||
m = m.startConfirm(confirmDeleteGroup, sel.id, msg, scrGroups)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -100,316 +109,142 @@ func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m Model) viewGroups() string {
|
||||
if m.awaitingGroup {
|
||||
return borderStyle.Render("Новая группа:\n\n" + m.groupNewName.View() + "\n\n[enter] создать [esc] отмена")
|
||||
}
|
||||
return borderStyle.Render(m.groupsList.View())
|
||||
}
|
||||
|
||||
// --- hosts within a group ---
|
||||
|
||||
func (m *Model) refreshHostsList() {
|
||||
st := m.loadState()
|
||||
g := st.FindGroup(m.selectedGroup)
|
||||
var items []list.Item
|
||||
if g != nil {
|
||||
for _, h := range g.Hosts {
|
||||
items = append(items, simpleItem{
|
||||
id: h.Name,
|
||||
title: h.Name,
|
||||
desc: fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services)),
|
||||
})
|
||||
}
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
|
||||
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [enter]сервисы хоста [d]удалить [esc]назад", m.selectedGroup)
|
||||
l.SetShowStatusBar(false)
|
||||
m.hostsList = l
|
||||
}
|
||||
|
||||
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for
|
||||
// every active user whose groups include groupName, reflecting the group's
|
||||
// current hosts/services — the only place server-side enforcement is
|
||||
// updated when the allowed destinations change over time.
|
||||
func resyncGroupUsers(st *model.State, groupName string) error {
|
||||
for i := range st.Users {
|
||||
u := &st.Users[i]
|
||||
if u.PublicKey == "" || u.Status != model.StatusActive {
|
||||
continue
|
||||
}
|
||||
inGroup := false
|
||||
for _, g := range u.Groups {
|
||||
if g == groupName {
|
||||
inGroup = true
|
||||
break
|
||||
func (m Model) updateGroupForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroups
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.groupForm[m.groupFormFocus].Blur()
|
||||
m.groupFormFocus = (m.groupFormFocus + 1) % len(m.groupForm)
|
||||
m.groupForm[m.groupFormFocus].Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.groupForm[0].Value()
|
||||
desc := m.groupForm[1].Value()
|
||||
if name == "" {
|
||||
m.err = "имя группы не может быть пустым"
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if !inGroup {
|
||||
continue
|
||||
}
|
||||
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedForwards(u)); err != nil {
|
||||
return fmt.Errorf("resync %s: %w", u.Username, err)
|
||||
if !model.ValidName(name) {
|
||||
m.err = validNameErr("группа", name)
|
||||
return m, nil
|
||||
}
|
||||
target := m.groupFormEditTarget
|
||||
m.err = ""
|
||||
if target == "" {
|
||||
m.mutate("создана группа "+name, func(st *model.State) error {
|
||||
if st.FindGroup(name) != nil {
|
||||
return fmt.Errorf("группа %q уже существует", name)
|
||||
}
|
||||
st.Groups = append(st.Groups, model.Group{Name: name, Description: desc})
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
m.mutate("изменена группа "+target, func(st *model.State) error {
|
||||
if name != target && st.FindGroup(name) != nil {
|
||||
return fmt.Errorf("группа %q уже существует", name)
|
||||
}
|
||||
if name != target {
|
||||
st.RenameGroup(target, name)
|
||||
}
|
||||
g := st.FindGroup(name)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", name)
|
||||
}
|
||||
g.Description = desc
|
||||
return nil
|
||||
})
|
||||
}
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
var cmd tea.Cmd
|
||||
m.groupForm[m.groupFormFocus], cmd = m.groupForm[m.groupFormFocus].Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m Model) viewGroupForm() string {
|
||||
title := "Новая группа"
|
||||
if m.groupFormEditTarget != "" {
|
||||
title = fmt.Sprintf("Изменить группу %q", m.groupFormEditTarget)
|
||||
}
|
||||
body := fmt.Sprintf("%s\n\nИмя: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
|
||||
title, m.groupForm[0].View(), m.groupForm[1].View())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
// --- pick which catalog hosts belong to the selected group ---
|
||||
|
||||
func (m Model) updateGroupHostsPick(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
case "n":
|
||||
m.screen = scrHostNew
|
||||
for i := range m.hostForm {
|
||||
m.hostForm[i] = textinput.New()
|
||||
case "up", "k":
|
||||
if m.groupHostsPickCursor > 0 {
|
||||
m.groupHostsPickCursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.groupHostsPickCursor < len(m.groupHostsPickOrder)-1 {
|
||||
m.groupHostsPickCursor++
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
if len(m.groupHostsPickOrder) > 0 {
|
||||
h := m.groupHostsPickOrder[m.groupHostsPickCursor]
|
||||
m.groupHostsPickSel[h] = !m.groupHostsPickSel[h]
|
||||
}
|
||||
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1 (без пробелов)"
|
||||
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
|
||||
m.hostFormFocus = 0
|
||||
m.hostForm[0].Focus()
|
||||
m.err = ""
|
||||
return m, nil
|
||||
case "enter":
|
||||
sel, ok := m.hostsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedHost = sel.id
|
||||
m.screen = scrHostServices
|
||||
m.refreshServicesList()
|
||||
return m, nil
|
||||
case "d":
|
||||
sel, ok := m.hostsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
groupName := m.selectedGroup
|
||||
nServices := 0
|
||||
if g := st.FindGroup(groupName); g != nil {
|
||||
for _, h := range g.Hosts {
|
||||
if h.Name == sel.id {
|
||||
nServices = len(h.Services)
|
||||
break
|
||||
}
|
||||
var hosts []string
|
||||
for _, h := range m.groupHostsPickOrder {
|
||||
if m.groupHostsPickSel[h] {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
m.pendingConfirm = confirmDeleteHost
|
||||
m.confirmTarget = sel.id
|
||||
m.confirmReturnTo = scrGroupHosts
|
||||
m.confirmMessage = fmt.Sprintf(
|
||||
"Удалить хост %q (и все его сервисы: %d шт.) из группы %q?\n\n"+
|
||||
"Все пользователи этой группы немедленно потеряют доступ ко всем сервисам\n"+
|
||||
"этого хоста — permitopen убирается из authorized_keys сразу же.",
|
||||
sel.id, nServices, groupName)
|
||||
m.screen = scrConfirmAction
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.hostsList, cmd = m.hostsList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewGroupHosts() string {
|
||||
return borderStyle.Render(m.hostsList.View())
|
||||
}
|
||||
|
||||
func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroupHosts
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.hostForm[m.hostFormFocus].Blur()
|
||||
m.hostFormFocus = (m.hostFormFocus + 1) % len(m.hostForm)
|
||||
m.hostForm[m.hostFormFocus].Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.hostForm[0].Value()
|
||||
addr := m.hostForm[1].Value()
|
||||
if name == "" || addr == "" {
|
||||
m.err = "имя и адрес обязательны"
|
||||
return m, nil
|
||||
}
|
||||
if !model.ValidName(name) {
|
||||
m.err = validNameErr("хост", name)
|
||||
return m, nil
|
||||
}
|
||||
groupName := m.selectedGroup
|
||||
m.err = ""
|
||||
var created bool
|
||||
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
|
||||
m.mutate("изменён список хостов группы "+groupName, func(st *model.State) error {
|
||||
g := st.FindGroup(groupName)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", groupName)
|
||||
}
|
||||
for _, h := range g.Hosts {
|
||||
if h.Name == name {
|
||||
return fmt.Errorf("host %q already exists in group %q", name, groupName)
|
||||
}
|
||||
}
|
||||
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr})
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if !created {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedHost = name
|
||||
m.screen = scrHostServices
|
||||
m.refreshServicesList()
|
||||
m.status = fmt.Sprintf("хост %s добавлен — теперь добавьте на нём хотя бы один сервис (порт)", name)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewHostNew() string {
|
||||
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\n\n[tab] след. поле [enter] сохранить и перейти к сервисам [esc] отмена",
|
||||
m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
// --- services within a host ---
|
||||
|
||||
func (m *Model) refreshServicesList() {
|
||||
st := m.loadState()
|
||||
g := st.FindGroup(m.selectedGroup)
|
||||
var items []list.Item
|
||||
if g != nil {
|
||||
for _, h := range g.Hosts {
|
||||
if h.Name != m.selectedHost {
|
||||
continue
|
||||
}
|
||||
for _, svc := range h.Services {
|
||||
items = append(items, simpleItem{
|
||||
id: svc.Name,
|
||||
title: fmt.Sprintf("%s (порт %d)", svc.Name, svc.Port),
|
||||
desc: svc.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
|
||||
l.Title = fmt.Sprintf("Сервисы хоста %q [n]добавить [d]удалить [esc]назад", m.selectedHost)
|
||||
l.SetShowStatusBar(false)
|
||||
m.servicesList = l
|
||||
}
|
||||
|
||||
func (m Model) updateHostServices(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroupHosts
|
||||
m.refreshHostsList()
|
||||
return m, nil
|
||||
case "n":
|
||||
m.screen = scrServiceNew
|
||||
for i := range m.serviceForm {
|
||||
m.serviceForm[i] = textinput.New()
|
||||
}
|
||||
m.serviceForm[0].Placeholder = "имя сервиса, например ssh или web-api (без пробелов)"
|
||||
m.serviceForm[1].Placeholder = "порт на хосте, например 22 или 7888"
|
||||
m.serviceForm[2].Placeholder = "описание (необязательно)"
|
||||
m.serviceFormFocus = 0
|
||||
m.serviceForm[0].Focus()
|
||||
m.err = ""
|
||||
return m, nil
|
||||
case "d":
|
||||
sel, ok := m.servicesList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
groupName, hostName := m.selectedGroup, m.selectedHost
|
||||
m.pendingConfirm = confirmDeleteService
|
||||
m.confirmTarget = sel.id
|
||||
m.confirmReturnTo = scrHostServices
|
||||
m.confirmMessage = fmt.Sprintf(
|
||||
"Удалить сервис %q у хоста %q (группа %q)?\n\n"+
|
||||
"Все пользователи этой группы немедленно потеряют доступ к этому сервису —\n"+
|
||||
"permitopen убирается из authorized_keys сразу же.",
|
||||
sel.id, hostName, groupName)
|
||||
m.screen = scrConfirmAction
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.servicesList, cmd = m.servicesList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewHostServices() string {
|
||||
return borderStyle.Render(m.servicesList.View())
|
||||
}
|
||||
|
||||
func (m Model) updateServiceNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrHostServices
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.serviceForm[m.serviceFormFocus].Blur()
|
||||
m.serviceFormFocus = (m.serviceFormFocus + 1) % len(m.serviceForm)
|
||||
m.serviceForm[m.serviceFormFocus].Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.serviceForm[0].Value()
|
||||
portStr := m.serviceForm[1].Value()
|
||||
desc := m.serviceForm[2].Value()
|
||||
if name == "" || portStr == "" {
|
||||
m.err = "имя и порт обязательны"
|
||||
return m, nil
|
||||
}
|
||||
if !model.ValidName(name) {
|
||||
m.err = validNameErr("сервис", name)
|
||||
return m, nil
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
m.err = "порт должен быть числом от 1 до 65535"
|
||||
return m, nil
|
||||
}
|
||||
groupName, hostName := m.selectedGroup, m.selectedHost
|
||||
m.err = ""
|
||||
m.mutate(fmt.Sprintf("добавлен сервис %s (порт %d) хосту %s", name, port, hostName), func(st *model.State) error {
|
||||
g := st.FindGroup(groupName)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", groupName)
|
||||
}
|
||||
for hi := range g.Hosts {
|
||||
if g.Hosts[hi].Name != hostName {
|
||||
continue
|
||||
}
|
||||
for _, svc := range g.Hosts[hi].Services {
|
||||
if svc.Name == name {
|
||||
return fmt.Errorf("service %q already exists on host %q", name, hostName)
|
||||
}
|
||||
}
|
||||
g.Hosts[hi].Services = append(g.Hosts[hi].Services, model.Service{Name: name, Port: port, Description: desc})
|
||||
}
|
||||
g.Hosts = hosts
|
||||
return resyncGroupUsers(st, groupName)
|
||||
})
|
||||
m.screen = scrHostServices
|
||||
m.refreshServicesList()
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
|
||||
return m, cmd
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewServiceNew() string {
|
||||
body := fmt.Sprintf("Новый сервис на хосте %q\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
|
||||
m.selectedHost, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View())
|
||||
func (m Model) viewGroupHostsPick() string {
|
||||
body := fmt.Sprintf("Какие хосты каталога входят в группу %q\n\n", m.selectedGroup)
|
||||
if len(m.groupHostsPickOrder) == 0 {
|
||||
body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n"
|
||||
}
|
||||
for i, h := range m.groupHostsPickOrder {
|
||||
cursor := " "
|
||||
if i == m.groupHostsPickCursor {
|
||||
cursor = "> "
|
||||
}
|
||||
check := "[ ]"
|
||||
if m.groupHostsPickSel[h] {
|
||||
check = "[x]"
|
||||
}
|
||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, h)
|
||||
}
|
||||
body += "\n[space] отметить [enter] сохранить [esc] назад"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
+6
-5
@@ -8,15 +8,13 @@ import (
|
||||
func newMenuList() list.Model {
|
||||
items := []list.Item{
|
||||
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
|
||||
simpleItem{id: "groups", title: "Группы и хосты", desc: "Кому какие хосты разрешены для прыжка/форвардинга"},
|
||||
simpleItem{id: "catalog", title: "Хосты и сервисы", desc: "Общий каталог: адреса и порты, один раз на всех, группы просто выбирают из него"},
|
||||
simpleItem{id: "groups", title: "Группы", desc: "Какие хосты из каталога входят в каждую группу"},
|
||||
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
|
||||
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
|
||||
simpleItem{id: "quit", title: "Выход", desc: ""},
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
||||
l.Title = "ssh-access-tui"
|
||||
l.SetShowStatusBar(false)
|
||||
return l
|
||||
return newList(items, "ssh-access-tui", 0, 0)
|
||||
}
|
||||
|
||||
func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -32,6 +30,9 @@ func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "users":
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
case "catalog":
|
||||
m.screen = scrCatalogHosts
|
||||
m.refreshCatalogHostsList()
|
||||
case "groups":
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
// resyncUsernames rewrites authorized_keys permitopen= restrictions for each
|
||||
// named user (skipping anyone without a key yet or not active), reflecting
|
||||
// whatever their current AllowedForwards computes to. This is the one place
|
||||
// server-side enforcement gets updated whenever group membership, a group's
|
||||
// referenced hosts, or a host's services change.
|
||||
func resyncUsernames(st *model.State, usernames []string) error {
|
||||
for _, uname := range usernames {
|
||||
u := st.FindUser(uname)
|
||||
if u == nil || u.PublicKey == "" || u.Status != model.StatusActive {
|
||||
continue
|
||||
}
|
||||
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedForwards(u)); err != nil {
|
||||
return fmt.Errorf("resync %s: %w", u.Username, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resyncGroupUsers(st *model.State, groupName string) error {
|
||||
return resyncUsernames(st, st.UsernamesInGroup(groupName))
|
||||
}
|
||||
|
||||
func resyncHostUsers(st *model.State, hostName string) error {
|
||||
return resyncUsernames(st, st.UsernamesUsingHost(hostName))
|
||||
}
|
||||
+4
-10
@@ -75,15 +75,12 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
username := sel.id
|
||||
m.pendingConfirm = confirmLockUser
|
||||
m.confirmTarget = username
|
||||
m.confirmReturnTo = scrUsers
|
||||
m.confirmMessage = fmt.Sprintf(
|
||||
msg := fmt.Sprintf(
|
||||
"Заблокировать пользователя %q?\n\n"+
|
||||
"Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+
|
||||
"истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+
|
||||
"Это обратимо: позже можно разблокировать клавишей [u].", username)
|
||||
m.screen = scrConfirmAction
|
||||
m = m.startConfirm(confirmLockUser, username, msg, scrUsers)
|
||||
return m, nil
|
||||
case "u":
|
||||
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||
@@ -118,16 +115,13 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
username := sel.id
|
||||
m.pendingConfirm = confirmDeleteUser
|
||||
m.confirmTarget = username
|
||||
m.confirmReturnTo = scrUsers
|
||||
m.confirmMessage = fmt.Sprintf(
|
||||
msg := fmt.Sprintf(
|
||||
"Удалить пользователя %q НЕОБРАТИМО?\n\n"+
|
||||
"Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+
|
||||
"удаляются безвозвратно.\n"+
|
||||
"Если нужно просто временно отключить доступ — используйте блокировку\n"+
|
||||
"[l] вместо удаления, её можно отменить.", username)
|
||||
m.screen = scrConfirmAction
|
||||
m = m.startConfirm(confirmDeleteUser, username, msg, scrUsers)
|
||||
return m, nil
|
||||
case "c":
|
||||
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||
|
||||
Reference in new issue
Block a user