feat: settings page — email, key rotation, sub-keys
This commit is contained in:
parent
eb3ef7745f
commit
ce155cd338
|
|
@ -17,6 +17,7 @@
|
||||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">+ New Monitor</a>
|
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">+ New Monitor</a>
|
||||||
|
<a href="/dashboard/settings" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Settings</a>
|
||||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PingQL — Settings</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
||||||
|
|
||||||
|
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
||||||
|
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||||
|
<div class="flex items-center gap-5 text-sm text-gray-500">
|
||||||
|
<a href="/dashboard/home" class="hover:text-gray-300 transition-colors">Monitors</a>
|
||||||
|
<span class="text-gray-300">Settings</span>
|
||||||
|
<button onclick="logout()" class="hover:text-gray-300 transition-colors">Logout</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="max-w-2xl mx-auto px-6 py-10 space-y-8">
|
||||||
|
|
||||||
|
<h1 class="text-xl font-semibold text-white">Settings</h1>
|
||||||
|
|
||||||
|
<!-- Account info -->
|
||||||
|
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Primary Key</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<code id="primary-key" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-blue-400 text-sm tracking-widest"></code>
|
||||||
|
<button onclick="copyKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-xs">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Member since</label>
|
||||||
|
<p id="created-at" class="text-sm text-gray-400"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-1">Recovery Email</h2>
|
||||||
|
<p class="text-xs text-gray-600 mb-4">Used for account recovery only. Stored as a one-way hash — we can't read it.</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="email-input" type="email" placeholder="you@example.com"
|
||||||
|
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<button onclick="saveEmail()" id="email-btn"
|
||||||
|
class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Save</button>
|
||||||
|
<button onclick="removeEmail()" id="remove-email-btn" class="hidden px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 hover:text-red-400 text-xs transition-colors">Remove</button>
|
||||||
|
</div>
|
||||||
|
<p id="email-status" class="text-xs mt-2 hidden"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Reset primary key -->
|
||||||
|
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 mb-1">Rotate Primary Key</h2>
|
||||||
|
<p class="text-xs text-gray-600 mb-4">Generates a new primary key. Your old key will stop working immediately. Sub-keys are not affected.</p>
|
||||||
|
<button onclick="confirmReset()" class="px-4 py-2.5 bg-gray-800 hover:bg-gray-700 border border-red-900/50 hover:border-red-700/50 text-red-400 rounded-lg text-sm transition-colors">Rotate Key</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sub-keys -->
|
||||||
|
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300">API Keys</h2>
|
||||||
|
<p class="text-xs text-gray-600 mt-0.5">Create separate keys for different apps, scripts, or teammates.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="showCreateKey()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-xs font-medium transition-colors">+ New Key</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create key form (hidden by default) -->
|
||||||
|
<div id="create-key-form" class="hidden mb-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1.5">Label</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="key-label" type="text" placeholder="e.g. ci-pipeline, mobile-app"
|
||||||
|
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<button onclick="createKey()" class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Create</button>
|
||||||
|
<button onclick="hideCreateKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 text-sm transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New key reveal (shown after creation) -->
|
||||||
|
<div id="new-key-reveal" class="hidden mb-4 p-4 bg-green-950/30 rounded-lg border border-green-900/50">
|
||||||
|
<p class="text-xs text-green-400 mb-2">Key created — copy it now.</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<code id="new-key-value" class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-sm tracking-widest"></code>
|
||||||
|
<button onclick="copyNewKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keys list -->
|
||||||
|
<div id="keys-list" class="space-y-2">
|
||||||
|
<p class="text-xs text-gray-600 italic">No API keys yet.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/dashboard/app.js"></script>
|
||||||
|
<script>
|
||||||
|
if (!requireAuth()) throw 'auth';
|
||||||
|
|
||||||
|
const key = localStorage.getItem('pingql_key');
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const data = await api('/account/settings');
|
||||||
|
|
||||||
|
document.getElementById('primary-key').textContent = key;
|
||||||
|
document.getElementById('created-at').textContent = new Date(data.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
if (data.has_email) {
|
||||||
|
document.getElementById('remove-email-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('email-input').placeholder = '●●●●●●●● (set)';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderKeys(data.api_keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKeys(keys) {
|
||||||
|
const el = document.getElementById('keys-list');
|
||||||
|
if (!keys.length) {
|
||||||
|
el.innerHTML = '<p class="text-xs text-gray-600 italic">No API keys yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = keys.map(k => `
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-200">${escapeHtml(k.label)}</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-0.5 font-mono">${k.id} · created ${new Date(k.created_at).toLocaleDateString()} ${k.last_used_at ? `· last used ${timeAgo(k.last_used_at)}` : '· never used'}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="deleteKey('${k.id}')" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
const btn = event.target;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmail() {
|
||||||
|
const email = document.getElementById('email-input').value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
const btn = document.getElementById('email-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Saving...';
|
||||||
|
try {
|
||||||
|
await api('/account/email', { method: 'POST', body: { email } });
|
||||||
|
showStatus('email-status', 'Saved.', 'green');
|
||||||
|
document.getElementById('remove-email-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('email-input').value = '';
|
||||||
|
document.getElementById('email-input').placeholder = '●●●●●●●● (set)';
|
||||||
|
} catch (e) {
|
||||||
|
showStatus('email-status', e.message, 'red');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Save';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEmail() {
|
||||||
|
if (!confirm('Remove recovery email?')) return;
|
||||||
|
await api('/account/email', { method: 'POST', body: { email: null } });
|
||||||
|
document.getElementById('remove-email-btn').classList.add('hidden');
|
||||||
|
document.getElementById('email-input').placeholder = 'you@example.com';
|
||||||
|
showStatus('email-status', 'Email removed.', 'gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReset() {
|
||||||
|
if (!confirm('Rotate your primary key?\n\nYour current key will stop working immediately. Make sure to copy the new one.')) return;
|
||||||
|
const data = await api('/account/reset-key', { method: 'POST', body: {} });
|
||||||
|
localStorage.setItem('pingql_key', data.key);
|
||||||
|
document.getElementById('primary-key').textContent = data.key;
|
||||||
|
alert(`New key: ${data.key}\n\nYour old key is now invalid.`);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateKey() {
|
||||||
|
document.getElementById('create-key-form').classList.remove('hidden');
|
||||||
|
document.getElementById('new-key-reveal').classList.add('hidden');
|
||||||
|
document.getElementById('key-label').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCreateKey() {
|
||||||
|
document.getElementById('create-key-form').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createKey() {
|
||||||
|
const label = document.getElementById('key-label').value.trim();
|
||||||
|
if (!label) return;
|
||||||
|
const data = await api('/account/keys', { method: 'POST', body: { label } });
|
||||||
|
document.getElementById('new-key-value').textContent = data.key;
|
||||||
|
document.getElementById('new-key-reveal').classList.remove('hidden');
|
||||||
|
hideCreateKey();
|
||||||
|
document.getElementById('key-label').value = '';
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyNewKey() {
|
||||||
|
const val = document.getElementById('new-key-value').textContent;
|
||||||
|
navigator.clipboard.writeText(val);
|
||||||
|
const btn = event.target;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.classList.add('text-green-400');
|
||||||
|
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKey(id) {
|
||||||
|
if (!confirm('Revoke this key? Any apps using it will stop working.')) return;
|
||||||
|
await api(`/account/keys/${id}`, { method: 'DELETE' });
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(id, msg, color) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = `text-xs mt-2 text-${color}-400`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
setTimeout(() => el.classList.add('hidden'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -42,5 +42,15 @@ export async function migrate() {
|
||||||
|
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
|
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
console.log("DB ready");
|
console.log("DB ready");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,48 @@ import { Elysia, t } from "elysia";
|
||||||
import { randomBytes, createHash } from "crypto";
|
import { randomBytes, createHash } from "crypto";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
|
|
||||||
function generateAccountKey(): string {
|
function generateKey(): string {
|
||||||
const bytes = randomBytes(8);
|
const bytes = randomBytes(8);
|
||||||
const hex = bytes.toString("hex").toUpperCase();
|
const hex = bytes.toString("hex").toUpperCase();
|
||||||
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashEmail(email: string): string {
|
||||||
|
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
export function requireAuth(app: Elysia) {
|
export function requireAuth(app: Elysia) {
|
||||||
return app.derive(async ({ headers, error }) => {
|
return app.derive(async ({ headers, error }) => {
|
||||||
const key = headers["authorization"]?.replace("Bearer ", "").trim();
|
const key = headers["authorization"]?.replace("Bearer ", "").trim();
|
||||||
if (!key) return error(401, { error: "Missing account key. Use: Authorization: Bearer <key>" });
|
if (!key) return error(401, { error: "Missing account key. Use: Authorization: Bearer <key>" });
|
||||||
|
|
||||||
|
// Check primary account key
|
||||||
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
||||||
if (!account) return error(401, { error: "Invalid account key" });
|
if (account) {
|
||||||
|
return { accountId: account.id, keyId: null as string | null };
|
||||||
|
}
|
||||||
|
|
||||||
return { accountId: account.id };
|
// Check sub-key
|
||||||
|
const [apiKey] = await sql`
|
||||||
|
SELECT id, account_id FROM api_keys WHERE id = ${key}
|
||||||
|
`;
|
||||||
|
if (apiKey) {
|
||||||
|
// Update last_used_at async (don't await)
|
||||||
|
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${key}`.catch(() => {});
|
||||||
|
return { accountId: apiKey.account_id, keyId: apiKey.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
return error(401, { error: "Invalid account key" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const account = new Elysia({ prefix: "/account" })
|
export const account = new Elysia({ prefix: "/account" })
|
||||||
// Create account
|
|
||||||
|
// ── Register ────────────────────────────────────────────────────────
|
||||||
.post("/register", async ({ body }) => {
|
.post("/register", async ({ body }) => {
|
||||||
const key = generateAccountKey();
|
const key = generateKey();
|
||||||
const emailHash = body.email
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
? createHash("sha256").update(body.email.toLowerCase().trim()).digest("hex")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await sql`INSERT INTO accounts (id, email_hash) VALUES (${key}, ${emailHash})`;
|
await sql`INSERT INTO accounts (id, email_hash) VALUES (${key}, ${emailHash})`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
...(body.email ? { email_registered: true } : { email_registered: false }),
|
...(body.email ? { email_registered: true } : { email_registered: false }),
|
||||||
|
|
@ -38,20 +52,60 @@ export const account = new Elysia({ prefix: "/account" })
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
|
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
|
||||||
}),
|
}),
|
||||||
detail: { summary: "Create account", tags: ["account"] },
|
})
|
||||||
|
|
||||||
|
// ── Auth-required routes below ───────────────────────────────────────
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
// Get account settings
|
||||||
|
.get("/settings", async ({ accountId }) => {
|
||||||
|
const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`;
|
||||||
|
const keys = await sql`SELECT id, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
||||||
|
return {
|
||||||
|
account_id: acc.id,
|
||||||
|
has_email: !!acc.email_hash,
|
||||||
|
created_at: acc.created_at,
|
||||||
|
api_keys: keys,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update email
|
// Update email
|
||||||
.use(requireAuth)
|
|
||||||
.post("/email", async ({ accountId, body }) => {
|
.post("/email", async ({ accountId, body }) => {
|
||||||
const emailHash = body.email
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
? createHash("sha256").update(body.email.toLowerCase().trim()).digest("hex")
|
|
||||||
: null;
|
|
||||||
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
email: t.Optional(t.String({ description: "Email for account recovery only." })),
|
email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))),
|
||||||
}),
|
}),
|
||||||
detail: { summary: "Update account email", tags: ["account"] },
|
})
|
||||||
|
|
||||||
|
// Reset primary key — generates a new one, old one immediately invalid
|
||||||
|
.post("/reset-key", async ({ accountId }) => {
|
||||||
|
const newKey = generateKey();
|
||||||
|
await sql`UPDATE accounts SET id = ${newKey} WHERE id = ${accountId}`;
|
||||||
|
return {
|
||||||
|
key: newKey,
|
||||||
|
message: "Primary key rotated. Your old key is now invalid.",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a sub-key (for different apps or shared access)
|
||||||
|
.post("/keys", async ({ accountId, body }) => {
|
||||||
|
const key = generateKey();
|
||||||
|
await sql`INSERT INTO api_keys (id, account_id, label) VALUES (${key}, ${accountId}, ${body.label})`;
|
||||||
|
return { key, label: body.label };
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete a sub-key
|
||||||
|
.delete("/keys/:id", async ({ accountId, params, error }) => {
|
||||||
|
const [deleted] = await sql`
|
||||||
|
DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
||||||
|
`;
|
||||||
|
if (!deleted) return error(404, { error: "Key not found" });
|
||||||
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ export const dashboard = new Elysia()
|
||||||
.get("/dashboard/home", () => Bun.file(`${dir}/home.html`), hide)
|
.get("/dashboard/home", () => Bun.file(`${dir}/home.html`), hide)
|
||||||
.get("/dashboard/monitors/new", () => Bun.file(`${dir}/new.html`), hide)
|
.get("/dashboard/monitors/new", () => Bun.file(`${dir}/new.html`), hide)
|
||||||
.get("/dashboard/monitors/:id", () => Bun.file(`${dir}/detail.html`), hide)
|
.get("/dashboard/monitors/:id", () => Bun.file(`${dir}/detail.html`), hide)
|
||||||
|
.get("/dashboard/settings", () => Bun.file(`${dir}/settings.html`), hide)
|
||||||
.get("/docs", () => Bun.file(`${dir}/docs.html`), hide);
|
.get("/docs", () => Bun.file(`${dir}/docs.html`), hide);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue