Add Days field to the TUI cert form

This commit is contained in:
Vlad Doloman
2026-08-15 05:30:31 +03:00
parent fad3e5187e
commit 96664f954c
2 changed files with 96 additions and 17 deletions

View File

@@ -791,15 +791,17 @@ class CertFormResult:
email: str
password: str
confirmed: bool
days: str = ""
_FORM_FIELDS = ("cn", "email", "password")
_FORM_FIELDS = ("cn", "email", "days", "password")
_FORM_LABELS = {
"cn": "CN",
"email": "Email",
"days": "Days (blank = EasyRSA default)",
"password": "Password",
}
_FORM_FIELD_Y = {"cn": 3, "email": 5, "password": 7}
_FORM_FIELD_Y = {"cn": 3, "email": 5, "days": 7, "password": 9}
def show_cert_form(
@@ -807,6 +809,7 @@ def show_cert_form(
cn: str = "",
email: str = "",
password: str = "",
days: str = "",
cn_readonly: bool = False,
) -> CertFormResult:
"""Modal cert-detail form.
@@ -816,7 +819,7 @@ def show_cert_form(
# Clamp to the screen so curses.newwin() can't raise "curses function
# returned NULL" outright on a terminal narrower/shorter than the usual
# 80x24 (e.g. a serial console with an unusually small viewport).
h, w = min(13, sh), min(62, sw)
h, w = min(15, sh), min(62, sw)
try:
win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2))
except curses.error:
@@ -826,7 +829,7 @@ def show_cert_form(
_BTN_CONTINUE = "_continue"
_BTN_CANCEL = "_cancel"
_BTN_Y = 9
_BTN_Y = 11
_btn_x_cont = (w - 26) // 2 # "[ Continue ]"=12 + gap=4 + "[ Cancel ]"=10 = 26
_btn_x_canc = _btn_x_cont + 16
@@ -835,18 +838,32 @@ def show_cert_form(
fields: Dict[str, InputField] = {
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
"days": InputField(win, _FORM_FIELD_Y["days"], 3, fw, initial=days,
allowed=string.digits),
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
initial=password if password else generate_password(),
mask=True),
}
focus = 0
error = ""
def _submit() -> CertFormResult:
def _submit() -> Optional[CertFormResult]:
nonlocal error
try:
# Validate with the same resolver the CLI and .conf use, so what
# counts as valid never diverges between entry points.
days_value = resolve_cert_days(fields["days"].value, "Days")
except ConfigError:
# The resolver's full sentence does not fit a 62-column window.
error = "Days must be a positive number, or blank to inherit"
return None
error = ""
final_cn = cn if cn_readonly else fields["cn"].value
return CertFormResult(
cn=final_cn,
email=fields["email"].value,
password=fields["password"].value,
days=days_value,
confirmed=True,
)
@@ -887,8 +904,11 @@ def show_cert_form(
pass
hint = "Tab=next F5=regen pwd Ctrl-G=confirm Esc=cancel"
hint_attr = curses.color_pair(COLOR_DISABLED)
if error:
hint, hint_attr = error, curses.color_pair(COLOR_ERROR)
try:
win.addstr(h - 2, 2, hint[:w - 4], curses.color_pair(COLOR_DISABLED))
win.addstr(h - 2, 2, hint[:w - 4], hint_attr)
except curses.error:
pass
@@ -913,8 +933,11 @@ def show_cert_form(
curses.curs_set(0)
return CertFormResult(cn=cn, email="", password="", confirmed=False)
if key == 0x07: # Ctrl-G: immediate submit
curses.curs_set(0)
return _submit()
result = _submit()
if result is not None:
curses.curs_set(0)
return result
continue
if key in (9, curses.KEY_DOWN): # Tab / Down: next
focus = (focus + 1) % len(active)
continue
@@ -934,9 +957,13 @@ def show_cert_form(
)
continue
if key in (10, 13, curses.KEY_ENTER, ord(" ")):
curses.curs_set(0)
if current == _BTN_CONTINUE:
return _submit()
result = _submit()
if result is not None:
curses.curs_set(0)
return result
continue
curses.curs_set(0)
return CertFormResult(cn=cn, email="", password="", confirmed=False)
else:
if key in (10, 13, curses.KEY_ENTER):

View File

@@ -115,11 +115,11 @@ def test_show_cert_form_uses_erase_not_clear():
def test_show_cert_form_confirm():
"""Enter advances through the 3 fields to the Continue button; Enter on
Continue confirms. That's 4 Enter keypresses total."""
"""Enter advances through the 4 fields to the Continue button; Enter on
Continue confirms. That's 5 Enter keypresses total."""
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10]
win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="bob@example.com")
assert isinstance(result, CertFormResult)
@@ -130,11 +130,11 @@ def test_show_cert_form_confirm():
def test_show_cert_form_cancel_button():
"""Tab to the Cancel button (4 Tabs from start) and press Enter cancels."""
"""Tab to the Cancel button (5 Tabs from start) and press Enter cancels."""
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
# Tab×4: cn→email→password→Continue→Cancel, then Enter
win.getch.side_effect = [9, 9, 9, 9, 10]
# Tab×5: cn→email→days→password→Continue→Cancel, then Enter
win.getch.side_effect = [9, 9, 9, 9, 9, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
assert isinstance(result, CertFormResult)
@@ -142,7 +142,7 @@ def test_show_cert_form_cancel_button():
def test_show_cert_form_clamps_to_narrow_screen():
"""The form's fixed 13x62 size must not exceed a smaller-than-usual
"""The form's fixed 15x62 size must not exceed a smaller-than-usual
screen, which would make curses.newwin() raise."""
stdscr = _make_stdscr(rows=10, cols=40)
win = _make_win()
@@ -156,6 +156,58 @@ def test_show_cert_form_clamps_to_narrow_screen():
assert x >= 0
def test_show_cert_form_returns_days():
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="90")
assert result.confirmed is True
assert result.days == "90"
def test_show_cert_form_blank_days_means_inherit():
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="")
assert result.confirmed is True
assert result.days == ""
def test_show_cert_form_days_field_is_digits_only():
# cn is read-only here, so focus starts on email: Tab once to reach days.
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [9] + [ord(c) for c in "9a0!"] + [10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="",
cn_readonly=True)
assert result.confirmed is True
assert result.days == "90"
def test_show_cert_form_refuses_zero_days():
# Submitting "0" must keep the form open rather than closing and failing
# later inside EasyRSA. Enter×5 tries to submit, Esc then cancels.
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10, 27]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="0")
assert result.confirmed is False
def test_show_cert_form_normalises_leading_zero_days():
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="090")
assert result.days == "90"
def test_show_cert_form_newwin_failure_returns_cancelled():
"""If curses.newwin() still fails despite clamping, show_cert_form must
degrade to an unconfirmed result rather than crash the whole app."""