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

328 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"fmt"
"time"
"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"
)
func (m *Model) refreshUsersList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Users))
for _, u := range st.Users {
fp := u.Fingerprint
if fp == "" {
fp = "ключ не выдан"
}
desc := fmt.Sprintf("%s | группы: %v | %s", u.Status, u.Groups, fp)
items = append(items, simpleItem{id: u.Username, title: u.Username, desc: desc})
}
l := list.New(items, list.NewDefaultDelegate(), m.usersList.Width(), m.usersList.Height())
l.Title = "Пользователи [n]новый [g]группы [c]обновить конфиг [l]заблокировать [u]разблокировать [d]удалить [esc]назад"
l.SetShowStatusBar(false)
m.usersList = l
}
func (m Model) updateUsers(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.screen = scrUserNew
m.userNewName = textinput.New()
m.userNewName.Placeholder = "имя пользователя (латиница, как логин Linux)"
m.userNewName.Focus()
m.userNewComment = textinput.New()
m.userNewComment.Placeholder = "комментарий (например ФИО) — необязательно"
m.userNewFocus = 0
m.err, m.status = "", ""
return m, nil
case "g":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
u := st.FindUser(sel.id)
if u == nil {
return m, nil
}
m.userNewGroupsSel = map[string]bool{}
for _, g := range u.Groups {
m.userNewGroupsSel[g] = true
}
m.userNewGroupsOrder = nil
for _, g := range st.Groups {
m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name)
}
m.userNewCursor = 0
m.userGroupsEditTarget = sel.id
m.err, m.status = "", ""
m.screen = scrUserNewGroups
return m, nil
case "l":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
username := sel.id
m.pendingConfirm = confirmLockUser
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
"Заблокировать пользователя %q?\n\n"+
"Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+
"истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+
"Это обратимо: позже можно разблокировать клавишей [u].", username)
m.screen = scrConfirmAction
return m, nil
case "u":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
username := sel.id
if err := sysadm.UnlockSystemUser(username); err != nil {
m.err = err.Error()
return m, nil
}
m.mutate("разблокирован пользователь "+username, func(st *model.State) error {
u := st.FindUser(username)
if u == nil {
return fmt.Errorf("user %s not found in state", username)
}
u.Status = model.StatusActive
u.LockedAt = nil
if u.PublicKey != "" {
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil {
return err
}
}
return nil
})
m.logAudit(username, "admin", "unlocked")
m.refreshUsersList()
return m, nil
case "d":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
username := sel.id
m.pendingConfirm = confirmDeleteUser
m.confirmTarget = username
m.confirmReturnTo = scrUsers
m.confirmMessage = fmt.Sprintf(
"Удалить пользователя %q НЕОБРАТИМО?\n\n"+
"Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+
"удаляются безвозвратно.\n"+
"Если нужно просто временно отключить доступ — используйте блокировку\n"+
"[l] вместо удаления, её можно отменить.", username)
m.screen = scrConfirmAction
return m, nil
case "c":
sel, ok := m.usersList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
return m.runConfigUpdate(sel.id)
}
}
var cmd tea.Cmd
m.usersList, cmd = m.usersList.Update(msg)
return m, cmd
}
func (m Model) viewUsers() string {
return borderStyle.Render(m.usersList.View())
}
// --- new user wizard: step 1, username/comment ---
func (m Model) updateUserNew(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrUsers
return m, nil
case "tab", "down":
m.userNewFocus = (m.userNewFocus + 1) % 2
m.applyUserNewFocus()
return m, nil
case "enter":
name := m.userNewName.Value()
if name == "" {
m.err = "имя пользователя не может быть пустым"
return m, nil
}
if sysadm.UserExists(name) {
m.err = fmt.Sprintf("системный пользователь %q уже существует", name)
return m, nil
}
st := m.loadState()
if st.FindUser(name) != nil {
m.err = fmt.Sprintf("пользователь %q уже есть в состоянии tool'а", name)
return m, nil
}
m.err = ""
m.userNewGroupsSel = map[string]bool{}
m.userNewGroupsOrder = nil
for _, g := range st.Groups {
m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name)
}
m.userNewCursor = 0
m.screen = scrUserNewGroups
return m, nil
}
}
var cmd tea.Cmd
if m.userNewFocus == 0 {
m.userNewName, cmd = m.userNewName.Update(msg)
} else {
m.userNewComment, cmd = m.userNewComment.Update(msg)
}
return m, cmd
}
func (m *Model) applyUserNewFocus() {
if m.userNewFocus == 0 {
m.userNewName.Focus()
m.userNewComment.Blur()
} else {
m.userNewName.Blur()
m.userNewComment.Focus()
}
}
func (m Model) viewUserNew() string {
body := fmt.Sprintf("Новый пользователь\n\nИмя: %s\nКомментарий: %s\n\n[tab] переключить поле [enter] далее: выбор групп [esc] отмена",
m.userNewName.View(), m.userNewComment.View())
return borderStyle.Render(body)
}
// --- new user wizard: step 2, group checkboxes ---
// updateUserNewGroups drives the group-checkbox screen, shared by two
// flows: assigning groups while creating a brand new user (userGroupsEditTarget
// empty), and changing an existing user's groups later (userGroupsEditTarget
// set to their username, entered via "g" on the users list).
func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
if m.userGroupsEditTarget != "" {
m.userGroupsEditTarget = ""
m.screen = scrUsers
} else {
m.screen = scrUserNew
}
return m, nil
case "up", "k":
if m.userNewCursor > 0 {
m.userNewCursor--
}
return m, nil
case "down", "j":
if m.userNewCursor < len(m.userNewGroupsOrder)-1 {
m.userNewCursor++
}
return m, nil
case " ":
if len(m.userNewGroupsOrder) > 0 {
g := m.userNewGroupsOrder[m.userNewCursor]
m.userNewGroupsSel[g] = !m.userNewGroupsSel[g]
}
return m, nil
case "enter":
var groups []string
for _, g := range m.userNewGroupsOrder {
if m.userNewGroupsSel[g] {
groups = append(groups, g)
}
}
if m.userGroupsEditTarget != "" {
username := m.userGroupsEditTarget
m.mutate("изменены группы пользователя "+username, func(st *model.State) error {
u := st.FindUser(username)
if u == nil {
return fmt.Errorf("user %s not found in state", username)
}
u.Groups = groups
if u.PublicKey != "" && u.Status == model.StatusActive {
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil {
return err
}
}
return nil
})
m.logAudit(username, "admin", "groups changed to "+fmt.Sprint(groups))
m.userGroupsEditTarget = ""
m.screen = scrUsers
m.refreshUsersList()
return m, nil
}
username := m.userNewName.Value()
comment := m.userNewComment.Value()
if err := sysadm.CreateSystemUser(username); err != nil {
m.err = err.Error()
m.screen = scrUsers
m.refreshUsersList()
return m, nil
}
m.mutate("создан пользователь "+username, func(st *model.State) error {
st.Users = append(st.Users, model.User{
Username: username,
Groups: groups,
Status: model.StatusActive,
Comment: comment,
CreatedAt: time.Now(),
})
return nil
})
m.logAudit(username, "admin", "created, groups="+fmt.Sprint(groups))
m.status = fmt.Sprintf("пользователь %s создан. Ключ ещё не выдан — сгенерируйте его в разделе «Экспорт архивов».", username)
m.screen = scrUsers
m.refreshUsersList()
return m, nil
}
}
return m, nil
}
func (m Model) viewUserNewGroups() string {
title := "Группы доступа для нового пользователя"
confirmHint := "создать пользователя"
if m.userGroupsEditTarget != "" {
title = fmt.Sprintf("Группы доступа пользователя %s", m.userGroupsEditTarget)
confirmHint = "сохранить"
}
body := title + " (эффективный доступ = объединение сервисов всех отмеченных групп)\n\n"
if len(m.userNewGroupsOrder) == 0 {
body += warnStyle.Render("Группы ещё не созданы — сначала создайте хотя бы одну в разделе «Группы и хосты».") + "\n"
}
for i, g := range m.userNewGroupsOrder {
cursor := " "
if i == m.userNewCursor {
cursor = "> "
}
check := "[ ]"
if m.userNewGroupsSel[g] {
check = "[x]"
}
body += fmt.Sprintf("%s%s %s\n", cursor, check, g)
}
body += fmt.Sprintf("\n[space] отметить [enter] %s [esc] назад", confirmHint)
return borderStyle.Render(body)
}