v0.1.2: working Edit sheet, centred search, brand assets

Fixes a bug that shipped in v0.1: the per-account Edit sheet never opened at
all. Its Cancel button used `onclick: dlg.close`, evaluated while building the
arguments to openModal() and therefore before `const dlg` was initialised — a
temporal-dead-zone ReferenceError that aborted the whole handler. Every other
button in that sheet was already a closure, which is why it went unnoticed:
browser testing had covered add, import and settings, but never opened this
sheet.

Renames now reach Discord almost immediately. The push loop skips any account
whose current code it has already sent, so a rename previously stayed invisible
until the code next rolled — up to a full period. update() now clears the
last-sent code when the naming actually changes, so the next tick treats the
tile as stale and rewrites it in about a second. Toggling push alone is not a
rename and still costs nothing.

- webui: pencil icon and "Edit account" title so the row action reads as edit
  rather than settings; both fields labelled, with the second one explained as
  the username/email and left untouched unless changed
- webui: topbar is a three-column grid so the search sits on the same centre
  line as the account tiles. Below 820px there isn't room for brand + 420px
  search + actions, so it falls back to a row rather than clipping the
  wordmark; the brand now reads "Discord Authenticator" in full
- webui: the add-account FAB pins to the viewport corner instead of tracking
  the content column edge
- brand: docs/brand/icon.svg plus PNGs rendered from it by da-render, so the
  README image, Gitea avatar and Unraid template icon all derive from one
  source rather than drifting. Unraid's <Icon> now points at a PNG, which it
  renders more reliably than SVG

126 tests (was 124).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Miles WardandClaude Opus 4.8 committed 2026-08-31 01:42:18 -04:00
1 parent eecdacd890
commit 7751ebe9b3
14 files changed
+274 -43

No files matched your search

Generated
+6 -6
View File
@@ -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",
+1 -1
View File
@@ -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"
+23 -8
View File
@@ -1,7 +1,13 @@
# Discord Authenticator
<p align="center">
<img src="docs/brand/icon-256.png" width="128" height="128" alt="Discord Authenticator" />
</p>
A self-hosted TOTP authenticator whose codes appear in Discord as **live
messages that rewrite themselves** as each code rolls.
<h1 align="center">Discord Authenticator</h1>
<p align="center">
A self-hosted TOTP authenticator whose codes appear in Discord as
<strong>live messages that rewrite themselves</strong> as each code rolls.
</p>
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
+15
View File
@@ -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();
+81
View File
@@ -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("[email protected]"), 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("[email protected]"),
"new label missing: {desc}"
);
assert!(
!desc.contains("[email protected]"),
"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();
+15
View File
@@ -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());
}
}
+35
View File
@@ -75,6 +75,41 @@ pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
let svg = build_svg(tile);
let tree = usvg::Tree::from_str(&svg, options())?;
+28 -3
View File
@@ -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;
+38 -24
View File
@@ -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' }),
]);
+1 -1
View File
@@ -45,7 +45,7 @@
<Category>Tools:Utilities Security:</Category>
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg</Icon>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/docs/brand/icon-256.png</Icon>
<ExtraParams/>
<PostArgs/>
<CPUset/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="Discord Authenticator">
<!-- App icon: the shield-and-arc mark on the product's own dark surface.
Colours are literal rather than currentColor so this renders correctly
as a favicon, a Gitea repo avatar and an Unraid template icon, none of
which supply a CSS context. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#232833"/>
<stop offset="1" stop-color="#161a22"/>
</linearGradient>
<linearGradient id="mark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6ea3ff"/>
<stop offset="1" stop-color="#3d78e8"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<rect x="1" y="1" width="510" height="510" rx="113"
fill="none" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/>
<g transform="translate(96 96) scale(10)">
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
fill="url(#mark)" fill-opacity=".18"/>
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
fill="none" stroke="url(#mark)" stroke-width="1.9" stroke-linejoin="round"/>
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3"
fill="none" stroke="url(#mark)" stroke-width="2.1" stroke-linecap="round"/>
<circle cx="16" cy="15.9" r="1.85" fill="url(#mark)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB