Skip to main content

max / makenotwork

Describe the library's Contacts tab
Author: Max Johnson <me@maxj.phd> · 2026-08-11 01:09 UTC
Signed with PGP, not checked
Commit: a14fbf41e3479a76f997c49690b9006a4d605d4a
Parent: e9e8b0d
7 files changed, +443 insertions, -30 deletions
@@ -594,11 +594,12 @@
594 594 // this server already has one. Mounted after `with_state` because the
595 595 // adapter carries its own state, resolved per request, and takes none from
596 596 // axum. The Askama route for a described screen is not registered (see
597 - // `dashboard_routes`), so nothing here overlaps.
598 - let app = match quasi::router(state) {
599 - Some(described) => app.nest_service("/dashboard/tabs/ssh-keys", described),
600 - None => app,
601 - };
597 + // `dashboard_routes` and `public_routes`), so nothing here overlaps.
598 + let app = quasi::mounts(state)
599 + .into_iter()
600 + .fold(app, |app, (path, described)| {
601 + app.nest_service(path, described)
602 + });
602 603
603 604 // There is no /metrics scrape endpoint. Prometheus and Grafana were retired
604 605 // on 2026-07-21 and PoM is the monitoring story, so the endpoint had no
@@ -31,6 +31,7 @@
31 31 use crate::AppState;
32 32 use crate::auth::SessionUser;
33 33
34 + pub mod library_contacts;
34 35 pub mod ssh_keys;
35 36
36 37 /// The state one request is answered against.
@@ -92,32 +93,63 @@
92 93 }
93 94 }
94 95
95 - /// Mount every converted screen, or nothing.
96 + /// Every converted screen that is switched on, with the address it answers.
96 97 ///
97 - /// Returns `None` when no screen in this module is switched on, so the caller
98 - /// registers its Askama routes exactly as before and the adapter is not in the
99 - /// stack at all. A conversion is a startup-time choice: config is read once,
100 - /// and a per-request branch would pay for a switch that never moves.
101 - pub fn router(app: &AppState) -> Option<axum::Router> {
102 - let screens = &app.config.quasi_screens;
103 - if !screens.enabled(ssh_keys::SCREEN) {
104 - return None;
98 + /// One mount per screen rather than one router for all of them, because axum
99 + /// strips a nest's prefix before the inner service sees the request: a single
100 + /// nest covering both would have to sit at a prefix the Askama routes also live
101 + /// under, and matchit refuses to hold a wildcard beside the parameterised routes
102 + /// already there. Measured, not assumed: nesting at `/dashboard/project` panics
103 + /// at startup against `/dashboard/project/{slug}/tabs/overview`.
104 + ///
105 + /// The list is empty when nothing is switched on, so the caller registers its
106 + /// Askama routes exactly as before and the adapter is not in the stack at all. A
107 + /// conversion is a startup-time choice: config is read once, and a per-request
108 + /// branch would pay for a switch that never moves.
109 + pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
110 + let mut mounted = Vec::new();
111 +
112 + if described(app, ssh_keys::SCREEN) {
113 + mounted.push((
114 + ssh_keys::PATH,
115 + mount(app, ssh_keys::screen, ssh_keys::renderer),
116 + ));
117 + }
118 + if described(app, library_contacts::SCREEN) {
119 + mounted.push((
120 + library_contacts::PATH,
121 + mount(app, library_contacts::screen, library_contacts::renderer),
122 + ));
105 123 }
106 124
107 - let quasi = quasi_router::Router::<Viewer>::new().get("/", ssh_keys::screen);
108 - Some(
109 - quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), |viewer, _, _| {
110 - ssh_keys::renderer(viewer)
111 - })
112 - .into_router(),
113 - )
125 + mounted
114 126 }
115 127
116 - /// Whether the SSH-keys tab serves from the description layer.
128 + /// One screen behind the adapter, answering the root of its own nest.
117 129 ///
118 - /// Read by the dashboard's route table, which mounts one or the other. Here
119 - /// rather than there so the switch and the screen it names stay together.
130 + /// The path is `/` because the nest has already taken the address off: a screen
131 + /// mounted at its own tab endpoint sees one route and never has to agree with
132 + /// the prefix twice.
133 + fn mount(
134 + app: &AppState,
135 + screen: fn(
136 + &Viewer,
137 + quasi_router::Request,
138 + ) -> Result<quasi_router::Response, quasi_router::RouteError>,
139 + renderer: fn(&Viewer) -> quasi_webview::Webview,
140 + ) -> axum::Router {
141 + let quasi = quasi_router::Router::<Viewer>::new().get("/", screen);
142 + quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), move |viewer, _, _| {
143 + renderer(viewer)
144 + })
145 + .into_router()
146 + }
147 +
148 + /// Whether a named screen serves from the description layer.
149 + ///
150 + /// Read by the route tables, which mount one or the other. Here rather than
151 + /// there so the switch and the screens it names stay together.
120 152 #[must_use]
121 - pub fn ssh_keys_described(app: &AppState) -> bool {
122 - app.config.quasi_screens.enabled(ssh_keys::SCREEN)
153 + pub fn described(app: &AppState, screen: &str) -> bool {
154 + app.config.quasi_screens.enabled(screen)
123 155 }
@@ -49,6 +49,9 @@
49 49 /// The conversion switch's name for this screen. `QUASI_SCREENS=user_ssh_keys`.
50 50 pub const SCREEN: &str = "user_ssh_keys";
51 51
52 + /// The address this screen answers, and the one the Askama route gives up.
53 + pub const PATH: &str = "/dashboard/tabs/ssh-keys";
54 +
52 55 /// The region the answer replaces: the settings pane the tab nav targets.
53 56 ///
54 57 /// The nav's own `hx-target` says the same thing. Naming it here is what lets
@@ -24,7 +24,7 @@
24 24 screens: &crate::config::QuasiScreens,
25 25 ) -> Router<AppState> {
26 26 CsrfRouter::new()
27 - .merge(public::public_routes(limits))
27 + .merge(public::public_routes(limits, screens))
28 28 .merge(sandbox::sandbox_routes(limits))
29 29 .merge(dashboard::dashboard_routes(screens))
30 30 .merge(email_actions::email_action_routes(limits))
@@ -164,7 +164,7 @@
164 164 tab_routes
165 165 } else {
166 166 tab_routes.route_get(
167 - "/dashboard/tabs/ssh-keys",
167 + crate::quasi::ssh_keys::PATH,
168 168 get(tabs::dashboard_tab_ssh_keys),
169 169 )
170 170 };
@@ -34,7 +34,10 @@
34 34 use tower_governor::GovernorLayer;
35 35
36 36 /// Register public page routes.
37 - pub(crate) fn public_routes(limits: constants::RateLimits) -> CsrfRouter<AppState> {
37 + pub(crate) fn public_routes(
38 + limits: constants::RateLimits,
39 + screens: &crate::config::QuasiScreens,
40 + ) -> CsrfRouter<AppState> {
38 41 let twofa_rate_limit = crate::helpers::rate_limiter_ms(
39 42 constants::TWO_FACTOR_RATE_LIMIT_MS,
40 43 constants::TWO_FACTOR_RATE_LIMIT_BURST,
@@ -52,7 +55,21 @@
52 55 constants::API_READ_RATE_LIMIT_BURST,
53 56 );
54 57
58 + // The contacts tab is registered here only when its screen is switched off:
59 + // when it is on, `crate::quasi` serves this address instead and axum panics
60 + // on two routes claiming one path. Same shape as the SSH-keys tab in
61 + // `dashboard_routes`.
62 + let contacts_route = if screens.enabled(crate::quasi::library_contacts::SCREEN) {
63 + CsrfRouter::new()
64 + } else {
65 + CsrfRouter::new().route_get(
66 + crate::quasi::library_contacts::PATH,
67 + get(landing::library_tab_contacts),
68 + )
69 + };
70 +
55 71 CsrfRouter::new()
72 + .merge(contacts_route)
56 73 .route_get("/", get(landing::index))
57 74 .route_get("/library", get(landing::library))
58 75 .route_get("/cart", get(landing::cart_page))
@@ -65,7 +82,6 @@
65 82 "/library/tabs/collections",
66 83 get(landing::library_tab_collections),
67 84 )
68 - .route_get("/library/tabs/contacts", get(landing::library_tab_contacts))
69 85 .route_get(
70 86 "/library/tabs/communities",
71 87 get(landing::library_tab_communities),
@@ -1,0 +1,361 @@
1 + //! The library's Contacts tab, described.
2 + //!
3 + //! The second authenticated screen through the description layer, and the first
4 + //! of S4's batches. Chosen by measurement rather than by the plan's original
5 + //! pick: it is one of six tab partials in the tree that hold a table and no
6 + //! client-side JavaScript at all, and of those it is the one whose route a
7 + //! reader can actually reach from a nav.
8 + //!
9 + //! Compare `routes::pages::public::landing::library_tab_contacts`, which answers
10 + //! the same address from Askama when the screen is switched off.
11 + //!
12 + //! # What it exercises
13 + //!
14 + //! Every table member the vocabulary has grown, and nothing that is still
15 + //! unnamed. Two tables; a value that is a link, in two columns and two flavours
16 + //! (a route this server answers, and a `mailto:` that leaves); a destructive
17 + //! per-row `DELETE`; and three states, since either table can be empty on its
18 + //! own and both empty is a third screen again.
19 + //!
20 + //! # It renders the same page, unlike S3's
21 + //!
22 + //! The SSH-keys tab could not be diffed against its Askama original because the
23 + //! Askama version lazy-loads two lists and the described version renders them
24 + //! inline. This one has no `hx-trigger="load"` anywhere: the Askama handler
25 + //! already runs both queries and renders both tables in the response, so the two
26 + //! renderings are comparable and the parity harness applies. That was the open
27 + //! question hanging over S4's safety argument, and picking a screen that renders
28 + //! its own data is the answer for this batch.
29 +
30 + use makeover_layout as layout;
31 + use quasi_router::screen::{Act, Cell, Cells, Column};
32 + use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
33 + use quasi_webview::{Shell, Webview};
34 +
35 + use super::Viewer;
36 + use crate::db;
37 +
38 + /// The conversion switch's name for this screen. `QUASI_SCREENS=library_contacts`.
39 + pub const SCREEN: &str = "library_contacts";
40 +
41 + /// The address this screen answers, and the one the Askama route gives up.
42 + pub const PATH: &str = "/library/tabs/contacts";
43 +
44 + /// The region the answer replaces: the pane the library's tab nav targets.
45 + const REGION: &str = "tab-content";
46 +
47 + /// One buyer who chose to share their email, as the screen needs it.
48 + pub struct BuyerView {
49 + username: String,
50 + email: String,
51 + purchases: String,
52 + spent: String,
53 + last_purchase: String,
54 + }
55 +
56 + /// One creator this reader has shared their own email with.
57 + pub struct SharedView {
58 + seller_id: String,
59 + username: String,
60 + name: String,
61 + }
62 +
63 + /// The tab.
64 + pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
65 + let user_id = viewer.user.id;
66 +
67 + let shared = viewer
68 + .block_on(db::transactions::get_shared_creators(
69 + &viewer.app.db,
70 + user_id,
71 + ))
72 + .map_err(|_| RouteError::internal("your contacts could not be read"))?;
73 +
74 + // A reader who cannot create projects has no buyers, so the second query is
75 + // skipped rather than answered with an empty set. The Askama handler makes
76 + // the same choice and it matters more here: every one of these round trips
77 + // holds a blocking-pool thread. See the module header on `super`.
78 + let profile = viewer
79 + .block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
80 + .map_err(|_| RouteError::internal("your account could not be read"))?
81 + .ok_or_else(|| RouteError::not_found("that account is gone"))?;
82 + let buyers = if profile.can_create_projects {
83 + viewer
84 + .block_on(db::transactions::get_seller_contacts(
85 + &viewer.app.db,
86 + user_id,
87 + ))
88 + .map_err(|_| RouteError::internal("your buyers could not be read"))?
89 + } else {
90 + Vec::new()
91 + };
92 +
93 + let buyers: Vec<BuyerView> = buyers
94 + .into_iter()
95 + .map(|c| BuyerView {
96 + username: c.username,
97 + email: c.email,
98 + purchases: c.total_purchases.to_string(),
99 + spent: crate::formatting::format_revenue(
100 + c.total_spent_cents,
101 + viewer.user.settlement_currency,
102 + ),
103 + last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(),
104 + })
105 + .collect();
106 + let shared: Vec<SharedView> = shared
107 + .into_iter()
108 + .map(|creator| SharedView {
109 + seller_id: creator.seller_id.to_string(),
110 + name: creator
111 + .display_name
112 + .clone()
113 + .unwrap_or_else(|| creator.username.clone()),
114 + username: creator.username,
115 + })
116 + .collect();
117 +
118 + Ok(Response::fragment(REGION, pane(&buyers, &shared)))
119 + }
120 +
121 + /// Everything inside the tab pane.
122 + ///
123 + /// Split from the handler so a test can build it without a database, the same
124 + /// split `ssh_keys` uses and the reason a described screen is testable at all.
125 + fn pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node {
126 + let mut slot = Slot::new(REGION, RegionKind::Pane);
127 +
128 + // Both empty is its own screen and not two empty tables. The Askama version
129 + // says this in a third `{% if %}` over the same two conditions.
130 + if buyers.is_empty() && shared.is_empty() {
131 + return Node::Region(slot.with(Node::empty("No contacts yet.")));
132 + }
133 +
134 + if !buyers.is_empty() {
135 + slot = slot
136 + .with(Node::section(format!("Your Buyers ({})", buyers.len())))
137 + .with(Node::text(
138 + "Buyers who opted to share their email with you at purchase time.",
139 + ))
140 + .with(buyers_table(buyers));
141 + }
142 +
143 + if !shared.is_empty() {
144 + slot = slot
145 + .with(Node::section("Shared With"))
146 + .with(Node::text(
147 + "You've shared your email with these creators. You can revoke sharing at any time.",
148 + ))
149 + .with(shared_table(shared));
150 + }
151 +
152 + Node::Region(slot)
153 + }
154 +
155 + /// The buyers who shared an email.
156 + fn buyers_table(buyers: &[BuyerView]) -> Node {
157 + Node::Table {
158 + columns: vec![
159 + Column::new("Username")
160 + .width(layout::Width::Content)
161 + .priority(layout::Priority::Essential),
162 + Column::new("Email")
163 + .width(layout::Width::Fill)
164 + .priority(layout::Priority::Essential),
165 + Column::new("Purchases").width(layout::Width::Content),
166 + Column::new("Total Spent").width(layout::Width::Content),
167 + Column::new("Last Purchase")
168 + .width(layout::Width::Content)
169 + .priority(layout::Priority::Optional),
170 + ],
171 + rows: buyers
172 + .iter()
173 + .map(|buyer| {
174 + Cells::new([
175 + // The two flavours of a linked value in one row. A profile
176 + // is a route this server answers, so it stays inside the
177 + // app; an address is not, so it leaves.
178 + Cell::new(buyer.username.clone())
179 + .activate(Action::get(format!("/u/{}", buyer.username))),
180 + Cell::new(buyer.email.clone())
181 + .activate(Action::external(format!("mailto:{}", buyer.email))),
182 + Cell::new(buyer.purchases.clone()),
183 + Cell::new(buyer.spent.clone()),
184 + Cell::new(buyer.last_purchase.clone()),
185 + ])
186 + })
187 + .collect(),
188 + }
189 + }
190 +
191 + /// The creators this reader has shared an email with.
192 + fn shared_table(shared: &[SharedView]) -> Node {
193 + Node::Table {
194 + columns: vec![
195 + Column::new("Creator")
196 + .width(layout::Width::Fill)
197 + .priority(layout::Priority::Essential),
198 + Column::new("")
199 + .width(layout::Width::Content)
200 + .priority(layout::Priority::Essential),
201 + ],
202 + rows: shared
203 + .iter()
204 + .map(|creator| {
205 + Cells::new([
206 + // The display name is what the row reads as and the username
207 + // is where it goes, which is the pairing the template made
208 + // with a nested `{% if let %}` inside the anchor.
209 + Cell::new(creator.name.clone())
210 + .activate(Action::get(format!("/u/{}", creator.username))),
211 + Cell::acts([Act::new(
212 + "Revoke",
213 + Action::delete(format!("/api/contacts/{}", creator.seller_id)),
214 + )
215 + // The template asked with hx-confirm. Said here, a
216 + // terminal host asks in its own way and no host can
217 + // forget to ask.
218 + .confirm(format!("Revoke contact sharing with {}?", creator.username))
219 + .tone(layout::Tone::Danger)]),
220 + ])
221 + })
222 + .collect(),
223 + }
224 + }
225 +
226 + /// The renderer this screen is drawn with.
227 + pub fn renderer(_viewer: &Viewer) -> Webview {
228 + Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
229 + }
230 +
231 + #[cfg(test)]
232 + mod tests {
233 + use super::*;
234 + use quasi_axum::Render;
235 +
236 + fn buyer(username: &str) -> BuyerView {
237 + BuyerView {
238 + username: username.into(),
239 + email: format!("{username}@example.com"),
240 + purchases: "3".into(),
241 + spent: "$42.00".into(),
242 + last_purchase: "Aug 10, 2026".into(),
243 + }
244 + }
245 +
246 + fn creator(id: &str, username: &str, name: &str) -> SharedView {
247 + SharedView {
248 + seller_id: id.into(),
249 + username: username.into(),
250 + name: name.into(),
251 + }
252 + }
253 +
254 + fn render(node: &Node) -> String {
255 + Webview::new().fragment(node)
256 + }
257 +
258 + #[test]
259 + fn the_region_matches_what_the_library_nav_targets() {
260 + // The router says what it changed, through HX-Retarget. If this and the
261 + // template's hx-target ever disagree the tab swaps into nothing, and
262 + // that failure is invisible to every other test.
263 + let nav = include_str!("../../templates/pages/library.html");
264 + assert!(
265 + nav.contains(&format!("hx-target=\"#{REGION}\"")),
266 + "the library nav targets #{REGION}"
267 + );
268 + assert!(nav.contains(&format!("hx-get=\"{PATH}\"")));
269 + }
270 +
271 + #[test]
272 + fn a_reader_with_no_contacts_gets_one_sentence_and_no_tables() {
273 + let html = render(&pane(&[], &[]));
274 + assert!(html.contains("No contacts yet."));
275 + assert!(!html.contains("role=\"table\""), "{html}");
276 + }
277 +
278 + #[test]
279 + fn each_table_appears_only_when_it_has_rows() {
280 + // Three screens, not one with two empty tables. A buyer with no shared
281 + // creators is the common case for a creator account, and the reverse is
282 + // the common case for everyone else.
283 + let buyers_only = render(&pane(&[buyer("ada")], &[]));
284 + assert!(buyers_only.contains("Your Buyers (1)"));
285 + assert!(!buyers_only.contains("Shared With"));
286 +
287 + let shared_only = render(&pane(&[], &[creator("s1", "grace", "Grace H")]));
288 + assert!(!shared_only.contains("Your Buyers"));
289 + assert!(shared_only.contains("Shared With"));
290 + }
291 +
292 + #[test]
293 + fn a_buyers_name_goes_to_their_profile_and_their_address_leaves() {
294 + let html = render(&buyers_table(&[buyer("ada")]));
295 +
296 + // A read of a route this server answers: an anchor with a real href, so
297 + // middle-click and copy-link work and it stays in the app.
298 + assert!(html.contains("href=\"/u/ada\""), "{html}");
299 + assert!(
300 + !html.contains("hx-get=\"mailto"),
301 + "no htmx on a mailto: {html}"
302 + );
303 + assert!(
304 + html.contains("href=\"mailto:ada@example.com\""),
305 + "the address is a link: {html}"
306 + );
307 + assert!(html.contains("target=\"_blank\""), "{html}");
308 + }
309 +
310 + #[test]
311 + fn revoking_asks_first_and_every_row_asks_about_itself() {
312 + let html = render(&shared_table(&[
313 + creator("s1", "grace", "Grace H"),
314 + creator("s2", "alan", "Alan T"),
315 + ]));
316 +
317 + assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}");
318 + assert!(
319 + html.contains("Revoke contact sharing with grace?"),
320 + "{html}"
321 + );
322 + // Per row rather than one shared endpoint, which is the mistake a loop
323 + // over rows makes when the id is read outside it.
324 + assert!(html.contains("hx-delete=\"/api/contacts/s1\""), "{html}");
325 + assert!(html.contains("hx-delete=\"/api/contacts/s2\""), "{html}");
326 + }
327 +
328 + #[test]
329 + fn the_row_addresses_are_the_ones_the_api_actually_answers() {
330 + // The conversion's real risk, and the one that already shipped once: a
331 + // described control addressing a route registered nowhere renders fine
332 + // and answers 404 when pressed. S3 shipped exactly that.
333 + let api = include_str!("../routes/api/mod.rs");
334 + assert!(
335 + api.contains("/api/contacts/{seller_id}"),
336 + "the revoke address is a registered route"
337 + );
338 +
339 + // The linked value is the same risk with no button to press: a title
340 + // that navigates nowhere is a dead link rather than a 404 on a write,
341 + // and nothing else in the suite would notice.
342 + let pages = include_str!("../routes/pages/public/mod.rs");
343 + assert!(
344 + pages.contains("\"/u/{username}\""),
345 + "a buyer's name goes to a registered route"
346 + );
347 + }
348 +
349 + #[test]
350 + fn a_name_a_reader_chose_cannot_smuggle_markup() {
351 + // A display name comes from a profile form, and it is the value of a
352 + // cell that is also a link, which is the newest of the paths a string
353 + // takes to the page.
354 + let html = render(&shared_table(&[creator(
355 + "s1",
356 + "grace",
357 + "<script>x()</script>",
358 + )]));
359 + assert!(!html.contains("<script>x()"), "{html}");
360 + }
361 + }