feat: TUI main selection screen with checkboxes, menu items, revoke hotkey
This commit is contained in:
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
|
||||
Reference in New Issue
Block a user