Add TOTP for Admin login
This commit is contained in:
@@ -97,6 +97,12 @@
|
||||
<label for="admin-password">Password</label>
|
||||
<input id="admin-password" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="field hidden" id="otp-field">
|
||||
<label for="admin-otp">Authenticator code</label>
|
||||
<input id="admin-otp" type="text" inputmode="numeric" pattern="[0-9]{6}"
|
||||
autocomplete="one-time-code" maxlength="6" placeholder="6-digit code"
|
||||
style="font-family:monospace;font-size:1.3rem;letter-spacing:.2em;text-align:center" />
|
||||
</div>
|
||||
<p id="login-error" class="error-msg hidden"></p>
|
||||
<button type="submit" class="btn btn-primary btn-full" style="margin-top:.25rem">Sign in</button>
|
||||
</form>
|
||||
@@ -531,6 +537,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TOTP setup modal ──────────────────────────────────────────────────── -->
|
||||
<div id="totp-modal" class="modal-backdrop hidden">
|
||||
<div class="modal" style="max-width:360px;text-align:center">
|
||||
<h3>Set up Two-Factor Authentication</h3>
|
||||
<div id="totp-step-scan">
|
||||
<p style="color:var(--text-muted);font-size:.88rem;margin:.75rem 0 1rem">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to confirm.
|
||||
</p>
|
||||
<div style="display:flex;justify-content:center;background:#fff;padding:.75rem;border-radius:8px;margin-bottom:1rem">
|
||||
<canvas id="totp-qr-canvas" style="max-width:100%;height:auto;display:block"></canvas>
|
||||
</div>
|
||||
<p id="totp-uri-fallback" style="font-size:.72rem;color:var(--text-muted);word-break:break-all;margin-bottom:1rem"></p>
|
||||
<div class="field">
|
||||
<label for="totp-confirm-code">Verification code</label>
|
||||
<input id="totp-confirm-code" type="text" inputmode="numeric" pattern="[0-9]{6}"
|
||||
maxlength="6" placeholder="000000"
|
||||
style="font-family:monospace;font-size:1.4rem;letter-spacing:.25em;text-align:center" />
|
||||
</div>
|
||||
<p id="totp-error" class="error-msg hidden"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="totp-cancel" class="btn btn-ghost">Cancel</button>
|
||||
<button type="button" id="totp-confirm-btn" class="btn btn-primary">Enable 2FA</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Toast ───────────────────────────────────────────────────────────── -->
|
||||
<div id="toast" class="toast hidden" aria-live="assertive"></div>
|
||||
|
||||
|
||||
@@ -63,20 +63,42 @@ function showAdmin() {
|
||||
}
|
||||
|
||||
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||
let _pendingCredentials = null; // { username, password } while OTP step is shown
|
||||
|
||||
document.getElementById("login-form").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById("admin-username").value.trim();
|
||||
const password = document.getElementById("admin-password").value;
|
||||
const errEl = document.getElementById("login-error");
|
||||
const btn = e.target.querySelector("button[type=submit]");
|
||||
const otpField = document.getElementById("otp-field");
|
||||
btn.disabled = true;
|
||||
errEl.classList.add("hidden");
|
||||
try {
|
||||
const data = await api("POST", "/api/auth/admin", { username, password });
|
||||
saveToken(data.token);
|
||||
showAdmin();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
const body = _pendingCredentials
|
||||
? { ..._pendingCredentials, otp_code: document.getElementById("admin-otp").value.trim() }
|
||||
: { username: document.getElementById("admin-username").value.trim(),
|
||||
password: document.getElementById("admin-password").value };
|
||||
const data = await api("POST", "/api/auth/admin", body);
|
||||
if (data.otp_required) {
|
||||
_pendingCredentials = {
|
||||
username: document.getElementById("admin-username").value.trim(),
|
||||
password: document.getElementById("admin-password").value,
|
||||
};
|
||||
otpField.classList.remove("hidden");
|
||||
document.getElementById("admin-otp").focus();
|
||||
errEl.textContent = "Enter the 6-digit code from your authenticator app.";
|
||||
errEl.classList.remove("hidden");
|
||||
} else {
|
||||
_pendingCredentials = null;
|
||||
otpField.classList.add("hidden");
|
||||
document.getElementById("admin-otp").value = "";
|
||||
saveToken(data.token);
|
||||
showAdmin();
|
||||
}
|
||||
} catch (err) {
|
||||
_pendingCredentials = null;
|
||||
otpField.classList.add("hidden");
|
||||
document.getElementById("admin-otp").value = "";
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove("hidden");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
@@ -641,15 +663,39 @@ async function loadAdmins() {
|
||||
const roleBadge = u.role === "admin"
|
||||
? '<span class="badge badge-green" style="font-size:.75em">admin</span>'
|
||||
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
|
||||
const totpBadge = u.totp_enabled
|
||||
? '<span class="badge badge-green" style="font-size:.75em;background:var(--accent-warn,#b45309);color:#fff" title="2FA enabled">2FA ✓</span>'
|
||||
: '';
|
||||
const is_me = u.username === me;
|
||||
const totpBtn = is_me
|
||||
? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-totp="${esc(u.username)}" data-totp-enabled="${u.totp_enabled}">${u.totp_enabled ? "Disable 2FA" : "Enable 2FA"}</button>`
|
||||
: "";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${u.username === me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</div></td>
|
||||
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${is_me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge} ${totpBadge}</div></td>
|
||||
<td><div style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
|
||||
${totpBtn}
|
||||
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
|
||||
${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
|
||||
${!is_me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
|
||||
</div></td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
tbody.querySelectorAll("[data-totp]").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const username = btn.dataset.totp;
|
||||
const enabled = btn.dataset.totpEnabled === "true";
|
||||
if (enabled) {
|
||||
if (!confirm("Disable two-factor authentication for your account?")) return;
|
||||
try {
|
||||
await api("DELETE", `/api/admin/admins/${encodeURIComponent(username)}/totp`);
|
||||
showToast("2FA disabled");
|
||||
loadAdmins();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
} else {
|
||||
openTotpSetup(username);
|
||||
}
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.getElementById("chpw-username").value = btn.dataset.chpw;
|
||||
@@ -671,6 +717,57 @@ async function loadAdmins() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── TOTP setup modal ──────────────────────────────────────────────────────────
|
||||
let _totpUsername = null;
|
||||
|
||||
async function openTotpSetup(username) {
|
||||
_totpUsername = username;
|
||||
document.getElementById("totp-confirm-code").value = "";
|
||||
document.getElementById("totp-error").classList.add("hidden");
|
||||
document.getElementById("totp-modal").classList.remove("hidden");
|
||||
try {
|
||||
const data = await api("POST", `/api/admin/admins/${encodeURIComponent(username)}/totp/setup`);
|
||||
// Render QR from base64 PNG returned by the server
|
||||
const canvas = document.getElementById("totp-qr-canvas");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.style.width = Math.min(img.width, 220) + "px";
|
||||
canvas.style.height = "auto";
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = "data:image/png;base64," + data.qr_image_b64;
|
||||
document.getElementById("totp-uri-fallback").textContent = data.provisioning_uri;
|
||||
} catch (e) {
|
||||
document.getElementById("totp-error").textContent = e.message;
|
||||
document.getElementById("totp-error").classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("totp-cancel").addEventListener("click", () => {
|
||||
document.getElementById("totp-modal").classList.add("hidden");
|
||||
_totpUsername = null;
|
||||
});
|
||||
|
||||
document.getElementById("totp-confirm-btn").addEventListener("click", async () => {
|
||||
if (!_totpUsername) return;
|
||||
const code = document.getElementById("totp-confirm-code").value.trim();
|
||||
const errEl = document.getElementById("totp-error");
|
||||
errEl.classList.add("hidden");
|
||||
if (!code) { errEl.textContent = "Enter the 6-digit code from your app."; errEl.classList.remove("hidden"); return; }
|
||||
try {
|
||||
await api("POST", `/api/admin/admins/${encodeURIComponent(_totpUsername)}/totp/enable`, { otp_code: code });
|
||||
document.getElementById("totp-modal").classList.add("hidden");
|
||||
_totpUsername = null;
|
||||
showToast("Two-factor authentication enabled");
|
||||
loadAdmins();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-new-admin").addEventListener("click", () => {
|
||||
document.getElementById("admin-new-username").value = "";
|
||||
document.getElementById("admin-new-password").value = "";
|
||||
|
||||
Reference in New Issue
Block a user