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:
1 parent
eecdacd890
commit
7751ebe9b3
14 files changed
+274
-43
No files matched your search
Generated
+6
-6
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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
@@ -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' }),
|
||||
]);
|
||||
|
||||
@@ -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 |
@@ -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 |
Reference in new issue
Block a user