This commit is contained in:
KingKDot
2026-06-01 16:37:23 -05:00
parent 421abd178c
commit 7f0a7b1d91
17 changed files with 815 additions and 5 deletions
+20 -1
View File
@@ -1,6 +1,8 @@
const form = document.getElementById("login-form");
const user = document.getElementById("user");
const pass = document.getElementById("pass");
const mfaGroup = document.getElementById("mfa-group");
const mfaCode = document.getElementById("mfa-code");
const errEl = document.getElementById("error");
(async () => {
@@ -218,9 +220,26 @@ form?.addEventListener("submit", async (e) => {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: user.value, pass: pass.value }),
body: JSON.stringify({
user: user.value,
pass: pass.value,
mfaCode: mfaCode?.value || "",
}),
});
if (res.status === 202) {
const data = await res.json().catch(() => ({}));
if (data.mfaRequired) {
if (mfaGroup) mfaGroup.style.display = "";
if (mfaCode) {
mfaCode.required = true;
mfaCode.focus();
}
errEl.textContent = "Enter your authenticator code to continue.";
return;
}
}
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Invalid credentials");
+128
View File
@@ -19,6 +19,19 @@ const currentPasswordInput = document.getElementById("current-password");
const newPasswordInput = document.getElementById("new-password");
const confirmPasswordInput = document.getElementById("confirm-password");
const passwordPolicyHint = document.getElementById("password-policy-hint");
const mfaStatusText = document.getElementById("mfa-status-text");
const mfaStartBtn = document.getElementById("mfa-start-btn");
const mfaSetupPanel = document.getElementById("mfa-setup-panel");
const mfaQrCode = document.getElementById("mfa-qr-code");
const mfaSecretText = document.getElementById("mfa-secret-text");
const mfaEnableCodeInput = document.getElementById("mfa-enable-code");
const mfaEnableBtn = document.getElementById("mfa-enable-btn");
const mfaOtpauthLink = document.getElementById("mfa-otpauth-link");
const mfaDisablePanel = document.getElementById("mfa-disable-panel");
const mfaDisablePasswordInput = document.getElementById("mfa-disable-password");
const mfaDisableCodeInput = document.getElementById("mfa-disable-code");
const mfaDisableBtn = document.getElementById("mfa-disable-btn");
const mfaMessage = document.getElementById("mfa-message");
const prefsForm = document.getElementById("prefs-form");
const prefNotificationsInput = document.getElementById("pref-notifications");
@@ -45,6 +58,8 @@ const securityRequireUppercaseInput = document.getElementById("security-require-
const securityRequireLowercaseInput = document.getElementById("security-require-lowercase");
const securityRequireNumberInput = document.getElementById("security-require-number");
const securityRequireSymbolInput = document.getElementById("security-require-symbol");
const securityMfaAdminsInput = document.getElementById("security-mfa-admins");
const securityMfaNonAdminsInput = document.getElementById("security-mfa-non-admins");
const tlsForm = document.getElementById("tls-form");
const tlsPermissionNote = document.getElementById("tls-permission-note");
@@ -184,6 +199,8 @@ function setSecurityFormDisabled(disabled) {
securityRequireLowercaseInput,
securityRequireNumberInput,
securityRequireSymbolInput,
securityMfaAdminsInput,
securityMfaNonAdminsInput,
securitySaveBtn,
];
@@ -231,6 +248,8 @@ function applySecurityForm() {
securityRequireLowercaseInput.checked = Boolean(securityConfig.passwordRequireLowercase);
securityRequireNumberInput.checked = Boolean(securityConfig.passwordRequireNumber);
securityRequireSymbolInput.checked = Boolean(securityConfig.passwordRequireSymbol);
if (securityMfaAdminsInput) securityMfaAdminsInput.checked = Boolean(securityConfig.mfaRequiredForAdmins);
if (securityMfaNonAdminsInput) securityMfaNonAdminsInput.checked = Boolean(securityConfig.mfaRequiredForNonAdmins);
updatePasswordPolicyUi();
}
@@ -246,6 +265,8 @@ async function loadSecurityPolicy() {
passwordRequireLowercase: false,
passwordRequireNumber: false,
passwordRequireSymbol: false,
mfaRequiredForAdmins: false,
mfaRequiredForNonAdmins: false,
};
updatePasswordPolicyUi();
return;
@@ -393,6 +414,107 @@ async function updatePassword(event) {
showMessage("Password updated successfully.");
}
function showMfaMessage(text, type = "ok") {
if (!mfaMessage) return;
mfaMessage.textContent = text;
mfaMessage.className = `text-sm rounded-lg px-3 py-2 border ${
type === "error"
? "border-rose-800 bg-rose-900/20 text-rose-200"
: "border-emerald-800 bg-emerald-900/20 text-emerald-200"
}`;
mfaMessage.classList.remove("hidden");
setTimeout(() => mfaMessage.classList.add("hidden"), 5000);
}
async function loadMfaStatus() {
if (!mfaStatusText) return;
try {
const res = await fetch("/api/mfa/status", { credentials: "include" });
if (!res.ok) {
mfaStatusText.textContent = "Failed to load MFA status.";
return;
}
const data = await res.json();
const requiredText = data.required ? " Required by policy." : "";
mfaStatusText.textContent = data.enabled
? `MFA is enabled.${requiredText}`
: `MFA is not enabled.${requiredText}`;
if (mfaStartBtn) mfaStartBtn.classList.toggle("hidden", !!data.enabled);
if (mfaDisablePanel) mfaDisablePanel.classList.toggle("hidden", !data.enabled);
if (mfaDisableBtn) mfaDisableBtn.disabled = !!data.required;
if (mfaDisablePanel) {
mfaDisablePanel.classList.toggle("opacity-60", !!data.required);
}
} catch {
mfaStatusText.textContent = "Failed to load MFA status.";
}
}
async function startMfaSetup() {
try {
const res = await fetch("/api/mfa/setup", { method: "POST", credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMfaMessage(data.error || "Failed to start MFA setup.", "error");
return;
}
if (mfaQrCode) mfaQrCode.innerHTML = data.qrSvg || "";
if (mfaSecretText) mfaSecretText.textContent = data.secret || "";
if (mfaOtpauthLink) mfaOtpauthLink.href = data.otpauthUrl || "#";
if (mfaSetupPanel) mfaSetupPanel.classList.remove("hidden");
if (mfaEnableCodeInput) mfaEnableCodeInput.focus();
} catch {
showMfaMessage("Network error while starting MFA setup.", "error");
}
}
async function enableMfa() {
const code = String(mfaEnableCodeInput?.value || "").trim();
try {
const res = await fetch("/api/mfa/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMfaMessage(data.error || "Failed to enable MFA.", "error");
return;
}
if (mfaSetupPanel) mfaSetupPanel.classList.add("hidden");
if (mfaEnableCodeInput) mfaEnableCodeInput.value = "";
showMfaMessage("MFA enabled.");
await loadMfaStatus();
} catch {
showMfaMessage("Network error while enabling MFA.", "error");
}
}
async function disableMfa() {
const currentPassword = String(mfaDisablePasswordInput?.value || "");
const code = String(mfaDisableCodeInput?.value || "").trim();
try {
const res = await fetch("/api/mfa/disable", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ currentPassword, code }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMfaMessage(data.error || "Failed to disable MFA.", "error");
return;
}
if (mfaDisablePasswordInput) mfaDisablePasswordInput.value = "";
if (mfaDisableCodeInput) mfaDisableCodeInput.value = "";
showMfaMessage("MFA disabled.");
await loadMfaStatus();
} catch {
showMfaMessage("Network error while disabling MFA.", "error");
}
}
function savePrefs(event) {
event.preventDefault();
@@ -452,6 +574,8 @@ async function saveSecurityPolicy(event) {
passwordRequireLowercase: securityRequireLowercaseInput.checked,
passwordRequireNumber: securityRequireNumberInput.checked,
passwordRequireSymbol: securityRequireSymbolInput.checked,
mfaRequiredForAdmins: !!securityMfaAdminsInput?.checked,
mfaRequiredForNonAdmins: !!securityMfaNonAdminsInput?.checked,
};
const res = await fetch("/api/settings/security", {
@@ -1837,6 +1961,7 @@ async function init() {
await loadCurrentUser();
applyPermissionVisibility();
loadPrefs();
await loadMfaStatus();
if (userHas("clients:build")) {
if (buildsShowAllWrap && !isAdmin(currentUser?.role)) {
@@ -1863,6 +1988,9 @@ async function init() {
}
passwordForm.addEventListener("submit", updatePassword);
if (mfaStartBtn) mfaStartBtn.addEventListener("click", startMfaSetup);
if (mfaEnableBtn) mfaEnableBtn.addEventListener("click", enableMfa);
if (mfaDisableBtn) mfaDisableBtn.addEventListener("click", disableMfa);
prefsForm.addEventListener("submit", savePrefs);
const sidebarBtn = document.getElementById("pref-nav-sidebar");
+16
View File
@@ -57,6 +57,22 @@
/>
</div>
</div>
<div id="mfa-group" class="form-group" style="display: none">
<label class="label">Authenticator Code</label>
<div class="input-row input-animated">
<i class="fa-solid fa-shield-halved"></i
><input
id="mfa-code"
name="mfa-code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
pattern="[0-9]{6}"
placeholder="6-digit code"
/>
</div>
</div>
<button type="submit" class="button primary full login-btn">
<span class="btn-text">Sign in</span>
<i class="fa-solid fa-arrow-right btn-icon"></i>
+90
View File
@@ -169,6 +169,76 @@
</button>
</form>
<div id="mfa-settings" class="space-y-3 mt-2 pt-4 border-t border-slate-800">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500 pb-1">Multi-Factor Authentication</p>
<p id="mfa-status-text" class="text-sm text-slate-400">Loading MFA status...</p>
</div>
<button
id="mfa-start-btn"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium"
>
<i class="fa-solid fa-shield-halved"></i>Set Up MFA
</button>
</div>
<div id="mfa-setup-panel" class="hidden rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 space-y-4">
<p class="text-sm text-slate-300 pb-1">Add this manual key to your authenticator app, then enter the 6-digit code.</p>
<div class="grid grid-cols-1 md:grid-cols-[20rem_minmax(0,1fr)] gap-5 items-start">
<div class="w-80 max-w-full">
<div id="mfa-qr-code" class="aspect-square w-full rounded-lg bg-white p-1 border border-slate-700 overflow-hidden"></div>
</div>
<div class="min-w-0 space-y-3">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500">Manual Key</p>
<div class="rounded bg-slate-950 border border-slate-800 px-3 py-2 font-mono text-sm text-sky-300 break-all" id="mfa-secret-text"></div>
<input
id="mfa-enable-code"
type="text"
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
<div class="flex gap-2 flex-wrap">
<button id="mfa-enable-btn" type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
<i class="fa-solid fa-check"></i>Enable MFA
</button>
<a id="mfa-otpauth-link" href="#" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors">
<i class="fa-solid fa-mobile-screen"></i>Open App Link
</a>
</div>
</div>
</div>
</div>
<div id="mfa-disable-panel" class="hidden rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
id="mfa-disable-password"
type="password"
autocomplete="current-password"
placeholder="Current password"
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
<input
id="mfa-disable-code"
type="text"
inputmode="numeric"
maxlength="6"
placeholder="6-digit code"
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
</div>
<button id="mfa-disable-btn" type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-rose-800/70 hover:bg-rose-700 transition-colors text-rose-100 border border-rose-700 text-sm font-medium">
<i class="fa-solid fa-lock-open"></i>Disable MFA
</button>
</div>
<p id="mfa-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</div>
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
@@ -574,6 +644,26 @@
</div>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">MFA Enforcement</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<label class="flex items-start gap-2 rounded-lg border border-slate-700/60 bg-slate-900/40 p-3">
<input id="security-mfa-admins" type="checkbox" class="mt-1 h-4 w-4 accent-indigo-500" />
<span>
<span class="block text-slate-200">Require MFA for admins</span>
<span class="block text-xs text-slate-500">Your admin account must have MFA enabled first.</span>
</span>
</label>
<label class="flex items-start gap-2 rounded-lg border border-slate-700/60 bg-slate-900/40 p-3">
<input id="security-mfa-non-admins" type="checkbox" class="mt-1 h-4 w-4 accent-indigo-500" />
<span>
<span class="block text-slate-200">Require MFA for non-admins</span>
<span class="block text-xs text-slate-500">Applies to operator and viewer accounts.</span>
</span>
</label>
</div>
</div>
<button
type="submit"
id="security-save-btn"