feat: no-JS support for all core UI — registration, settings, monitor CRUD, logout
This commit is contained in:
parent
632f006988
commit
61560ae521
|
|
@ -93,22 +93,22 @@ export const account = new Elysia({ prefix: "/account" })
|
|||
set.redirect = "/dashboard";
|
||||
}, { detail: { hide: true } })
|
||||
|
||||
.post("/register", async ({ body, cookie, request, error }) => {
|
||||
.post("/register", async ({ body, cookie, request, set, error }) => {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||
if (!checkAuthRateLimit(ip, 5)) return error(429, { error: "Too many registrations. Try again later." });
|
||||
|
||||
const key = generateKey();
|
||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||
const emailHash = (body as any).email ? hashEmail((body as any).email) : null;
|
||||
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||
return {
|
||||
key,
|
||||
...(body.email ? { email_registered: true } : { email_registered: false }),
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
|
||||
}),
|
||||
|
||||
// Form submission → redirect to welcome page showing the key
|
||||
if ((body as any)._form) {
|
||||
set.redirect = `/dashboard/welcome?key=${encodeURIComponent(key)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
return { key, email_registered: !!emailHash };
|
||||
})
|
||||
|
||||
.use(requireAuth)
|
||||
|
|
@ -124,31 +124,31 @@ export const account = new Elysia({ prefix: "/account" })
|
|||
};
|
||||
})
|
||||
|
||||
.post("/email", async ({ accountId, body }) => {
|
||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||
.post("/email", async ({ accountId, body, set }) => {
|
||||
const emailHash = (body as any).email ? hashEmail((body as any).email) : null;
|
||||
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
||||
if ((body as any)._form) { set.redirect = "/dashboard/settings"; return; }
|
||||
return { ok: true };
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))),
|
||||
}),
|
||||
})
|
||||
|
||||
.post("/reset-key", async ({ accountId, cookie }) => {
|
||||
.post("/reset-key", async ({ accountId, cookie, body, set }) => {
|
||||
const key = generateKey();
|
||||
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
|
||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||
if ((body as any)?._form) { set.redirect = "/dashboard/settings"; return; }
|
||||
return { key, message: "Primary key rotated. Your old key is now invalid." };
|
||||
})
|
||||
|
||||
.post("/keys", async ({ accountId, body }) => {
|
||||
.post("/keys", async ({ accountId, body, set }) => {
|
||||
const key = generateKey();
|
||||
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`;
|
||||
return { key, id: created.id, label: body.label };
|
||||
}, {
|
||||
body: t.Object({
|
||||
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
|
||||
}),
|
||||
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${(body as any).label}) RETURNING id`;
|
||||
if ((body as any)._form) { set.redirect = "/dashboard/settings"; return; }
|
||||
return { key, id: created.id, label: (body as any).label };
|
||||
})
|
||||
|
||||
.post("/keys/:id/delete", async ({ accountId, params, set }) => {
|
||||
await sql`DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId}`;
|
||||
set.redirect = "/dashboard/settings";
|
||||
})
|
||||
|
||||
.delete("/keys/:id", async ({ accountId, params, error }) => {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,13 @@ export const dashboard = new Elysia()
|
|||
return redirect("/dashboard");
|
||||
})
|
||||
|
||||
// Welcome page — shows new account key after registration (no-JS flow)
|
||||
.get("/dashboard/welcome", async ({ cookie, headers, query }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
return html("welcome", { key: query.key || cookie?.pingql_key?.value || "" });
|
||||
})
|
||||
|
||||
// Home — SSR monitor list
|
||||
.get("/dashboard/home", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
|
|
@ -325,6 +332,77 @@ export const dashboard = new Elysia()
|
|||
});
|
||||
})
|
||||
|
||||
// ── Form-based monitor actions (no-JS support) ─────────────────────
|
||||
|
||||
// Create monitor via form POST
|
||||
.post("/dashboard/monitors/new", async ({ cookie, headers, body, set }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
||||
const b = body as any;
|
||||
const regions = Array.isArray(b.regions) ? b.regions : (b.regions ? [b.regions] : []);
|
||||
const query = b.query ? (typeof b.query === "string" ? JSON.parse(b.query) : b.query) : undefined;
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
// Collect header_key[]/header_value[] pairs
|
||||
const hKeys = Array.isArray(b.header_key) ? b.header_key : (b.header_key ? [b.header_key] : []);
|
||||
const hVals = Array.isArray(b.header_value) ? b.header_value : (b.header_value ? [b.header_value] : []);
|
||||
for (let i = 0; i < hKeys.length; i++) {
|
||||
if (hKeys[i]?.trim()) requestHeaders[hKeys[i].trim()] = hVals[i] || "";
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/monitors/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
|
||||
body: JSON.stringify({
|
||||
name: b.name,
|
||||
url: b.url,
|
||||
method: b.method || "GET",
|
||||
interval_s: Number(b.interval_s) || 30,
|
||||
timeout_ms: Number(b.timeout_ms) || 10000,
|
||||
regions,
|
||||
request_headers: Object.keys(requestHeaders).length ? requestHeaders : null,
|
||||
request_body: b.request_body || null,
|
||||
query,
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
|
||||
set.redirect = "/dashboard/home";
|
||||
})
|
||||
|
||||
// Delete monitor via form POST
|
||||
.post("/dashboard/monitors/:id/delete", async ({ cookie, headers, params, set }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/monitors/${params.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${key}` },
|
||||
});
|
||||
|
||||
set.redirect = "/dashboard/home";
|
||||
})
|
||||
|
||||
// Toggle monitor via form POST
|
||||
.post("/dashboard/monitors/:id/toggle", async ({ cookie, headers, params, set }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
||||
const apiUrl = process.env.API_URL || "https://api.pingql.com";
|
||||
const key = cookie?.pingql_key?.value;
|
||||
await fetch(`${apiUrl}/monitors/${params.id}/toggle`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${key}` },
|
||||
});
|
||||
|
||||
set.redirect = `/dashboard/monitors/${params.id}`;
|
||||
})
|
||||
|
||||
// Docs
|
||||
.get("/docs", () => html("docs", {}))
|
||||
.get("/privacy", () => html("privacy", {}))
|
||||
|
|
|
|||
|
|
@ -29,8 +29,12 @@
|
|||
<p id="monitor-url" class="text-sm text-gray-500 mt-1"><%= m.url %></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border transition-colors <%= m.enabled ? 'border-gray-700 hover:border-gray-600 text-gray-300' : 'border-green-800 hover:border-green-700 text-green-400' %>"><%= m.enabled ? 'Pause' : 'Resume' %></button>
|
||||
<button id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/50 text-red-400 hover:bg-red-900/20 transition-colors">Delete</button>
|
||||
<form action="/dashboard/monitors/<%= m.id %>/toggle" method="POST" class="inline">
|
||||
<button type="submit" id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border transition-colors <%= m.enabled ? 'border-gray-700 hover:border-gray-600 text-gray-300' : 'border-green-800 hover:border-green-700 text-green-400' %>"><%= m.enabled ? 'Pause' : 'Resume' %></button>
|
||||
</form>
|
||||
<form action="/dashboard/monitors/<%= m.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete this monitor? This cannot be undone.')">
|
||||
<button type="submit" id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/50 text-red-400 hover:bg-red-900/20 transition-colors">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,13 @@
|
|||
|
||||
<div class="mt-6 pt-5 border-t border-gray-800 text-center">
|
||||
<p class="text-gray-500 text-sm mb-3">No account?</p>
|
||||
<button id="register-btn"
|
||||
<form id="register-form" action="/account/register" method="POST">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<button type="submit" id="register-btn"
|
||||
class="w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 font-medium py-3 rounded-lg transition-colors">
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -102,8 +105,9 @@
|
|||
} catch { showError('Connection error'); }
|
||||
});
|
||||
|
||||
// Register
|
||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||
// Register — JS-enhanced (overrides form POST for inline key display)
|
||||
document.getElementById('register-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch('/account/register', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@
|
|||
const bg = container.closest('form').querySelector('input')?.className.match(/bg-gray-\d+/)?.[0] || 'bg-gray-900';
|
||||
const border = container.closest('form').querySelector('input')?.className.match(/border-gray-\d+/)?.[0] || 'border-gray-800';
|
||||
row.innerHTML = `
|
||||
<input type="text" placeholder="Header name" class="hk flex-1 ${bg} border ${border} rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<input type="text" placeholder="Value" class="hv flex-1 ${bg} border ${border} rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<input type="text" name="header_key" placeholder="Header name" class="hk flex-1 ${bg} border ${border} rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<input type="text" name="header_value" placeholder="Value" class="hv flex-1 ${bg} border ${border} rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<button type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
|
|
|
|||
|
|
@ -17,23 +17,23 @@
|
|||
const bodyHidden = ['GET','HEAD','OPTIONS'].includes(curMethod);
|
||||
%>
|
||||
|
||||
<form id="<%= formId %>" class="space-y-<%= isEdit ? '5' : '6' %>">
|
||||
<form id="<%= formId %>" action="<%= isEdit ? '' : '/dashboard/monitors/new' %>" method="POST" class="space-y-<%= isEdit ? '5' : '6' %>">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
||||
<input id="<%= prefix %>name" type="text" required value="<%= monitor.name || '' %>" placeholder="Production API"
|
||||
<input id="<%= prefix %>name" name="name" type="text" required value="<%= monitor.name || '' %>" placeholder="Production API"
|
||||
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
||||
<div class="flex gap-2">
|
||||
<select id="<%= prefix %>method"
|
||||
<select id="<%= prefix %>method" name="method"
|
||||
class="<%= bg %> border <%= border %> rounded-lg px-3 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||
<% methods.forEach(function(method) { %>
|
||||
<option <%= curMethod === method ? 'selected' : '' %>><%= method %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<input id="<%= prefix %>url" type="url" required value="<%= monitor.url || '' %>" placeholder="https://api.example.com/health"
|
||||
<input id="<%= prefix %>url" name="url" type="url" required value="<%= monitor.url || '' %>" placeholder="https://api.example.com/health"
|
||||
class="flex-1 <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -48,8 +48,8 @@
|
|||
<% if (monitor.request_headers && typeof monitor.request_headers === 'object') {
|
||||
Object.entries(monitor.request_headers).forEach(function([k, v]) { %>
|
||||
<div class="header-row flex gap-2">
|
||||
<input type="text" value="<%= k %>" placeholder="Header name" class="hk flex-1 <%= bg %> border <%= border %> rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<input type="text" value="<%= v %>" placeholder="Value" class="hv flex-1 <%= bg %> border <%= border %> rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<input type="text" name="header_key" value="<%= k %>" placeholder="Header name" class="hk flex-1 <%= bg %> border <%= border %> rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<input type="text" name="header_value" value="<%= v %>" placeholder="Value" class="hv flex-1 <%= bg %> border <%= border %> rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<button type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
|
||||
</div>
|
||||
<% }) } %>
|
||||
|
|
@ -59,14 +59,14 @@
|
|||
<!-- Request Body -->
|
||||
<div id="<%= prefix %>body-section" class="<%= bodyHidden ? 'hidden' : '' %>">
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Request Body <span class="text-gray-600">(optional)</span></label>
|
||||
<textarea id="<%= prefix %>request-body" rows="4" placeholder='{"key": "value"}'
|
||||
<textarea id="<%= prefix %>request-body" name="request_body" rows="4" placeholder='{"key": "value"}'
|
||||
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm resize-y"><%= monitor.request_body || '' %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-400 mb-1.5"><%= isEdit ? 'Interval' : 'Ping Interval' %></label>
|
||||
<select id="<%= prefix %>interval"
|
||||
<select id="<%= prefix %>interval" name="interval_s"
|
||||
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||
<% intervals.forEach(function([val, label]) { %>
|
||||
<option value="<%= val %>" <%= String(monitor.interval_s || '30') === val ? 'selected' : '' %>><%= label %></option>
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Timeout</label>
|
||||
<select id="<%= prefix %>timeout"
|
||||
<select id="<%= prefix %>timeout" name="timeout_ms"
|
||||
class="w-full <%= bg %> border <%= border %> rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||
<% timeouts.forEach(function([val, label]) { %>
|
||||
<option value="<%= val %>" <%= String(monitor.timeout_ms || '10000') === val ? 'selected' : '' %>><%= label %></option>
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
<div class="flex flex-wrap gap-2">
|
||||
<% regions.forEach(function([val, label]) { %>
|
||||
<label class="flex items-center gap-2 <%= bg %> border <%= border %> hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
|
||||
<input type="checkbox" value="<%= val %>" class="<%= prefix %>region-check accent-blue-500" <%= selectedRegions.includes(val) ? 'checked' : '' %>> <span class="text-sm text-gray-300"><%~ label %></span>
|
||||
<input type="checkbox" name="regions" value="<%= val %>" class="<%= prefix %>region-check accent-blue-500" <%= selectedRegions.includes(val) ? 'checked' : '' %>> <span class="text-sm text-gray-300"><%~ label %></span>
|
||||
</label>
|
||||
<% }) %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@
|
|||
<% } else { %>
|
||||
<a href="/dashboard/settings" class="hover:text-gray-300 transition-colors">Settings</a>
|
||||
<% } %>
|
||||
<button onclick="logout()" class="hover:text-gray-300 transition-colors">Logout</button>
|
||||
<a href="/account/logout" class="hover:text-gray-300 transition-colors">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@
|
|||
<div class="flex gap-2">
|
||||
<code id="login-key-display" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-blue-400 text-sm font-mono select-all"><%= it.loginKey %></code>
|
||||
<button onclick="copyLoginKey()" 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>
|
||||
<button onclick="confirmReset()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-red-900/50 hover:border-red-700/50 text-red-400 rounded-lg text-xs transition-colors">Rotate</button>
|
||||
<form action="/account/reset-key" method="POST" class="inline" onsubmit="return confirm('Rotate your login key? Your current key stops working immediately.')">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<button type="submit" class="px-3 bg-gray-800 hover:bg-gray-700 border border-red-900/50 hover:border-red-700/50 text-red-400 rounded-lg text-xs transition-colors h-full py-2.5">Rotate</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
|
@ -112,13 +115,18 @@
|
|||
<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>
|
||||
<form action="/account/email" method="POST">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<div class="flex gap-2">
|
||||
<input id="email-input" type="email" placeholder="<%= hasEmail ? '●●●●●●●● (set)' : 'you@example.com' %>"
|
||||
<input id="email-input" name="email" type="email" placeholder="<%= hasEmail ? '●●●●●●●● (set)' : '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"
|
||||
<button type="submit" 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="<%= hasEmail ? '' : '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>
|
||||
<% if (hasEmail) { %>
|
||||
<button type="submit" name="email" value="" class="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>
|
||||
</form>
|
||||
<p id="email-status" class="text-xs mt-2 hidden"></p>
|
||||
</section>
|
||||
<% } %>
|
||||
|
|
@ -133,19 +141,19 @@
|
|||
<h2 class="text-sm font-semibold text-gray-300">Sub-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 Sub-Key</button>
|
||||
<button onclick="document.getElementById('create-key-form').classList.toggle('hidden')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-xs font-medium transition-colors">+ New Sub-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">
|
||||
<!-- Create key form -->
|
||||
<form id="create-key-form" action="/account/keys" method="POST" class="hidden mb-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<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"
|
||||
<input name="label" type="text" placeholder="e.g. ci-pipeline, mobile-app" required
|
||||
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>
|
||||
<button type="submit" class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Sub-keys list -->
|
||||
<div id="keys-list" class="space-y-2">
|
||||
|
|
@ -156,7 +164,10 @@
|
|||
<div class="p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm text-gray-200"><%= k.label %></p>
|
||||
<button onclick="deleteKey('<%= k.id %>', this)" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
||||
<form action="/account/keys/<%= k.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Revoke this key? Any apps using it will stop working.')">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<button type="submit" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<code class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key %></code>
|
||||
|
|
@ -174,31 +185,7 @@
|
|||
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
async function saveEmail() {
|
||||
const email = document.getElementById('email-input').value.trim();
|
||||
if (!email) return;
|
||||
try {
|
||||
await api('/account/email', { method: 'POST', body: { email } });
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
showStatus('email-status', e.message, 'red');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmail() {
|
||||
if (!confirm('Remove recovery email?')) return;
|
||||
await api('/account/email', { method: 'POST', body: { email: null } });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
if (!confirm('Rotate your login key? Your current key stops working immediately.')) return;
|
||||
const data = await api('/account/reset-key', { method: 'POST', body: {} });
|
||||
document.getElementById('login-key-display').textContent = data.key;
|
||||
}
|
||||
|
||||
// JS enhancements — clipboard copy (progressive, not required)
|
||||
function copyLoginKey() {
|
||||
const val = document.getElementById('login-key-display').textContent;
|
||||
navigator.clipboard.writeText(val);
|
||||
|
|
@ -207,42 +194,11 @@
|
|||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
||||
}
|
||||
|
||||
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;
|
||||
await api('/account/keys', { method: 'POST', body: { label } });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function copyKey(btn, val) {
|
||||
navigator.clipboard.writeText(val);
|
||||
btn.textContent = 'Copied!'; btn.classList.add('text-green-400');
|
||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
||||
}
|
||||
|
||||
async function deleteKey(id, btn) {
|
||||
if (!confirm('Revoke this key? Any apps using it will stop working.')) return;
|
||||
await api(`/account/keys/${id}`, { method: 'DELETE' });
|
||||
btn.closest('.p-3').remove();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<%~ include('./partials/foot') %>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<%~ include('./partials/head', { title: 'Account Created' }) %>
|
||||
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-900 rounded-xl p-6 border border-gray-800" style="box-shadow:0 0 40px rgba(59,130,246,0.08)">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400 text-lg">✓</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Account created</p>
|
||||
<p class="text-xs text-gray-500">Save your key — it's how you access your account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Your Account Key</label>
|
||||
<code class="block bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-blue-400 text-sm font-mono select-all break-all mb-5"><%= it.key %></code>
|
||||
|
||||
<div class="pt-5 border-t border-gray-800">
|
||||
<form action="/account/email" method="POST" class="space-y-3">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-1">Email <span class="text-gray-600 normal-case">(optional — recovery only)</span></label>
|
||||
<input name="email" type="email" placeholder="you@example.com"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors text-sm">Save & Continue</button>
|
||||
<a href="/dashboard/home" class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-400 rounded-lg transition-colors text-sm flex items-center">Skip</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
Loading…
Reference in New Issue