diff --git a/Cargo.lock b/Cargo.lock index 2269916..3b6788e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "da-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "argon2", "base32", @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "da-discord" -version = "0.1.1" +version = "0.1.2" dependencies = [ "axum", "da-core", @@ -478,7 +478,7 @@ dependencies = [ [[package]] name = "da-http-api" -version = "0.1.1" +version = "0.1.2" dependencies = [ "axum", "base64 0.22.1", @@ -500,7 +500,7 @@ dependencies = [ [[package]] name = "da-render" -version = "0.1.1" +version = "0.1.2" dependencies = [ "image", "resvg", @@ -512,7 +512,7 @@ dependencies = [ [[package]] name = "da-webui" -version = "0.1.1" +version = "0.1.2" [[package]] name = "data-url" @@ -542,7 +542,7 @@ dependencies = [ [[package]] name = "discord-authenticator" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index e0d37ca..95e7d03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2021" rust-version = "1.90" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 880626f..1ddfe0a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,13 @@ -# Discord Authenticator +

+ Discord Authenticator +

-A self-hosted TOTP authenticator whose codes appear in Discord as **live -messages that rewrite themselves** as each code rolls. +

Discord Authenticator

+ +

+ A self-hosted TOTP authenticator whose codes appear in Discord as + live messages that rewrite themselves as each code rolls. +

