agilentics / boiler
{% 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 %}