322 lines
10 KiB
Go
322 lines
10 KiB
Go
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
|
||
msg := fmt.Sprintf(
|
||
"Заблокировать пользователя %q?\n\n"+
|
||
"Аккаунт будет немедленно заблокирован (пароль отключён, срок действия\n"+
|
||
"истёк, ключ в authorized_keys закомментирован) — доступ пропадёт сразу.\n"+
|
||
"Это обратимо: позже можно разблокировать клавишей [u].", username)
|
||
m = m.startConfirm(confirmLockUser, username, msg, scrUsers)
|
||
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
|
||
msg := fmt.Sprintf(
|
||
"Удалить пользователя %q НЕОБРАТИМО?\n\n"+
|
||
"Будет выполнено userdel -r: системный аккаунт и его домашняя папка\n"+
|
||
"удаляются безвозвратно.\n"+
|
||
"Если нужно просто временно отключить доступ — используйте блокировку\n"+
|
||
"[l] вместо удаления, её можно отменить.", username)
|
||
m = m.startConfirm(confirmDeleteUser, username, msg, scrUsers)
|
||
return m, nil
|
||
case "c":
|
||
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
return m.runConfigUpdate([]string{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)
|
||
}
|