max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
5 files changed,
+317 insertions,
-141 deletions
| @@ -163,7 +163,7 @@ | |||
| 163 | 163 | # through quasi beside its Askama equivalent, so the cost of describing a screen | |
| 164 | 164 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 165 | 165 | # See wiki look-wave-2, tier G. | |
| 166 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.104" } | |
| 166 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.105" } | |
| 167 | 167 | # The declared form. A screen written as `declare!` is compiled to the same | |
| 168 | 168 | # `quasi_router` constructors the hand-written shape called, at build time, so | |
| 169 | 169 | # nothing new reaches the binary and the macro is not in the request path. | |
| @@ -183,13 +183,13 @@ | |||
| 183 | 183 | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 184 | 184 | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 185 | 185 | http = "1.3.1" | |
| 186 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.104" } | |
| 187 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.104" } | |
| 186 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.105" } | |
| 187 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.105" } | |
| 188 | 188 | # The first-party widget set. The carousel was proved here against three pages | |
| 189 | 189 | # and then moved there; what is left in `quasi/widgets/carousel.rs` is the | |
| 190 | 190 | # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it | |
| 191 | 191 | # sits above quasi-router rather than inside the description suite. | |
| 192 | - | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.104" } | |
| 192 | + | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.105" } | |
| 193 | 193 | ||
| 194 | 194 | # Git source browser | |
| 195 | 195 | gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] } | |
| @@ -288,7 +288,7 @@ | |||
| 288 | 288 | # Written from the crate rather than copied into the repo for the reason the | |
| 289 | 289 | # constants exist: a stale copy in a static directory is a silently dead bulk | |
| 290 | 290 | # bar, and the version has to be the one the emitter agrees with. | |
| 291 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.104" } | |
| 291 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.105" } | |
| 292 | 292 | ||
| 293 | 293 | [dev-dependencies] | |
| 294 | 294 | tower = { version = "0.5.3", features = ["util"] } |
| @@ -516,7 +516,22 @@ | |||
| 516 | 516 | vec![ | |
| 517 | 517 | ( | |
| 518 | 518 | ssh_keys::PATH, | |
| 519 | - | mount(app, ssh_keys::screen, ssh_keys::WRITES, ssh_keys::renderer), | |
| 519 | + | served_panel_mount( | |
| 520 | + | app, | |
| 521 | + | ssh_keys::REGION, | |
| 522 | + | ssh_keys::WRITES, | |
| 523 | + | ssh_keys::renderer, | |
| 524 | + | |viewer, _| { | |
| 525 | + | let (username, keys, tokens, themes) = ssh_keys::reading(viewer)?; | |
| 526 | + | Ok(ssh_keys::pane_serve( | |
| 527 | + | &residuals::SSH_KEYS, | |
| 528 | + | &username, | |
| 529 | + | &keys, | |
| 530 | + | &tokens, | |
| 531 | + | &themes, | |
| 532 | + | )) | |
| 533 | + | }, | |
| 534 | + | ), | |
| 520 | 535 | ), | |
| 521 | 536 | ( | |
| 522 | 537 | library_contacts::PATH, |
| @@ -101,6 +101,7 @@ | |||
| 101 | 101 | ("CREATORS", |plan| { | |
| 102 | 102 | Node::Region(super::creators::page_region_staged(plan)) | |
| 103 | 103 | }), | |
| 104 | + | ("SSH_KEYS", |plan| super::ssh_keys::pane_staged(plan)), | |
| 104 | 105 | ] | |
| 105 | 106 | } | |
| 106 | 107 | ||
| @@ -709,6 +710,67 @@ | |||
| 709 | 710 | } | |
| 710 | 711 | } | |
| 711 | 712 | ||
| 713 | + | /// The SSH keys pane fills to what the renderer builds. | |
| 714 | + | /// | |
| 715 | + | /// The first residual holding a **select whose options mark themselves**, | |
| 716 | + | /// which is what quasicoherent `c32bb877` added and what this screen was | |
| 717 | + | /// blocked on. A picker used to say which option was marked once, at the | |
| 718 | + | /// field, as a value makeover-webview compared against each option -- and a | |
| 719 | + | /// residual holds one compiled body per loop, so "exactly one row differs" | |
| 720 | + | /// was not something the body could carry. Filled here with each theme | |
| 721 | + | /// marked in turn and with none, because a residual that had baked one | |
| 722 | + | /// row's mark would still pass on the shape where that row is the marked | |
| 723 | + | /// one. | |
| 724 | + | /// | |
| 725 | + | /// Two tables behind guards as well, each with its own empty state, so the | |
| 726 | + | /// counts are crossed the way the contact panes' are. | |
| 727 | + | #[test] | |
| 728 | + | fn the_ssh_keys_residual_fills_to_what_the_renderer_builds() { | |
| 729 | + | use crate::quasi::ssh_keys::{pane, pane_serve, sample_key, sample_token}; | |
| 730 | + | use quasi_axum::Serves as _; | |
| 731 | + | ||
| 732 | + | let installed = crate::theming::console_theme_options(None); | |
| 733 | + | assert!( | |
| 734 | + | installed.len() > 1, | |
| 735 | + | "the picker needs more than one option to be worth filling" | |
| 736 | + | ); | |
| 737 | + | ||
| 738 | + | for key_count in [0, 2] { | |
| 739 | + | for token_count in [0, 2] { | |
| 740 | + | let keys: Vec<_> = (0..key_count) | |
| 741 | + | .map(|n| sample_key(&format!("k{n}"), &format!("SHA256:{n}"))) | |
| 742 | + | .collect(); | |
| 743 | + | let tokens: Vec<_> = (0..token_count) | |
| 744 | + | .map(|n| sample_token(&format!("t{n}"), &format!("token{n}"))) | |
| 745 | + | .collect(); | |
| 746 | + | ||
| 747 | + | // Every theme marked in turn, and then a list with none marked: | |
| 748 | + | // an account whose stored theme is no longer installed. | |
| 749 | + | let none_marked: Vec<_> = installed | |
| 750 | + | .iter() | |
| 751 | + | .map(|theme| crate::theming::ThemeOption { | |
| 752 | + | id: theme.id.clone(), | |
| 753 | + | name: theme.name.clone(), | |
| 754 | + | selected: false, | |
| 755 | + | }) | |
| 756 | + | .collect(); | |
| 757 | + | let cases = installed | |
| 758 | + | .iter() | |
| 759 | + | .map(|theme| crate::theming::console_theme_options(Some(&theme.id))) | |
| 760 | + | .chain(std::iter::once(none_marked)); | |
| 761 | + | ||
| 762 | + | for themes in cases { | |
| 763 | + | let marked = themes.iter().position(|theme| theme.selected); | |
| 764 | + | assert_eq!( | |
| 765 | + | pane_serve(&SSH_KEYS, "max", &keys, &tokens, &themes), | |
| 766 | + | Webview::new().fragment(&pane("max", &keys, &tokens, &themes)), | |
| 767 | + | "ssh keys, {key_count} keys, {token_count} tokens, theme {marked:?}", | |
| 768 | + | ); | |
| 769 | + | } | |
| 770 | + | } | |
| 771 | + | } | |
| 772 | + | } | |
| 773 | + | ||
| 712 | 774 | /// The payout card fills to what the renderer builds. | |
| 713 | 775 | /// | |
| 714 | 776 | /// The first residual holding a **figure strip**, which is the other half | |
| @@ -804,18 +866,16 @@ | |||
| 804 | 866 | ||
| 805 | 867 | /// The addresses still served by building a `Node` per request. | |
| 806 | 868 | /// | |
| 807 | - | /// - `/dashboard/tabs/ssh-keys`: its theme `Select` marks the chosen | |
| 808 | - | /// option by comparing one value against each option, which is | |
| 809 | - | /// structure rather than a gap, and a compiled loop body cannot carry | |
| 810 | - | /// "exactly one row differs". quasicoherent `c32bb877`. | |
| 811 | 869 | /// - `/dashboard/tabs/analytics`: its revenue chart is a ceded region, | |
| 812 | 870 | /// whose markup the renderer looks up while it renders, so there is | |
| 813 | - | /// nowhere in a residual for it to go. quasicoherent `7d6ad166`. | |
| 871 | + | /// nowhere in a residual for it to go. Ruled 2026-09-08: the answer | |
| 872 | + | /// is to describe the chart rather than to compile around it, so this | |
| 873 | + | /// screen stays here until a chart is in the vocabulary. | |
| 874 | + | /// quasicoherent `7d6ad166`. | |
| 814 | 875 | /// - `/git` and `/feed`: both page, and a pager is a supplied `Rest` | |
| 815 | 876 | /// with no sentinel. The answer is to describe `more` rather than | |
| 816 | 877 | /// supply it. quasicoherent `cbb63155`. | |
| 817 | 878 | const OFF_THE_SEAM: &[&str] = &[ | |
| 818 | - | crate::quasi::ssh_keys::PATH, | |
| 819 | 879 | crate::quasi::user_analytics::PATH, | |
| 820 | 880 | crate::quasi::git_explore::PATH, | |
| 821 | 881 | crate::quasi::feeds::PATH, | |
| @@ -859,9 +919,9 @@ | |||
| 859 | 919 | fn every_screen_on_the_seam_is_checked() { | |
| 860 | 920 | /// Screens with their own filling test: `/use-cases`, `/fan-plus`, | |
| 861 | 921 | /// `/c/{username}/{slug}`, `/dashboard/export`, `/git/{owner}`, and the | |
| 862 | - | /// two forum panes, the two contact panes, the payout card and | |
| 863 | - | /// `/creators`. | |
| 864 | - | const HOLED: usize = 11; | |
| 922 | + | /// two forum panes, the two contact panes, the payout card, | |
| 923 | + | /// `/creators` and `/dashboard/tabs/ssh-keys`. | |
| 924 | + | const HOLED: usize = 12; | |
| 865 | 925 | ||
| 866 | 926 | assert_eq!( | |
| 867 | 927 | roster().len(), |
| @@ -102,12 +102,22 @@ | |||
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | /// The tab. | |
| 105 | - | pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 105 | + | /// The three reads this pane makes, for the mount that serves it from a | |
| 106 | + | /// residual. | |
| 107 | + | /// | |
| 108 | + | /// Everything the pane needs from one call, because the mount answers the | |
| 109 | + | /// screen and its markup from one read: two closures read the database twice | |
| 110 | + | /// and can disagree about what they saw. | |
| 111 | + | /// | |
| 112 | + | /// Each of these round trips holds a blocking thread. The thing S3 exists to | |
| 113 | + | /// measure; see the module header on `super`. | |
| 114 | + | pub(crate) fn reading( | |
| 115 | + | viewer: &Viewer, | |
| 116 | + | ) -> Result<(String, Vec<KeyView>, Vec<TokenView>, Vec<ThemeOption>), RouteError> { | |
| 106 | 117 | let user = viewer.reader()?; | |
| 107 | 118 | let user_id = user.id; | |
| 119 | + | let username = user.username.as_ref().to_owned(); | |
| 108 | 120 | ||
| 109 | - | // Three round trips, each holding this blocking thread. The thing S3 | |
| 110 | - | // exists to measure; see the module header on `super`. | |
| 111 | 121 | let keys = viewer | |
| 112 | 122 | .block_on(db::ssh_keys::list_keys_by_user(&viewer.app.db, user_id)) | |
| 113 | 123 | .map_err(|_| RouteError::internal("your keys could not be read"))?; | |
| @@ -140,9 +150,14 @@ | |||
| 140 | 150 | .collect(); | |
| 141 | 151 | let themes = crate::theming::console_theme_options(profile.console_theme.as_deref()); | |
| 142 | 152 | ||
| 153 | + | Ok((username, keys, tokens, themes)) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 157 | + | let (username, keys, tokens, themes) = reading(viewer)?; | |
| 143 | 158 | Ok(Response::fragment( | |
| 144 | 159 | REGION, | |
| 145 | - | pane(user.username.as_ref(), &keys, &tokens, &themes), | |
| 160 | + | pane(&username, &keys, &tokens, &themes), | |
| 146 | 161 | )) | |
| 147 | 162 | } | |
| 148 | 163 | ||
| @@ -202,7 +217,8 @@ | |||
| 202 | 217 | /// Split from the handler so a test can build it without a database, which | |
| 203 | 218 | /// is the same split `quasi_spike` used and the reason the description | |
| 204 | 219 | /// layer is testable at all: the screen is a value. | |
| 205 | - | shape pane( | |
| 220 | + | #[staged] | |
| 221 | + | pub(crate) shape pane( | |
| 206 | 222 | username: &str, | |
| 207 | 223 | keys: &[KeyView], | |
| 208 | 224 | tokens: &[TokenView], | |
| @@ -213,7 +229,7 @@ | |||
| 213 | 229 | section "SSH Keys"; | |
| 214 | 230 | text "Manage SSH keys for git clone and push access. \ | |
| 215 | 231 | Clone URL: git@makenot.work:{username}/{{repo}}.git"; | |
| 216 | - | include keys_list(keys); | |
| 232 | + | include each keys_list(keys); | |
| 217 | 233 | include add_key_form(); | |
| 218 | 234 | ||
| 219 | 235 | section "Console theme"; | |
| @@ -223,7 +239,7 @@ | |||
| 223 | 239 | section "Access Tokens (HTTPS)"; | |
| 224 | 240 | text "Personal access tokens for git over HTTPS. Use a token as the password. \ | |
| 225 | 241 | Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git"; | |
| 226 | - | include tokens_list(tokens); | |
| 242 | + | include each tokens_list(tokens); | |
| 227 | 243 | include add_token_form(); | |
| 228 | 244 | } | |
| 229 | 245 | } | |
| @@ -240,60 +256,60 @@ | |||
| 240 | 256 | /// | |
| 241 | 257 | /// No paging described here either: every one of these tables is a whole | |
| 242 | 258 | /// set the handler already counted. | |
| 243 | - | shape keys_list(keys: &[KeyView]) -> Node; | |
| 259 | + | #[staged] | |
| 260 | + | shape keys_list(keys: &[KeyView]) -> Vec<Node>; | |
| 244 | 261 | ||
| 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 | - | } | |
| 262 | + | empty "No SSH keys registered." when keys.is_empty(); | |
| 263 | 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 | - | } | |
| 264 | + | table { | |
| 265 | + | column "Fingerprint" { | |
| 266 | + | width Fill; | |
| 267 | + | priority Essential; | |
| 268 | + | } | |
| 269 | + | column "Label" { | |
| 270 | + | width Content; | |
| 271 | + | } | |
| 272 | + | column "Added" { | |
| 273 | + | width Content; | |
| 274 | + | priority Optional; | |
| 275 | + | } | |
| 276 | + | column "" { | |
| 277 | + | width Content; | |
| 278 | + | priority Essential; | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | for key in keys.iter() { | |
| 282 | + | cells { | |
| 283 | + | // `19d7602d`. A SHA256 fingerprint is a run of characters a | |
| 284 | + | // reader compares against another one, and a proportional | |
| 285 | + | // face makes that harder than it has to be. Plain rather | |
| 286 | + | // than classified: nothing lexed it and nothing should, so | |
| 287 | + | // what this buys is the monospace and not a colour. | |
| 288 | + | cell "" { | |
| 289 | + | literal key.fingerprint.clone(); | |
| 290 | + | } | |
| 291 | + | cell key.label.clone(); | |
| 292 | + | cell "Added {key.added}"; | |
| 293 | + | cell "" { | |
| 294 | + | // This screen's own route, under its own nest, so the | |
| 295 | + | // answer is the pane it changed. It addressed the API | |
| 296 | + | // route until 2026-08-11 and swapped a whole Askama | |
| 297 | + | // table into this button; see `remove_key`. `awaiting` | |
| 298 | + | // for the same reason the forms carry it: the answer is | |
| 299 | + | // the whole pane rebuilt, so there is a wait with | |
| 300 | + | // nothing on screen saying so. The confirm gates the | |
| 301 | + | // first press, not the second one after it. | |
| 302 | + | act "Remove" to delete "{PATH}/keys/{key.id}" awaiting { | |
| 303 | + | // The template asked with hx-confirm. Said here, a | |
| 304 | + | // terminal host asks in its own way and no host can | |
| 305 | + | // forget to ask. | |
| 306 | + | confirm "Remove this SSH key?"; | |
| 307 | + | tone Danger; | |
| 292 | 308 | } | |
| 293 | 309 | } | |
| 294 | 310 | } | |
| 295 | 311 | } | |
| 296 | - | } | |
| 312 | + | } unless keys.is_empty(); | |
| 297 | 313 | } | |
| 298 | 314 | ||
| 299 | 315 | declare! { | |
| @@ -304,6 +320,8 @@ | |||
| 304 | 320 | /// as one word here and the renderer locks the submit button from it, which | |
| 305 | 321 | /// is what `frontend/src/core/loading.ts` was written to do by hand and is | |
| 306 | 322 | /// losing ground against. | |
| 323 | + | #[staged] | |
| 324 | + | #[constant] | |
| 307 | 325 | shape add_key_form() -> Node; | |
| 308 | 326 | ||
| 309 | 327 | form post "/api/users/me/ssh-keys" awaiting { | |
| @@ -316,26 +334,9 @@ | |||
| 316 | 334 | } | |
| 317 | 335 | } | |
| 318 | 336 | ||
| 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 | 337 | declare! { | |
| 338 | 338 | /// The console-theme picker. | |
| 339 | + | #[staged] | |
| 339 | 340 | shape theme_form(themes: &[ThemeOption]) -> Node; | |
| 340 | 341 | ||
| 341 | 342 | // A PUT, so a second submit overwrites rather than duplicating. Marked | |
| @@ -347,10 +348,17 @@ | |||
| 347 | 348 | hint "Following the terminal picks a light or dark palette from what your \ | |
| 348 | 349 | terminal reports. Separate from your profile theme, which is what \ | |
| 349 | 350 | visitors see."; | |
| 351 | + | // Marked per option rather than by a `value` the renderer compares | |
| 352 | + | // against each one. `ThemeOption` already knows per row, so the | |
| 353 | + | // old spelling collapsed that to one string for makeover-webview | |
| 354 | + | // to re-derive, which is one fact stated twice -- and it is the | |
| 355 | + | // form a residual cannot hold, since a compiled loop body cannot | |
| 356 | + | // carry "exactly one row differs". quasicoherent `c32bb877`. | |
| 350 | 357 | for theme in themes.iter() { | |
| 351 | - | option Choice::new(theme.id.clone(), theme.name.clone()); | |
| 358 | + | option Choice::new(theme.id.clone(), theme.name.clone()) { | |
| 359 | + | chosen when theme.selected; | |
| 360 | + | } | |
| 352 | 361 | } | |
| 353 | - | value chosen_theme(themes) when a_theme_is_chosen(themes); | |
| 354 | 362 | } | |
| 355 | 363 | } | |
| 356 | 364 | } | |
| @@ -368,47 +376,47 @@ | |||
| 368 | 376 | /// | |
| 369 | 377 | /// No paging described here either: every one of these tables is a whole | |
| 370 | 378 | /// set the handler already counted. | |
| 371 | - | shape tokens_list(tokens: &[TokenView]) -> Node; | |
| 379 | + | #[staged] | |
| 380 | + | shape tokens_list(tokens: &[TokenView]) -> Vec<Node>; | |
| 372 | 381 | ||
| 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 | - | } | |
| 382 | + | empty "No access tokens." when tokens.is_empty(); | |
| 395 | 383 | ||
| 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 | - | } | |
| 384 | + | table { | |
| 385 | + | column "Name" { | |
| 386 | + | width Fill; | |
| 387 | + | priority Essential; | |
| 388 | + | } | |
| 389 | + | column "Scope" { | |
| 390 | + | width Content; | |
| 391 | + | } | |
| 392 | + | column "Expires" { | |
| 393 | + | width Content; | |
| 394 | + | priority Optional; | |
| 395 | + | } | |
| 396 | + | column "Last used" { | |
| 397 | + | width Content; | |
| 398 | + | priority Optional; | |
| 399 | + | } | |
| 400 | + | column "" { | |
| 401 | + | width Content; | |
| 402 | + | priority Essential; | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | for token in tokens.iter() { | |
| 406 | + | cells { | |
| 407 | + | cell token.name.clone(); | |
| 408 | + | cell token.scope; | |
| 409 | + | cell token.expires.clone(); | |
| 410 | + | cell token.last_used.clone(); | |
| 411 | + | cell "" { | |
| 412 | + | act "Revoke" to delete "{PATH}/tokens/{token.id}" awaiting { | |
| 413 | + | confirm "Revoke this token?"; | |
| 414 | + | tone Danger; | |
| 407 | 415 | } | |
| 408 | 416 | } | |
| 409 | 417 | } | |
| 410 | 418 | } | |
| 411 | - | } | |
| 419 | + | } unless tokens.is_empty(); | |
| 412 | 420 | } | |
| 413 | 421 | ||
| 414 | 422 | declare! { | |
| @@ -421,6 +429,8 @@ | |||
| 421 | 429 | /// it at makeover-layout 0.15.0. It did become one word, and the hint came | |
| 422 | 430 | /// out with it: the format is the description's now, `layout::DATE_FORMAT`, | |
| 423 | 431 | /// so saying it again here would be a second place for it to drift. | |
| 432 | + | #[staged] | |
| 433 | + | #[constant] | |
| 424 | 434 | shape add_token_form() -> Node; | |
| 425 | 435 | ||
| 426 | 436 | // Creates a record, and unlike an SSH key the answer is a secret shown | |
| @@ -450,25 +460,53 @@ | |||
| 450 | 460 | Webview::new().with_shell(viewer.shell()) | |
| 451 | 461 | } | |
| 452 | 462 | ||
| 463 | + | /// One key as the tests draw it. | |
| 464 | + | /// | |
| 465 | + | /// Module-level rather than inside `mod tests` because `quasi::residuals` needs | |
| 466 | + | /// one too, and `KeyView` is this module's own type. Test-only. | |
| 467 | + | #[cfg(test)] | |
| 468 | + | pub(crate) fn sample_key(id: &str, fingerprint: &str) -> KeyView { | |
| 469 | + | KeyView { | |
| 470 | + | id: id.into(), | |
| 471 | + | fingerprint: fingerprint.into(), | |
| 472 | + | label: "laptop".into(), | |
| 473 | + | added: "Aug 10, 2026".into(), | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | /// One token as the tests draw it. See [`sample_key`]. | |
| 478 | + | #[cfg(test)] | |
| 479 | + | pub(crate) fn sample_token(id: &str, name: &str) -> TokenView { | |
| 480 | + | TokenView { | |
| 481 | + | id: id.into(), | |
| 482 | + | name: name.into(), | |
| 483 | + | scope: "Read + push", | |
| 484 | + | expires: "Never".into(), | |
| 485 | + | last_used: "Never".into(), | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 453 | 489 | #[cfg(test)] | |
| 454 | 490 | mod tests { | |
| 455 | 491 | use super::*; | |
| 456 | 492 | use quasi_axum::Serves; | |
| 457 | 493 | use quasi_router::Node; | |
| 458 | 494 | ||
| 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 | - | } | |
| 495 | + | use super::{sample_key as key, sample_token}; | |
| 467 | 496 | ||
| 468 | 497 | fn render(node: &Node) -> String { | |
| 469 | 498 | Webview::new().fragment(node) | |
| 470 | 499 | } | |
| 471 | 500 | ||
| 501 | + | /// The same, for a shape answering a run of nodes rather than one. | |
| 502 | + | /// | |
| 503 | + | /// `keys_list` and `tokens_list` each say a sentence or a table, which is | |
| 504 | + | /// two emissions and therefore a `Vec<Node>`; the pane splices them with | |
| 505 | + | /// `include each`. Rendered here the way the pane's region renders them. | |
| 506 | + | fn render_all(nodes: &[Node]) -> String { | |
| 507 | + | nodes.iter().map(render).collect() | |
| 508 | + | } | |
| 509 | + | ||
| 472 | 510 | #[test] | |
| 473 | 511 | fn the_region_matches_what_the_strip_draws() { | |
| 474 | 512 | // The router says what it changed, through HX-Retarget. If this and the | |
| @@ -515,7 +553,7 @@ | |||
| 515 | 553 | ||
| 516 | 554 | #[test] | |
| 517 | 555 | fn removing_a_key_asks_first_and_every_key_asks_about_itself() { | |
| 518 | - | let html = render(&keys_list(&[ | |
| 556 | + | let html = render_all(&keys_list(&[ | |
| 519 | 557 | key("k1", "SHA256:aaa"), | |
| 520 | 558 | key("k2", "SHA256:bbb"), | |
| 521 | 559 | ])); | |
| @@ -537,13 +575,7 @@ | |||
| 537 | 575 | let html = render(&pane( | |
| 538 | 576 | "max", | |
| 539 | 577 | &[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 | - | }], | |
| 578 | + | &[sample_token("t1", "laptop")], | |
| 547 | 579 | &[], | |
| 548 | 580 | )); | |
| 549 | 581 |
| @@ -473,4 +473,73 @@ | |||
| 473 | 473 | ])), | |
| 474 | 474 | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"creators-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 475 | 475 | ]); | |
| 476 | + | ||
| 477 | + | pub static SSH_KEYS: ::quasi_router::stage::Residual = | |
| 478 | + | ::quasi_router::stage::Residual::compiled(&[ | |
| 479 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"settings-ssh-keys\" class=\"region pane\"><h2 class=\"heading\">SSH Keys</h2><p class=\"text\">Manage SSH keys for git clone and push access. Clone URL: git@makenot.work:")), | |
| 480 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 0 }, | |
| 481 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("/{repo}.git</p>")), | |
| 482 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 483 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"placeholder\" data-state=\"empty\" role=\"status\" aria-live=\"polite\"><p class=\"placeholder-text\">No SSH keys registered.</p></div>")), | |
| 484 | + | ||
| 485 | + | ])), | |
| 486 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 487 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"table\" class=\"table\"><div role=\"row\" class=\"table-head\"><span role=\"columnheader\" class=\"table-heading col-Fingerprint cell-fill cell-keeps\">Fingerprint</span><span role=\"columnheader\" class=\"table-heading col-Label cell-content cell-drops-next\">Label</span><span role=\"columnheader\" class=\"table-heading col-Added cell-content cell-drops-first\">Added</span><span role=\"columnheader\" class=\"table-heading col- cell-content cell-keeps\"></span></div>")), | |
| 488 | + | ::quasi_router::stage::Op::Loop(::std::borrow::Cow::Borrowed(&[ | |
| 489 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"row\" class=\"table-row\" data-row><div class=\"cell col-Fingerprint cell-fill cell-keeps\"><code class=\"code\">")), | |
| 490 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 0 }, | |
| 491 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</code></div><div class=\"cell col-Label cell-content cell-drops-next cell-value\">")), | |
| 492 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 1 }, | |
| 493 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Added cell-content cell-drops-first cell-value\">Added ")), | |
| 494 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 2 }, | |
| 495 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col- cell-content cell-keeps\"><span class=\"cell-actions\"><button type=\"button\" class=\"button\" data-tone=\"danger\" data-act hx-delete=\"/dashboard/tabs/ssh-keys/keys/")), | |
| 496 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 3 }, | |
| 497 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\" hx-confirm=\"Remove this SSH key?\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Remove</button></span></div></div>")), | |
| 498 | + | ||
| 499 | + | ])), | |
| 500 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div>")), | |
| 501 | + | ||
| 502 | + | ])), | |
| 503 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<form class=\"form\" hx-post=\"/api/users/me/ssh-keys\" hx-swap=\"outerMorph\" hx-disable=\"find button[type='submit']\" data-awaiting=\"indeterminate\"><div class=\"form-group\"><label class=\"form-label\" for=\"public_key\">Public Key</label><textarea class=\"field\" id=\"public_key\" name=\"public_key\" required aria-describedby=\"public_key-hint\"></textarea><div class=\"form-hint\" id=\"public_key-hint\">Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file</div></div><div class=\"form-group\"><label class=\"form-label\" for=\"label\">Label</label><input type=\"text\" class=\"field\" id=\"label\" name=\"label\" value=\"\"></div><button type=\"submit\" class=\"button act-submit\">Add SSH Key</button></form><h2 class=\"heading\">Console theme</h2><p class=\"text\">Color palette for your terminal dashboard over ssh makenot.work.</p><form class=\"form\" hx-put=\"/api/users/me/console-theme\" hx-swap=\"outerMorph\" hx-disable=\"find button[type='submit']\" data-awaiting=\"indeterminate\"><div class=\"form-group\"><label class=\"form-label\" for=\"theme_id\">Console theme</label><select class=\"field\" id=\"theme_id\" name=\"theme_id\" aria-describedby=\"theme_id-hint\">")), | |
| 504 | + | ::quasi_router::stage::Op::Loop(::std::borrow::Cow::Borrowed(&[ | |
| 505 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<option value=\"")), | |
| 506 | + | ::quasi_router::stage::Op::Hole { scope: 2, id: 0 }, | |
| 507 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"")), | |
| 508 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 509 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(" selected")), | |
| 510 | + | ||
| 511 | + | ])), | |
| 512 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(">")), | |
| 513 | + | ::quasi_router::stage::Op::Hole { scope: 2, id: 1 }, | |
| 514 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</option>")), | |
| 515 | + | ||
| 516 | + | ])), | |
| 517 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</select><div class=\"form-hint\" id=\"theme_id-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.</div></div><button type=\"submit\" class=\"button act-submit\">Save Theme</button></form><h2 class=\"heading\">Access Tokens (HTTPS)</h2><p class=\"text\">Personal access tokens for git over HTTPS. Use a token as the password. Clone URL: https://<token>@makenot.work/")), | |
| 518 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 1 }, | |
| 519 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("/{repo}.git</p>")), | |
| 520 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 521 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"placeholder\" data-state=\"empty\" role=\"status\" aria-live=\"polite\"><p class=\"placeholder-text\">No access tokens.</p></div>")), | |
| 522 | + | ||
| 523 | + | ])), | |
| 524 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 525 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"table\" class=\"table\"><div role=\"row\" class=\"table-head\"><span role=\"columnheader\" class=\"table-heading col-Name cell-fill cell-keeps\">Name</span><span role=\"columnheader\" class=\"table-heading col-Scope cell-content cell-drops-next\">Scope</span><span role=\"columnheader\" class=\"table-heading col-Expires cell-content cell-drops-first\">Expires</span><span role=\"columnheader\" class=\"table-heading col-Last-used cell-content cell-drops-first\">Last used</span><span role=\"columnheader\" class=\"table-heading col- cell-content cell-keeps\"></span></div>")), | |
| 526 | + | ::quasi_router::stage::Op::Loop(::std::borrow::Cow::Borrowed(&[ | |
| 527 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"row\" class=\"table-row\" data-row><div class=\"cell col-Name cell-fill cell-keeps cell-value\">")), | |
| 528 | + | ::quasi_router::stage::Op::Hole { scope: 3, id: 0 }, | |
| 529 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Scope cell-content cell-drops-next cell-value\">")), | |
| 530 | + | ::quasi_router::stage::Op::Hole { scope: 3, id: 1 }, | |
| 531 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Expires cell-content cell-drops-first cell-value\">")), | |
| 532 | + | ::quasi_router::stage::Op::Hole { scope: 3, id: 2 }, | |
| 533 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Last-used cell-content cell-drops-first cell-value\">")), | |
| 534 | + | ::quasi_router::stage::Op::Hole { scope: 3, id: 3 }, | |
| 535 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col- cell-content cell-keeps\"><span class=\"cell-actions\"><button type=\"button\" class=\"button\" data-tone=\"danger\" data-act hx-delete=\"/dashboard/tabs/ssh-keys/tokens/")), | |
| 536 | + | ::quasi_router::stage::Op::Hole { scope: 3, id: 4 }, | |
| 537 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\" hx-confirm=\"Revoke this token?\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Revoke</button></span></div></div>")), | |
| 538 | + | ||
| 539 | + | ])), | |
| 540 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div>")), | |
| 541 | + | ||
| 542 | + | ])), | |
| 543 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<form class=\"form\" hx-post=\"/api/users/me/git-tokens\" hx-swap=\"outerMorph\" hx-disable=\"find button[type='submit']\" data-awaiting=\"indeterminate\"><div class=\"form-group\"><label class=\"form-label\" for=\"name\">Name</label><input type=\"text\" class=\"field\" id=\"name\" name=\"name\" required value=\"\"></div><div class=\"form-group\"><label class=\"form-label\" for=\"expires_on\">Expires (optional)</label><input type=\"date\" class=\"field\" id=\"expires_on\" name=\"expires_on\" aria-describedby=\"expires_on-hint\" value=\"\"><div class=\"form-hint\" id=\"expires_on-hint\">Leave blank for a token that does not expire.</div></div><div class=\"form-group\"><label class=\"form-checkbox-label\"><input type=\"checkbox\" id=\"can_push\" name=\"can_push\"><span>Allow push (write access)</span></label></div><button type=\"submit\" class=\"button act-submit\">Create Token</button></form><div class=\"anchored\" id=\"settings-ssh-keys-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 544 | + | ]); | |
| 476 | 545 |