Add dark: variants for the green success banner on the account page so it doesn't appear overly bright against the dark background. Also tidy .gitignore to catch all .db and .db-journal files.
425 lines
14 KiB
HTML
425 lines
14 KiB
HTML
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
|
{% block title %}Brewlog · Admin{% endblock %}
|
|
{% block head %}
|
|
<script src="/webauthn.js"></script>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<!-- Passkeys -->
|
|
<section>
|
|
<h2 class="text-lg font-semibold text-accent 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 bg-surface p-4 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-text">{% call icons::key("inline h-4 w-4 mr-1 text-accent") %}{{ passkey.name }}</span>
|
|
</div>
|
|
{% if passkeys.len() > 1 %}
|
|
<button
|
|
type="button"
|
|
class="shrink-0 rounded-md p-1 text-text-muted 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-text-muted">
|
|
<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-text-muted">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 bg-surface p-5">
|
|
<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-text">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"
|
|
onclick="registerPasskey()"
|
|
class="shrink-0 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Register
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick="hidePasskeyForm()"
|
|
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<div id="add-passkey-loading" class="mt-3 hidden flex items-center gap-3 text-sm text-accent">
|
|
{% call icons::spinner("h-5 w-5") %}
|
|
Follow the prompts from your browser or device...
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
id="show-add-passkey"
|
|
type="button"
|
|
onclick="showPasskeyForm()"
|
|
class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover"
|
|
>
|
|
Add Passkey
|
|
</button>
|
|
</section>
|
|
|
|
<!-- API Tokens -->
|
|
<section>
|
|
<h2 class="text-lg font-semibold text-accent mb-3">API Tokens</h2>
|
|
|
|
{% if tokens.is_empty() %}
|
|
<p class="text-sm text-text-muted">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 bg-surface p-4 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-text">{{ token.name }}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="shrink-0 rounded-md p-1 text-text-muted 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-text-muted">
|
|
<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 bg-surface p-5">
|
|
<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-text">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"
|
|
onclick="createToken()"
|
|
class="shrink-0 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Create
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick="hideTokenForm()"
|
|
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text"
|
|
>
|
|
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 dark:border-green-800 bg-green-50 dark:bg-green-950/40 p-5">
|
|
<p class="text-sm font-medium text-green-800 dark:text-green-300">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-surface px-3 py-2 text-sm font-mono text-text border border-green-200 dark:border-green-800 break-all select-all"></code>
|
|
<button
|
|
type="button"
|
|
onclick="copyToken(this)"
|
|
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
|
|
type="button"
|
|
onclick="window.location.reload()"
|
|
class="mt-2 text-sm text-text-muted hover:text-text"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
id="show-create-token"
|
|
type="button"
|
|
onclick="showTokenForm()"
|
|
class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover"
|
|
>
|
|
Create Token
|
|
</button>
|
|
</section>
|
|
|
|
<!-- Data -->
|
|
<section>
|
|
<h2 class="text-lg font-semibold text-accent mb-3">Data</h2>
|
|
<p class="text-sm text-text-muted mb-3">Export all coffee data as JSON, or restore from a previous backup.</p>
|
|
|
|
<div class="flex flex-wrap gap-3">
|
|
<a
|
|
href="/api/v1/backup"
|
|
download
|
|
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover inline-block"
|
|
>
|
|
Download Backup
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="rounded-md border bg-surface px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt hover:text-text"
|
|
onclick="document.getElementById('restore-file-input').click()"
|
|
>
|
|
Restore from Backup
|
|
</button>
|
|
</div>
|
|
|
|
<input type="file" id="restore-file-input" accept=".json" class="hidden"
|
|
onchange="restoreFromFile(this)" />
|
|
|
|
<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-accent mb-3">OpenRouter Usage</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<div class="rounded-lg border bg-surface p-4">
|
|
<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 class="rounded-lg border bg-surface p-4">
|
|
<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 class="rounded-lg border bg-surface p-4">
|
|
<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>
|
|
</section>
|
|
{% endif %}
|
|
|
|
<script>
|
|
// --- Passkey form ---
|
|
|
|
const showPasskeyForm = () => {
|
|
document.getElementById("add-passkey-form").classList.remove("hidden");
|
|
document.getElementById("show-add-passkey").classList.add("hidden");
|
|
document.getElementById("passkey-name").focus();
|
|
};
|
|
|
|
const hidePasskeyForm = () => {
|
|
document.getElementById("add-passkey-form").classList.add("hidden");
|
|
document.getElementById("show-add-passkey").classList.remove("hidden");
|
|
document.getElementById("add-passkey-error").classList.add("hidden");
|
|
document.getElementById("passkey-name").value = "";
|
|
};
|
|
|
|
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 form ---
|
|
|
|
const showTokenForm = () => {
|
|
document.getElementById("create-token-form").classList.remove("hidden");
|
|
document.getElementById("show-create-token").classList.add("hidden");
|
|
document.getElementById("token-name").focus();
|
|
};
|
|
|
|
const hideTokenForm = () => {
|
|
document.getElementById("create-token-form").classList.add("hidden");
|
|
document.getElementById("show-create-token").classList.remove("hidden");
|
|
document.getElementById("create-token-error").classList.add("hidden");
|
|
document.getElementById("token-name").value = "";
|
|
};
|
|
|
|
const createToken = async () => {
|
|
const name = document.getElementById("token-name").value.trim();
|
|
const errorEl = document.getElementById("create-token-error");
|
|
const btn = document.getElementById("create-token-btn");
|
|
|
|
if (!name) {
|
|
errorEl.textContent = "Please enter a name for this token.";
|
|
errorEl.classList.remove("hidden");
|
|
return;
|
|
}
|
|
|
|
errorEl.classList.add("hidden");
|
|
btn.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();
|
|
|
|
document.getElementById("create-token-form").classList.add("hidden");
|
|
document.getElementById("token-value").textContent = data.token;
|
|
document.getElementById("token-created").classList.remove("hidden");
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove("hidden");
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// --- 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 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 your 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) {
|
|
window.location.reload();
|
|
} else {
|
|
alert("Failed to revoke token.");
|
|
}
|
|
} catch (err) {
|
|
alert(`Failed to revoke token: ${err.message}`);
|
|
}
|
|
};
|
|
</script>
|
|
{% endblock %}
|