Add an authenticated POST /api/v1/invites endpoint that mints a single-use, 7-day registration link after the first-user bootstrap. Expose it via a new "Invite" section on the admin page and a `brewlog invite create` CLI command. The registration_tokens table, repository, and NewRegistrationToken already supported additional tokens; this adds the authenticated surfaces (session cookie or bearer token) to trigger creation.
687 lines
25 KiB
HTML
687 lines
25 KiB
HTML
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
|
{% block title %}Brewlog · Admin{% endblock %}
|
|
{% block head %}
|
|
<script
|
|
defer
|
|
src="/static/js/webauthn.js?v={{ version_info.commit }}"
|
|
></script>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<header class="flex flex-col gap-2">
|
|
<h1 class="text-3xl font-semibold">Admin</h1>
|
|
<p class="max-w-2xl text-sm text-text-secondary">
|
|
Manage passkeys, API tokens, and data.
|
|
</p>
|
|
</header>
|
|
|
|
<!-- Passkeys -->
|
|
<section
|
|
data-signals:_show-passkey-form="false"
|
|
class="rounded-lg border bg-surface p-5"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-text">Passkeys</h2>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Passkeys enable secure sign-in without a password.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
{% for passkey in passkeys %}
|
|
<div
|
|
class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"
|
|
>
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
{{ icons::key("h-4 w-4 text-accent shrink-0") }}
|
|
<div class="min-w-0">
|
|
<span class="block text-sm font-semibold text-text"
|
|
>{{ passkey.name }}</span
|
|
>
|
|
<span class="block text-xs text-text-muted">
|
|
Added {{ passkey.created_at }} ·
|
|
{% if let Some(last_used) = passkey.last_used_at %}
|
|
Last used {{ last_used }}
|
|
{% else %}
|
|
Never used
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{% if passkeys.len() > 1 %}
|
|
<button
|
|
type="button"
|
|
class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium"
|
|
data-id="{{ passkey.id }}"
|
|
data-name="{{ passkey.name }}"
|
|
onclick="deletePasskey(this.dataset.id, this.dataset.name)"
|
|
aria-label="Delete passkey"
|
|
>
|
|
{{ icons::delete("h-4 w-4") }}
|
|
<span class="hidden sm:inline">Delete</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% if passkeys.len() <= 1 %}
|
|
<p class="text-xs text-text-muted">
|
|
Add another passkey before removing the only one.
|
|
</p>
|
|
{% endif %}
|
|
|
|
<!-- Add passkey form -->
|
|
<div
|
|
id="add-passkey-form"
|
|
class="rounded-md border bg-surface-alt p-4"
|
|
data-show="$_showPasskeyForm"
|
|
style="display: none"
|
|
>
|
|
<div
|
|
id="add-passkey-error"
|
|
class="mb-3 hidden rounded-md bg-error-bg border border-error-border p-2 text-sm text-error-text"
|
|
role="alert"
|
|
></div>
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<label class="flex flex-col gap-1 text-sm sm:flex-1">
|
|
<span class="text-text">Passkey Name</span>
|
|
<input
|
|
type="text"
|
|
id="passkey-name"
|
|
required
|
|
aria-required="true"
|
|
class="input-field"
|
|
placeholder="e.g. MacBook Touch ID, iPhone"
|
|
/>
|
|
</label>
|
|
<div class="flex gap-3">
|
|
<button
|
|
id="add-passkey-btn"
|
|
type="button"
|
|
onclick="registerPasskey()"
|
|
class="flex-1 inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed sm:flex-initial"
|
|
>
|
|
{{ icons::key("h-4 w-4") }} Register
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-on:click="$_showPasskeyForm = false; document.getElementById('add-passkey-error').classList.add('hidden'); document.getElementById('passkey-name').value = ''"
|
|
class="flex-1 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt sm:flex-initial"
|
|
>
|
|
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
id="add-passkey-loading"
|
|
class="mt-3 hidden flex items-center gap-3 text-sm text-accent"
|
|
>
|
|
{{ icons::spinner("h-5 w-5") }} Follow the prompts from the browser or
|
|
device...
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
type="button"
|
|
data-show="!$_showPasskeyForm"
|
|
data-on:click="$_showPasskeyForm = true; setTimeout(() => document.getElementById('passkey-name').focus(), 50)"
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
|
>
|
|
{{ icons::key("h-4 w-4") }} New Passkey
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- API Tokens -->
|
|
<section
|
|
class="rounded-lg border bg-surface p-5"
|
|
data-signals:_show-token-form="false"
|
|
data-signals:_creating-token="false"
|
|
data-signals:_token-created="false"
|
|
data-signals:_token-value="''"
|
|
data-signals:_token-error="''"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-text">API Tokens</h2>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Use tokens to authenticate the CLI or REST API.
|
|
</p>
|
|
</div>
|
|
|
|
{% if tokens.is_empty() %}
|
|
<p class="text-sm text-text-muted">No active tokens.</p>
|
|
{% else %}
|
|
<div class="flex flex-col gap-2">
|
|
{% for token in tokens %}
|
|
<div
|
|
id="token-row-{{ token.id }}"
|
|
class="flex items-center justify-between gap-4 rounded-md bg-surface-alt px-4 py-3"
|
|
>
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
{{ icons::terminal("h-4 w-4 text-accent shrink-0") }}
|
|
<div class="min-w-0">
|
|
<span class="block text-sm font-semibold text-text"
|
|
>{{ token.name }}</span
|
|
>
|
|
<span class="block text-xs text-text-muted">
|
|
Created {{ token.created_at }} ·
|
|
{% if let Some(last_used) = token.last_used_at %}
|
|
Last used {{ last_used }}
|
|
{% else %}
|
|
Never used
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium"
|
|
data-id="{{ token.id }}"
|
|
data-name="{{ token.name }}"
|
|
onclick="revokeToken(this.dataset.id, this.dataset.name)"
|
|
aria-label="Revoke token"
|
|
>
|
|
{{ icons::delete("h-4 w-4") }}
|
|
<span class="hidden sm:inline">Revoke</span>
|
|
</button>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Create token form -->
|
|
<div
|
|
class="rounded-md border bg-surface-alt p-4"
|
|
data-show="$_showTokenForm && !$_tokenCreated"
|
|
style="display: none"
|
|
>
|
|
<p
|
|
data-show="$_tokenError"
|
|
data-text="$_tokenError"
|
|
style="display: none"
|
|
class="mb-3 rounded-md bg-error-bg border border-error-border p-2 text-sm text-error-text"
|
|
role="alert"
|
|
></p>
|
|
<form
|
|
data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})"
|
|
data-on:datastar-fetch="if (!$_creatingToken) return;
|
|
if (evt.detail.type === 'finished') { $_creatingToken = false; document.getElementById('token-name').value = '' }
|
|
else if (evt.detail.type === 'error') { $_creatingToken = false; $_tokenError = 'Failed to create token.' }"
|
|
>
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<label class="flex flex-col gap-1 text-sm sm:flex-1">
|
|
<span class="text-text">Token Name</span>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
id="token-name"
|
|
required
|
|
aria-required="true"
|
|
class="input-field"
|
|
placeholder="e.g. laptop-cli, ci-server"
|
|
/>
|
|
</label>
|
|
<div class="flex gap-3">
|
|
<button
|
|
type="submit"
|
|
class="flex-1 inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed sm:flex-initial"
|
|
data-attr:disabled="$_creatingToken"
|
|
>
|
|
{{ icons::plus("h-4 w-4") }} Create
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-on:click="$_showTokenForm = false; $_tokenError = ''"
|
|
class="flex-1 inline-flex items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt sm:flex-initial"
|
|
>
|
|
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- One-time token display -->
|
|
<div
|
|
data-show="$_tokenCreated"
|
|
style="display: none"
|
|
class="relative rounded-md border border-success-border bg-success-bg p-4"
|
|
>
|
|
<button
|
|
type="button"
|
|
onclick="window.location.reload()"
|
|
class="absolute top-3.5 right-3 inline-flex h-6 w-6 items-center justify-center rounded text-success-text transition hover:text-accent-text"
|
|
aria-label="Dismiss"
|
|
>
|
|
{{ icons::x_mark("h-4 w-4") }}
|
|
</button>
|
|
<p class="pr-6 text-sm font-medium text-success-text">
|
|
Token created! Copy it now — it will not be shown again.
|
|
</p>
|
|
<div class="mt-3 flex items-center gap-2">
|
|
<code
|
|
id="token-value"
|
|
data-text="$_tokenValue"
|
|
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-success-border break-all select-all"
|
|
></code>
|
|
<button
|
|
type="button"
|
|
onclick="copyToken(this)"
|
|
class="shrink-0 inline-flex items-center gap-2 rounded-md bg-success px-3 py-1.5 text-sm font-medium text-accent-text transition hover:bg-success"
|
|
>
|
|
{{ icons::clipboard("h-4 w-4") }} Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
type="button"
|
|
data-show="!$_showTokenForm && !$_tokenCreated"
|
|
data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)"
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
|
>
|
|
{{ icons::terminal("h-4 w-4") }} New Token
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Invite -->
|
|
<section
|
|
class="rounded-lg border bg-surface p-5"
|
|
data-signals:_creating-invite="false"
|
|
data-signals:_invite-created="false"
|
|
data-signals:_invite-url="''"
|
|
data-signals:_invite-error="''"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-text">Invite</h2>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Generate a registration link to onboard another user. The link is
|
|
valid for 7 days and can be used once.
|
|
</p>
|
|
</div>
|
|
|
|
<p
|
|
data-show="$_inviteError"
|
|
data-text="$_inviteError"
|
|
style="display: none"
|
|
class="rounded-md bg-error-bg border border-error-border p-2 text-sm text-error-text"
|
|
role="alert"
|
|
></p>
|
|
|
|
<!-- One-time invite link display -->
|
|
<div
|
|
data-show="$_inviteCreated"
|
|
style="display: none"
|
|
class="relative rounded-md border border-success-border bg-success-bg p-4"
|
|
>
|
|
<button
|
|
type="button"
|
|
onclick="window.location.reload()"
|
|
class="absolute top-3.5 right-3 inline-flex h-6 w-6 items-center justify-center rounded text-success-text transition hover:text-accent-text"
|
|
aria-label="Dismiss"
|
|
>
|
|
{{ icons::x_mark("h-4 w-4") }}
|
|
</button>
|
|
<p class="pr-6 text-sm font-medium text-success-text">
|
|
Invite link created! Share it with the new user — it expires in 7
|
|
days and can be used once.
|
|
</p>
|
|
<div class="mt-3 flex items-center gap-2">
|
|
<code
|
|
id="invite-url"
|
|
data-text="$_inviteUrl"
|
|
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-success-border break-all select-all"
|
|
></code>
|
|
<button
|
|
type="button"
|
|
onclick="copyInvite(this)"
|
|
class="shrink-0 inline-flex items-center gap-2 rounded-md bg-success px-3 py-1.5 text-sm font-medium text-accent-text transition hover:bg-success"
|
|
>
|
|
{{ icons::clipboard("h-4 w-4") }} Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div data-show="!$_inviteCreated">
|
|
<button
|
|
type="button"
|
|
data-on:click="$_creatingInvite = true; $_inviteError = ''; @post('/api/v1/invites')"
|
|
data-on:datastar-fetch="if (!$_creatingInvite) return;
|
|
if (evt.detail.type === 'finished') { $_creatingInvite = false }
|
|
else if (evt.detail.type === 'error') { $_creatingInvite = false; $_inviteError = 'Failed to create invite link.' }"
|
|
data-attr:disabled="$_creatingInvite"
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
|
|
>
|
|
<span data-show="!$_creatingInvite">{{ icons::user("h-4 w-4") }}</span>
|
|
<span data-show="$_creatingInvite" style="display:none"
|
|
>{{ icons::spinner("h-4 w-4") }}</span
|
|
>
|
|
New Invite
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Data -->
|
|
<section class="rounded-lg border bg-surface p-5">
|
|
<div class="flex flex-col gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-text">Data</h2>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Export all coffee data as JSON, restore from a previous backup, or
|
|
reset to start fresh.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
|
<a
|
|
href="/api/v1/backup"
|
|
download
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover sm:w-auto sm:min-w-44"
|
|
>
|
|
{{ icons::arrow_down_tray("h-4 w-4") }} New Backup
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
|
onclick="document.getElementById('restore-file-input').click()"
|
|
>
|
|
{{ icons::arrow_up_tray("h-4 w-4") }} Restore
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-signals:_recomputing="false"
|
|
data-on:click="$_recomputing = true; @post('/api/v1/stats/recompute')"
|
|
data-on:datastar-fetch="if (!$_recomputing) return;
|
|
if (evt.detail.type === 'finished') { $_recomputing = false; document.getElementById('backup-status').textContent = 'Stats recomputed successfully.'; document.getElementById('backup-status').classList.remove('hidden') }
|
|
else if (evt.detail.type === 'error') { $_recomputing = false; document.getElementById('backup-error').textContent = 'Stats recompute failed.'; document.getElementById('backup-error').classList.remove('hidden') }"
|
|
data-attr:disabled="$_recomputing"
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
|
|
>
|
|
<span data-show="!$_recomputing"
|
|
>{{ icons::refresh("h-4 w-4") }}</span
|
|
>
|
|
<span data-show="$_recomputing" style="display:none"
|
|
>{{ icons::spinner("h-4 w-4") }}</span
|
|
>
|
|
Recompute Stats
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
|
onclick="resetDatabase()"
|
|
>
|
|
{{ icons::delete("h-4 w-4") }} Reset
|
|
</button>
|
|
</div>
|
|
|
|
<input
|
|
type="file"
|
|
id="restore-file-input"
|
|
accept=".json"
|
|
class="hidden"
|
|
onchange="restoreFromFile(this)"
|
|
/>
|
|
|
|
<div
|
|
id="backup-status"
|
|
class="hidden rounded-md border border-success-border bg-success-bg p-4 text-sm text-success-text"
|
|
></div>
|
|
<div
|
|
id="backup-error"
|
|
class="hidden rounded-md bg-error-bg border border-error-border p-3 text-sm text-error-text"
|
|
role="alert"
|
|
></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- AI Usage -->
|
|
{% if let Some(usage) = ai_usage %}
|
|
<section class="rounded-lg border bg-surface p-5">
|
|
<div class="flex flex-col gap-4">
|
|
<h2 class="text-lg font-semibold text-text">AI Usage</h2>
|
|
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<span class="block text-sm text-text-muted">Total Cost</span>
|
|
<span class="mt-1 block text-lg font-semibold text-text"
|
|
>{{ usage.cost }}</span
|
|
>
|
|
</div>
|
|
<div>
|
|
<span class="block text-sm text-text-muted">API Calls</span>
|
|
<span class="mt-1 block text-lg font-semibold text-text"
|
|
>{{ usage.calls }}</span
|
|
>
|
|
</div>
|
|
<div>
|
|
<span class="block text-sm text-text-muted">Total Tokens</span>
|
|
<span class="mt-1 block text-lg font-semibold text-text"
|
|
>{{ usage.tokens }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
<!-- Sign Out -->
|
|
<section class="rounded-lg border bg-surface p-5">
|
|
<div class="flex flex-col gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-text">Sign Out</h2>
|
|
<p class="mt-1 text-sm text-text-secondary">Sign out on this device.</p>
|
|
</div>
|
|
<div>
|
|
<form method="post" action="/logout">
|
|
<button
|
|
type="submit"
|
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"
|
|
>
|
|
{{ icons::logout("h-4 w-4") }} Sign Out
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<script>
|
|
// --- Passkey form ---
|
|
|
|
const registerPasskey = async () => {
|
|
const name = document.getElementById("passkey-name").value.trim();
|
|
const errorEl = document.getElementById("add-passkey-error");
|
|
const loadingEl = document.getElementById("add-passkey-loading");
|
|
const btn = document.getElementById("add-passkey-btn");
|
|
|
|
if (!name) {
|
|
errorEl.textContent = "Please enter a name for this passkey.";
|
|
errorEl.classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
errorEl.classList.add("hidden");
|
|
loadingEl.classList.remove("hidden");
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
await addPasskey(name);
|
|
window.location.reload();
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove("hidden");
|
|
loadingEl.classList.add("hidden");
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
|
|
// --- Token ---
|
|
|
|
const copyToken = (btn) => {
|
|
const token = document.getElementById("token-value").textContent;
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(token).then(() => {
|
|
btn.textContent = "Copied!";
|
|
setTimeout(() => {
|
|
btn.textContent = "Copy";
|
|
}, 2000);
|
|
});
|
|
} else {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(document.getElementById("token-value"));
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
};
|
|
|
|
// --- Invite ---
|
|
|
|
const copyInvite = (btn) => {
|
|
const url = document.getElementById("invite-url").textContent;
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
btn.textContent = "Copied!";
|
|
setTimeout(() => {
|
|
btn.textContent = "Copy";
|
|
}, 2000);
|
|
});
|
|
} else {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(document.getElementById("invite-url"));
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
};
|
|
|
|
// --- Data management ---
|
|
|
|
const restoreFromFile = async (input) => {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
input.value = "";
|
|
|
|
if (
|
|
!confirm(
|
|
"Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const status = document.getElementById("backup-status");
|
|
const error = document.getElementById("backup-error");
|
|
status.classList.add("hidden");
|
|
error.classList.add("hidden");
|
|
|
|
try {
|
|
const text = await file.text();
|
|
JSON.parse(text);
|
|
|
|
const response = await fetch("/api/v1/backup/restore", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: text,
|
|
});
|
|
|
|
if (response.status === 409) {
|
|
throw new Error(
|
|
"Database is not empty. Restore requires an empty database.",
|
|
);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Restore failed (HTTP ${response.status}).`);
|
|
}
|
|
|
|
status.textContent = "Backup restored successfully.";
|
|
status.classList.remove("hidden");
|
|
} catch (err) {
|
|
error.textContent =
|
|
err instanceof SyntaxError ? "Invalid JSON file." : err.message;
|
|
error.classList.remove("hidden");
|
|
}
|
|
};
|
|
|
|
const resetDatabase = async () => {
|
|
if (
|
|
!confirm(
|
|
"Reset all coffee data?\n\nThis will permanently delete all roasters, roasts, bags, gear, brews, cafes, cups, and timeline events.\n\nThis cannot be undone.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!confirm("Confirm? All coffee data will be permanently deleted.")) {
|
|
return;
|
|
}
|
|
|
|
const status = document.getElementById("backup-status");
|
|
const error = document.getElementById("backup-error");
|
|
status.classList.add("hidden");
|
|
error.classList.add("hidden");
|
|
|
|
try {
|
|
const response = await fetch("/api/v1/backup/reset", {
|
|
method: "POST",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Reset failed (HTTP ${response.status}).`);
|
|
}
|
|
|
|
status.textContent =
|
|
"Database reset successfully. All coffee data has been deleted.";
|
|
status.classList.remove("hidden");
|
|
} catch (err) {
|
|
error.textContent = err.message;
|
|
error.classList.remove("hidden");
|
|
}
|
|
};
|
|
|
|
const deletePasskey = async (id, name) => {
|
|
if (!confirm(`Delete passkey "${name}"? This cannot be undone.`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/passkeys/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (response.ok) {
|
|
window.location.reload();
|
|
} else if (response.status === 409) {
|
|
alert("Cannot delete the only passkey. Add another passkey first.");
|
|
} else {
|
|
alert("Failed to delete passkey.");
|
|
}
|
|
} catch (err) {
|
|
alert(`Failed to delete passkey: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
const revokeToken = async (id, name) => {
|
|
if (!confirm(`Revoke token "${name}"? This cannot be undone.`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/tokens/${id}/revoke`, {
|
|
method: "POST",
|
|
});
|
|
if (response.ok) {
|
|
const row = document.getElementById(`token-row-${id}`);
|
|
if (row) row.remove();
|
|
} else {
|
|
alert("Failed to revoke token.");
|
|
}
|
|
} catch (err) {
|
|
alert(`Failed to revoke token: ${err.message}`);
|
|
}
|
|
};
|
|
</script>
|
|
{% endblock %}
|