package ui import ( "fmt" tea "github.com/charmbracelet/bubbletea" "ssh-access-tui/internal/keymgr" "ssh-access-tui/internal/model" ) // initConfigUpdateSelect lists every already-issued active user, so the // admin can force-refresh anyone's config even if it isn't detected as // stale — the status annotation (see viewConfigUpdateSelect) shows which // ones actually need it. func (m *Model) initConfigUpdateSelect() { st := m.loadState() m.configUpdateSel = map[string]bool{} m.configUpdateOrder = nil for _, u := range st.Users { if u.Status == model.StatusActive && u.PublicKey != "" { m.configUpdateOrder = append(m.configUpdateOrder, u.Username) } } m.configUpdateCursor = 0 m.configUpdateErr = "" } func (m Model) updateConfigUpdateSelect(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 "up", "k": if m.configUpdateCursor > 0 { m.configUpdateCursor-- } return m, nil case "down", "j": if m.configUpdateCursor < len(m.configUpdateOrder)-1 { m.configUpdateCursor++ } return m, nil case " ": if len(m.configUpdateOrder) > 0 { u := m.configUpdateOrder[m.configUpdateCursor] m.configUpdateSel[u] = !m.configUpdateSel[u] } return m, nil case "a": for _, u := range m.configUpdateOrder { m.configUpdateSel[u] = true } return m, nil case "enter": var chosen []string for _, u := range m.configUpdateOrder { if m.configUpdateSel[u] { chosen = append(chosen, u) } } if len(chosen) == 0 { m.err = "выберите хотя бы одного пользователя (space)" return m, nil } return m.runConfigUpdate(chosen) } } return m, nil } func (m Model) viewConfigUpdateSelect() string { st := m.loadState() body := "Обновить конфиги — выберите пользователей (ключи не трогает, общий пароль на партию)\n\n" if len(m.configUpdateOrder) == 0 { body += warnStyle.Render("Нет пользователей с уже выданным ключом.") + "\n" } for i, uname := range m.configUpdateOrder { cursor := " " if i == m.configUpdateCursor { cursor = "> " } check := "[ ]" if m.configUpdateSel[uname] { check = "[x]" } status := okStyle.Render("конфиг актуален") if u := st.FindUser(uname); u != nil && st.IsConfigStale(u) { status = warnStyle.Render("конфиг устарел — доступ изменился") } body += fmt.Sprintf("%s%s %-20s %s\n", cursor, check, uname, status) } body += "\n[space] отметить [a] отметить всех [enter] сгенерировать [esc] назад" return borderStyle.Render(body) } // runConfigUpdate is the lightweight alternative to a full export: when // only group membership or the catalog's services changed, server-side // access is already updated immediately (authorized_keys permitopen=) — this // just hands the selected users a fresh client config reflecting that, // without touching any key. See keymgr.BuildConfigOnlyArchive. Not // destructive (no key is rotated, nothing existing breaks), so it runs // without a confirmation screen regardless of batch size. func (m Model) runConfigUpdate(usernames []string) (tea.Model, tea.Cmd) { password, err := keymgr.GenerateBatchPassword(20) if err != nil { m.err = err.Error() return m, nil } st := m.loadState() var results []keymgr.ConfigOnlyResult var failures []string for _, username := range usernames { u := st.FindUser(username) if u == nil { failures = append(failures, username+": не найден в состоянии") continue } if u.PublicKey == "" { failures = append(failures, username+": ещё нет ключа, используйте «Экспорт архивов»") continue } forwards := st.AllowedForwards(u) res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards) if err != nil { failures = append(failures, username+": "+err.Error()) continue } u.ForwardsHash = model.HashForwards(forwards) results = append(results, *res) m.logAudit(username, "admin", "config-only update issued (key unchanged)") } if err := m.st.WithLock(func() error { return m.st.Save(st, fmt.Sprintf("config-update: обновлены конфиги для %v", usernames)) }); err != nil { failures = append(failures, "сохранение состояния: "+err.Error()) } m.configUpdateRes = results m.configUpdatePass = password m.configUpdateErr = "" for _, f := range failures { m.configUpdateErr += f + "\n" } m.screen = scrConfigUpdateResult return m, nil } func (m Model) updateConfigUpdateResult(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { if key.String() == "esc" || key.String() == "enter" { m.screen = scrMenu m.err, m.status = "", "" return m, nil } } return m, nil } func (m Model) viewConfigUpdateResult() string { body := "Готово — ключи НЕ менялись, обновлён только config.\n\n" body += "Пароль архива (один на всю партию, покажется только сейчас!):\n\n" body += titleStyle.Render(m.configUpdatePass) + "\n\n" body += warnStyle.Render("Сообщите пароль каждому получателю отдельным от архива каналом.") + "\n\n" for _, r := range m.configUpdateRes { body += fmt.Sprintf(" %-20s -> %s\n", r.Username, r.ArchivePath) } if m.configUpdateErr != "" { body += "\n" + errStyle.Render("Ошибки:\n"+m.configUpdateErr) } body += "\n[enter/esc] в меню" return borderStyle.Render(body) }