Skip to main content

max / makenotwork

19.0 KB · 479 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::{Shell, Webview};
47
48 use super::Viewer;
49 use crate::db;
50 use crate::theming::ThemeOption;
51
52 /// The conversion switch's name for this screen. `QUASI_SCREENS=user_ssh_keys`.
53 pub const SCREEN: &str = "user_ssh_keys";
54
55 /// The address this screen answers, and the one the Askama route gives up.
56 pub const PATH: &str = "/dashboard/tabs/ssh-keys";
57
58 /// The region the answer replaces: the settings pane the tab nav targets.
59 ///
60 /// The nav's own `hx-target` says the same thing. Naming it here is what lets
61 /// the router say what it changed rather than leaving the client to infer it
62 /// from which link was clicked, and the two agreeing is checked below.
63 const REGION: &str = "settings-body";
64
65 /// The address removing a key calls, relative to this screen's own nest.
66 const REMOVE_KEY: &str = "/keys/{id}";
67
68 /// The address revoking a token calls, relative to this screen's own nest.
69 const REVOKE_TOKEN: &str = "/tokens/{id}";
70
71 /// The writes this screen serves. Registered under its nest by `super::mount`.
72 pub const WRITES: &[(Method, &str, super::Screen)] = &[
73 (Method::Delete, REMOVE_KEY, remove_key),
74 (Method::Delete, REVOKE_TOKEN, revoke_token),
75 ];
76
77 /// One registered key, as the screen needs it.
78 ///
79 /// The description is built from these rather than from `db::DbSshKey` so the
80 /// shape of a screen can be tested without a database, which is most of what
81 /// makes a described screen cheaper to hold than a template.
82 pub struct KeyView {
83 id: String,
84 fingerprint: String,
85 label: String,
86 added: String,
87 }
88
89 /// One issued token, as the screen needs it.
90 pub struct TokenView {
91 id: String,
92 name: String,
93 scope: &'static str,
94 expires: String,
95 last_used: String,
96 }
97
98 /// The tab.
99 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
100 let user_id = viewer.user.id;
101
102 // Three round trips, each holding this blocking thread. The thing S3
103 // exists to measure; see the module header on `super`.
104 let keys = viewer
105 .block_on(db::ssh_keys::list_keys_by_user(&viewer.app.db, user_id))
106 .map_err(|_| RouteError::internal("your keys could not be read"))?;
107 let tokens = viewer
108 .block_on(db::git_access_tokens::list_by_user(&viewer.app.db, user_id))
109 .map_err(|_| RouteError::internal("your tokens could not be read"))?;
110 let profile = viewer
111 .block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
112 .map_err(|_| RouteError::internal("your account could not be read"))?
113 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
114
115 let keys: Vec<KeyView> = keys
116 .iter()
117 .map(|k| KeyView {
118 id: k.id.to_string(),
119 fingerprint: k.fingerprint.clone(),
120 label: k.label.clone(),
121 added: k.created_at.format("%b %d, %Y").to_string(),
122 })
123 .collect();
124 let tokens: Vec<TokenView> = tokens
125 .iter()
126 .map(|t| TokenView {
127 id: t.id.to_string(),
128 name: t.name.clone(),
129 scope: if t.can_push { "Read + push" } else { "Read" },
130 expires: never_or(t.expires_at),
131 last_used: never_or(t.last_used_at),
132 })
133 .collect();
134 let themes = crate::theming::console_theme_options(profile.console_theme.as_deref());
135
136 Ok(Response::fragment(
137 REGION,
138 pane(viewer.user.username.as_ref(), &keys, &tokens, &themes),
139 ))
140 }
141
142 /// The id in the path, as the database wants it.
143 fn captured(captures: &quasi_router::Params) -> Result<uuid::Uuid, RouteError> {
144 captures
145 .get("id")
146 .and_then(|id| id.parse().ok())
147 .ok_or_else(|| RouteError::not_found("no such thing"))
148 }
149
150 /// Remove one key, and answer with the pane as it now stands.
151 ///
152 /// This screen's own route rather than `DELETE /api/users/me/ssh-keys/{id}`,
153 /// which the Askama version calls. That endpoint answers an htmx request with
154 /// the whole re-rendered Askama list and the Askama markup targeted
155 /// `#ssh-keys-list`; the described control named no target, so htmx swapped that
156 /// entire table into the button that was pressed. Found 2026-08-11 by reading
157 /// what the endpoint returns. A described write answers with the region it
158 /// changed, which is decision 7 working as designed.
159 pub fn remove_key(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
160 // Moved out because the handler signature is quasi's: the request is
161 // consumed here rather than borrowed from.
162 let captures = request.captures;
163 let id = captured(&captures)?;
164 viewer
165 .block_on(db::ssh_keys::delete_key(
166 &viewer.app.db,
167 id.into(),
168 viewer.user.id,
169 ))
170 .map_err(|_| RouteError::internal("that key could not be removed"))?;
171 screen(viewer, Request::get(PATH))
172 }
173
174 /// Revoke one token, and answer with the pane as it now stands.
175 pub fn revoke_token(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
176 let captures = request.captures;
177 let id = captured(&captures)?;
178 viewer
179 .block_on(db::git_access_tokens::revoke(
180 &viewer.app.db,
181 id.into(),
182 viewer.user.id,
183 ))
184 .map_err(|_| RouteError::internal("that token could not be revoked"))?;
185 screen(viewer, Request::get(PATH))
186 }
187
188 /// A date, or the word for not having one.
189 fn never_or(at: Option<chrono::DateTime<chrono::Utc>>) -> String {
190 at.map_or_else(|| "Never".to_owned(), |d| d.format("%b %d, %Y").to_string())
191 }
192
193 /// Everything inside the settings pane.
194 ///
195 /// Split from the handler so a test can build it without a database, which is
196 /// the same split `quasi_spike` used and the reason the description layer is
197 /// testable at all: the screen is a value.
198 fn pane(username: &str, keys: &[KeyView], tokens: &[TokenView], themes: &[ThemeOption]) -> Node {
199 Node::Region(
200 Slot::new(REGION, RegionKind::Pane)
201 .with(Node::section("SSH Keys"))
202 .with(Node::text(format!(
203 "Manage SSH keys for git clone and push access. \
204 Clone URL: git@makenot.work:{username}/{{repo}}.git"
205 )))
206 .with(keys_list(keys))
207 .with(add_key_form())
208 .with(Node::section("Console theme"))
209 .with(Node::text(
210 "Color palette for your terminal dashboard over ssh makenot.work.",
211 ))
212 .with(theme_form(themes))
213 .with(Node::section("Access Tokens (HTTPS)"))
214 .with(Node::text(format!(
215 "Personal access tokens for git over HTTPS. Use a token as the password. \
216 Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git"
217 )))
218 .with(tokens_list(tokens))
219 .with(add_token_form()),
220 )
221 }
222
223 /// The registered keys, or the sentence saying there are none.
224 fn keys_list(keys: &[KeyView]) -> Node {
225 if keys.is_empty() {
226 return Node::empty("No SSH keys registered.");
227 }
228 Node::Table {
229 // The template's four columns, in its order, the last one the empty
230 // header its actions sit under.
231 columns: vec![
232 Column::new("Fingerprint")
233 .width(layout::Width::Fill)
234 .priority(layout::Priority::Essential),
235 Column::new("Label").width(layout::Width::Content),
236 Column::new("Added")
237 .width(layout::Width::Content)
238 .priority(layout::Priority::Optional),
239 Column::new("")
240 .width(layout::Width::Content)
241 .priority(layout::Priority::Essential),
242 ],
243 rows: keys
244 .iter()
245 .map(|key| {
246 Cells::new([
247 Cell::new(key.fingerprint.clone()),
248 Cell::new(key.label.clone()),
249 Cell::new(format!("Added {}", key.added)),
250 Cell::acts([Act::new(
251 "Remove",
252 // This screen's own route, under its own nest, so the
253 // answer is the pane it changed. It addressed the API
254 // route until 2026-08-11 and swapped a whole Askama
255 // table into this button; see `remove_key`.
256 Action::delete(format!("{PATH}/keys/{}", key.id)),
257 )
258 // The template asked with hx-confirm. Said here, a terminal
259 // host asks in its own way and no host can forget to ask.
260 .confirm("Remove this SSH key?")
261 .tone(layout::Tone::Danger)]),
262 ])
263 })
264 .collect(),
265 }
266 }
267
268 /// The add-a-key form.
269 fn add_key_form() -> Node {
270 Node::Form {
271 action: Action::post("/api/users/me/ssh-keys"),
272 submit: "Add SSH Key".into(),
273 fields: vec![
274 Field::new(layout::FieldKind::Textarea, "public_key", "Public Key")
275 .required()
276 .hint(
277 "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file",
278 ),
279 Field::new(layout::FieldKind::Text, "label", "Label"),
280 ],
281 }
282 }
283
284 /// The console-theme picker.
285 fn theme_form(themes: &[ThemeOption]) -> Node {
286 let options: Vec<Choice> = themes
287 .iter()
288 .map(|t| Choice::new(t.id.clone(), t.name.clone()))
289 .collect();
290 let mut field = Field::select("theme_id", "Console theme", options).hint(
291 "Following the terminal picks a light or dark palette from what your terminal reports. \
292 Separate from your profile theme, which is what visitors see.",
293 );
294 if let Some(chosen) = themes.iter().find(|t| t.selected) {
295 field = field.value(chosen.id.clone());
296 }
297 Node::Form {
298 action: Action::put("/api/users/me/console-theme"),
299 submit: "Save Theme".into(),
300 fields: vec![field],
301 }
302 }
303
304 /// The issued tokens, or the sentence saying there are none.
305 fn tokens_list(tokens: &[TokenView]) -> Node {
306 if tokens.is_empty() {
307 return Node::empty("No access tokens.");
308 }
309 Node::Table {
310 // The four independent facts the list version had to run together into
311 // one `meta` string, back in their own columns. This is the table that
312 // lost the most by being a list: name, scope, expiry and last use are
313 // read down the column, which is what a table is for.
314 columns: vec![
315 Column::new("Name")
316 .width(layout::Width::Fill)
317 .priority(layout::Priority::Essential),
318 Column::new("Scope").width(layout::Width::Content),
319 Column::new("Expires")
320 .width(layout::Width::Content)
321 .priority(layout::Priority::Optional),
322 Column::new("Last used")
323 .width(layout::Width::Content)
324 .priority(layout::Priority::Optional),
325 Column::new("")
326 .width(layout::Width::Content)
327 .priority(layout::Priority::Essential),
328 ],
329 rows: tokens
330 .iter()
331 .map(|token| {
332 Cells::new([
333 Cell::new(token.name.clone()),
334 Cell::new(token.scope),
335 Cell::new(token.expires.clone()),
336 Cell::new(token.last_used.clone()),
337 Cell::acts([Act::new(
338 "Revoke",
339 Action::delete(format!("{PATH}/tokens/{}", token.id)),
340 )
341 .confirm("Revoke this token?")
342 .tone(layout::Tone::Danger)]),
343 ])
344 })
345 .collect(),
346 }
347 }
348
349 /// The mint-a-token form.
350 ///
351 /// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an
352 /// Askama form that spelled it `<input type="date">`: no native picker, no
353 /// platform validation, and the hint doing both jobs in prose. That was a
354 /// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed it
355 /// at makeover-layout 0.15.0. It did become one word, and the hint came out with
356 /// it: the format is the description's now, `layout::DATE_FORMAT`, so saying it
357 /// again here would be a second place for it to drift.
358 fn add_token_form() -> Node {
359 Node::Form {
360 action: Action::post("/api/users/me/git-tokens"),
361 submit: "Create Token".into(),
362 fields: vec![
363 Field::new(layout::FieldKind::Text, "name", "Name").required(),
364 Field::new(layout::FieldKind::Date, "expires_on", "Expires (optional)")
365 .hint("Leave blank for a token that does not expire."),
366 Field::new(
367 layout::FieldKind::Checkbox,
368 "can_push",
369 "Allow push (write access)",
370 ),
371 ],
372 }
373 }
374
375 /// The renderer this screen is drawn with.
376 ///
377 /// Per request because `Adapter::per_viewer` builds one per request, and this
378 /// screen has nothing viewer-specific to say to it yet. It will when S2 puts the
379 /// site chrome here.
380 pub fn renderer(_viewer: &Viewer) -> Webview {
381 // The fragment path never emits a document, so the shell's asset paths do
382 // not arise here. It is still the one the rest of the site uses, so a screen
383 // that later answers as a whole page cannot disagree with `crate::shell`.
384 Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
385 }
386
387 #[cfg(test)]
388 mod tests {
389 use super::*;
390 use quasi_axum::Serves;
391
392 fn key(id: &str, fingerprint: &str) -> KeyView {
393 KeyView {
394 id: id.into(),
395 fingerprint: fingerprint.into(),
396 label: "laptop".into(),
397 added: "Aug 10, 2026".into(),
398 }
399 }
400
401 fn render(node: &Node) -> String {
402 Webview::new().fragment(node)
403 }
404
405 #[test]
406 fn the_region_matches_what_the_tab_nav_targets() {
407 // The router says what it changed, through HX-Retarget. If this and the
408 // template's hx-target ever disagree the tab swaps into nothing, and
409 // that failure is invisible to every other test.
410 let nav = include_str!("../../templates/partials/tabs/user_settings.html");
411 assert!(
412 nav.contains(&format!("hx-target=\"#{REGION}\"")),
413 "the settings nav targets #{REGION}"
414 );
415 assert!(nav.contains("hx-get=\"/dashboard/tabs/ssh-keys\""));
416 }
417
418 #[test]
419 fn an_empty_account_says_so_rather_than_showing_two_empty_lists() {
420 let html = render(&pane("max", &[], &[], &[]));
421 assert!(html.contains("No SSH keys registered."));
422 assert!(html.contains("No access tokens."));
423 }
424
425 #[test]
426 fn the_clone_urls_carry_the_viewers_own_name_and_cannot_smuggle_markup() {
427 let html = render(&pane("max", &[], &[], &[]));
428 assert!(html.contains("git@makenot.work:max/{repo}.git"));
429
430 // A username reaches this from the session, and the session from a
431 // signup form. The description layer escapes every string it is handed;
432 // this is the check that the format! above did not route around it.
433 let hostile = render(&pane("<script>x()</script>", &[], &[], &[]));
434 assert!(!hostile.contains("<script>x()"));
435 }
436
437 #[test]
438 fn removing_a_key_asks_first_and_every_key_asks_about_itself() {
439 let html = render(&keys_list(&[
440 key("k1", "SHA256:aaa"),
441 key("k2", "SHA256:bbb"),
442 ]));
443
444 assert!(html.contains("SHA256:aaa") && html.contains("SHA256:bbb"));
445 assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}");
446 // The addresses are per key rather than one shared endpoint, which is
447 // the mistake a loop over rows makes when the id is read outside it.
448 assert!(html.contains(&format!("hx-delete=\"{PATH}/keys/k1\"")));
449 assert!(html.contains(&format!("hx-delete=\"{PATH}/keys/k2\"")));
450 }
451
452 #[test]
453 fn the_theme_picker_offers_every_theme_and_marks_the_chosen_one() {
454 let themes = crate::theming::console_theme_options(Some("nord"));
455 let html = render(&theme_form(&themes));
456
457 assert!(html.contains("nord"));
458 assert_eq!(
459 html.matches("selected").count(),
460 1,
461 "exactly one theme is current: {html}"
462 );
463 }
464
465 #[test]
466 fn the_forms_post_to_the_addresses_the_api_actually_answers() {
467 // The conversion's real risk: a described form that posts somewhere the
468 // server does not serve fails at runtime and nowhere else.
469 let html = render(&pane("max", &[], &[], &[]));
470 for address in [
471 "/api/users/me/ssh-keys",
472 "/api/users/me/console-theme",
473 "/api/users/me/git-tokens",
474 ] {
475 assert!(html.contains(address), "{address} is posted to: {html}");
476 }
477 }
478 }
479