add edit and all in one menu for hosts and services

This commit is contained in:
redlineo committed 2026-07-22 15:54:44 +03:00
1 parent 8c1b93c14e
commit e39ad275b5
15 files changed
+1136 -675

No files matched your search

+106 -45
View File
@@ -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
}