//! 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("