Skip to main content

max / makenotwork

24.1 KB · 626 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 quasi_declare::declare;
44 use quasi_router::screen::Choice;
45 use quasi_router::{Method, Request, Response, RouteError};
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 = viewer.reader()?;
107 let user_id = user.id;
108
109 // Three round trips, each holding this blocking thread. The thing S3
110 // exists to measure; see the module header on `super`.
111 let keys = viewer
112 .block_on(db::ssh_keys::list_keys_by_user(&viewer.app.db, user_id))
113 .map_err(|_| RouteError::internal("your keys could not be read"))?;
114 let tokens = viewer
115 .block_on(db::git_access_tokens::list_by_user(&viewer.app.db, user_id))
116 .map_err(|_| RouteError::internal("your tokens could not be read"))?;
117 let profile = viewer
118 .block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
119 .map_err(|_| RouteError::internal("your account could not be read"))?
120 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
121
122 let keys: Vec<KeyView> = keys
123 .iter()
124 .map(|k| KeyView {
125 id: k.id.to_string(),
126 fingerprint: k.fingerprint.clone(),
127 label: k.label.clone(),
128 added: k.created_at.format("%b %d, %Y").to_string(),
129 })
130 .collect();
131 let tokens: Vec<TokenView> = tokens
132 .iter()
133 .map(|t| TokenView {
134 id: t.id.to_string(),
135 name: t.name.clone(),
136 scope: if t.can_push { "Read + push" } else { "Read" },
137 expires: never_or(t.expires_at),
138 last_used: never_or(t.last_used_at),
139 })
140 .collect();
141 let themes = crate::theming::console_theme_options(profile.console_theme.as_deref());
142
143 Ok(Response::fragment(
144 REGION,
145 pane(user.username.as_ref(), &keys, &tokens, &themes),
146 ))
147 }
148
149 /// The id in the path, as the database wants it.
150 fn captured(captures: &quasi_router::Params) -> Result<uuid::Uuid, RouteError> {
151 captures
152 .get("id")
153 .and_then(|id| id.parse().ok())
154 .ok_or_else(|| RouteError::not_found("no such thing"))
155 }
156
157 /// Remove one key, and answer with the pane as it now stands.
158 ///
159 /// This screen's own route rather than `DELETE /api/users/me/ssh-keys/{id}`,
160 /// which the Askama version calls. That endpoint answers an htmx request with
161 /// the whole re-rendered Askama list and the Askama markup targeted
162 /// `#ssh-keys-list`, so a described control naming no target has htmx swap that
163 /// entire table into the button that was pressed. A described write answers with
164 /// the region it changed.
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.reader()?.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.reader()?.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 declare! {
200 /// Everything inside the settings pane.
201 ///
202 /// Split from the handler so a test can build it without a database, which
203 /// is the same split `quasi_spike` used and the reason the description
204 /// layer is testable at all: the screen is a value.
205 shape pane(
206 username: &str,
207 keys: &[KeyView],
208 tokens: &[TokenView],
209 themes: &[ThemeOption],
210 ) -> Node;
211
212 region REGION as Pane {
213 section "SSH Keys";
214 text "Manage SSH keys for git clone and push access. \
215 Clone URL: git@makenot.work:{username}/{{repo}}.git";
216 include keys_list(keys);
217 include add_key_form();
218
219 section "Console theme";
220 text "Color palette for your terminal dashboard over ssh makenot.work.";
221 include theme_form(themes);
222
223 section "Access Tokens (HTTPS)";
224 text "Personal access tokens for git over HTTPS. Use a token as the password. \
225 Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git";
226 include tokens_list(tokens);
227 include add_token_form();
228 }
229 }
230
231 declare! {
232 /// The registered keys, or the sentence saying there are none.
233 ///
234 /// The template's four columns, in its order, the last one the empty header
235 /// its actions sit under.
236 ///
237 /// The cells stay positional because the column list is right here to read
238 /// against them: four columns, four cells in every row, and no branch that
239 /// drops one. Naming would buy nothing a reader cannot already see.
240 ///
241 /// No paging described here either: every one of these tables is a whole
242 /// set the handler already counted.
243 shape keys_list(keys: &[KeyView]) -> Node;
244
245 given keys.is_empty() {
246 true -> empty "No SSH keys registered.";
247 otherwise -> table {
248 column "Fingerprint" {
249 width Fill;
250 priority Essential;
251 }
252 column "Label" {
253 width Content;
254 }
255 column "Added" {
256 width Content;
257 priority Optional;
258 }
259 column "" {
260 width Content;
261 priority Essential;
262 }
263
264 for key in keys.iter() {
265 cells {
266 // `19d7602d`. A SHA256 fingerprint is a run of characters a
267 // reader compares against another one, and a proportional
268 // face makes that harder than it has to be. Plain rather
269 // than classified: nothing lexed it and nothing should, so
270 // what this buys is the monospace and not a colour.
271 cell "" {
272 literal key.fingerprint.clone();
273 }
274 cell key.label.clone();
275 cell "Added {key.added}";
276 cell "" {
277 // This screen's own route, under its own nest, so the
278 // answer is the pane it changed. It addressed the API
279 // route until 2026-08-11 and swapped a whole Askama
280 // table into this button; see `remove_key`. `awaiting`
281 // for the same reason the forms carry it: the answer is
282 // the whole pane rebuilt, so there is a wait with
283 // nothing on screen saying so. The confirm gates the
284 // first press, not the second one after it.
285 act "Remove" to delete "{PATH}/keys/{key.id}" awaiting {
286 // The template asked with hx-confirm. Said here, a
287 // terminal host asks in its own way and no host can
288 // forget to ask.
289 confirm "Remove this SSH key?";
290 tone Danger;
291 }
292 }
293 }
294 }
295 }
296 }
297 }
298
299 declare! {
300 /// The add-a-key form.
301 ///
302 /// `awaiting` is the double-submit guard: this creates a record, so a
303 /// second submit while the first is in flight is a duplicate key. It reads
304 /// as one word here and the renderer locks the submit button from it, which
305 /// is what `frontend/src/core/loading.ts` was written to do by hand and is
306 /// losing ground against.
307 shape add_key_form() -> Node;
308
309 form post "/api/users/me/ssh-keys" awaiting {
310 submit "Add SSH Key";
311 field Textarea "public_key" "Public Key" {
312 required;
313 hint "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file";
314 }
315 field Text "label" "Label";
316 }
317 }
318
319 /// Which theme the reader is on, or nothing.
320 ///
321 /// A supplier because `find` takes a closure. It hands back a `String`, which
322 /// is the smallest type that works and keeps it out of the population, and
323 /// [`a_theme_is_chosen`] is the predicate that decides whether it is asked for.
324 fn chosen_theme(themes: &[ThemeOption]) -> String {
325 themes
326 .iter()
327 .find(|theme| theme.selected)
328 .map(|theme| theme.id.clone())
329 .unwrap_or_default()
330 }
331
332 /// Whether any of them is marked.
333 fn a_theme_is_chosen(themes: &[ThemeOption]) -> bool {
334 themes.iter().any(|theme| theme.selected)
335 }
336
337 declare! {
338 /// The console-theme picker.
339 shape theme_form(themes: &[ThemeOption]) -> Node;
340
341 // A PUT, so a second submit overwrites rather than duplicating. Marked
342 // anyway: the wait is real and the reader has no other way to tell the save
343 // landed from the save being slow.
344 form put "/api/users/me/console-theme" awaiting {
345 submit "Save Theme";
346 field Select "theme_id" "Console theme" {
347 hint "Following the terminal picks a light or dark palette from what your \
348 terminal reports. Separate from your profile theme, which is what \
349 visitors see.";
350 for theme in themes.iter() {
351 option Choice::new(theme.id.clone(), theme.name.clone());
352 }
353 value chosen_theme(themes) when a_theme_is_chosen(themes);
354 }
355 }
356 }
357
358 declare! {
359 /// The issued tokens, or the sentence saying there are none.
360 ///
361 /// The four independent facts the list version had to run together into one
362 /// `meta` string, back in their own columns. This is the table that lost the
363 /// most by being a list: name, scope, expiry and last use are read down the
364 /// column, which is what a table is for.
365 ///
366 /// Positional for the same reason the key table is: the columns are in this
367 /// declaration, every row carries all five cells, and nothing branches.
368 ///
369 /// No paging described here either: every one of these tables is a whole
370 /// set the handler already counted.
371 shape tokens_list(tokens: &[TokenView]) -> Node;
372
373 given tokens.is_empty() {
374 true -> empty "No access tokens.";
375 otherwise -> table {
376 column "Name" {
377 width Fill;
378 priority Essential;
379 }
380 column "Scope" {
381 width Content;
382 }
383 column "Expires" {
384 width Content;
385 priority Optional;
386 }
387 column "Last used" {
388 width Content;
389 priority Optional;
390 }
391 column "" {
392 width Content;
393 priority Essential;
394 }
395
396 for token in tokens.iter() {
397 cells {
398 cell token.name.clone();
399 cell token.scope;
400 cell token.expires.clone();
401 cell token.last_used.clone();
402 cell "" {
403 act "Revoke" to delete "{PATH}/tokens/{token.id}" awaiting {
404 confirm "Revoke this token?";
405 tone Danger;
406 }
407 }
408 }
409 }
410 }
411 }
412 }
413
414 declare! {
415 /// The mint-a-token form.
416 ///
417 /// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an
418 /// Askama form that spelled it `<input type="date">`: no native picker, no
419 /// platform validation, and the hint doing both jobs in prose. That was a
420 /// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed
421 /// it at makeover-layout 0.15.0. It did become one word, and the hint came
422 /// out with it: the format is the description's now, `layout::DATE_FORMAT`,
423 /// so saying it again here would be a second place for it to drift.
424 shape add_token_form() -> Node;
425
426 // Creates a record, and unlike an SSH key the answer is a secret shown
427 // once. Two of these from one impatient double-press is two tokens, one of
428 // which the creator never sees and cannot recognise later.
429 form post "/api/users/me/git-tokens" awaiting {
430 submit "Create Token";
431 field Text "name" "Name" {
432 required;
433 }
434 field Date "expires_on" "Expires (optional)" {
435 hint "Leave blank for a token that does not expire.";
436 }
437 field Checkbox "can_push" "Allow push (write access)";
438 }
439 }
440
441 /// The renderer this screen is drawn with.
442 ///
443 /// Per request because `Adapter::per_viewer` builds one per request, and this
444 /// screen has nothing viewer-specific to say to it yet. It will when S2 puts the
445 /// site chrome here.
446 pub fn renderer(viewer: &Viewer) -> Webview {
447 // The fragment path never emits a document, so the shell's asset paths do
448 // not arise here. It is still the one the rest of the site uses, so a screen
449 // that later answers as a whole page cannot disagree with `crate::shell`.
450 Webview::new().with_shell(viewer.shell())
451 }
452
453 #[cfg(test)]
454 mod tests {
455 use super::*;
456 use quasi_axum::Serves;
457 use quasi_router::Node;
458
459 fn key(id: &str, fingerprint: &str) -> KeyView {
460 KeyView {
461 id: id.into(),
462 fingerprint: fingerprint.into(),
463 label: "laptop".into(),
464 added: "Aug 10, 2026".into(),
465 }
466 }
467
468 fn render(node: &Node) -> String {
469 Webview::new().fragment(node)
470 }
471
472 #[test]
473 fn the_region_matches_what_the_strip_draws() {
474 // The router says what it changed, through HX-Retarget. If this and the
475 // frame the settings strip draws ever disagree the section swaps into
476 // nothing, and that failure is invisible to every other test.
477 //
478 // Read off the described strip since `6b24f2df` step 4. It was the
479 // hand-written nav's `hx-target`, which no longer exists.
480 let nav = crate::quasi::settings_tabs::html(
481 0,
482 "",
483 crate::quasi::settings_tabs::Gates {
484 has_media: true,
485 git_enabled: true,
486 has_mt_memberships: true,
487 has_sync_apps: true,
488 },
489 );
490 assert!(
491 nav.contains(&format!("id=\"{REGION}\"")),
492 "the settings strip draws #{REGION}:\n{nav}"
493 );
494 assert!(nav.contains(&format!("hx-get=\"{PATH}\"")), "{nav}");
495 }
496
497 #[test]
498 fn an_empty_account_says_so_rather_than_showing_two_empty_lists() {
499 let html = render(&pane("max", &[], &[], &[]));
500 assert!(html.contains("No SSH keys registered."));
501 assert!(html.contains("No access tokens."));
502 }
503
504 #[test]
505 fn the_clone_urls_carry_the_viewers_own_name_and_cannot_smuggle_markup() {
506 let html = render(&pane("max", &[], &[], &[]));
507 assert!(html.contains("git@makenot.work:max/{repo}.git"));
508
509 // A username reaches this from the session, and the session from a
510 // signup form. The description layer escapes every string it is handed;
511 // this is the check that the format! above did not route around it.
512 let hostile = render(&pane("<script>x()</script>", &[], &[], &[]));
513 assert!(!hostile.contains("<script>x()"));
514 }
515
516 #[test]
517 fn removing_a_key_asks_first_and_every_key_asks_about_itself() {
518 let html = render(&keys_list(&[
519 key("k1", "SHA256:aaa"),
520 key("k2", "SHA256:bbb"),
521 ]));
522
523 assert!(html.contains("SHA256:aaa") && html.contains("SHA256:bbb"));
524 assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}");
525 // The addresses are per key rather than one shared endpoint, which is
526 // the mistake a loop over rows makes when the id is read outside it.
527 assert!(html.contains(&format!("hx-delete=\"{PATH}/keys/k1\"")));
528 assert!(html.contains(&format!("hx-delete=\"{PATH}/keys/k2\"")));
529 }
530
531 #[test]
532 fn what_destroys_something_is_marked_destructive_and_nothing_else_is() {
533 // `b279b9eb`: destructiveness is marked separately from asking first.
534 // Both acts on this screen are destructive and both ask, so the screen
535 // is the reference pair; what it also proves is the other side, that
536 // three forms which write without destroying take no tone at all.
537 let html = render(&pane(
538 "max",
539 &[key("k1", "SHA256:aaa")],
540 &[TokenView {
541 id: "t1".into(),
542 name: "laptop".into(),
543 scope: "read",
544 expires: "Never".into(),
545 last_used: "Never".into(),
546 }],
547 &[],
548 ));
549
550 assert_eq!(
551 html.matches(r#"data-tone="danger""#).count(),
552 2,
553 "removing a key and revoking a token, and nothing else: {html}"
554 );
555 // The pairing is the point rather than the count: every tone here sits
556 // on an act that also asks.
557 assert_eq!(
558 html.matches("hx-confirm").count(),
559 2,
560 "and both of them ask first: {html}"
561 );
562 assert!(html.contains("Remove this SSH key?"), "{html}");
563 assert!(html.contains("Revoke this token?"), "{html}");
564 }
565
566 #[test]
567 fn the_theme_picker_offers_every_theme_and_marks_the_chosen_one() {
568 let themes = crate::theming::console_theme_options(Some("nord"));
569 let html = render(&theme_form(&themes));
570
571 assert!(html.contains("nord"));
572 assert_eq!(
573 html.matches("selected").count(),
574 1,
575 "exactly one theme is current: {html}"
576 );
577 }
578
579 #[test]
580 fn every_write_on_this_screen_locks_itself_while_it_waits() {
581 // `0da6a218`. Both halves of the guard, because they are emitted
582 // differently and only one of them was ever right: a form names the
583 // button it has to lock (`930947c3`), an act locks itself.
584 let html = render(&pane("max", &[key("k1", "SHA256:aaa")], &[], &[]));
585
586 // Three forms, three buttons named. `find` rather than `this` is the
587 // whole of the fix, so asserting the count catches a fourth form added
588 // without the mark as well as a regression in the emitter.
589 assert_eq!(
590 html.matches(r#"hx-disable="find button[type='submit']""#)
591 .count(),
592 3,
593 "every form guards its submit: {html}"
594 );
595
596 // The delete is an act, and an act is a button already.
597 assert!(
598 html.contains(r#"hx-disable="this""#),
599 "the remove control locks itself: {html}"
600 );
601
602 // Nothing here is a measured wait. A determinate mark would mean a
603 // figure was written down, and none of these knows one.
604 assert!(!html.contains("data-awaiting-amount"), "{html}");
605 assert_eq!(
606 html.matches(r#"data-awaiting="indeterminate""#).count(),
607 4,
608 "three forms and one remove: {html}"
609 );
610 }
611
612 #[test]
613 fn the_forms_post_to_the_addresses_the_api_actually_answers() {
614 // The conversion's real risk: a described form that posts somewhere the
615 // server does not serve fails at runtime and nowhere else.
616 let html = render(&pane("max", &[], &[], &[]));
617 for address in [
618 "/api/users/me/ssh-keys",
619 "/api/users/me/console-theme",
620 "/api/users/me/git-tokens",
621 ] {
622 assert!(html.contains(address), "{address} is posted to: {html}");
623 }
624 }
625 }
626