brewlog/templates/account.html
Jon Seager 9eb9a9a56c
feat(account): display OpenRouter usage on account page
Show cumulative cost, API call count, and total tokens in card
layout when the user has AI usage data.
2026-02-05 12:09:43 +00:00

510 lines
18 KiB
HTML

{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Account{% endblock %}
{% block head %}
<script src="/webauthn.js"></script>
{% endblock %}
{% block content %}
<!-- Passkeys -->
<section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">Passkeys</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{% for passkey in passkeys %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm flex flex-col justify-between">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="block font-semibold text-amber-800">{% call icons::key("inline h-4 w-4 mr-1 text-amber-600") %}{{ passkey.name }}</span>
</div>
{% if passkeys.len() > 1 %}
<button
type="button"
class="shrink-0 rounded-md p-1 text-stone-400 transition hover:text-red-600"
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')"
aria-label="Delete passkey"
>
{% call icons::delete("h-4 w-4") %}
</button>
{% endif %}
</div>
<div class="mt-2 space-y-0.5 text-sm text-stone-500">
<p>Added {{ passkey.created_at }}</p>
{% if let Some(last_used) = passkey.last_used_at %}
<p>Last used {{ last_used }}</p>
{% else %}
<p>Never used</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if passkeys.len() <= 1 %}
<p class="mt-2 text-xs text-stone-400">Add another passkey before removing your only one.</p>
{% endif %}
<!-- Add passkey form (hidden by default) -->
<div id="add-passkey-form" class="mt-3 hidden rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
<div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Passkey Name</span>
<input
type="text"
id="passkey-name"
required
class="input-field"
placeholder="e.g. MacBook Touch ID, iPhone"
/>
</label>
<button
id="add-passkey-btn"
type="button"
class="shrink-0 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Register
</button>
<button
id="cancel-add-passkey"
type="button"
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-stone-500 transition hover:text-stone-800"
>
Cancel
</button>
</div>
<div id="add-passkey-loading" class="mt-3 hidden flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-5 w-5") %}
Follow the prompts from your browser or device...
</div>
</div>
<button
id="show-add-passkey"
type="button"
class="mt-3 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500"
>
Add Passkey
</button>
</section>
<!-- API Tokens -->
<section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">API Tokens</h2>
{% if tokens.is_empty() %}
<p class="text-sm text-stone-500">No active tokens. Create one to use the CLI or API.</p>
{% else %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{% for token in tokens %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm flex flex-col justify-between">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="block font-semibold text-amber-800">{{ token.name }}</span>
</div>
<button
type="button"
class="shrink-0 rounded-md p-1 text-stone-400 transition hover:text-red-600"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
aria-label="Revoke token"
>
{% call icons::delete("h-4 w-4") %}
</button>
</div>
<div class="mt-2 space-y-0.5 text-sm text-stone-500">
<p>Created {{ token.created_at }}</p>
{% if let Some(last_used) = token.last_used_at %}
<p>Last used {{ last_used }}</p>
{% else %}
<p>Never used</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Create token form (hidden by default) -->
<div id="create-token-form" class="mt-3 hidden rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div id="create-token-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
<div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Token Name</span>
<input
type="text"
id="token-name"
required
class="input-field"
placeholder="e.g. laptop-cli, ci-server"
/>
</label>
<button
id="create-token-btn"
type="button"
class="shrink-0 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Create
</button>
<button
id="cancel-create-token"
type="button"
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-stone-500 transition hover:text-stone-800"
>
Cancel
</button>
</div>
</div>
<!-- One-time token display (hidden by default) -->
<div id="token-created" class="mt-3 hidden rounded-lg border border-green-300 bg-green-50 p-5 shadow-sm">
<p class="text-sm font-medium text-green-800">Token created! Copy it now — you won't see it again.</p>
<div class="mt-3 flex items-center gap-2">
<code id="token-value" class="flex-1 rounded bg-white px-3 py-2 text-sm font-mono text-stone-800 border border-green-200 break-all select-all"></code>
<button
id="copy-token-btn"
type="button"
class="shrink-0 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-green-50 transition hover:bg-green-500"
>
Copy
</button>
</div>
<button
id="dismiss-token"
type="button"
class="mt-2 text-sm text-stone-500 hover:text-stone-700"
>
Dismiss
</button>
</div>
<button
id="show-create-token"
type="button"
class="mt-3 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500"
>
Create Token
</button>
</section>
<!-- Data -->
<section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">Data</h2>
<p class="text-sm text-stone-500 mb-3">Export all coffee data as JSON, or restore from a previous backup.</p>
<div class="flex flex-wrap gap-3">
<button
id="download-backup-btn"
type="button"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Download Backup
</button>
<button
id="restore-backup-btn"
type="button"
class="rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-50 hover:text-stone-800"
>
Restore from Backup
</button>
</div>
<input type="file" id="restore-file-input" accept=".json" class="hidden" />
<div id="backup-status" class="mt-3 hidden rounded-lg border border-green-300 bg-green-50 p-4 text-sm text-green-800"></div>
<div id="backup-error" class="mt-3 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
</section>
<!-- OpenRouter Usage -->
{% if let Some(usage) = ai_usage %}
<section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">OpenRouter Usage</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm">
<span class="block text-sm text-stone-500">Total Cost</span>
<span class="mt-1 block text-lg font-semibold text-amber-800">{{ usage.total_cost }}</span>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm">
<span class="block text-sm text-stone-500">API Calls</span>
<span class="mt-1 block text-lg font-semibold text-amber-800">{{ usage.total_calls }}</span>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm">
<span class="block text-sm text-stone-500">Total Tokens</span>
<span class="mt-1 block text-lg font-semibold text-amber-800">{{ usage.total_tokens }}</span>
</div>
</div>
</section>
{% endif %}
<!-- Sign Out -->
<section>
<form method="post" action="/logout">
<button
type="submit"
class="rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-50 hover:text-stone-800"
>
Sign Out
</button>
</form>
</section>
<script>
document.addEventListener("DOMContentLoaded", function () {
// --- Passkey management ---
const showAddPasskey = document.getElementById("show-add-passkey");
const addPasskeyForm = document.getElementById("add-passkey-form");
const cancelAddPasskey = document.getElementById("cancel-add-passkey");
const addPasskeyBtn = document.getElementById("add-passkey-btn");
const passkeyNameInput = document.getElementById("passkey-name");
const addPasskeyError = document.getElementById("add-passkey-error");
const addPasskeyLoading = document.getElementById("add-passkey-loading");
showAddPasskey.addEventListener("click", function () {
addPasskeyForm.classList.remove("hidden");
showAddPasskey.classList.add("hidden");
passkeyNameInput.focus();
});
cancelAddPasskey.addEventListener("click", function () {
addPasskeyForm.classList.add("hidden");
showAddPasskey.classList.remove("hidden");
addPasskeyError.classList.add("hidden");
passkeyNameInput.value = "";
});
addPasskeyBtn.addEventListener("click", async function () {
const name = passkeyNameInput.value.trim();
if (!name) {
addPasskeyError.textContent = "Please enter a name for this passkey.";
addPasskeyError.classList.remove("hidden");
return;
}
addPasskeyError.classList.add("hidden");
addPasskeyLoading.classList.remove("hidden");
addPasskeyBtn.disabled = true;
try {
await addPasskey(name);
window.location.reload();
} catch (err) {
addPasskeyError.textContent = err.message;
addPasskeyError.classList.remove("hidden");
addPasskeyLoading.classList.add("hidden");
addPasskeyBtn.disabled = false;
}
});
// --- Token management ---
const showCreateToken = document.getElementById("show-create-token");
const createTokenForm = document.getElementById("create-token-form");
const cancelCreateToken = document.getElementById("cancel-create-token");
const createTokenBtn = document.getElementById("create-token-btn");
const tokenNameInput = document.getElementById("token-name");
const createTokenError = document.getElementById("create-token-error");
const tokenCreated = document.getElementById("token-created");
const tokenValue = document.getElementById("token-value");
const copyTokenBtn = document.getElementById("copy-token-btn");
const dismissToken = document.getElementById("dismiss-token");
showCreateToken.addEventListener("click", function () {
createTokenForm.classList.remove("hidden");
showCreateToken.classList.add("hidden");
tokenNameInput.focus();
});
cancelCreateToken.addEventListener("click", function () {
createTokenForm.classList.add("hidden");
showCreateToken.classList.remove("hidden");
createTokenError.classList.add("hidden");
tokenNameInput.value = "";
});
createTokenBtn.addEventListener("click", async function () {
const name = tokenNameInput.value.trim();
if (!name) {
createTokenError.textContent = "Please enter a name for this token.";
createTokenError.classList.remove("hidden");
return;
}
createTokenError.classList.add("hidden");
createTokenBtn.disabled = true;
try {
const response = await fetch("/api/v1/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!response.ok) {
throw new Error("Failed to create token (HTTP " + response.status + ").");
}
const data = await response.json();
// Show the one-time token value
createTokenForm.classList.add("hidden");
tokenValue.textContent = data.token;
tokenCreated.classList.remove("hidden");
} catch (err) {
createTokenError.textContent = err.message;
createTokenError.classList.remove("hidden");
createTokenBtn.disabled = false;
}
});
copyTokenBtn.addEventListener("click", function () {
const token = tokenValue.textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(token).then(function () {
copyTokenBtn.textContent = "Copied!";
setTimeout(function () {
copyTokenBtn.textContent = "Copy";
}, 2000);
});
} else {
// Fallback: select the text
const range = document.createRange();
range.selectNodeContents(tokenValue);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
});
dismissToken.addEventListener("click", function () {
tokenCreated.classList.add("hidden");
showCreateToken.classList.remove("hidden");
tokenNameInput.value = "";
createTokenBtn.disabled = false;
window.location.reload();
});
// --- Backup / Restore ---
const downloadBackupBtn = document.getElementById("download-backup-btn");
const restoreBackupBtn = document.getElementById("restore-backup-btn");
const restoreFileInput = document.getElementById("restore-file-input");
const backupStatus = document.getElementById("backup-status");
const backupError = document.getElementById("backup-error");
function showBackupStatus(message) {
backupError.classList.add("hidden");
backupStatus.textContent = message;
backupStatus.classList.remove("hidden");
}
function showBackupError(message) {
backupStatus.classList.add("hidden");
backupError.textContent = message;
backupError.classList.remove("hidden");
}
downloadBackupBtn.addEventListener("click", async function () {
downloadBackupBtn.disabled = true;
backupStatus.classList.add("hidden");
backupError.classList.add("hidden");
try {
const response = await fetch("/api/v1/backup");
if (!response.ok) {
throw new Error("Failed to download backup (HTTP " + response.status + ").");
}
const blob = await response.blob();
const date = new Date().toISOString().slice(0, 10);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "brewlog-backup-" + date + ".json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
showBackupError(err.message);
} finally {
downloadBackupBtn.disabled = false;
}
});
restoreBackupBtn.addEventListener("click", function () {
restoreFileInput.click();
});
restoreFileInput.addEventListener("change", async function () {
const file = restoreFileInput.files[0];
if (!file) return;
restoreFileInput.value = "";
if (!confirm("Restore from backup? This will replace all data.\n\nThe database must be empty for restore to succeed.")) {
return;
}
backupStatus.classList.add("hidden");
backupError.classList.add("hidden");
restoreBackupBtn.disabled = true;
try {
const text = await file.text();
JSON.parse(text); // validate JSON before sending
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 + ").");
}
showBackupStatus("Backup restored successfully.");
} catch (err) {
if (err instanceof SyntaxError) {
showBackupError("Invalid JSON file.");
} else {
showBackupError(err.message);
}
} finally {
restoreBackupBtn.disabled = false;
}
});
});
// --- Global functions for inline onclick handlers ---
async function deletePasskey(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 your only passkey. Add another passkey first.");
} else {
alert("Failed to delete passkey.");
}
} catch (err) {
alert("Failed to delete passkey: " + err.message);
}
}
async function revokeToken(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) {
window.location.reload();
} else {
alert("Failed to revoke token.");
}
} catch (err) {
alert("Failed to revoke token: " + err.message);
}
}
</script>
{% endblock %}