//! 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 quasi_declare::declare;
use quasi_router::screen::Choice;
use quasi_router::{Method, Request, Response, RouteError};
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.
/// The three reads this pane makes, for the mount that serves it from a
/// residual.
///
/// Everything the pane needs from one call, because the mount answers the
/// screen and its markup from one read: two closures read the database twice
/// and can disagree about what they saw.
///
/// Each of these round trips holds a blocking thread. The thing S3 exists to
/// measure; see the module header on `super`.
pub(crate) fn reading(
viewer: &Viewer,
) -> Result<(String, Vec, Vec, Vec), RouteError> {
let user = viewer.reader()?;
let user_id = user.id;
let username = user.username.as_ref().to_owned();
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((username, keys, tokens, themes))
}
pub fn screen(viewer: &Viewer, _request: Request) -> Result {
let (username, keys, tokens, themes) = reading(viewer)?;
Ok(Response::fragment(
REGION,
pane(&username, &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`, so a described control naming no target has htmx swap that
/// entire table into the button that was pressed. A described write answers with
/// the region it changed.
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.reader()?.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.reader()?.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())
}
declare! {
/// 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.
#[staged]
pub(crate) shape pane(
username: &str,
keys: &[KeyView],
tokens: &[TokenView],
themes: &[ThemeOption],
) -> Node;
region REGION as Pane {
section "SSH Keys";
text "Manage SSH keys for git clone and push access. \
Clone URL: git@makenot.work:{username}/{{repo}}.git";
include each keys_list(keys);
include add_key_form();
section "Console theme";
text "Color palette for your terminal dashboard over ssh makenot.work.";
include theme_form(themes);
section "Access Tokens (HTTPS)";
text "Personal access tokens for git over HTTPS. Use a token as the password. \
Clone URL: https://@makenot.work/{username}/{{repo}}.git";
include each tokens_list(tokens);
include add_token_form();
}
}
declare! {
/// The registered keys, or the sentence saying there are none.
///
/// The template's four columns, in its order, the last one the empty header
/// its actions sit under.
///
/// The cells stay positional because the column list is right here to read
/// against them: four columns, four cells in every row, and no branch that
/// drops one. Naming would buy nothing a reader cannot already see.
///
/// No paging described here either: every one of these tables is a whole
/// set the handler already counted.
#[staged]
shape keys_list(keys: &[KeyView]) -> Vec;
empty "No SSH keys registered." when keys.is_empty();
table {
column "Fingerprint" {
width Fill;
priority Essential;
}
column "Label" {
width Content;
}
column "Added" {
width Content;
priority Optional;
}
column "" {
width Content;
priority Essential;
}
for key in keys.iter() {
cells {
// `19d7602d`. A SHA256 fingerprint is a run of characters a
// reader compares against another one, and a proportional
// face makes that harder than it has to be. Plain rather
// than classified: nothing lexed it and nothing should, so
// what this buys is the monospace and not a colour.
cell "" {
literal key.fingerprint.clone();
}
cell key.label.clone();
cell "Added {key.added}";
cell "" {
// 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.
act "Remove" to delete "{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 Danger;
}
}
}
}
} unless keys.is_empty();
}
declare! {
/// 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.
#[staged]
#[constant]
shape add_key_form() -> Node;
form post "/api/users/me/ssh-keys" awaiting {
submit "Add SSH Key";
field Textarea "public_key" "Public Key" {
required;
hint "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file";
}
field Text "label" "Label";
}
}
declare! {
/// The console-theme picker.
#[staged]
shape theme_form(themes: &[ThemeOption]) -> Node;
// 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.
form put "/api/users/me/console-theme" awaiting {
submit "Save Theme";
field Select "theme_id" "Console theme" {
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.";
// Marked per option rather than by a `value` the renderer compares
// against each one. `ThemeOption` already knows per row, so the
// old spelling collapsed that to one string for makeover-webview
// to re-derive, which is one fact stated twice -- and it is the
// form a residual cannot hold, since a compiled loop body cannot
// carry "exactly one row differs". quasicoherent `c32bb877`.
for theme in themes.iter() {
option Choice::new(theme.id.clone(), theme.name.clone()) {
chosen when theme.selected;
}
}
}
}
}
declare! {
/// The issued tokens, or the sentence saying there are none.
///
/// 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.
///
/// Positional for the same reason the key table is: the columns are in this
/// declaration, every row carries all five cells, and nothing branches.
///
/// No paging described here either: every one of these tables is a whole
/// set the handler already counted.
#[staged]
shape tokens_list(tokens: &[TokenView]) -> Vec;
empty "No access tokens." when tokens.is_empty();
table {
column "Name" {
width Fill;
priority Essential;
}
column "Scope" {
width Content;
}
column "Expires" {
width Content;
priority Optional;
}
column "Last used" {
width Content;
priority Optional;
}
column "" {
width Content;
priority Essential;
}
for token in tokens.iter() {
cells {
cell token.name.clone();
cell token.scope;
cell token.expires.clone();
cell token.last_used.clone();
cell "" {
act "Revoke" to delete "{PATH}/tokens/{token.id}" awaiting {
confirm "Revoke this token?";
tone Danger;
}
}
}
}
} unless tokens.is_empty();
}
declare! {
/// 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.
#[staged]
#[constant]
shape add_token_form() -> Node;
// 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.
form post "/api/users/me/git-tokens" awaiting {
submit "Create Token";
field Text "name" "Name" {
required;
}
field Date "expires_on" "Expires (optional)" {
hint "Leave blank for a token that does not expire.";
}
field 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())
}
/// One key as the tests draw it.
///
/// Module-level rather than inside `mod tests` because `quasi::residuals` needs
/// one too, and `KeyView` is this module's own type. Test-only.
#[cfg(test)]
pub(crate) fn sample_key(id: &str, fingerprint: &str) -> KeyView {
KeyView {
id: id.into(),
fingerprint: fingerprint.into(),
label: "laptop".into(),
added: "Aug 10, 2026".into(),
}
}
/// One token as the tests draw it. See [`sample_key`].
#[cfg(test)]
pub(crate) fn sample_token(id: &str, name: &str) -> TokenView {
TokenView {
id: id.into(),
name: name.into(),
scope: "Read + push",
expires: "Never".into(),
last_used: "Never".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use quasi_axum::Serves;
use quasi_router::Node;
use super::{sample_key as key, sample_token};
fn render(node: &Node) -> String {
Webview::new().fragment(node)
}
/// The same, for a shape answering a run of nodes rather than one.
///
/// `keys_list` and `tokens_list` each say a sentence or a table, which is
/// two emissions and therefore a `Vec`; the pane splices them with
/// `include each`. Rendered here the way the pane's region renders them.
fn render_all(nodes: &[Node]) -> String {
nodes.iter().map(render).collect()
}
#[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("