-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 @@
+