better password shit
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
|
import { createPasswordPolicyChecklist, loadPasswordPolicy } from "./password-policy.js";
|
||||||
|
|
||||||
const form = document.getElementById("change-password-form");
|
const form = document.getElementById("change-password-form");
|
||||||
const currentPass = document.getElementById("current-pass");
|
const currentPass = document.getElementById("current-pass");
|
||||||
const newPass = document.getElementById("new-pass");
|
const newPass = document.getElementById("new-pass");
|
||||||
const confirmPass = document.getElementById("confirm-pass");
|
const confirmPass = document.getElementById("confirm-pass");
|
||||||
const errEl = document.getElementById("error");
|
const errEl = document.getElementById("error");
|
||||||
const successEl = document.getElementById("success");
|
const successEl = document.getElementById("success");
|
||||||
|
const policyEl = document.getElementById("password-policy");
|
||||||
|
|
||||||
const tempToken = sessionStorage.getItem("temp_token");
|
const tempToken = sessionStorage.getItem("temp_token");
|
||||||
const tempUser = sessionStorage.getItem("temp_user");
|
const tempUser = sessionStorage.getItem("temp_user");
|
||||||
@@ -14,6 +17,14 @@ if (!tempToken || !tempUser) {
|
|||||||
|
|
||||||
const user = JSON.parse(tempUser);
|
const user = JSON.parse(tempUser);
|
||||||
|
|
||||||
|
const passwordChecklist = createPasswordPolicyChecklist({
|
||||||
|
passwordInput: newPass,
|
||||||
|
confirmInput: confirmPass,
|
||||||
|
container: policyEl,
|
||||||
|
});
|
||||||
|
|
||||||
|
loadPasswordPolicy().then((policy) => passwordChecklist.setPolicy(policy));
|
||||||
|
|
||||||
if (typeof anime !== "undefined") {
|
if (typeof anime !== "undefined") {
|
||||||
const particles = document.querySelectorAll(".particle");
|
const particles = document.querySelectorAll(".particle");
|
||||||
particles.forEach((particle, i) => {
|
particles.forEach((particle, i) => {
|
||||||
@@ -131,15 +142,12 @@ form?.addEventListener("submit", async (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPass.value.length < 6) {
|
const policyError = passwordChecklist.firstError();
|
||||||
errEl.textContent = "New password must be at least 6 characters";
|
if (policyError) {
|
||||||
|
errEl.textContent = policyError;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentPass.value === newPass.value) {
|
|
||||||
errEl.textContent = "New password must be different from current password";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/users/${user.id}/password`, {
|
const res = await fetch(`/api/users/${user.id}/password`, {
|
||||||
|
|||||||
@@ -1195,6 +1195,63 @@ article.card[data-bookmarked="true"] {
|
|||||||
color: #6b7a98;
|
color: #6b7a98;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-policy-card {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-policy-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-policy-title i {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-policy-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-policy-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
transition: color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-policy-item i {
|
||||||
|
width: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-policy-item.is-met {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-policy-item.is-met i {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
.button.primary {
|
.button.primary {
|
||||||
background: linear-gradient(120deg, #4f6bff, #715dff);
|
background: linear-gradient(120deg, #4f6bff, #715dff);
|
||||||
border-color: rgba(129, 140, 248, 0.55);
|
border-color: rgba(129, 140, 248, 0.55);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const DEFAULT_POLICY = {
|
||||||
|
minLength: 6,
|
||||||
|
requireUppercase: false,
|
||||||
|
requireLowercase: false,
|
||||||
|
requireNumber: false,
|
||||||
|
requireSymbol: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePolicy(raw) {
|
||||||
|
const source = raw && typeof raw === "object" ? raw : {};
|
||||||
|
return {
|
||||||
|
minLength: Math.min(128, Math.max(6, Number(source.minLength) || DEFAULT_POLICY.minLength)),
|
||||||
|
requireUppercase: Boolean(source.requireUppercase),
|
||||||
|
requireLowercase: Boolean(source.requireLowercase),
|
||||||
|
requireNumber: Boolean(source.requireNumber),
|
||||||
|
requireSymbol: Boolean(source.requireSymbol),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPasswordPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/registration/status", { credentials: "include" });
|
||||||
|
if (!res.ok) return DEFAULT_POLICY;
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return normalizePolicy(data.passwordPolicy);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_POLICY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRules(password, confirmPassword, policy, includeMatch) {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
id: "minLength",
|
||||||
|
label: `At least ${policy.minLength} characters`,
|
||||||
|
met: password.length >= policy.minLength,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (policy.requireUppercase) {
|
||||||
|
rules.push({ id: "uppercase", label: "One uppercase letter", met: /[A-Z]/.test(password) });
|
||||||
|
}
|
||||||
|
if (policy.requireLowercase) {
|
||||||
|
rules.push({ id: "lowercase", label: "One lowercase letter", met: /[a-z]/.test(password) });
|
||||||
|
}
|
||||||
|
if (policy.requireNumber) {
|
||||||
|
rules.push({ id: "number", label: "One number", met: /[0-9]/.test(password) });
|
||||||
|
}
|
||||||
|
if (policy.requireSymbol) {
|
||||||
|
rules.push({ id: "symbol", label: "One symbol", met: /[^A-Za-z0-9]/.test(password) });
|
||||||
|
}
|
||||||
|
if (includeMatch) {
|
||||||
|
rules.push({
|
||||||
|
id: "match",
|
||||||
|
label: "Passwords match",
|
||||||
|
met: password.length > 0 && confirmPassword.length > 0 && password === confirmPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(container, rules) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="password-policy-title">
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
<span>Password requirements</span>
|
||||||
|
</div>
|
||||||
|
<ul class="password-policy-list">
|
||||||
|
${rules
|
||||||
|
.map(
|
||||||
|
(rule) => `
|
||||||
|
<li class="password-policy-item ${rule.met ? "is-met" : "is-unmet"}" data-password-rule="${rule.id}">
|
||||||
|
<i class="fa-solid ${rule.met ? "fa-circle-check" : "fa-circle"}"></i>
|
||||||
|
<span>${rule.label}</span>
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPasswordPolicyChecklist({ passwordInput, confirmInput, container }) {
|
||||||
|
let policy = DEFAULT_POLICY;
|
||||||
|
const includeMatch = Boolean(confirmInput);
|
||||||
|
|
||||||
|
function currentRules() {
|
||||||
|
return buildRules(passwordInput?.value || "", confirmInput?.value || "", policy, includeMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (!container) return;
|
||||||
|
render(container, currentRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstError() {
|
||||||
|
const failed = currentRules().find((rule) => !rule.met);
|
||||||
|
return failed ? failed.label : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSatisfied() {
|
||||||
|
return firstError() === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPolicy(nextPolicy) {
|
||||||
|
policy = normalizePolicy(nextPolicy);
|
||||||
|
if (passwordInput) {
|
||||||
|
passwordInput.minLength = policy.minLength;
|
||||||
|
passwordInput.placeholder = `At least ${policy.minLength} characters`;
|
||||||
|
}
|
||||||
|
if (confirmInput) {
|
||||||
|
confirmInput.minLength = policy.minLength;
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordInput?.addEventListener("input", update);
|
||||||
|
confirmInput?.addEventListener("input", update);
|
||||||
|
update();
|
||||||
|
|
||||||
|
return { firstError, isSatisfied, setPolicy, update };
|
||||||
|
}
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
|
import { createPasswordPolicyChecklist, loadPasswordPolicy } from "./password-policy.js";
|
||||||
|
|
||||||
const form = document.getElementById("register-form");
|
const form = document.getElementById("register-form");
|
||||||
const errorEl = document.getElementById("error");
|
const errorEl = document.getElementById("error");
|
||||||
const successEl = document.getElementById("success");
|
const successEl = document.getElementById("success");
|
||||||
const keyGroup = document.getElementById("key-group");
|
const keyGroup = document.getElementById("key-group");
|
||||||
const keyInput = document.getElementById("reg-key");
|
const keyInput = document.getElementById("reg-key");
|
||||||
|
const passwordInput = document.getElementById("reg-pass");
|
||||||
|
const confirmPasswordInput = document.getElementById("reg-pass-confirm");
|
||||||
|
const policyEl = document.getElementById("password-policy");
|
||||||
|
|
||||||
|
const passwordChecklist = createPasswordPolicyChecklist({
|
||||||
|
passwordInput,
|
||||||
|
confirmInput: confirmPasswordInput,
|
||||||
|
container: policyEl,
|
||||||
|
});
|
||||||
|
|
||||||
|
loadPasswordPolicy().then((policy) => passwordChecklist.setPolicy(policy));
|
||||||
|
|
||||||
|
|
||||||
async function checkStatus() {
|
async function checkStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -87,16 +101,17 @@ form.addEventListener("submit", async (e) => {
|
|||||||
successEl.style.display = "none";
|
successEl.style.display = "none";
|
||||||
|
|
||||||
const username = document.getElementById("reg-user").value.trim();
|
const username = document.getElementById("reg-user").value.trim();
|
||||||
const password = document.getElementById("reg-pass").value;
|
const password = passwordInput.value;
|
||||||
const confirmPassword = document.getElementById("reg-pass-confirm").value;
|
const confirmPassword = confirmPasswordInput.value;
|
||||||
const key = keyInput.value.trim();
|
const key = keyInput.value.trim();
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
errorEl.textContent = "Passwords do not match.";
|
errorEl.textContent = "Passwords do not match.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 6) {
|
const policyError = passwordChecklist.firstError();
|
||||||
errorEl.textContent = "Password must be at least 6 characters.";
|
if (policyError) {
|
||||||
|
errorEl.textContent = policyError;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1007,7 +1007,7 @@
|
|||||||
<i class="fa-solid fa-file-signature text-blue-400"></i> Startup File Name
|
<i class="fa-solid fa-file-signature text-blue-400"></i> Startup File Name
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="startup-name" name="startup-name" maxlength="32"
|
<input type="text" id="startup-name" name="startup-name" maxlength="32"
|
||||||
pattern="[A-Za-z0-9_.-]+"
|
pattern="[A-Za-z0-9_.\x2d]+"
|
||||||
placeholder="ovd (default)"
|
placeholder="ovd (default)"
|
||||||
class="w-full px-3 py-2 bg-slate-800/80 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-orange-500 transition-colors placeholder:text-slate-600" />
|
class="w-full px-3 py-2 bg-slate-800/80 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-orange-500 transition-colors placeholder:text-slate-600" />
|
||||||
<p class="text-xs text-slate-500 mt-1">Custom name for the installed executable. On Windows: used as filename (<code>svchost</code> → <code>svchost.exe</code>). On macOS: also used as the LaunchAgent label — <span id="startup-name-macos-hint" class="hidden text-orange-400 font-medium">must start with <code>com.</code> (e.g. <code>com.apple.updater</code>).</span><span id="startup-name-default-hint">Leave blank for a default randomized name.</span></p>
|
<p class="text-xs text-slate-500 mt-1">Custom name for the installed executable. On Windows: used as filename (<code>svchost</code> → <code>svchost.exe</code>). On macOS: also used as the LaunchAgent label — <span id="startup-name-macos-hint" class="hidden text-orange-400 font-medium">must start with <code>com.</code> (e.g. <code>com.apple.updater</code>).</span><span id="startup-name-default-hint">Leave blank for a default randomized name.</span></p>
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="password-policy" class="password-policy-card" aria-live="polite"></div>
|
||||||
|
|
||||||
<button type="submit" class="button primary full login-btn">
|
<button type="submit" class="button primary full login-btn">
|
||||||
<span class="btn-text">Update password</span>
|
<span class="btn-text">Update password</span>
|
||||||
<i class="fa-solid fa-arrow-right btn-icon"></i>
|
<i class="fa-solid fa-arrow-right btn-icon"></i>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
required
|
required
|
||||||
minlength="3"
|
minlength="3"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
pattern="[a-zA-Z0-9_\-]+"
|
pattern="[a-zA-Z0-9_\x2d]+"
|
||||||
placeholder="Choose a username"
|
placeholder="Choose a username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="password-policy" class="password-policy-card" aria-live="polite"></div>
|
||||||
<div class="form-group" id="key-group" style="display: none">
|
<div class="form-group" id="key-group" style="display: none">
|
||||||
<label class="label">Registration Key</label>
|
<label class="label">Registration Key</label>
|
||||||
<div class="input-row input-animated">
|
<div class="input-row input-animated">
|
||||||
@@ -101,6 +102,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/register.js"></script>
|
<script type="module" src="/assets/register.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
pattern="[a-zA-Z0-9_-]+"
|
pattern="[a-zA-Z0-9_\x2d]+"
|
||||||
minlength="3"
|
minlength="3"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
@@ -187,6 +187,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p id="generated-password-hint" class="hidden text-xs text-emerald-400 mt-1">Generated password filled. Copy it before saving.</p>
|
<p id="generated-password-hint" class="hidden text-xs text-emerald-400 mt-1">Generated password filled. Copy it before saving.</p>
|
||||||
<p class="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
|
<p class="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
|
||||||
|
<p class="text-xs text-amber-300/80 mt-1">New users are prompted to confirm or change this password on first login.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ setInterval(() => {
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
|
||||||
|
function getPublicPasswordPolicy() {
|
||||||
|
const security = getConfig().security;
|
||||||
|
return {
|
||||||
|
minLength: Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6)),
|
||||||
|
requireUppercase: Boolean(security.passwordRequireUppercase),
|
||||||
|
requireLowercase: Boolean(security.passwordRequireLowercase),
|
||||||
|
requireNumber: Boolean(security.passwordRequireNumber),
|
||||||
|
requireSymbol: Boolean(security.passwordRequireSymbol),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type RegistrationRouteDeps = {
|
type RegistrationRouteDeps = {
|
||||||
requestIP?: (req: Request) => { address?: string } | null | undefined;
|
requestIP?: (req: Request) => { address?: string } | null | undefined;
|
||||||
};
|
};
|
||||||
@@ -80,6 +91,7 @@ export async function handleRegistrationRoutes(
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
enabled: mode !== "off",
|
enabled: mode !== "off",
|
||||||
mode: mode === "off" ? "off" : mode,
|
mode: mode === "off" ? "off" : mode,
|
||||||
|
passwordPolicy: getPublicPasswordPolicy(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { generateToken } from "../../auth";
|
||||||
|
import { createUser, deleteUser, getUserById } from "../../users";
|
||||||
|
import { handleUsersRoutes } from "./users-routes";
|
||||||
|
|
||||||
|
const PASSWORD = "Aa1!MustChangeRouteTestPass_2026";
|
||||||
|
const createdUserIds: number[] = [];
|
||||||
|
|
||||||
|
const mockServer = {
|
||||||
|
requestIP: () => ({ address: "127.0.0.1" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createAuthedAdmin() {
|
||||||
|
const username = `mcp_admin_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||||
|
const result = await createUser(username, PASSWORD, "admin", "test");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(typeof result.userId).toBe("number");
|
||||||
|
createdUserIds.push(result.userId!);
|
||||||
|
const user = getUserById(result.userId!);
|
||||||
|
expect(user).not.toBeNull();
|
||||||
|
const token = await generateToken(user!);
|
||||||
|
return { user: user!, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (createdUserIds.length > 0) {
|
||||||
|
const id = createdUserIds.pop();
|
||||||
|
if (typeof id === "number") deleteUser(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin-created users must change password", () => {
|
||||||
|
test("POST /api/users marks new users for password change", async () => {
|
||||||
|
const admin = await createAuthedAdmin();
|
||||||
|
const username = `mcp_user_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||||
|
const url = new URL("https://localhost/api/users");
|
||||||
|
|
||||||
|
const res = await handleUsersRoutes(
|
||||||
|
new Request(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${admin.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password: PASSWORD, role: "viewer" }),
|
||||||
|
}),
|
||||||
|
url,
|
||||||
|
mockServer,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res!.status).toBe(200);
|
||||||
|
const body = await res!.json();
|
||||||
|
expect(typeof body.userId).toBe("number");
|
||||||
|
createdUserIds.push(body.userId);
|
||||||
|
const created = getUserById(body.userId);
|
||||||
|
expect(created?.must_change_password).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("self password update may reuse the initial password and clears the prompt", async () => {
|
||||||
|
const admin = await createAuthedAdmin();
|
||||||
|
const username = `mcp_same_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||||
|
const createUrl = new URL("https://localhost/api/users");
|
||||||
|
const createRes = await handleUsersRoutes(
|
||||||
|
new Request(createUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${admin.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password: PASSWORD, role: "operator" }),
|
||||||
|
}),
|
||||||
|
createUrl,
|
||||||
|
mockServer,
|
||||||
|
);
|
||||||
|
expect(createRes).not.toBeNull();
|
||||||
|
expect(createRes!.status).toBe(200);
|
||||||
|
const createBody = await createRes!.json();
|
||||||
|
createdUserIds.push(createBody.userId);
|
||||||
|
const created = getUserById(createBody.userId);
|
||||||
|
expect(created?.must_change_password).toBe(1);
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
|
||||||
|
const userToken = await generateToken(created!);
|
||||||
|
const updateUrl = new URL(`https://localhost/api/users/${createBody.userId}/password`);
|
||||||
|
const updateRes = await handleUsersRoutes(
|
||||||
|
new Request(updateUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${userToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ currentPassword: PASSWORD, newPassword: PASSWORD }),
|
||||||
|
}),
|
||||||
|
updateUrl,
|
||||||
|
mockServer,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateRes).not.toBeNull();
|
||||||
|
expect(updateRes!.status).toBe(200);
|
||||||
|
expect(getUserById(createBody.userId)?.must_change_password).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,7 +94,7 @@ export async function handleUsersRoutes(
|
|||||||
return Response.json({ error: "Invalid role" }, { status: 400 });
|
return Response.json({ error: "Invalid role" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createUser(username, password, role, authedUser.username);
|
const result = await createUser(username, password, role, authedUser.username, true);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const ip = server.requestIP(req)?.address || "unknown";
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
|
|||||||
@@ -667,6 +667,7 @@ export async function createUser(
|
|||||||
password: string,
|
password: string,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
|
mustChangePassword = false,
|
||||||
): Promise<{ success: boolean; error?: string; userId?: number }> {
|
): Promise<{ success: boolean; error?: string; userId?: number }> {
|
||||||
if (!username || username.length < 3 || username.length > 32) {
|
if (!username || username.length < 3 || username.length > 32) {
|
||||||
return {
|
return {
|
||||||
@@ -701,9 +702,9 @@ export async function createUser(
|
|||||||
|
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none", role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0, role === "admin" ? 1 : 0);
|
.run(username, passwordHash, role, Date.now(), createdBy, mustChangePassword ? 1 : 0, role === "admin" ? "all" : "none", role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0, role === "admin" ? 1 : 0);
|
||||||
|
|
||||||
invalidateNotificationDeliveryCache();
|
invalidateNotificationDeliveryCache();
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ test.describe("authenticated browser smoke tests", () => {
|
|||||||
await expect(page.locator("#modal-img")).toHaveAttribute("src", /data:image\/gif/);
|
await expect(page.locator("#modal-img")).toHaveAttribute("src", /data:image\/gif/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("change password page shows live password requirements", async ({ page }) => {
|
||||||
|
await page.goto("/login.html");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
sessionStorage.setItem("temp_token", "e2e-temp-token");
|
||||||
|
sessionStorage.setItem("temp_user", JSON.stringify({ id: 1, username: "e2e-user" }));
|
||||||
|
});
|
||||||
|
await page.goto("/change-password.html");
|
||||||
|
|
||||||
|
await expect(page.locator("#password-policy")).toContainText("At least 6 characters");
|
||||||
|
await expect(page.locator('[data-password-rule="minLength"]')).toHaveClass(/is-unmet/);
|
||||||
|
await page.locator("#new-pass").fill("abcdef");
|
||||||
|
await expect(page.locator('[data-password-rule="minLength"]')).toHaveClass(/is-met/);
|
||||||
|
await expect(page.locator('[data-password-rule="match"]')).toHaveClass(/is-unmet/);
|
||||||
|
await page.locator("#confirm-pass").fill("abcdef");
|
||||||
|
await expect(page.locator('[data-password-rule="match"]')).toHaveClass(/is-met/);
|
||||||
|
});
|
||||||
|
|
||||||
test("password inputs expose browser-safe form semantics", async ({ page }) => {
|
test("password inputs expose browser-safe form semantics", async ({ page }) => {
|
||||||
const issues = collectBrowserIssues(page);
|
const issues = collectBrowserIssues(page);
|
||||||
await login(page);
|
await login(page);
|
||||||
@@ -100,6 +117,9 @@ test.describe("authenticated browser smoke tests", () => {
|
|||||||
|
|
||||||
await page.goto("/users");
|
await page.goto("/users");
|
||||||
await expect(page.locator("#password")).toHaveAttribute("autocomplete", "new-password");
|
await expect(page.locator("#password")).toHaveAttribute("autocomplete", "new-password");
|
||||||
|
|
||||||
|
await page.goto("/register.html");
|
||||||
|
await expect(page.locator("#reg-user")).toHaveAttribute("pattern", "[a-zA-Z0-9_\\x2d]+");
|
||||||
expect(issues).toEqual([]);
|
expect(issues).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user