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