max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
14 files changed,
+273 insertions,
-5 deletions
| @@ -17,7 +17,9 @@ | |||
| 17 | 17 | use std::collections::BTreeMap; | |
| 18 | 18 | use std::sync::LazyLock; | |
| 19 | 19 | ||
| 20 | - | use makeover::{ThemeMeta, embedded_themes, intent_css_vars, parse_theme_str, resolve}; | |
| 20 | + | use makeover::{ | |
| 21 | + | ThemeMeta, ThemeSelection, embedded_themes, intent_css_vars, parse_theme_str, resolve, | |
| 22 | + | }; | |
| 21 | 23 | ||
| 22 | 24 | /// The platform default theme id, the stock parchment look. Reproduces the | |
| 23 | 25 | /// historical `:root` exactly, so an unset (`None`) choice renders unchanged. | |
| @@ -100,6 +102,25 @@ | |||
| 100 | 102 | } | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 105 | + | /// Validate and normalize a submitted console-theme selection. | |
| 106 | + | /// | |
| 107 | + | /// Unlike [`normalize_theme_id`] this yields a `makeover::ThemeSelection` | |
| 108 | + | /// string rather than an optional id, because "follow the terminal" is a choice | |
| 109 | + | /// the row holds and not an absence: a creator who picks it after pinning a | |
| 110 | + | /// theme is saying something, and clearing the column back to `NULL` would lose | |
| 111 | + | /// the difference between that and never having chosen. | |
| 112 | + | /// | |
| 113 | + | /// Absent or empty input is [`makeover::FOLLOW`], matching | |
| 114 | + | /// `ThemeSelection::parse`. Any other value must name a bundled theme, since an | |
| 115 | + | /// id the CLI's embedded set does not carry cannot be honoured there either. | |
| 116 | + | pub fn normalize_console_theme(raw: Option<&str>) -> Result<String, String> { | |
| 117 | + | match ThemeSelection::parse(raw) { | |
| 118 | + | ThemeSelection::Follow => Ok(makeover::FOLLOW.to_string()), | |
| 119 | + | ThemeSelection::Fixed(id) if is_valid_theme(&id) => Ok(id), | |
| 120 | + | ThemeSelection::Fixed(id) => Err(id), | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 103 | 124 | /// One `<option>` for a theme `<select>`: id, display name, and whether it is | |
| 104 | 125 | /// the creator's current choice. | |
| 105 | 126 | #[derive(Debug, Clone)] | |
| @@ -127,6 +148,31 @@ | |||
| 127 | 148 | .collect() | |
| 128 | 149 | } | |
| 129 | 150 | ||
| 151 | + | /// Build the console picker options: "follow the terminal" first, then every | |
| 152 | + | /// bundled theme. | |
| 153 | + | /// | |
| 154 | + | /// Follow leads because it is what an unconfigured console does and what the | |
| 155 | + | /// house convention makes the default. `stored` is the raw column value, so an | |
| 156 | + | /// unset row and a stored `"system"` both land on it, as does a pinned theme | |
| 157 | + | /// that has since left the bundled set. | |
| 158 | + | pub fn console_theme_options(stored: Option<&str>) -> Vec<ThemeOption> { | |
| 159 | + | let current = match ThemeSelection::parse(stored) { | |
| 160 | + | ThemeSelection::Fixed(id) if is_valid_theme(&id) => id, | |
| 161 | + | _ => makeover::FOLLOW.to_string(), | |
| 162 | + | }; | |
| 163 | + | let mut options = vec![ThemeOption { | |
| 164 | + | id: makeover::FOLLOW.to_string(), | |
| 165 | + | name: "Follow the terminal".to_string(), | |
| 166 | + | selected: current == makeover::FOLLOW, | |
| 167 | + | }]; | |
| 168 | + | options.extend(list_themes().into_iter().map(|m| ThemeOption { | |
| 169 | + | id: m.id.clone(), | |
| 170 | + | name: m.name.clone(), | |
| 171 | + | selected: m.id == current, | |
| 172 | + | })); | |
| 173 | + | options | |
| 174 | + | } | |
| 175 | + | ||
| 130 | 176 | #[cfg(test)] | |
| 131 | 177 | mod tests { | |
| 132 | 178 | use super::*; | |
| @@ -170,6 +216,39 @@ | |||
| 170 | 216 | assert_ne!(theme_css(Some("nord")), theme_css(None)); | |
| 171 | 217 | } | |
| 172 | 218 | ||
| 219 | + | #[test] | |
| 220 | + | fn console_selection_normalizes_to_a_theme_selection_string() { | |
| 221 | + | assert_eq!(normalize_console_theme(None).unwrap(), makeover::FOLLOW); | |
| 222 | + | assert_eq!(normalize_console_theme(Some("")).unwrap(), makeover::FOLLOW); | |
| 223 | + | assert_eq!( | |
| 224 | + | normalize_console_theme(Some("system")).unwrap(), | |
| 225 | + | makeover::FOLLOW | |
| 226 | + | ); | |
| 227 | + | assert_eq!(normalize_console_theme(Some(" nord ")).unwrap(), "nord"); | |
| 228 | + | assert_eq!( | |
| 229 | + | normalize_console_theme(Some("no-such-theme")), | |
| 230 | + | Err("no-such-theme".to_string()) | |
| 231 | + | ); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[test] | |
| 235 | + | fn console_picker_leads_with_follow_and_marks_one_option() { | |
| 236 | + | for stored in [None, Some(""), Some("system"), Some("no-such-theme")] { | |
| 237 | + | let options = console_theme_options(stored); | |
| 238 | + | assert_eq!(options[0].id, makeover::FOLLOW); | |
| 239 | + | assert!( | |
| 240 | + | options[0].selected, | |
| 241 | + | "unset/unknown must fall back to follow, got {stored:?}" | |
| 242 | + | ); | |
| 243 | + | assert_eq!(options.iter().filter(|o| o.selected).count(), 1); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | let options = console_theme_options(Some("nord")); | |
| 247 | + | assert!(!options[0].selected); | |
| 248 | + | assert_eq!(options.iter().filter(|o| o.selected).count(), 1); | |
| 249 | + | assert!(options.iter().any(|o| o.id == "nord" && o.selected)); | |
| 250 | + | } | |
| 251 | + | ||
| 173 | 252 | #[test] | |
| 174 | 253 | fn picker_list_is_nonempty_and_includes_default() { | |
| 175 | 254 | let themes = list_themes(); |
| @@ -102,7 +102,7 @@ | |||
| 102 | 102 | SELECT u.id AS user_id, u.username, u.display_name, u.email, | |
| 103 | 103 | u.creator_tier, u.can_create_projects, | |
| 104 | 104 | (u.suspended_at IS NOT NULL) AS suspended, | |
| 105 | - | u.settlement_currency | |
| 105 | + | u.settlement_currency, u.console_theme | |
| 106 | 106 | FROM ssh_keys sk | |
| 107 | 107 | JOIN users u ON u.id = sk.user_id | |
| 108 | 108 | WHERE sk.fingerprint = $1 |
| @@ -191,6 +191,19 @@ | |||
| 191 | 191 | Ok(()) | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | + | /// Set a user's SSH console theme. Takes a `makeover::ThemeSelection` string | |
| 195 | + | /// (a bundled theme id, or `"system"`), validated before this call. | |
| 196 | + | #[tracing::instrument(skip_all)] | |
| 197 | + | pub async fn update_user_console_theme(pool: &PgPool, id: UserId, selection: &str) -> Result<()> { | |
| 198 | + | sqlx::query("UPDATE users SET console_theme = $2, updated_at = NOW() WHERE id = $1") | |
| 199 | + | .bind(id) | |
| 200 | + | .bind(selection) | |
| 201 | + | .execute(pool) | |
| 202 | + | .await?; | |
| 203 | + | ||
| 204 | + | Ok(()) | |
| 205 | + | } | |
| 206 | + | ||
| 194 | 207 | /// Replace a user's password hash and invalidate outstanding JWTs. | |
| 195 | 208 | #[tracing::instrument(skip_all)] | |
| 196 | 209 | pub async fn update_user_password(pool: &PgPool, id: UserId, password_hash: &str) -> Result<()> { |
| @@ -712,6 +712,11 @@ | |||
| 712 | 712 | #[template(path = "partials/tabs/user_ssh_keys_tab.html")] | |
| 713 | 713 | pub struct UserSshKeysTabTemplate { | |
| 714 | 714 | pub username: String, | |
| 715 | + | /// Choices for the console theme picker: "follow the terminal" first, then | |
| 716 | + | /// every bundled theme. Lives on this tab rather than the profile one | |
| 717 | + | /// because it themes the SSH console the tab's keys grant access to, not | |
| 718 | + | /// the public profile. | |
| 719 | + | pub theme_options: Vec<crate::theming::ThemeOption>, | |
| 715 | 720 | } | |
| 716 | 721 | ||
| 717 | 722 | /// Git access-token list partial for HTMX updates. `new_token` carries a |
| @@ -42,6 +42,58 @@ | |||
| 42 | 42 | ); | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | #[tokio::test] | |
| 46 | + | async fn lookup_carries_the_console_theme_selection() { | |
| 47 | + | let db = TestDb::new().await; | |
| 48 | + | let user = seed_user(&db.pool, "ssh_console_theme").await; | |
| 49 | + | ||
| 50 | + | ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:theme", "laptop") | |
| 51 | + | .await | |
| 52 | + | .unwrap(); | |
| 53 | + | ||
| 54 | + | // Never chosen. Absent here becomes an absent `theme_id` on the wire, which | |
| 55 | + | // the CLI reads as `ThemeSelection::Follow` — today's behaviour, and what a | |
| 56 | + | // CLI talking to a server that predates the column already does. | |
| 57 | + | let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme") | |
| 58 | + | .await | |
| 59 | + | .unwrap() | |
| 60 | + | .expect("known fingerprint resolves"); | |
| 61 | + | assert_eq!(found.console_theme, None); | |
| 62 | + | ||
| 63 | + | // Following the terminal is a stored choice, not an absence: the row has to | |
| 64 | + | // be able to say it, or a creator who pins a theme can never go back. | |
| 65 | + | makenotwork::db::users::update_user_console_theme(&db.pool, user, makeover::FOLLOW) | |
| 66 | + | .await | |
| 67 | + | .unwrap(); | |
| 68 | + | let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme") | |
| 69 | + | .await | |
| 70 | + | .unwrap() | |
| 71 | + | .unwrap(); | |
| 72 | + | assert_eq!(found.console_theme.as_deref(), Some(makeover::FOLLOW)); | |
| 73 | + | ||
| 74 | + | // A pinned theme reaches the lookup verbatim. This is the whole point of the | |
| 75 | + | // column: the CLI has no other source for the creator's choice. | |
| 76 | + | makenotwork::db::users::update_user_console_theme(&db.pool, user, "nord") | |
| 77 | + | .await | |
| 78 | + | .unwrap(); | |
| 79 | + | let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme") | |
| 80 | + | .await | |
| 81 | + | .unwrap() | |
| 82 | + | .unwrap(); | |
| 83 | + | assert_eq!(found.console_theme.as_deref(), Some("nord")); | |
| 84 | + | ||
| 85 | + | // The public-profile theme is a different question and must not move with it. | |
| 86 | + | assert_eq!( | |
| 87 | + | makenotwork::db::users::get_user_by_id(&db.pool, user) | |
| 88 | + | .await | |
| 89 | + | .unwrap() | |
| 90 | + | .unwrap() | |
| 91 | + | .theme_id, | |
| 92 | + | None, | |
| 93 | + | "the console choice must not write the profile palette visitors see" | |
| 94 | + | ); | |
| 95 | + | } | |
| 96 | + | ||
| 45 | 97 | #[tokio::test] | |
| 46 | 98 | async fn duplicate_fingerprint_for_same_user_is_rejected() { | |
| 47 | 99 | let db = TestDb::new().await; |
| @@ -55,6 +55,12 @@ | |||
| 55 | 55 | /// because the CLI needs it before any dashboard call returns: a screen that | |
| 56 | 56 | /// waits for revenue data to learn the symbol renders the wrong one first. | |
| 57 | 57 | pub settlement_currency: SettlementCurrency, | |
| 58 | + | /// The creator's SSH console theme as a `makeover::ThemeSelection` string, | |
| 59 | + | /// or `None` where they have never chosen. Carried on the login lookup for | |
| 60 | + | /// the same reason as the currency: the CLI paints its first frame before | |
| 61 | + | /// any dashboard call returns, and a console that repaints once the theme | |
| 62 | + | /// arrives has already shown the wrong one. | |
| 63 | + | pub console_theme: Option<String>, | |
| 58 | 64 | } | |
| 59 | 65 | ||
| 60 | 66 | /// An OAuth2 authorization code for PKCE flow. |