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(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) }