Skip to main content

max / makenotwork

23.7 KB · 587 lines History Blame Raw
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 //! # The vocabulary gaps this screen found
31 //!
32 //! Both filed on quasicoherent rather than worked around silently.
33 //!
34 //! 1. **A table row could not carry an action**, so both tables here had to be
35 //! lists and lost their column headers. **Closed** by quasi@`b4e3e21`: a
36 //! table cell holds controls as well as a string, and both are tables again.
37 //! 2. **No date field.** The token form's expiry was `<input type="date">` and
38 //! had to be `Text` with a format hint. **Closed** by makeover-layout 0.15.0:
39 //! `FieldKind::Date` and `FieldKind::DateTime`, admitted on the argument
40 //! `Email` was, and the format named once as `layout::DATE_FORMAT`. See
41 //! [`add_token_form`].
42
43 use makeover_layout as layout;
44 use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Field};
45 use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
46 use quasi_webview::Webview;
47
48 use super::Viewer;
49 use crate::db;
50 use crate::theming::ThemeOption;
51
52 /// This screen's name, as the settings strip marks the section described.
53 ///
54 /// Was the `QUASI_SCREENS` switch name until `64b33b26` deleted the flag. It
55 /// still has one reader: `settings_tabs`'s table, where `screen: Some(..)` is
56 /// what tells the strip to leave the section to name its own region.
57 pub const SCREEN: &str = "user_ssh_keys";
58
59 /// The address this screen answers, and the one the Askama route gives up.
60 pub const PATH: &str = "/dashboard/tabs/ssh-keys";
61
62 /// The region the answer replaces: this section's own frame in the settings
63 /// strip.
64 ///
65 /// Was `settings-body`, the single pane six sections shared while the sub-nav
66 /// was hand-written. `6b24f2df` step 4 described that nav, so each section has a
67 /// frame of its own and this names the one that is ours. `quasi::settings_tabs`
68 /// draws the frame from this constant, and its tests assert the two agree.
69 pub const REGION: &str = "settings-ssh-keys";
70
71 /// The address removing a key calls, relative to this screen's own nest.
72 const REMOVE_KEY: &str = "/keys/{id}";
73
74 /// The address revoking a token calls, relative to this screen's own nest.
75 const REVOKE_TOKEN: &str = "/tokens/{id}";
76
77 /// The writes this screen serves. Registered under its nest by `super::mount`.
78 pub const WRITES: &[(Method, &str, super::Screen)] = &[
79 (Method::Delete, REMOVE_KEY, remove_key),
80 (Method::Delete, REVOKE_TOKEN, revoke_token),
81 ];
82
83 /// One registered key, as the screen needs it.
84 ///
85 /// The description is built from these rather than from `db::DbSshKey` so the
86 /// shape of a screen can be tested without a database, which is most of what
87 /// makes a described screen cheaper to hold than a template.
88 pub struct KeyView {
89 id: String,
90 fingerprint: String,
91 label: String,
92 added: String,
93 }
94
95 /// One issued token, as the screen needs it.
96 pub struct TokenView {
97 id: String,
98 name: String,
99 scope: &'static str,
100 expires: String,
101 last_used: String,
102 }
103
104 /// The tab.
105 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
106 let user_id = viewer.user.id;
107
108 // Three round trips, each holding this blocking thread. The thing S3
109 // exists to measure; see the module header on `super`.
110 let keys = viewer
111 .block_on(db::ssh_keys::list_keys_by_user(&viewer.app.db, user_id))
112 .map_err(|_| RouteError::internal("your keys could not be read"))?;
113 let tokens = viewer
114 .block_on(db::git_access_tokens::list_by_user(&viewer.app.db, user_id))
115 .map_err(|_| RouteError::internal("your tokens could not be read"))?;
116 let profile = viewer
117 .block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
118 .map_err(|_| RouteError::internal("your account could not be read"))?
119 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
120
121 let keys: Vec<KeyView> = keys
122 .iter()
123 .map(|k| KeyView {
124 id: k.id.to_string(),
125 fingerprint: k.fingerprint.clone(),
126 label: k.label.clone(),
127 added: k.created_at.format("%b %d, %Y").to_string(),
128 })
129 .collect();
130 let tokens: Vec<TokenView> = tokens
131 .iter()
132 .map(|t| TokenView {
133 id: t.id.to_string(),
134 name: t.name.clone(),
135 scope: if t.can_push { "Read + push" } else { "Read" },
136 expires: never_or(t.expires_at),
137 last_used: never_or(t.last_used_at),
138 })
139 .collect();
140 let themes = crate::theming::console_theme_options(profile.console_theme.as_deref());
141
142 Ok(Response::fragment(
143 REGION,
144 pane(viewer.user.username.as_ref(), &keys, &tokens, &themes),
145 ))
146 }
147
148 /// The id in the path, as the database wants it.
149 fn captured(captures: &quasi_router::Params) -> Result<uuid::Uuid, RouteError> {
150 captures
151 .get("id")
152 .and_then(|id| id.parse().ok())
153 .ok_or_else(|| RouteError::not_found("no such thing"))
154 }
155
156 /// Remove one key, and answer with the pane as it now stands.
157 ///
158 /// This screen's own route rather than `DELETE /api/users/me/ssh-keys/{id}`,
159 /// which the Askama version calls. That endpoint answers an htmx request with
160 /// the whole re-rendered Askama list and the Askama markup targeted
161 /// `#ssh-keys-list`; the described control named no target, so htmx swapped that
162 /// entire table into the button that was pressed. Found 2026-08-11 by reading
163 /// what the endpoint returns. A described write answers with the region it
164 /// changed, which is decision 7 working as designed.
165 pub fn remove_key(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
166 // Moved out because the handler signature is quasi's: the request is
167 // consumed here rather than borrowed from.
168 let captures = request.captures;
169 let id = captured(&captures)?;
170 viewer
171 .block_on(db::ssh_keys::delete_key(
172 &viewer.app.db,
173 id.into(),
174 viewer.user.id,
175 ))
176 .map_err(|_| RouteError::internal("that key could not be removed"))?;
177 screen(viewer, Request::get(PATH))
178 }
179
180 /// Revoke one token, and answer with the pane as it now stands.
181 pub fn revoke_token(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
182 let captures = request.captures;
183 let id = captured(&captures)?;
184 viewer
185 .block_on(db::git_access_tokens::revoke(
186 &viewer.app.db,
187 id.into(),
188 viewer.user.id,
189 ))
190 .map_err(|_| RouteError::internal("that token could not be revoked"))?;
191 screen(viewer, Request::get(PATH))
192 }
193
194 /// A date, or the word for not having one.
195 fn never_or(at: Option<chrono::DateTime<chrono::Utc>>) -> String {
196 at.map_or_else(|| "Never".to_owned(), |d| d.format("%b %d, %Y").to_string())
197 }
198
199 /// Everything inside the settings pane.
200 ///
201 /// Split from the handler so a test can build it without a database, which is
202 /// the same split `quasi_spike` used and the reason the description layer is
203 /// testable at all: the screen is a value.
204 fn pane(username: &str, keys: &[KeyView], tokens: &[TokenView], themes: &[ThemeOption]) -> Node {
205 Node::Region(
206 Slot::new(REGION, RegionKind::Pane)
207 .with(Node::section("SSH Keys"))
208 .with(Node::text(format!(
209 "Manage SSH keys for git clone and push access. \
210 Clone URL: git@makenot.work:{username}/{{repo}}.git"
211 )))
212 .with(keys_list(keys))
213 .with(add_key_form())
214 .with(Node::section("Console theme"))
215 .with(Node::text(
216 "Color palette for your terminal dashboard over ssh makenot.work.",
217 ))
218 .with(theme_form(themes))
219 .with(Node::section("Access Tokens (HTTPS)"))
220 .with(Node::text(format!(
221 "Personal access tokens for git over HTTPS. Use a token as the password. \
222 Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git"
223 )))
224 .with(tokens_list(tokens))
225 .with(add_token_form()),
226 )
227 }
228
229 /// The registered keys, or the sentence saying there are none.
230 fn keys_list(keys: &[KeyView]) -> Node {
231 if keys.is_empty() {
232 return Node::empty("No SSH keys registered.");
233 }
234 Node::Table {
235 // The template's four columns, in its order, the last one the empty
236 // header its actions sit under.
237 columns: vec![
238 Column::new("Fingerprint")
239 .width(layout::Width::Fill)
240 .priority(layout::Priority::Essential),
241 Column::new("Label").width(layout::Width::Content),
242 Column::new("Added")
243 .width(layout::Width::Content)
244 .priority(layout::Priority::Optional),
245 Column::new("")
246 .width(layout::Width::Content)
247 .priority(layout::Priority::Essential),
248 ],
249 rows: keys
250 .iter()
251 .map(|key| {
252 Cells::new([
253 Cell::new(key.fingerprint.clone()),
254 Cell::new(key.label.clone()),
255 Cell::new(format!("Added {}", key.added)),
256 Cell::acts([Act::new(
257 "Remove",
258 // This screen's own route, under its own nest, so the
259 // answer is the pane it changed. It addressed the API
260 // route until 2026-08-11 and swapped a whole Askama
261 // table into this button; see `remove_key`.
262 // `awaiting` for the same reason the forms carry it: the
263 // answer is the whole pane rebuilt, so there is a wait
264 // with nothing on screen saying so. The confirm gates
265 // the first press, not the second one after it.
266 Action::delete(format!("{PATH}/keys/{}", key.id)).awaiting(),
267 )
268 // The template asked with hx-confirm. Said here, a terminal
269 // host asks in its own way and no host can forget to ask.
270 .confirm("Remove this SSH key?")
271 .tone(layout::Tone::Danger)]),
272 ])
273 })
274 .collect(),
275 // No paging described here: every one of these tables is a
276 // whole set the handler already counted.
277 more: None,
278 }
279 }
280
281 /// The add-a-key form.
282 ///
283 /// `awaiting` is the double-submit guard: this creates a record, so a second
284 /// submit while the first is in flight is a duplicate key. It reads as one word
285 /// here and the renderer locks the submit button from it, which is what
286 /// `frontend/src/core/loading.ts` was written to do by hand and is losing
287 /// ground against.
288 fn add_key_form() -> Node {
289 Node::Form {
290 action: Action::post("/api/users/me/ssh-keys").awaiting(),
291 submit: "Add SSH Key".into(),
292 fields: vec![
293 Field::new(layout::FieldKind::Textarea, "public_key", "Public Key")
294 .required()
295 .hint(
296 "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file",
297 ),
298 Field::new(layout::FieldKind::Text, "label", "Label"),
299 ],
300 }
301 }
302
303 /// The console-theme picker.
304 fn theme_form(themes: &[ThemeOption]) -> Node {
305 let options: Vec<Choice> = themes
306 .iter()
307 .map(|t| Choice::new(t.id.clone(), t.name.clone()))
308 .collect();
309 let mut field = Field::select("theme_id", "Console theme", options).hint(
310 "Following the terminal picks a light or dark palette from what your terminal reports. \
311 Separate from your profile theme, which is what visitors see.",
312 );
313 if let Some(chosen) = themes.iter().find(|t| t.selected) {
314 field = field.value(chosen.id.clone());
315 }
316 Node::Form {
317 // A PUT, so a second submit overwrites rather than duplicating. Marked
318 // anyway: the wait is real and the reader has no other way to tell the
319 // save landed from the save being slow.
320 action: Action::put("/api/users/me/console-theme").awaiting(),
321 submit: "Save Theme".into(),
322 fields: vec![field],
323 }
324 }
325
326 /// The issued tokens, or the sentence saying there are none.
327 fn tokens_list(tokens: &[TokenView]) -> Node {
328 if tokens.is_empty() {
329 return Node::empty("No access tokens.");
330 }
331 Node::Table {
332 // The four independent facts the list version had to run together into
333 // one `meta` string, back in their own columns. This is the table that
334 // lost the most by being a list: name, scope, expiry and last use are
335 // read down the column, which is what a table is for.
336 columns: vec![
337 Column::new("Name")
338 .width(layout::Width::Fill)
339 .priority(layout::Priority::Essential),
340 Column::new("Scope").width(layout::Width::Content),
341 Column::new("Expires")
342 .width(layout::Width::Content)
343 .priority(layout::Priority::Optional),
344 Column::new("Last used")
345 .width(layout::Width::Content)
346 .priority(layout::Priority::Optional),
347 Column::new("")
348 .width(layout::Width::Content)
349 .priority(layout::Priority::Essential),
350 ],
351 rows: tokens
352 .iter()
353 .map(|token| {
354 Cells::new([
355 Cell::new(token.name.clone()),
356 Cell::new(token.scope),
357 Cell::new(token.expires.clone()),
358 Cell::new(token.last_used.clone()),
359 Cell::acts([Act::new(
360 "Revoke",
361 Action::delete(format!("{PATH}/tokens/{}", token.id)).awaiting(),
362 )
363 .confirm("Revoke this token?")
364 .tone(layout::Tone::Danger)]),
365 ])
366 })
367 .collect(),
368 // No paging described here: every one of these tables is a
369 // whole set the handler already counted.
370 more: None,
371 }
372 }
373
374 /// The mint-a-token form.
375 ///
376 /// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an
377 /// Askama form that spelled it `<input type="date">`: no native picker, no
378 /// platform validation, and the hint doing both jobs in prose. That was a
379 /// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed it
380 /// at makeover-layout 0.15.0. It did become one word, and the hint came out with
381 /// it: the format is the description's now, `layout::DATE_FORMAT`, so saying it
382 /// again here would be a second place for it to drift.
383 fn add_token_form() -> Node {
384 Node::Form {
385 // Creates a record, and unlike an SSH key the answer is a secret shown
386 // once. Two of these from one impatient double-press is two tokens, one
387 // of which the creator never sees and cannot recognise later.
388 action: Action::post("/api/users/me/git-tokens").awaiting(),
389 submit: "Create Token".into(),
390 fields: vec![
391 Field::new(layout::FieldKind::Text, "name", "Name").required(),
392 Field::new(layout::FieldKind::Date, "expires_on", "Expires (optional)")
393 .hint("Leave blank for a token that does not expire."),
394 Field::new(
395 layout::FieldKind::Checkbox,
396 "can_push",
397 "Allow push (write access)",
398 ),
399 ],
400 }
401 }
402
403 /// The renderer this screen is drawn with.
404 ///
405 /// Per request because `Adapter::per_viewer` builds one per request, and this
406 /// screen has nothing viewer-specific to say to it yet. It will when S2 puts the
407 /// site chrome here.
408 pub fn renderer(viewer: &Viewer) -> Webview {
409 // The fragment path never emits a document, so the shell's asset paths do
410 // not arise here. It is still the one the rest of the site uses, so a screen
411 // that later answers as a whole page cannot disagree with `crate::shell`.
412 Webview::new().with_shell(viewer.shell())
413 }
414
415 #[cfg(test)]
416 mod tests {
417 use super::*;
418 use quasi_axum::Serves;
419
420 fn key(id: &str, fingerprint: &str) -> KeyView {
421 KeyView {
422 id: id.into(),
423 fingerprint: fingerprint.into(),
424 label: "laptop".into(),
425 added: "Aug 10, 2026".into(),
426 }
427 }
428
429 fn render(node: &Node) -> String {
430 Webview::new().fragment(node)
431 }
432
433 #[test]
434 fn the_region_matches_what_the_strip_draws() {
435 // The router says what it changed, through HX-Retarget. If this and the
436 // frame the settings strip draws ever disagree the section swaps into
437 // nothing, and that failure is invisible to every other test.
438 //
439 // Read off the described strip since `6b24f2df` step 4. It was the
440 // hand-written nav's `hx-target`, which no longer exists.
441 let nav = crate::quasi::settings_tabs::html(
442 0,
443 "",
444 crate::quasi::settings_tabs::Gates {
445 has_media: true,
446 git_enabled: true,
447 has_mt_memberships: true,
448 has_sync_apps: true,
449 },
450 );
451 assert!(
452 nav.contains(&format!("id=\"{REGION}\"")),
453 "the settings strip draws #{REGION}:\n{nav}"
454 );
455 assert!(nav.contains(&format!("hx-get=\"{PATH}\"")), "{nav}");
456 }
457
458 #[test]
459 fn an_empty_account_says_so_rather_than_showing_two_empty_lists() {
460 let html = render(&pane("max", &[], &[], &[]));
461 assert!(html.contains("No SSH keys registered."));
462 assert!(html.contains("No access tokens."));
463 }
464
465 #[test]
466 fn the_clone_urls_carry_the_viewers_own_name_and_cannot_smuggle_markup() {
467 let html = render(&pane("max", &[], &[], &[]));
468 assert!(html.contains("git@makenot.work:max/{repo}.git"));
469
470 // A username reaches this from the session, and the session from a
471 // signup form. The description layer escapes every string it is handed;
472 // this is the check that the format! above did not route around it.
473 let hostile = render(&pane("<script>x()</script>", &[], &[], &[]));
474 assert!(!hostile.contains("<script>x()"));
475 }
476
477 #[test]
478 fn removing_a_key_asks_first_and_every_key_asks_about_itself() {
479 let html = render(&keys_list(&[
480 key("k1", "SHA256:aaa"),
481 key("k2", "SHA256:bbb"),
482 ]));
483
484 assert!(html.contains("SHA256:aaa") && html.contains("SHA256:bbb"));
485 assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}");
486 // The addresses are per key rather than one shared endpoint, which is
487 // the mistake a loop over rows makes when the id is read outside it.
488 assert!(html.contains(&format!("hx-delete=\"{PATH}/keys/k1\"")));
489 assert!(html.contains(&format!("hx-delete=\"{PATH}/keys/k2\"")));
490 }
491
492 #[test]
493 fn what_destroys_something_is_marked_destructive_and_nothing_else_is() {
494 // `b279b9eb`: destructiveness is marked separately from asking first.
495 // Both acts on this screen are destructive and both ask, so the screen
496 // is the reference pair; what it also proves is the other side, that
497 // three forms which write without destroying take no tone at all.
498 let html = render(&pane(
499 "max",
500 &[key("k1", "SHA256:aaa")],
501 &[TokenView {
502 id: "t1".into(),
503 name: "laptop".into(),
504 scope: "read",
505 expires: "Never".into(),
506 last_used: "Never".into(),
507 }],
508 &[],
509 ));
510
511 assert_eq!(
512 html.matches(r#"data-tone="danger""#).count(),
513 2,
514 "removing a key and revoking a token, and nothing else: {html}"
515 );
516 // The pairing is the point rather than the count: every tone here sits
517 // on an act that also asks.
518 assert_eq!(
519 html.matches("hx-confirm").count(),
520 2,
521 "and both of them ask first: {html}"
522 );
523 assert!(html.contains("Remove this SSH key?"), "{html}");
524 assert!(html.contains("Revoke this token?"), "{html}");
525 }
526
527 #[test]
528 fn the_theme_picker_offers_every_theme_and_marks_the_chosen_one() {
529 let themes = crate::theming::console_theme_options(Some("nord"));
530 let html = render(&theme_form(&themes));
531
532 assert!(html.contains("nord"));
533 assert_eq!(
534 html.matches("selected").count(),
535 1,
536 "exactly one theme is current: {html}"
537 );
538 }
539
540 #[test]
541 fn every_write_on_this_screen_locks_itself_while_it_waits() {
542 // `0da6a218`. Both halves of the guard, because they are emitted
543 // differently and only one of them was ever right: a form names the
544 // button it has to lock (`930947c3`), an act locks itself.
545 let html = render(&pane("max", &[key("k1", "SHA256:aaa")], &[], &[]));
546
547 // Three forms, three buttons named. `find` rather than `this` is the
548 // whole of the fix, so asserting the count catches a fourth form added
549 // without the mark as well as a regression in the emitter.
550 assert_eq!(
551 html.matches(r#"hx-disable="find button[type='submit']""#)
552 .count(),
553 3,
554 "every form guards its submit: {html}"
555 );
556
557 // The delete is an act, and an act is a button already.
558 assert!(
559 html.contains(r#"hx-disable="this""#),
560 "the remove control locks itself: {html}"
561 );
562
563 // Nothing here is a measured wait. A determinate mark would mean a
564 // figure was written down, and none of these knows one.
565 assert!(!html.contains("data-awaiting-amount"), "{html}");
566 assert_eq!(
567 html.matches(r#"data-awaiting="indeterminate""#).count(),
568 4,
569 "three forms and one remove: {html}"
570 );
571 }
572
573 #[test]
574 fn the_forms_post_to_the_addresses_the_api_actually_answers() {
575 // The conversion's real risk: a described form that posts somewhere the
576 // server does not serve fails at runtime and nowhere else.
577 let html = render(&pane("max", &[], &[], &[]));
578 for address in [
579 "/api/users/me/ssh-keys",
580 "/api/users/me/console-theme",
581 "/api/users/me/git-tokens",
582 ] {
583 assert!(html.contains(address), "{address} is posted to: {html}");
584 }
585 }
586 }
587