Skip to main content

max / makenotwork

Describe the SSH-keys tab, behind its own switch Step S3. The first authenticated screen through the description layer, on Q1's per-request state, and the reason it is this screen is that it isolates what the conversion actually asks: two lists with a destructive act per row, two forms, a picker, and no bespoke markup worth keeping. It is off everywhere. QUASI_SCREENS=user_ssh_keys turns it on, and with the variable unset the Askama handler is registered exactly as before and the adapter is not in the stack at all. crate::auth::authenticate is split out of the AuthUser extractor because the extractor is no longer the only caller: the state factory holds a Session out of the same extensions and needs the same answer. Split rather than copied, because the revocation rules are the security-relevant part and a second copy is a second place for them to fall behind. Three things found by doing it, all of them the point of doing it first: - It cannot render the same page as the template, and should not. The Askama tab renders two empty divs that fetch themselves on load, so opening it costs three round trips and shows two "Loading..." lines. A description has no word for that and should not have one. So the parity harness cannot cover this conversion, which is worth knowing before S4 leans on it. - A table row cannot carry an action. Node::Table's rows are Cells, which offer activate and nothing else, so both tables are lists here and their column headers are gone. Filed on quasicoherent, 022f0c59. - FieldKind has Email, Url and Tel but no Date, so the token expiry is a text box with a format hint. Same argument the other three were admitted on. Filed on quasicoherent, 367f20ec. The load scenario now asks for this tab under its own label and the runner reads QUASI_SCREENS instead of hardcoding it off, so the one open cost is measured by running the harness twice rather than argued about.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 19:18 UTC
Signed with PGP, not checked
Commit: 05d851d433667f246aa9f3b68d299fca62d78c51
Parent: 147c183
9 files changed, +591 insertions, -12 deletions
@@ -155,6 +155,14 @@
155 155 # here is measured rather than argued. Not load-bearing for any shipped route.
156 156 # See wiki look-wave-2, tier G.
157 157 quasi-router = { git = "https://makenot.work/git/max/quasi.git" }
158 + # The description vocabulary quasi's screen types are built from. Pinned here
159 + # rather than reached through quasi-router's re-export because a described
160 + # screen names FieldKind and Tone directly; it has to track what quasi-router
161 + # resolves or the two `layout::` paths are different crates.
162 + makeover-layout = "0.12.0"
163 + # For the request head the per-viewer state factory reads. axum re-exports it,
164 + # but the factory's signature is quasi-axum's and names `http::request::Parts`.
165 + http = "1.3.1"
158 166 quasi-axum = { git = "https://makenot.work/git/max/quasi.git" }
159 167 quasi-webview = { git = "https://makenot.work/git/max/quasi.git" }
160 168
@@ -172,6 +172,27 @@
172 172 .get::<Session>()
173 173 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
174 174
175 + authenticate(session, state).await.map(AuthUser)
176 + }
177 + }
178 +
179 + /// Resolve the signed-in user from a session, or refuse.
180 + ///
181 + /// The whole of [`AuthUser`]'s work, split out because the extractor is no
182 + /// longer the only caller: a described screen is served by quasi's adapter,
183 + /// which resolves the state a request is answered against from the request
184 + /// head, in async context, before the sync router runs. It holds a [`Session`]
185 + /// out of the same extensions the extractor reads and needs the same answer.
186 + ///
187 + /// Split rather than duplicated for the obvious reason and one less obvious
188 + /// one: the revocation rules here are the security-relevant part (a session
189 + /// with no tracking row is refused, a touch that fails invalidates), and a
190 + /// second copy is a second place for them to fall behind.
191 + pub async fn authenticate(
192 + session: &Session,
193 + state: &crate::AppState,
194 + ) -> Result<SessionUser, AppError> {
195 + {
175 196 let user: SessionUser = session
176 197 .get(USER_SESSION_KEY)
177 198 .await
@@ -248,7 +269,7 @@
248 269 // (DB queries, error handlers, etc.) include it automatically.
249 270 tracing::Span::current().record("user_id", tracing::field::display(&user.id));
250 271
251 - Ok(AuthUser(user))
272 + Ok(user)
252 273 }
253 274 }
254 275
@@ -48,6 +48,7 @@
48 48 pub mod openapi;
49 49 pub mod payments;
50 50 pub mod pricing;
51 + pub mod quasi;
51 52 pub mod quasi_spike;
52 53 pub mod rate_limit;
53 54 pub mod routes;
@@ -554,7 +555,10 @@
554 555 .merge(build_routes())
555 556 .finalize();
556 557 let app = Router::new()
557 - .merge(page_routes(state.config.rate_limits))
558 + .merge(page_routes(
559 + state.config.rate_limits,
560 + &state.config.quasi_screens,
561 + ))
558 562 .merge(sso_routes())
559 563 .merge(csrf_routes)
560 564 .merge(git_routes())
@@ -585,6 +589,17 @@
585 589 .fallback(routes::custom_domain::custom_domain_fallback)
586 590 .with_state(state.clone());
587 591
592 + // The description layer, when a screen is switched on. Nested as a service
593 + // for the same reason the spike is: the adapter mounts as a fallback and
594 + // this server already has one. Mounted after `with_state` because the
595 + // adapter carries its own state, resolved per request, and takes none from
596 + // axum. The Askama route for a described screen is not registered (see
597 + // `dashboard_routes`), so nothing here overlaps.
598 + let app = match quasi::router(&state) {
599 + Some(described) => app.nest_service("/dashboard/tabs/ssh-keys", described),
600 + None => app,
601 + };
602 +
588 603 // There is no /metrics scrape endpoint. Prometheus and Grafana were retired
589 604 // on 2026-07-21 and PoM is the monitoring story, so the endpoint had no
590 605 // consumer left. The recorder itself stays: the admin metrics dashboard
@@ -71,7 +71,14 @@
71 71 // The load runner drives thousands of requests from one IP, so the
72 72 // production limiter would measure the limiter rather than the server.
73 73 rate_limits: makenotwork::constants::RateLimits::relaxed(),
74 - quasi_screens: makenotwork::config::QuasiScreens::default(),
74 + // Read from the environment rather than defaulted off, because the
75 + // conversion's one open cost is measured by running this twice: once
76 + // with every screen on Askama and once with QUASI_SCREENS naming the
77 + // described one. A default here would silently measure the same side
78 + // twice. See wiki `mnw-server-conversion-plan`, S3.
79 + quasi_screens: makenotwork::config::QuasiScreens::parse(
80 + &std::env::var("QUASI_SCREENS").unwrap_or_default(),
81 + ),
75 82 build: BuildConfig {
76 83 trigger_token: None,
77 84 host_linux: None,
@@ -399,7 +399,19 @@
399 399 }
400 400 sleep(think_time).await;
401 401
402 - let tabs = ["details", "payments", "projects", "creator", "promotions"];
402 + // ssh-keys is the described screen (S3). It is in the mix rather than
403 + // measured alone because the question is what a described route does to the
404 + // rest of the server, not what it costs on an idle box: it holds a
405 + // blocking-pool thread for three database round trips while everything
406 + // else contends for the same pool.
407 + let tabs = [
408 + "details",
409 + "payments",
410 + "projects",
411 + "creator",
412 + "promotions",
413 + "ssh-keys",
414 + ];
403 415
404 416 while Instant::now() < deadline {
405 417 timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await;
@@ -407,7 +419,16 @@
407 419
408 420 for tab in &tabs {
409 421 let url = format!("/dashboard/tabs/{tab}");
410 - timed_htmx_get(&mut client, &url, "HTMX /dashboard/tabs/{tab}", &metrics).await;
422 + // ssh-keys reports under its own label. The others share one on
423 + // purpose (they are one population), but the described screen is
424 + // the population being compared, and folding it into the rest
425 + // would average away exactly the number S3 is after.
426 + let label = if *tab == "ssh-keys" {
427 + "HTMX /dashboard/tabs/ssh-keys"
428 + } else {
429 + "HTMX /dashboard/tabs/{tab}"
430 + };
431 + timed_htmx_get(&mut client, &url, label, &metrics).await;
411 432 sleep(think_time).await;
412 433 }
413 434
@@ -19,11 +19,14 @@
19 19 /// merged `email_actions`/`sandbox`/`feeds`/`blog` as bare `Router`s, and
20 20 /// `email_actions`'s `POST /forgot-password` (plus `sandbox`'s `POST /sandbox`)
21 21 /// skipped the envelope. `finalize()` drops the wrapper exactly once, here.
22 - pub fn page_routes(limits: crate::constants::RateLimits) -> Router<AppState> {
22 + pub fn page_routes(
23 + limits: crate::constants::RateLimits,
24 + screens: &crate::config::QuasiScreens,
25 + ) -> Router<AppState> {
23 26 CsrfRouter::new()
24 27 .merge(public::public_routes(limits))
25 28 .merge(sandbox::sandbox_routes(limits))
26 - .merge(dashboard::dashboard_routes())
29 + .merge(dashboard::dashboard_routes(screens))
27 30 .merge(email_actions::email_action_routes(limits))
28 31 .merge(feeds::feed_routes())
29 32 .merge(blog::blog_routes())
@@ -48,7 +48,7 @@
48 48 }
49 49
50 50 /// Register dashboard page routes.
51 - pub(crate) fn dashboard_routes() -> CsrfRouter<AppState> {
51 + pub(crate) fn dashboard_routes(screens: &crate::config::QuasiScreens) -> CsrfRouter<AppState> {
52 52 let read_rate_limit = crate::helpers::rate_limiter_ms(
53 53 constants::DASHBOARD_READ_RATE_LIMIT_MS,
54 54 constants::DASHBOARD_READ_RATE_LIMIT_BURST,
@@ -79,11 +79,10 @@
79 79 .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
80 80 .route_get("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums))
81 81 .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
82 - .route_get(
83 - "/dashboard/tabs/ssh-keys",
84 - get(tabs::dashboard_tab_ssh_keys),
85 - )
86 82 .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
83 + // The SSH-keys tab is registered below rather than here: when its
84 + // screen is switched on, `crate::quasi` serves this address instead and
85 + // axum panics on two routes claiming one path.
87 86 .route_get(
88 87 "/dashboard/tabs/contacts",
89 88 get(tabs::dashboard_tab_contacts),
@@ -158,6 +157,18 @@
158 157 )
159 158 .route_layer(GovernorLayer::new(read_rate_limit));
160 159
160 + // The one screen the description layer serves, when it is switched on. The
161 + // Askama handler stays registered and reachable in every other deployment,
162 + // which is what makes the conversion revert by editing an env var.
163 + let tab_routes = if screens.enabled(crate::quasi::ssh_keys::SCREEN) {
164 + tab_routes
165 + } else {
166 + tab_routes.route_get(
167 + "/dashboard/tabs/ssh-keys",
168 + get(tabs::dashboard_tab_ssh_keys),
169 + )
170 + };
171 +
161 172 CsrfRouter::new()
162 173 .merge(wizards::wizard_routes())
163 174 .route_get("/dashboard", get(main::dashboard))
@@ -1,0 +1,123 @@
1 + //! The description layer: screens served through quasi rather than Askama.
2 + //!
3 + //! Wiki note `mnw-server-conversion-plan`, step S3 onward. `crate::shell`
4 + //! already put the document head under quasi's renderer (S1); this is where
5 + //! screens themselves start moving. One screen at a time, each behind
6 + //! [`QuasiScreens`](crate::config::QuasiScreens), so a conversion ships when
7 + //! its own test is green and reverts by editing an env var.
8 + //!
9 + //! # Why a per-request state and not a per-request handler argument
10 + //!
11 + //! `quasi_router::Handler<S> = fn(&S, Params)`. `S` is the app, and on every
12 + //! other host in the tree there is one app and one viewer for the life of the
13 + //! process. A server has one process and many viewers, and that is the only
14 + //! assumption it breaks, so the fix is that the adapter builds `S` per request
15 + //! rather than that the handler grows a parameter. `Adapter::per_viewer` is
16 + //! that: the factory runs in async context, where the session lookup already
17 + //! lives, and hands the sync router a [`Viewer`] with the answer already in it.
18 + //!
19 + //! # The cost this exists to measure
20 + //!
21 + //! The router is sync (quasi's decision 6, taken for hosts with no runtime), so
22 + //! quasi-axum dispatches on `spawn_blocking` and a handler reaching sqlx does it
23 + //! through `Handle::block_on`. Every described request therefore holds a
24 + //! blocking-pool thread for the length of its database round trips. That is
25 + //! fine on a desktop app over rusqlite and is an open question on the one host
26 + //! in the tree with many concurrent readers. It is not arguable, only
27 + //! measurable: see `tests/load` and the S3 numbers in the wiki note.
28 +
29 + use tokio::runtime::Handle;
30 +
31 + use crate::AppState;
32 + use crate::auth::SessionUser;
33 +
34 + pub mod ssh_keys;
35 +
36 + /// The state one request is answered against.
37 + ///
38 + /// Built per request by the adapter's factory, which is the whole of quasi's
39 + /// answer to a server: everything resolvable from the request head is loaded
40 + /// before the sync router runs, and a handler reads it off `&S` the way every
41 + /// other host's handler reads its app.
42 + pub struct Viewer {
43 + /// The long-lived application state. Cloning it clones handles, not data.
44 + pub app: AppState,
45 + /// Who is asking. Resolved and revocation-checked by
46 + /// [`crate::auth::authenticate`], the same path the extractor takes.
47 + pub user: SessionUser,
48 + /// The runtime the request arrived on, so a sync handler can reach the
49 + /// async database. Captured in the factory rather than read inside the
50 + /// handler: `Handle::current` works on a blocking thread today, and
51 + /// depending on that is depending on where quasi-axum happens to dispatch.
52 + pub runtime: Handle,
53 + }
54 +
55 + impl Viewer {
56 + /// Run a database future from inside a sync handler.
57 + ///
58 + /// The blocking hop, named in one place so the thing being measured is
59 + /// countable rather than spread across every handler. Every call holds this
60 + /// blocking thread until the query answers.
61 + pub fn block_on<F: Future>(&self, future: F) -> F::Output {
62 + self.runtime.block_on(future)
63 + }
64 + }
65 +
66 + /// Build the state factory the adapter calls per request.
67 + ///
68 + /// Refuses with `Unauthorized` when there is no session to resolve, which the
69 + /// adapter turns into a bare status with no body. That is the right shape here
70 + /// and not a shortcut: a store that will not answer is not a signed-out reader,
71 + /// and rendering a sign-in notice would need the renderer that is built from
72 + /// the state that could not be resolved.
73 + fn viewer_factory(
74 + app: AppState,
75 + ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static {
76 + move |parts| {
77 + let app = app.clone();
78 + let runtime = Handle::current();
79 + // Taken out of the head first, so the future owns what it needs.
80 + let session = parts.extensions.get::<tower_sessions::Session>().cloned();
81 + Box::pin(async move {
82 + let Some(session) = session else {
83 + // The session layer runs in front of this. Its absence is a
84 + // wiring mistake rather than a signed-out reader.
85 + return Err(quasi_router::RouteError::internal("no session layer"));
86 + };
87 + match crate::auth::authenticate(&session, &app).await {
88 + Ok(user) => Ok(Viewer { app, user, runtime }),
89 + Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")),
90 + }
91 + })
92 + }
93 + }
94 +
95 + /// Mount every converted screen, or nothing.
96 + ///
97 + /// Returns `None` when no screen in this module is switched on, so the caller
98 + /// registers its Askama routes exactly as before and the adapter is not in the
99 + /// stack at all. A conversion is a startup-time choice: config is read once,
100 + /// and a per-request branch would pay for a switch that never moves.
101 + pub fn router(app: &AppState) -> Option<axum::Router> {
102 + let screens = &app.config.quasi_screens;
103 + if !screens.enabled(ssh_keys::SCREEN) {
104 + return None;
105 + }
106 +
107 + let quasi = quasi_router::Router::<Viewer>::new().get("/", ssh_keys::screen);
108 + Some(
109 + quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), |viewer, _, _| {
110 + ssh_keys::renderer(viewer)
111 + })
112 + .into_router(),
113 + )
114 + }
115 +
116 + /// Whether the SSH-keys tab serves from the description layer.
117 + ///
118 + /// Read by the dashboard's route table, which mounts one or the other. Here
119 + /// rather than there so the switch and the screen it names stay together.
120 + #[must_use]
121 + pub fn ssh_keys_described(app: &AppState) -> bool {
122 + app.config.quasi_screens.enabled(ssh_keys::SCREEN)
123 + }
@@ -1,0 +1,370 @@
1 + //! The SSH-keys settings tab, described.
2 + //!
3 + //! The first authenticated screen through the description layer, chosen because
4 + //! it isolates what the conversion is actually asking: two lists, each with a
5 + //! destructive per-row action, two add forms and a picker, no bespoke markup
6 + //! worth keeping, and it is where the console-theme work already landed.
7 + //!
8 + //! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_ssh_keys`,
9 + //! which answers the same address from Askama when the screen is switched off.
10 + //!
11 + //! # It does not render the same page, on purpose
12 + //!
13 + //! The Askama tab renders two empty divs that fetch their own contents on load:
14 + //! `#ssh-keys-list` and `#git-tokens-list` each carry `hx-trigger="load"`, so
15 + //! opening the tab costs three round trips and shows two "Loading..." lines on
16 + //! the way. A description has no word for "an empty region that fetches itself",
17 + //! and should not: the handler is already in the request, and a list it can read
18 + //! now is a list the reader should not wait for twice.
19 + //!
20 + //! So this renders both lists inline, in one response. That is a better screen
21 + //! and it is also why **the parity harness cannot cover this conversion** — the
22 + //! two renderings differ by design rather than by accident. See the S3 notes in
23 + //! wiki `mnw-server-conversion-plan`.
24 + //!
25 + //! It is also what makes the screen worth measuring. The Askama tab handler runs
26 + //! one query; this one runs three, which is what a described dashboard screen
27 + //! will typically look like, and therefore what the blocking-pool question is
28 + //! actually about.
29 + //!
30 + //! # Two vocabulary gaps this screen hit
31 + //!
32 + //! Both filed on quasicoherent rather than worked around silently.
33 + //!
34 + //! 1. **A table row cannot carry an action.** `Node::Table`'s rows are
35 + //! [`Cells`], which offer `activate` and nothing else, so a table whose rows
36 + //! each have a Remove button cannot be said. `Node::List` can say it, so both
37 + //! lists here are lists, and the column headers the templates had are gone.
38 + //! That is a real loss on the tokens list, which has four columns.
39 + //! 2. **No date field.** The token form's expiry was `<input type="date">` and
40 + //! is now `Text`. See [`add_token_form`].
41 +
42 + use makeover_layout as layout;
43 + use quasi_router::screen::{Act, Choice, Field, Row};
44 + use quasi_router::{Action, Node, Params, RegionKind, Response, RouteError, Slot};
45 + use quasi_webview::{Shell, Webview};
46 +
47 + use super::Viewer;
48 + use crate::db;
49 + use crate::theming::ThemeOption;
50 +
51 + /// The conversion switch's name for this screen. `QUASI_SCREENS=user_ssh_keys`.
52 + pub const SCREEN: &str = "user_ssh_keys";
53 +
54 + /// The region the answer replaces: the settings pane the tab nav targets.
55 + ///
56 + /// The nav's own `hx-target` says the same thing. Naming it here is what lets
57 + /// the router say what it changed rather than leaving the client to infer it
58 + /// from which link was clicked, and the two agreeing is checked below.
59 + const REGION: &str = "settings-body";
60 +
61 + /// One registered key, as the screen needs it.
62 + ///
63 + /// The description is built from these rather than from `db::DbSshKey` so the
64 + /// shape of a screen can be tested without a database, which is most of what
65 + /// makes a described screen cheaper to hold than a template.
66 + pub struct KeyView {
67 + id: String,
68 + fingerprint: String,
69 + label: String,
70 + added: String,
71 + }
72 +
73 + /// One issued token, as the screen needs it.
74 + pub struct TokenView {
75 + id: String,
76 + name: String,
77 + scope: &'static str,
78 + expires: String,
79 + last_used: String,
80 + }
81 +
82 + /// The tab.
83 + pub fn screen(viewer: &Viewer, _params: Params) -> Result<Response, RouteError> {
84 + let user_id = viewer.user.id;
85 +
86 + // Three round trips, each holding this blocking thread. The thing S3
87 + // exists to measure; see the module header on `super`.
88 + let keys = viewer
89 + .block_on(db::ssh_keys::list_keys_by_user(&viewer.app.db, user_id))
90 + .map_err(|_| RouteError::internal("your keys could not be read"))?;
91 + let tokens = viewer
92 + .block_on(db::git_access_tokens::list_by_user(&viewer.app.db, user_id))
93 + .map_err(|_| RouteError::internal("your tokens could not be read"))?;
94 + let profile = viewer
95 + .block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
96 + .map_err(|_| RouteError::internal("your account could not be read"))?
97 + .ok_or_else(|| RouteError::not_found("that account is gone"))?;
98 +
99 + let keys: Vec<KeyView> = keys
100 + .iter()
101 + .map(|k| KeyView {
102 + id: k.id.to_string(),
103 + fingerprint: k.fingerprint.clone(),
104 + label: k.label.clone(),
105 + added: k.created_at.format("%b %d, %Y").to_string(),
106 + })
107 + .collect();
108 + let tokens: Vec<TokenView> = tokens
109 + .iter()
110 + .map(|t| TokenView {
111 + id: t.id.to_string(),
112 + name: t.name.clone(),
113 + scope: if t.can_push { "Read + push" } else { "Read" },
114 + expires: never_or(t.expires_at),
115 + last_used: never_or(t.last_used_at),
116 + })
117 + .collect();
118 + let themes = crate::theming::console_theme_options(profile.console_theme.as_deref());
119 +
120 + Ok(Response::fragment(
121 + REGION,
122 + pane(&viewer.user.username.to_string(), &keys, &tokens, &themes),
123 + ))
124 + }
125 +
126 + /// A date, or the word for not having one.
127 + fn never_or(at: Option<chrono::DateTime<chrono::Utc>>) -> String {
128 + at.map_or_else(|| "Never".to_owned(), |d| d.format("%b %d, %Y").to_string())
129 + }
130 +
131 + /// Everything inside the settings pane.
132 + ///
133 + /// Split from the handler so a test can build it without a database, which is
134 + /// the same split `quasi_spike` used and the reason the description layer is
135 + /// testable at all: the screen is a value.
136 + fn pane(username: &str, keys: &[KeyView], tokens: &[TokenView], themes: &[ThemeOption]) -> Node {
137 + Node::Region(
138 + Slot::new(REGION, RegionKind::Pane)
139 + .with(Node::section("SSH Keys"))
140 + .with(Node::text(format!(
141 + "Manage SSH keys for git clone and push access. \
142 + Clone URL: git@makenot.work:{username}/{{repo}}.git"
143 + )))
144 + .with(keys_list(keys))
145 + .with(add_key_form())
146 + .with(Node::section("Console theme"))
147 + .with(Node::text(
148 + "Color palette for your terminal dashboard over ssh makenot.work.",
149 + ))
150 + .with(theme_form(themes))
151 + .with(Node::section("Access Tokens (HTTPS)"))
152 + .with(Node::text(format!(
153 + "Personal access tokens for git over HTTPS. Use a token as the password. \
154 + Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git"
155 + )))
156 + .with(tokens_list(tokens))
157 + .with(add_token_form()),
158 + )
159 + }
160 +
161 + /// The registered keys, or the sentence saying there are none.
162 + fn keys_list(keys: &[KeyView]) -> Node {
163 + if keys.is_empty() {
164 + return Node::empty("No SSH keys registered.");
165 + }
166 + Node::list(keys.iter().map(|key| {
167 + Row::new(key.fingerprint.clone())
168 + .secondary(key.label.clone())
169 + .meta(format!("Added {}", key.added))
170 + .act(
171 + Act::new(
172 + "Remove",
173 + Action::post(format!("/api/users/me/ssh-keys/{}/delete", key.id)),
174 + )
175 + // The template asked with hx-confirm. Said here, a terminal host
176 + // asks in its own way and no host can forget to ask.
177 + .confirm("Remove this SSH key?")
178 + .tone(layout::Tone::Danger),
179 + )
180 + }))
181 + }
182 +
183 + /// The add-a-key form.
184 + fn add_key_form() -> Node {
185 + Node::Form {
186 + action: Action::post("/api/users/me/ssh-keys"),
187 + submit: "Add SSH Key".into(),
188 + fields: vec![
189 + Field::new(layout::FieldKind::Textarea, "public_key", "Public Key")
190 + .required()
191 + .hint(
192 + "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file",
193 + ),
194 + Field::new(layout::FieldKind::Text, "label", "Label"),
195 + ],
196 + }
197 + }
198 +
199 + /// The console-theme picker.
200 + fn theme_form(themes: &[ThemeOption]) -> Node {
201 + let options: Vec<Choice> = themes
202 + .iter()
203 + .map(|t| Choice::new(t.id.clone(), t.name.clone()))
204 + .collect();
205 + let mut field = Field::select("theme_id", "Console theme", options).hint(
206 + "Following the terminal picks a light or dark palette from what your terminal reports. \
207 + Separate from your profile theme, which is what visitors see.",
208 + );
209 + if let Some(chosen) = themes.iter().find(|t| t.selected) {
210 + field = field.value(chosen.id.clone());
211 + }
212 + Node::Form {
213 + action: Action::post("/api/users/me/console-theme"),
214 + submit: "Save Theme".into(),
215 + fields: vec![field],
216 + }
217 + }
218 +
219 + /// The issued tokens, or the sentence saying there are none.
220 + fn tokens_list(tokens: &[TokenView]) -> Node {
221 + if tokens.is_empty() {
222 + return Node::empty("No access tokens.");
223 + }
224 + Node::list(tokens.iter().map(|token| {
225 + Row::new(token.name.clone())
226 + .secondary(token.scope)
227 + .meta(format!(
228 + "Expires {} - last used {}",
229 + token.expires, token.last_used
230 + ))
231 + .act(
232 + Act::new(
233 + "Revoke",
234 + Action::post(format!("/api/users/me/git-tokens/{}/delete", token.id)),
235 + )
236 + .confirm("Revoke this token?")
237 + .tone(layout::Tone::Danger),
238 + )
239 + }))
240 + }
241 +
242 + /// The mint-a-token form.
243 + ///
244 + /// `expires_on` is a `Text` field and the Askama form spelled it
245 + /// `<input type="date">`. That is a real regression in the browser (no native
246 + /// picker, no platform validation) and it is a vocabulary gap rather than a
247 + /// choice: `layout::FieldKind` has `Email`, `Url` and `Tel`, each admitted for
248 + /// exactly the reason a date qualifies, and `Date` is the one common input type
249 + /// missing. Filed on quasicoherent; when it lands this becomes one word.
250 + fn add_token_form() -> Node {
251 + Node::Form {
252 + action: Action::post("/api/users/me/git-tokens"),
253 + submit: "Create Token".into(),
254 + fields: vec![
255 + Field::new(layout::FieldKind::Text, "name", "Name").required(),
256 + Field::new(layout::FieldKind::Text, "expires_on", "Expires (optional)")
257 + .hint("YYYY-MM-DD. Leave blank for a token that does not expire."),
258 + Field::new(
259 + layout::FieldKind::Checkbox,
260 + "can_push",
261 + "Allow push (write access)",
262 + ),
263 + ],
264 + }
265 + }
266 +
267 + /// The renderer this screen is drawn with.
268 + ///
269 + /// Per request because `Adapter::per_viewer` builds one per request, and this
270 + /// screen has nothing viewer-specific to say to it yet. It will when S2 puts the
271 + /// site chrome here.
272 + pub fn renderer(_viewer: &Viewer) -> Webview {
273 + // The fragment path never emits a document, so the shell's asset paths do
274 + // not arise here. It is still the one the rest of the site uses, so a screen
275 + // that later answers as a whole page cannot disagree with `crate::shell`.
276 + Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
277 + }
278 +
279 + #[cfg(test)]
280 + mod tests {
281 + use super::*;
282 + use quasi_axum::Render;
283 +
284 + fn key(id: &str, fingerprint: &str) -> KeyView {
285 + KeyView {
286 + id: id.into(),
287 + fingerprint: fingerprint.into(),
288 + label: "laptop".into(),
289 + added: "Aug 10, 2026".into(),
290 + }
291 + }
292 +
293 + fn render(node: &Node) -> String {
294 + Webview::new().fragment(node)
295 + }
296 +
297 + #[test]
298 + fn the_region_matches_what_the_tab_nav_targets() {
299 + // The router says what it changed, through HX-Retarget. If this and the
300 + // template's hx-target ever disagree the tab swaps into nothing, and
301 + // that failure is invisible to every other test.
302 + let nav = include_str!("../../templates/partials/tabs/user_settings.html");
303 + assert!(
304 + nav.contains(&format!("hx-target=\"#{REGION}\"")),
305 + "the settings nav targets #{REGION}"
306 + );
307 + assert!(nav.contains("hx-get=\"/dashboard/tabs/ssh-keys\""));
308 + }
309 +
310 + #[test]
311 + fn an_empty_account_says_so_rather_than_showing_two_empty_lists() {
312 + let html = render(&pane("max", &[], &[], &[]));
313 + assert!(html.contains("No SSH keys registered."));
314 + assert!(html.contains("No access tokens."));
315 + }
316 +
317 + #[test]
318 + fn the_clone_urls_carry_the_viewers_own_name_and_cannot_smuggle_markup() {
319 + let html = render(&pane("max", &[], &[], &[]));
320 + assert!(html.contains("git@makenot.work:max/{repo}.git"));
321 +
322 + // A username reaches this from the session, and the session from a
323 + // signup form. The description layer escapes every string it is handed;
324 + // this is the check that the format! above did not route around it.
325 + let hostile = render(&pane("<script>x()</script>", &[], &[], &[]));
326 + assert!(!hostile.contains("<script>x()"));
327 + }
328 +
329 + #[test]
330 + fn removing_a_key_asks_first_and_every_key_asks_about_itself() {
331 + let html = render(&keys_list(&[
332 + key("k1", "SHA256:aaa"),
333 + key("k2", "SHA256:bbb"),
334 + ]));
335 +
336 + assert!(html.contains("SHA256:aaa") && html.contains("SHA256:bbb"));
337 + assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}");
338 + // The addresses are per key rather than one shared endpoint, which is
339 + // the mistake a loop over rows makes when the id is read outside it.
340 + assert!(html.contains("/api/users/me/ssh-keys/k1/delete"));
341 + assert!(html.contains("/api/users/me/ssh-keys/k2/delete"));
342 + }
343 +
344 + #[test]
345 + fn the_theme_picker_offers_every_theme_and_marks_the_chosen_one() {
346 + let themes = crate::theming::console_theme_options(Some("nord"));
347 + let html = render(&theme_form(&themes));
348 +
349 + assert!(html.contains("nord"));
350 + assert_eq!(
351 + html.matches("selected").count(),
352 + 1,
353 + "exactly one theme is current: {html}"
354 + );
355 + }
356 +
357 + #[test]
358 + fn the_forms_post_to_the_addresses_the_api_actually_answers() {
359 + // The conversion's real risk: a described form that posts somewhere the
360 + // server does not serve fails at runtime and nowhere else.
361 + let html = render(&pane("max", &[], &[], &[]));
362 + for address in [
363 + "/api/users/me/ssh-keys",
364 + "/api/users/me/console-theme",
365 + "/api/users/me/git-tokens",
366 + ] {
367 + assert!(html.contains(address), "{address} is posted to: {html}");
368 + }
369 + }
370 + }