//! The SSH-keys settings tab, described.
//!
//! The first authenticated screen through the description layer, chosen because
//! it isolates what the conversion is actually asking: two lists, each with a
//! destructive per-row action, two add forms and a picker, no bespoke markup
//! worth keeping, and it is where the console-theme work already landed.
//!
//! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_ssh_keys`,
//! which answers the same address from Askama when the screen is switched off.
//!
//! # It does not render the same page, on purpose
//!
//! The Askama tab renders two empty divs that fetch their own contents on load:
//! `#ssh-keys-list` and `#git-tokens-list` each carry `hx-trigger="load"`, so
//! opening the tab costs three round trips and shows two "Loading..." lines on
//! the way. A description has no word for "an empty region that fetches itself",
//! and should not: the handler is already in the request, and a list it can read
//! now is a list the reader should not wait for twice.
//!
//! So this renders both lists inline, in one response. That is a better screen
//! and it is also why **the parity harness cannot cover this conversion** — the
//! two renderings differ by design rather than by accident. See the S3 notes in
//! wiki `mnw-server-conversion-plan`.
//!
//! It is also what makes the screen worth measuring. The Askama tab handler runs
//! one query; this one runs three, which is what a described dashboard screen
//! will typically look like, and therefore what the blocking-pool question is
//! actually about.
//!
//! # The vocabulary gaps this screen found
//!
//! Both filed on quasicoherent rather than worked around silently.
//!
//! 1. **A table row could not carry an action**, so both tables here had to be
//! lists and lost their column headers. **Closed** by quasi@`b4e3e21`: a
//! table cell holds controls as well as a string, and both are tables again.
//! 2. **No date field.** The token form's expiry was `` and
//! had to be `Text` with a format hint. **Closed** by makeover-layout 0.15.0:
//! `FieldKind::Date` and `FieldKind::DateTime`, admitted on the argument
//! `Email` was, and the format named once as `layout::DATE_FORMAT`. See
//! [`add_token_form`].
use makeover_layout as layout;
use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Field};
use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
use quasi_webview::Webview;
use super::Viewer;
use crate::db;
use crate::theming::ThemeOption;
/// This screen's name, as the settings strip marks the section described.
///
/// Was the `QUASI_SCREENS` switch name until `64b33b26` deleted the flag. It
/// still has one reader: `settings_tabs`'s table, where `screen: Some(..)` is
/// what tells the strip to leave the section to name its own region.
pub const SCREEN: &str = "user_ssh_keys";
/// The address this screen answers, and the one the Askama route gives up.
pub const PATH: &str = "/dashboard/tabs/ssh-keys";
/// The region the answer replaces: this section's own frame in the settings
/// strip.
///
/// Was `settings-body`, the single pane six sections shared while the sub-nav
/// was hand-written. `6b24f2df` step 4 described that nav, so each section has a
/// frame of its own and this names the one that is ours. `quasi::settings_tabs`
/// draws the frame from this constant, and its tests assert the two agree.
pub const REGION: &str = "settings-ssh-keys";
/// The address removing a key calls, relative to this screen's own nest.
const REMOVE_KEY: &str = "/keys/{id}";
/// The address revoking a token calls, relative to this screen's own nest.
const REVOKE_TOKEN: &str = "/tokens/{id}";
/// The writes this screen serves. Registered under its nest by `super::mount`.
pub const WRITES: &[(Method, &str, super::Screen)] = &[
(Method::Delete, REMOVE_KEY, remove_key),
(Method::Delete, REVOKE_TOKEN, revoke_token),
];
/// One registered key, as the screen needs it.
///
/// The description is built from these rather than from `db::DbSshKey` so the
/// shape of a screen can be tested without a database, which is most of what
/// makes a described screen cheaper to hold than a template.
pub struct KeyView {
id: String,
fingerprint: String,
label: String,
added: String,
}
/// One issued token, as the screen needs it.
pub struct TokenView {
id: String,
name: String,
scope: &'static str,
expires: String,
last_used: String,
}
/// The tab.
pub fn screen(viewer: &Viewer, _request: Request) -> Result {
let user_id = viewer.user.id;
// Three round trips, each holding this blocking thread. The thing S3
// exists to measure; see the module header on `super`.
let keys = viewer
.block_on(db::ssh_keys::list_keys_by_user(&viewer.app.db, user_id))
.map_err(|_| RouteError::internal("your keys could not be read"))?;
let tokens = viewer
.block_on(db::git_access_tokens::list_by_user(&viewer.app.db, user_id))
.map_err(|_| RouteError::internal("your tokens could not be read"))?;
let profile = viewer
.block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
.map_err(|_| RouteError::internal("your account could not be read"))?
.ok_or_else(|| RouteError::not_found("that account is gone"))?;
let keys: Vec = keys
.iter()
.map(|k| KeyView {
id: k.id.to_string(),
fingerprint: k.fingerprint.clone(),
label: k.label.clone(),
added: k.created_at.format("%b %d, %Y").to_string(),
})
.collect();
let tokens: Vec = tokens
.iter()
.map(|t| TokenView {
id: t.id.to_string(),
name: t.name.clone(),
scope: if t.can_push { "Read + push" } else { "Read" },
expires: never_or(t.expires_at),
last_used: never_or(t.last_used_at),
})
.collect();
let themes = crate::theming::console_theme_options(profile.console_theme.as_deref());
Ok(Response::fragment(
REGION,
pane(viewer.user.username.as_ref(), &keys, &tokens, &themes),
))
}
/// The id in the path, as the database wants it.
fn captured(captures: &quasi_router::Params) -> Result {
captures
.get("id")
.and_then(|id| id.parse().ok())
.ok_or_else(|| RouteError::not_found("no such thing"))
}
/// Remove one key, and answer with the pane as it now stands.
///
/// This screen's own route rather than `DELETE /api/users/me/ssh-keys/{id}`,
/// which the Askama version calls. That endpoint answers an htmx request with
/// the whole re-rendered Askama list and the Askama markup targeted
/// `#ssh-keys-list`; the described control named no target, so htmx swapped that
/// entire table into the button that was pressed. Found 2026-08-11 by reading
/// what the endpoint returns. A described write answers with the region it
/// changed, which is decision 7 working as designed.
pub fn remove_key(viewer: &Viewer, request: Request) -> Result {
// Moved out because the handler signature is quasi's: the request is
// consumed here rather than borrowed from.
let captures = request.captures;
let id = captured(&captures)?;
viewer
.block_on(db::ssh_keys::delete_key(
&viewer.app.db,
id.into(),
viewer.user.id,
))
.map_err(|_| RouteError::internal("that key could not be removed"))?;
screen(viewer, Request::get(PATH))
}
/// Revoke one token, and answer with the pane as it now stands.
pub fn revoke_token(viewer: &Viewer, request: Request) -> Result {
let captures = request.captures;
let id = captured(&captures)?;
viewer
.block_on(db::git_access_tokens::revoke(
&viewer.app.db,
id.into(),
viewer.user.id,
))
.map_err(|_| RouteError::internal("that token could not be revoked"))?;
screen(viewer, Request::get(PATH))
}
/// A date, or the word for not having one.
fn never_or(at: Option>) -> String {
at.map_or_else(|| "Never".to_owned(), |d| d.format("%b %d, %Y").to_string())
}
/// Everything inside the settings pane.
///
/// Split from the handler so a test can build it without a database, which is
/// the same split `quasi_spike` used and the reason the description layer is
/// testable at all: the screen is a value.
fn pane(username: &str, keys: &[KeyView], tokens: &[TokenView], themes: &[ThemeOption]) -> Node {
Node::Region(
Slot::new(REGION, RegionKind::Pane)
.with(Node::section("SSH Keys"))
.with(Node::text(format!(
"Manage SSH keys for git clone and push access. \
Clone URL: git@makenot.work:{username}/{{repo}}.git"
)))
.with(keys_list(keys))
.with(add_key_form())
.with(Node::section("Console theme"))
.with(Node::text(
"Color palette for your terminal dashboard over ssh makenot.work.",
))
.with(theme_form(themes))
.with(Node::section("Access Tokens (HTTPS)"))
.with(Node::text(format!(
"Personal access tokens for git over HTTPS. Use a token as the password. \
Clone URL: https://@makenot.work/{username}/{{repo}}.git"
)))
.with(tokens_list(tokens))
.with(add_token_form()),
)
}
/// The registered keys, or the sentence saying there are none.
fn keys_list(keys: &[KeyView]) -> Node {
if keys.is_empty() {
return Node::empty("No SSH keys registered.");
}
Node::Table {
// The template's four columns, in its order, the last one the empty
// header its actions sit under.
columns: vec![
Column::new("Fingerprint")
.width(layout::Width::Fill)
.priority(layout::Priority::Essential),
Column::new("Label").width(layout::Width::Content),
Column::new("Added")
.width(layout::Width::Content)
.priority(layout::Priority::Optional),
Column::new("")
.width(layout::Width::Content)
.priority(layout::Priority::Essential),
],
rows: keys
.iter()
.map(|key| {
Cells::new([
Cell::new(key.fingerprint.clone()),
Cell::new(key.label.clone()),
Cell::new(format!("Added {}", key.added)),
Cell::acts([Act::new(
"Remove",
// This screen's own route, under its own nest, so the
// answer is the pane it changed. It addressed the API
// route until 2026-08-11 and swapped a whole Askama
// table into this button; see `remove_key`.
// `awaiting` for the same reason the forms carry it: the
// answer is the whole pane rebuilt, so there is a wait
// with nothing on screen saying so. The confirm gates
// the first press, not the second one after it.
Action::delete(format!("{PATH}/keys/{}", key.id)).awaiting(),
)
// The template asked with hx-confirm. Said here, a terminal
// host asks in its own way and no host can forget to ask.
.confirm("Remove this SSH key?")
.tone(layout::Tone::Danger)]),
])
})
.collect(),
// No paging described here: every one of these tables is a
// whole set the handler already counted.
more: None,
}
}
/// The add-a-key form.
///
/// `awaiting` is the double-submit guard: this creates a record, so a second
/// submit while the first is in flight is a duplicate key. It reads as one word
/// here and the renderer locks the submit button from it, which is what
/// `frontend/src/core/loading.ts` was written to do by hand and is losing
/// ground against.
fn add_key_form() -> Node {
Node::Form {
action: Action::post("/api/users/me/ssh-keys").awaiting(),
submit: "Add SSH Key".into(),
fields: vec![
Field::new(layout::FieldKind::Textarea, "public_key", "Public Key")
.required()
.hint(
"Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file",
),
Field::new(layout::FieldKind::Text, "label", "Label"),
],
}
}
/// The console-theme picker.
fn theme_form(themes: &[ThemeOption]) -> Node {
let options: Vec = themes
.iter()
.map(|t| Choice::new(t.id.clone(), t.name.clone()))
.collect();
let mut field = Field::select("theme_id", "Console theme", options).hint(
"Following the terminal picks a light or dark palette from what your terminal reports. \
Separate from your profile theme, which is what visitors see.",
);
if let Some(chosen) = themes.iter().find(|t| t.selected) {
field = field.value(chosen.id.clone());
}
Node::Form {
// A PUT, so a second submit overwrites rather than duplicating. Marked
// anyway: the wait is real and the reader has no other way to tell the
// save landed from the save being slow.
action: Action::put("/api/users/me/console-theme").awaiting(),
submit: "Save Theme".into(),
fields: vec![field],
}
}
/// The issued tokens, or the sentence saying there are none.
fn tokens_list(tokens: &[TokenView]) -> Node {
if tokens.is_empty() {
return Node::empty("No access tokens.");
}
Node::Table {
// The four independent facts the list version had to run together into
// one `meta` string, back in their own columns. This is the table that
// lost the most by being a list: name, scope, expiry and last use are
// read down the column, which is what a table is for.
columns: vec![
Column::new("Name")
.width(layout::Width::Fill)
.priority(layout::Priority::Essential),
Column::new("Scope").width(layout::Width::Content),
Column::new("Expires")
.width(layout::Width::Content)
.priority(layout::Priority::Optional),
Column::new("Last used")
.width(layout::Width::Content)
.priority(layout::Priority::Optional),
Column::new("")
.width(layout::Width::Content)
.priority(layout::Priority::Essential),
],
rows: tokens
.iter()
.map(|token| {
Cells::new([
Cell::new(token.name.clone()),
Cell::new(token.scope),
Cell::new(token.expires.clone()),
Cell::new(token.last_used.clone()),
Cell::acts([Act::new(
"Revoke",
Action::delete(format!("{PATH}/tokens/{}", token.id)).awaiting(),
)
.confirm("Revoke this token?")
.tone(layout::Tone::Danger)]),
])
})
.collect(),
// No paging described here: every one of these tables is a
// whole set the handler already counted.
more: None,
}
}
/// The mint-a-token form.
///
/// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an
/// Askama form that spelled it ``: no native picker, no
/// platform validation, and the hint doing both jobs in prose. That was a
/// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed it
/// at makeover-layout 0.15.0. It did become one word, and the hint came out with
/// it: the format is the description's now, `layout::DATE_FORMAT`, so saying it
/// again here would be a second place for it to drift.
fn add_token_form() -> Node {
Node::Form {
// Creates a record, and unlike an SSH key the answer is a secret shown
// once. Two of these from one impatient double-press is two tokens, one
// of which the creator never sees and cannot recognise later.
action: Action::post("/api/users/me/git-tokens").awaiting(),
submit: "Create Token".into(),
fields: vec![
Field::new(layout::FieldKind::Text, "name", "Name").required(),
Field::new(layout::FieldKind::Date, "expires_on", "Expires (optional)")
.hint("Leave blank for a token that does not expire."),
Field::new(
layout::FieldKind::Checkbox,
"can_push",
"Allow push (write access)",
),
],
}
}
/// The renderer this screen is drawn with.
///
/// Per request because `Adapter::per_viewer` builds one per request, and this
/// screen has nothing viewer-specific to say to it yet. It will when S2 puts the
/// site chrome here.
pub fn renderer(viewer: &Viewer) -> Webview {
// The fragment path never emits a document, so the shell's asset paths do
// not arise here. It is still the one the rest of the site uses, so a screen
// that later answers as a whole page cannot disagree with `crate::shell`.
Webview::new().with_shell(viewer.shell())
}
#[cfg(test)]
mod tests {
use super::*;
use quasi_axum::Serves;
fn key(id: &str, fingerprint: &str) -> KeyView {
KeyView {
id: id.into(),
fingerprint: fingerprint.into(),
label: "laptop".into(),
added: "Aug 10, 2026".into(),
}
}
fn render(node: &Node) -> String {
Webview::new().fragment(node)
}
#[test]
fn the_region_matches_what_the_strip_draws() {
// The router says what it changed, through HX-Retarget. If this and the
// frame the settings strip draws ever disagree the section swaps into
// nothing, and that failure is invisible to every other test.
//
// Read off the described strip since `6b24f2df` step 4. It was the
// hand-written nav's `hx-target`, which no longer exists.
let nav = crate::quasi::settings_tabs::html(
0,
"",
crate::quasi::settings_tabs::Gates {
has_media: true,
git_enabled: true,
has_mt_memberships: true,
has_sync_apps: true,
},
);
assert!(
nav.contains(&format!("id=\"{REGION}\"")),
"the settings strip draws #{REGION}:\n{nav}"
);
assert!(nav.contains(&format!("hx-get=\"{PATH}\"")), "{nav}");
}
#[test]
fn an_empty_account_says_so_rather_than_showing_two_empty_lists() {
let html = render(&pane("max", &[], &[], &[]));
assert!(html.contains("No SSH keys registered."));
assert!(html.contains("No access tokens."));
}
#[test]
fn the_clone_urls_carry_the_viewers_own_name_and_cannot_smuggle_markup() {
let html = render(&pane("max", &[], &[], &[]));
assert!(html.contains("git@makenot.work:max/{repo}.git"));
// A username reaches this from the session, and the session from a
// signup form. The description layer escapes every string it is handed;
// this is the check that the format! above did not route around it.
let hostile = render(&pane("", &[], &[], &[]));
assert!(!hostile.contains("