The web UI is what you'd expect from Google or Microsoft Authenticator: a searchable list of accounts, each with its six digits and a countdown ring. @@ -209,9 +215,17 @@ and `=`-padded secrets, and where the issuer in the path disagrees with the `?issuer=` parameter — which Microsoft does — it takes the query parameter rather than refusing the account. -**In the list**, click any code to copy it. `/` focuses the search box. The -gear on a row renames the account or pauses its Discord push; pausing removes -its tile from the channel. +**In the list**, click any code to copy it. `/` focuses the search box. + +The pencil on a row opens **Edit account**. The *Name* is what shows in the +list and on the Discord tile; the *Account* field underneath (usually the +username or email the code belongs to) is left alone unless you change it — +it's editable, since providers sometimes put something unhelpful there. The +same sheet pauses Discord push for that account, or removes it entirely. + +A rename reaches Discord within a second or two. It doesn't wait for the code +to roll: the push loop normally skips any account whose current code it has +already sent, so renaming clears that marker to force one refresh. --- @@ -271,7 +285,7 @@ JetBrains Mono. Light and dark both ship. ### Testing -124 tests, including: +126 tests, including: - The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512. - Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG, @@ -284,7 +298,8 @@ JetBrains Mono. Light and dark both ship. - The **live push cycle against a mock Discord**: that the first call carries `?wait=true`, that later passes PATCH the same message rather than posting again, that a deleted tile is reposted, that a dead webhook is surfaced - instead of hammered, and that concurrent rollovers are paced apart. + instead of hammered, that concurrent rollovers are paced apart, and that a + rename refreshes the tile immediately without re-posting it or looping. ```bash cargo test --workspace diff --git a/crates/core/src/vault.rs b/crates/core/src/vault.rs index 4c80d32..e80b87b 100644 --- a/crates/core/src/vault.rs +++ b/crates/core/src/vault.rs @@ -354,16 +354,20 @@ impl Vault { .iter_mut() .find(|a| a.id == id) .ok_or_else(|| Error::not_found("account"))?; + + let mut renamed = false; if let Some(v) = issuer.map(str::trim).filter(|s| !s.is_empty()) { if v.len() > 128 { return Err(Error::invalid("issuer must be 128 characters or fewer")); } + renamed |= a.issuer != v; a.issuer = v.to_string(); } if let Some(v) = label.map(str::trim) { if v.len() > 128 { return Err(Error::invalid("account must be 128 characters or fewer")); } + renamed |= a.label != v; a.label = v.to_string(); } if let Some(v) = push_enabled { @@ -374,6 +378,17 @@ impl Vault { } a.push_enabled = v; } + + // A rename changes what the Discord tile should say, but not the + // code — and the push loop skips any account whose code it has + // already sent. Without this the new name would sit invisible + // until the code next rolled, up to a full period later. Clearing + // the last-sent code makes the very next tick treat the tile as + // stale, so the rename shows up in about a second. + if renamed { + a.discord.last_code = None; + } + AccountPublic::from(&*a) }; self.persist(); diff --git a/crates/discord/tests/live_push.rs b/crates/discord/tests/live_push.rs index e670829..717c2ce 100644 --- a/crates/discord/tests/live_push.rs +++ b/crates/discord/tests/live_push.rs @@ -385,6 +385,87 @@ async fn a_dead_webhook_is_surfaced_and_not_hammered() { assert!(vault.push_targets()[0].message_id.is_some()); } +#[tokio::test] +async fn renaming_an_account_refreshes_its_tile_without_waiting_for_the_code() { + let dir = tempfile::tempdir().unwrap(); + let (url, mock) = spawn_mock().await; + // A 30-second period: the code will NOT roll during this test, so any + // update we see must have been triggered by the rename itself. + let (vault, settings) = fixture(dir.path(), &url, "embed", 30); + let engine = PushEngine::new(vault.clone(), settings); + + engine.sync_once().await; + let message_id = vault.push_targets()[0].message_id.clone().unwrap(); + assert_eq!(mock.log.lock().unwrap().len(), 1); + + // Nothing changed yet, so a second pass must stay silent. + engine.sync_once().await; + assert_eq!( + mock.log.lock().unwrap().len(), + 1, + "an idle pass must cost nothing" + ); + + // Rename, then run one pass — no sleeping, no code rollover. + let id = vault.list()[0].id.clone(); + vault + .update(&id, Some("Cloudflare Work"), Some("ops@corp.example"), None) + .unwrap(); + assert_eq!( + engine.sync_once().await, + 1, + "the rename should trigger a push" + ); + + { + let log = mock.log.lock().unwrap(); + assert_eq!(log.len(), 2); + assert_eq!( + log[1].method, "PATCH", + "the same tile is edited, not replaced" + ); + assert!(log[1].path.ends_with(&format!("/messages/{message_id}"))); + + // The edited message must carry the new naming. + let body = log[1].json(); + assert_eq!(body["embeds"][0]["title"], "Cloudflare Work"); + let desc = body["embeds"][0]["description"].as_str().unwrap(); + assert!( + desc.contains("ops@corp.example"), + "new label missing: {desc}" + ); + assert!( + !desc.contains("me@example.com"), + "old label still present: {desc}" + ); + } + + // And it settles: a further pass sends nothing more. + engine.sync_once().await; + assert_eq!( + mock.log.lock().unwrap().len(), + 2, + "rename must not re-push forever" + ); +} + +#[tokio::test] +async fn toggling_push_alone_does_not_rewrite_the_tile() { + let dir = tempfile::tempdir().unwrap(); + let (url, mock) = spawn_mock().await; + let (vault, settings) = fixture(dir.path(), &url, "embed", 30); + let engine = PushEngine::new(vault.clone(), settings); + + engine.sync_once().await; + let id = vault.list()[0].id.clone(); + + // Re-affirming the same push state is not a rename and changes nothing + // the tile displays, so it should not cost a request. + vault.update(&id, None, None, Some(true)).unwrap(); + engine.sync_once().await; + assert_eq!(mock.log.lock().unwrap().len(), 1); +} + #[tokio::test] async fn pausing_an_account_removes_its_tile() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/render/examples/brand.rs b/crates/render/examples/brand.rs new file mode 100644 index 0000000..c58a89a --- /dev/null +++ b/crates/render/examples/brand.rs @@ -0,0 +1,15 @@ +//! Rasterise the brand icon to PNG at the sizes the README, the Gitea repo +//! avatar and the Unraid template need: +//! `cargo run -p da-render --example brand -- docs/brand` +fn main() { + let dir = std::env::args() + .nth(1) + .unwrap_or_else(|| "docs/brand".into()); + let svg = std::fs::read_to_string(format!("{dir}/icon.svg")).expect("read icon.svg"); + for size in [512u32, 256, 128] { + let png = da_render::rasterise_svg(&svg, size, size).expect("render"); + let path = format!("{dir}/icon-{size}.png"); + std::fs::write(&path, &png).expect("write"); + println!("wrote {path} ({} bytes)", png.len()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3a926f9..89cee7e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -75,6 +75,41 @@ pub fn tile_png(tile: &Tile<'_>) -> Result> { Ok(out.into_inner()) } +/// Rasterise arbitrary SVG source to a PNG at a given size. +/// +/// Exposed so the brand assets (README image, Gitea avatar, Unraid template +/// icon) are generated from the same `icon.svg` the app ships, rather than +/// being hand-exported and drifting out of sync with it. +pub fn rasterise_svg(svg: &str, width: u32, height: u32) -> Result> { + let tree = usvg::Tree::from_str(svg, options())?; + let mut pixmap = + tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Pixmap(width, height))?; + + // Scale the document to fill the requested pixmap. + let size = tree.size(); + let scale = (width as f32 / size.width()).min(height as f32 / size.height()); + resvg::render( + &tree, + tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + + let mut rgba = Vec::with_capacity((width * height * 4) as usize); + for px in pixmap.pixels() { + let c = px.demultiply(); + rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]); + } + + let mut out = std::io::Cursor::new(Vec::new()); + image::codecs::png::PngEncoder::new(&mut out).write_image( + &rgba, + width, + height, + image::ExtendedColorType::Rgba8, + )?; + Ok(out.into_inner()) +} + fn rasterise(tile: &Tile<'_>) -> Result> { let svg = build_svg(tile); let tree = usvg::Tree::from_str(&svg, options())?; diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index b518f50..96d38c5 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -160,15 +160,39 @@ a:hover { text-decoration: underline; } .shell { display: flex; flex-direction: column; min-height: 100vh; } +/* Three columns: the search occupies the middle one, so it lands on the same + centre line as the account tiles below no matter how wide the brand or the + action cluster get. The outer columns are equal-width and allowed to + shrink, which keeps the centring exact rather than approximate. */ .topbar { position: sticky; top: 0; z-index: 30; - display: flex; align-items: center; gap: 12px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 420px) minmax(0, 1fr); + align-items: center; gap: 12px; height: var(--topbar-h); padding: 0 16px; background: color-mix(in oklab, var(--page) 82%, transparent); backdrop-filter: saturate(1.6) blur(12px); border-bottom: 1px solid var(--line); } +.topbar .brand { justify-self: start; min-width: 0; } +.topbar .brand strong { overflow: hidden; text-overflow: ellipsis; } +.topbar .actions { + justify-self: end; + display: flex; align-items: center; gap: 2px; +} + +/* True centring needs room for all three: the brand wordmark (~210px), the + 420px search, the action cluster (~110px), plus gaps and padding — about + 800px in total. Below that the grid starts clipping "Discord Authenticator", + so fall back to a plain row where the brand keeps its space and the search + takes whatever is left. */ +@media (max-width: 820px) { + .topbar { display: flex; } + .topbar .brand { flex: 0 0 auto; } + .topbar .search { flex: 1 1 auto; justify-self: auto; } + .topbar .actions { margin-left: auto; flex: 0 0 auto; } +} .brand { display: flex; align-items: center; gap: 9px; flex: none; } .brand svg { width: 26px; height: 26px; display: block; } @@ -192,8 +216,9 @@ a:hover { text-decoration: underline; } .search { position: relative; - flex: 1 1 auto; + width: 100%; max-width: 420px; + justify-self: center; } .search svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); @@ -483,7 +508,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px; .fab { position: fixed; - right: max(20px, calc(50vw - 344px)); + right: 24px; bottom: 24px; z-index: 25; display: inline-flex; align-items: center; gap: 8px; diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index f2fe995..d1b3254 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -89,6 +89,7 @@ const ICON = { back: 'M19 12H5m7 7-7-7 7-7', key: ['M12.7 11.3a5 5 0 1 0-4 4L10 14h2v-2h2l-1.3-1.3Z'], send: ['M22 2 11 13', 'M22 2l-7 20-4-9-9-4 20-7z'], + edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'], }; // ── State ────────────────────────────────────────────────────────────────── @@ -349,27 +350,31 @@ function renderShell() { const logoImg = h('img', { src: '/assets/logo.svg', alt: '', width: 26, height: 26 }); + // Three columns so the search sits on the page's centre line, matching the + // centred account tiles below it, regardless of how wide the brand or the + // action cluster happen to be. const topbar = h('header.topbar', {}, [ h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [ - logoImg, h('strong', { text: 'Authenticator' }), + logoImg, h('strong', { text: 'Discord Authenticator' }), ]), searchWrap, - h('div.spacer'), - h('button.icon-btn.theme-toggle', { - type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme', - onclick: toggleTheme, - }, [ - icon(ICON.sun, { className: 't-sun', width: 2 }), - icon(ICON.moon, { className: 't-moon', width: 2 }), + h('div.actions', {}, [ + h('button.icon-btn.theme-toggle', { + type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme', + onclick: toggleTheme, + }, [ + icon(ICON.sun, { className: 't-sun', width: 2 }), + icon(ICON.moon, { className: 't-moon', width: 2 }), + ]), + h('button.icon-btn', { + type: 'button', 'aria-label': 'Settings', title: 'Settings', + onclick: () => go('settings'), + }, [icon(ICON.settings, { width: 1.7 })]), + h('button.icon-btn', { + type: 'button', 'aria-label': 'Sign out', title: 'Sign out', + onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); }, + }, [icon(ICON.logout, { width: 2 })]), ]), - h('button.icon-btn', { - type: 'button', 'aria-label': 'Settings', title: 'Settings', - onclick: () => go('settings'), - }, [icon(ICON.settings, { width: 1.7 })]), - h('button.icon-btn', { - type: 'button', 'aria-label': 'Sign out', title: 'Sign out', - onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); }, - }, [icon(ICON.logout, { width: 2 })]), ]); root().replaceChildren(h('div.shell', {}, [topbar, h('main.main', { id: 'view' })])); @@ -480,9 +485,9 @@ function acctRow(a) { codeBtn, ring, h('button.icon-btn', { - type: 'button', 'aria-label': `Options for ${a.issuer}`, title: 'Options', + type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit', onclick: () => openAccountSheet(a), - }, [icon(ICON.settings, { width: 1.7 })]), + }, [icon(ICON.edit, { width: 1.9 })]), ]), ]); return row; @@ -1057,15 +1062,21 @@ function openAccountSheet(a) { const label = h('input.input', { value: a.label || '' }); const push = h('input', { type: 'checkbox', checked: !!a.push_enabled }); - const dlg = openModal(a.issuer || 'Account', [ + const dlg = openModal('Edit account', [ a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null, - h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]), - h('div.field', {}, [h('label', { text: 'Account' }), label]), + h('div.field', {}, [ + h('label', { text: 'Name' }), issuer, + h('div.hint', { text: 'What this account is called — shown in the list and on the Discord tile.' }), + ]), + h('div.field', {}, [ + h('label', { text: 'Account' }), label, + h('div.hint', { text: 'Usually the username or email the code belongs to. Left as-is unless you change it.' }), + ]), h('label.switch', { style: 'margin:16px 0 4px' }, [ push, h('span.track'), h('span.switch-label', { text: 'Stream this code to Discord' }), ]), - h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls.' }), + h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls. Renaming updates that message within a second or two.' }), ], [ h('button.btn.btn-danger', { onclick: async () => { if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return; @@ -1075,14 +1086,17 @@ function openAccountSheet(a) { } catch (e) { toast(e.message, 'err'); } } }, [icon(ICON.trash, { width: 1.9 }), 'Remove']), h('div', { style: 'flex:1' }), - h('button.btn', { onclick: dlg.close, text: 'Cancel' }), + // Deferred: `dlg` is still in its temporal dead zone while these + // arguments are being built, so `onclick: dlg.close` throws and the whole + // sheet fails to open. Every other handler here is already a closure. + h('button.btn', { onclick: () => dlg.close(), text: 'Cancel' }), h('button.btn.btn-primary', { onclick: async () => { try { await api('/api/accounts/' + encodeURIComponent(a.id), { method: 'PATCH', body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked }, }); - dlg.close(); toast('Saved'); await reloadAll(); + dlg.close(); toast('Saved — the Discord tile updates shortly'); await reloadAll(); } catch (e) { toast(e.message, 'err'); } }, text: 'Save' }), ]); diff --git a/deploy/unraid/discord-authenticator.xml b/deploy/unraid/discord-authenticator.xml index 513a717..ee09974 100644 --- a/deploy/unraid/discord-authenticator.xml +++ b/deploy/unraid/discord-authenticator.xml @@ -45,7 +45,7 @@ Tools:Utilities Security: http://[IP]:[PORT:8710]/ - https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg + https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/docs/brand/icon-256.png diff --git a/docs/brand/icon-128.png b/docs/brand/icon-128.png new file mode 100644 index 0000000..8ad3433 Binary files /dev/null and b/docs/brand/icon-128.png differ diff --git a/docs/brand/icon-256.png b/docs/brand/icon-256.png new file mode 100644 index 0000000..48169e7 Binary files /dev/null and b/docs/brand/icon-256.png differ diff --git a/docs/brand/icon-512.png b/docs/brand/icon-512.png new file mode 100644 index 0000000..9c21b0e Binary files /dev/null and b/docs/brand/icon-512.png differ diff --git a/docs/brand/icon.svg b/docs/brand/icon.svg new file mode 100644 index 0000000..f49584d --- /dev/null +++ b/docs/brand/icon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + +