feat: TUI main selection screen with checkboxes, menu items, revoke hotkey

This commit is contained in:
Vlad Doloman
2026-06-24 00:20:10 +03:00
parent 565a152243
commit ca6fe9f525
2 changed files with 431 additions and 0 deletions

View File

@@ -599,6 +599,153 @@ def show_cert_form(
fields[active[focus]].handle_key(key)
# ============================================================
# === TUI SCREEN ===
# ============================================================
class Action(Enum):
RENEW_SELECTED = auto()
NEW_CERT = auto()
REGEN_CRL = auto()
REVOKE = auto()
EXIT = auto()
@dataclass
class ScreenResult:
action: Action
selected_cns: list[str] = field(default_factory=list)
_MENU_NEW = "Create New Certificate"
_MENU_CRL = "Regenerate CRL"
_MENU_EXIT = "Exit"
_MENU_ITEMS = [_MENU_NEW, _MENU_CRL, _MENU_EXIT]
def _expiry_label(c: CertInfo) -> str:
if c.days_left < 0:
return f"[EXPIRED {abs(c.days_left)}d ago]"
if c.days_left == 0:
return "[EXPIRES TODAY!]"
return f"[exp in {c.days_left}d] "
def show_main_screen(stdscr, certs: list[CertInfo]) -> ScreenResult:
"""Display cert selection list. Returns when user activates an action."""
init_colors()
curses.curs_set(0)
n_certs = len(certs)
n_items = n_certs + len(_MENU_ITEMS)
checked: set[str] = set()
cursor = 0
def is_cert(idx): return idx < n_certs
def menu_item(idx): return _MENU_ITEMS[idx - n_certs] if idx >= n_certs else None
def draw():
stdscr.clear()
sh, sw = stdscr.getmaxyx()
# Header bar
hdr = " openvpncertupdate — VPN Certificate Manager "
try:
stdscr.addstr(0, 0, hdr.ljust(sw)[:sw],
curses.color_pair(COLOR_TITLE) | curses.A_BOLD)
except curses.error:
pass
# Footer hint
hint = " ↑↓:Navigate Space:Check Enter:Confirm R:Revoke Q:Quit"
try:
stdscr.addstr(sh - 1, 0, hint[:sw], curses.color_pair(COLOR_DISABLED))
except curses.error:
pass
list_h = sh - 2
scroll = clamp(cursor - list_h // 2, 0, max(0, n_items - list_h))
row_y = 1
for i in range(scroll, min(scroll + list_h, n_items)):
if row_y >= sh - 1:
break
is_cur = (i == cursor)
sel_attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_NORMAL)
if is_cert(i):
cert = certs[i]
box = "[X]" if cert.cn in checked else "[ ]"
lbl = _expiry_label(cert)
line = f" {box} {lbl:<18} {cert.cn}"
try:
stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], sel_attr)
except curses.error:
pass
else:
item = menu_item(i)
any_check = bool(checked)
if item == _MENU_NEW and any_check:
notice = " ← clear all checkboxes to use"
line = f" {item}{notice}"
attr = curses.color_pair(COLOR_SELECTED if is_cur else COLOR_DISABLED)
else:
line = f" {item}"
attr = sel_attr
try:
stdscr.addstr(row_y, 0, line.ljust(sw - 1)[:sw - 1], attr)
except curses.error:
pass
row_y += 1
stdscr.refresh()
while True:
draw()
key = stdscr.getch()
if key in (ord("q"), ord("Q")):
return ScreenResult(action=Action.EXIT)
elif key == curses.KEY_UP:
cursor = clamp(cursor - 1, 0, n_items - 1)
elif key == curses.KEY_DOWN:
cursor = clamp(cursor + 1, 0, n_items - 1)
elif key == ord(" ") and is_cert(cursor):
cn = certs[cursor].cn
if cn in checked:
checked.discard(cn)
else:
checked.add(cn)
elif key in (10, 13, curses.KEY_ENTER):
if is_cert(cursor):
# Enter on a cert row: add to selection and confirm immediately
checked.add(certs[cursor].cn)
return ScreenResult(action=Action.RENEW_SELECTED,
selected_cns=sorted(checked))
else:
item = menu_item(cursor)
if item == _MENU_NEW and not checked:
return ScreenResult(action=Action.NEW_CERT)
elif item == _MENU_CRL:
return ScreenResult(action=Action.REGEN_CRL)
elif item == _MENU_EXIT:
return ScreenResult(action=Action.EXIT)
# _MENU_NEW when checked → grayed, ignore
elif key in (ord("r"), ord("R")) and is_cert(cursor):
cert = certs[cursor]
if show_confirm(
stdscr,
f"Revoke certificate for:\n CN: {cert.cn}\n\nThis cannot be undone.",
):
return ScreenResult(action=Action.REVOKE, selected_cns=[cert.cn])
# (remaining sections added in later tasks)
# ============================================================