{% extends "shell.html" %}
{% block title %}Security{% endblock %}
{% block content %}
<div class="card">
<h1>Security</h1>
<p class="muted">Everything that can be used to get into this account, in one
place - so auditing your own access is one page rather than three.</p>
</div>
<div class="card">
<h2>Password</h2>
<form id="password-form">
<label>Current password
<input type="password" name="current" autocomplete="current-password" required>
</label>
<label>New password
<input type="password" name="new" autocomplete="new-password" minlength="12" required>
<span class="hint">At least 12 characters.</span>
</label>
<p class="error" id="password-error" hidden></p>
<p class="note" id="password-ok" hidden>Password changed.</p>
<button type="submit">Change password</button>
</form>
</div>
{# Hidden entirely when the browser cannot do WebAuthn, rather than shown and
failing on click. shell.js sets this - see the script below. #}
<div class="card" id="passkey-card" hidden>
<h2>Passkeys</h2>
<p class="muted">
A passkey is a second factor after your password: a fingerprint, Windows
Hello, or a key on your phone. Unlike a code from an app it is bound to this
site, so it cannot be handed to a look-alike.
</p>
<p class="note">
Once you enrol one it is <strong>required</strong> to sign in. If you lose
every enrolled device, use <a href="/forgot">password reset</a> - it clears
them, which is the way back in.
</p>
<ul class="rows" id="passkeys"></ul>
<p class="muted" id="passkeys-empty" hidden>No passkeys enrolled.</p>
<form id="passkey-form">
<label>Name this device
<input type="text" name="name" placeholder="Phone" maxlength="120">
</label>
<p class="error" id="passkey-error" hidden></p>
<button type="submit">Enrol a passkey</button>
</form>
</div>
<div class="card">
<h2>API tokens</h2>
<p class="muted">
What a script presents instead of a cookie, as HTTP Basic
<code>email:token</code>. A token carries your role, so it can do what you
can do in this organisation and no more.
</p>
<ul class="rows" id="tokens"></ul>
<p class="muted" id="tokens-empty" hidden>No tokens.</p>
{# The secret is shown once and cannot be fetched again - only its digest is
stored. The panel says so rather than letting somebody discover it. #}
<div class="note" id="token-secret-row" hidden>
<p><strong>Copy this now.</strong> It is not stored and cannot be shown again.</p>
<pre id="token-secret"></pre>
</div>
<form id="token-form">
<label>What is it for
<input type="text" name="name" placeholder="CI deploy" maxlength="120" required>
</label>
<p class="error" id="token-error" hidden></p>
<button type="submit">Create token</button>
</form>
</div>
{% endblock %}
{% block page_scripts %}
<script src="/static/js/webauthn.js"></script>
<script>
// ---- password ----
const pwForm = document.getElementById("password-form");
pwForm.addEventListener("submit", async (e) => {
e.preventDefault();
const err = document.getElementById("password-error");
const ok = document.getElementById("password-ok");
err.hidden = true; ok.hidden = true;
const data = new FormData(pwForm);
try {
await api.post("/api/password", {
current: data.get("current"), new: data.get("new"),
});
pwForm.reset();
ok.hidden = false;
} catch (e2) { err.textContent = e2.message; err.hidden = false; }
});
// ---- passkeys ----
const pkCard = document.getElementById("passkey-card");
const pkList = document.getElementById("passkeys");
const pkEmpty = document.getElementById("passkeys-empty");
async function loadPasskeys() {
const { credentials } = await api.get("/api/webauthn/credentials");
pkList.innerHTML = "";
pkEmpty.hidden = credentials.length > 0;
for (const c of credentials) {
const li = document.createElement("li");
const label = document.createElement("span");
label.textContent = c.name + (c.last_used_at ? ` · last used ${c.last_used_at.slice(0, 10)}` : "");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "linkbtn danger";
btn.textContent = "Remove";
btn.addEventListener("click", async () => {
await api.del(`/api/webauthn/credentials/${c.id}`);
loadPasskeys();
});
li.append(label, btn);
pkList.append(li);
}
}
if (webauthn.supported()) {
pkCard.hidden = false;
loadPasskeys();
document.getElementById("passkey-form").addEventListener("submit", async (e) => {
e.preventDefault();
const err = document.getElementById("passkey-error");
err.hidden = true;
const form = e.target;
try {
await webauthn.enrol(new FormData(form).get("name"));
form.reset();
loadPasskeys();
} catch (e2) { err.textContent = e2.message; err.hidden = false; }
});
}
// ---- tokens ----
const tkList = document.getElementById("tokens");
const tkEmpty = document.getElementById("tokens-empty");
async function loadTokens() {
const { tokens } = await api.get("/api/tokens");
tkList.innerHTML = "";
tkEmpty.hidden = tokens.length > 0;
for (const t of tokens) {
const li = document.createElement("li");
const label = document.createElement("span");
label.textContent = `${t.name} · ${t.prefix}…` + (t.revoked ? " · revoked" : "");
if (t.revoked) li.classList.add("dim");
li.append(label);
if (!t.revoked) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "linkbtn danger";
btn.textContent = "Revoke";
btn.addEventListener("click", async () => {
await api.del(`/api/tokens/${t.id}`);
loadTokens();
});
li.append(btn);
}
tkList.append(li);
}
}
loadTokens();
document.getElementById("token-form").addEventListener("submit", async (e) => {
e.preventDefault();
const err = document.getElementById("token-error");
err.hidden = true;
const form = e.target;
try {
const res = await api.post("/api/tokens", { name: new FormData(form).get("name") });
document.getElementById("token-secret").textContent = res.secret;
document.getElementById("token-secret-row").hidden = false;
form.reset();
loadTokens();
} catch (e2) { err.textContent = e2.message; err.hidden = false; }
});
</script>
{% endblock %}