Merge pull request #7 from KingIronMan2011/copilot/delete-user-cascade-warning

Add password validation, i18n support, self-deactivation prevention, and enhanced delete warnings to user management
This commit is contained in:
Julian Speckmann 2025-12-24 05:00:49 +01:00 committed by GitHub
commit f76d3b25cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 225 additions and 19 deletions

View file

@ -2,6 +2,19 @@ const { checkLogin } = require("../util-server");
const { R } = require("redbean-node");
const passwordHash = require("../password-hash");
const { log } = require("../../src/util");
const { passwordStrength } = require("check-password-strength");
/**
* Validates password strength
* @param {string} password Password to validate
* @returns {void}
* @throws {Error} If password is too weak
*/
function validatePasswordStrength(password) {
if (passwordStrength(password).value === "Too weak") {
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
}
}
/**
* Handlers for user management
@ -45,6 +58,9 @@ module.exports.userManagementSocketHandler = (socket, server) => {
throw new Error("Username and password are required");
}
// Validate password strength
validatePasswordStrength(userData.password);
// Check if username already exists
const existingUser = await R.findOne("user", " username = ? ", [ userData.username.trim() ]);
if (existingUser) {
@ -64,6 +80,7 @@ module.exports.userManagementSocketHandler = (socket, server) => {
callback({
ok: true,
msg: "User created successfully",
msgi18n: true,
userId: user.id,
});
} catch (e) {
@ -71,6 +88,7 @@ module.exports.userManagementSocketHandler = (socket, server) => {
callback({
ok: false,
msg: e.message,
msgi18n: true,
});
}
});
@ -89,6 +107,11 @@ module.exports.userManagementSocketHandler = (socket, server) => {
const isEditingSelf = Number(userId) === Number(socket.userID);
const usernameChanged = userData.username && userData.username.trim() !== user.username;
// Don't allow deactivating yourself
if (isEditingSelf && typeof userData.active !== "undefined" && !userData.active) {
throw new Error("Cannot deactivate your own account");
}
// Update user fields
if (userData.username && userData.username.trim() !== user.username) {
// Check if new username already exists
@ -108,6 +131,8 @@ module.exports.userManagementSocketHandler = (socket, server) => {
// Update password if provided
if (userData.password) {
// Validate password strength
validatePasswordStrength(userData.password);
user.password = await passwordHash.generate(userData.password);
}
@ -118,6 +143,7 @@ module.exports.userManagementSocketHandler = (socket, server) => {
callback({
ok: true,
msg: "User updated successfully",
msgi18n: true,
requiresLogout: isEditingSelf && usernameChanged,
});
} catch (e) {
@ -125,6 +151,7 @@ module.exports.userManagementSocketHandler = (socket, server) => {
callback({
ok: false,
msg: e.message,
msgi18n: true,
});
}
});
@ -155,11 +182,13 @@ module.exports.userManagementSocketHandler = (socket, server) => {
callback({
ok: true,
msg: "User deleted successfully",
msgi18n: true,
});
} catch (error) {
callback({
ok: false,
msg: error.message,
msgi18n: true,
});
}
});

View file

@ -51,18 +51,18 @@
</div>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteUser">
{{ $t("Are you sure you want to delete this user?") }}
{{ $t("deleteUserWarning") }}
</Confirm>
<!-- Add/Edit User Dialog -->
<div class="modal fade" :class="{ show: showDialog }" :style="{ display: showDialog ? 'block' : 'none' }" tabindex="-1">
<div ref="userDialog" class="modal fade" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ editMode ? $t("Edit User") : $t("Add User") }}
</h5>
<button type="button" class="btn-close" @click="closeDialog"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveUser">
@ -105,7 +105,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeDialog">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{{ $t("Cancel") }}
</button>
<button type="button" class="btn btn-primary" @click="saveUser">
@ -115,11 +115,11 @@
</div>
</div>
</div>
<div v-if="showDialog" class="modal-backdrop fade show"></div>
</div>
</template>
<script>
import { Modal } from "bootstrap";
import Confirm from "../Confirm.vue";
export default {
@ -130,7 +130,7 @@ export default {
return {
users: [],
loading: false,
showDialog: false,
userDialog: null,
editMode: false,
selectedUserID: null,
formData: {
@ -143,10 +143,29 @@ export default {
},
mounted() {
this.userDialog = new Modal(this.$refs.userDialog);
this.loadUsers();
},
beforeUnmount() {
this.cleanupModal();
},
methods: {
/**
* Cleanup modal instance
* @returns {void}
*/
cleanupModal() {
if (this.userDialog) {
try {
this.userDialog.hide();
} catch (e) {
console.warn("Modal hide failed:", e);
}
}
},
/**
* Load all users from the server
* @returns {void}
@ -175,7 +194,7 @@ export default {
password: "",
active: true,
};
this.showDialog = true;
this.userDialog.show();
},
/**
@ -191,15 +210,7 @@ export default {
password: "",
active: user.active === 1,
};
this.showDialog = true;
},
/**
* Close the add/edit dialog
* @returns {void}
*/
closeDialog() {
this.showDialog = false;
this.userDialog.show();
},
/**
@ -220,7 +231,7 @@ export default {
this.$root.getSocket().emit("updateUser", this.formData.id, updateData, (res) => {
if (res.ok) {
this.$root.toastSuccess(res.msg);
this.closeDialog();
this.userDialog.hide();
this.loadUsers();
// If user edited their own username, they will be logged out
@ -237,7 +248,7 @@ export default {
this.$root.getSocket().emit("addUser", this.formData, (res) => {
if (res.ok) {
this.$root.toastSuccess(res.msg);
this.closeDialog();
this.userDialog.hide();
this.loadUsers();
} else {
this.$root.toastError(res.msg);

View file

@ -1248,5 +1248,10 @@
"Cannot delete your own account": "Cannot delete your own account",
"Active": "Active",
"Inactive": "Inactive",
"No users": "No users"
"No users": "No users",
"Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.": "Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.",
"User not found": "User not found",
"Username and password are required": "Username and password are required",
"deleteUserWarning": "Deleting this user will permanently delete all associated API keys and will orphan monitors and maintenance records by setting their user_id to NULL. Are you sure you want to continue?",
"Cannot deactivate your own account": "Cannot deactivate your own account"
}

View file

@ -0,0 +1,161 @@
import { expect, test } from "@playwright/test";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
test.afterEach(async ({ page }, testInfo) => {
await screenshot(testInfo, page);
});
test("add new user", async ({ page }, testInfo) => {
await page.goto("./settings/users");
await login(page);
await screenshot(testInfo, page);
// Click Add User button
await page.getByRole("button", { name: "Add User" }).click();
await expect(page.locator(".modal.show")).toBeVisible();
// Fill in user details
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("testpass123");
// Save the user
await page.getByRole("button", { name: "Save" }).click();
// Wait for modal to close
await expect(page.locator(".modal.show")).not.toBeVisible();
// Verify user appears in the list
await expect(page.getByText("testuser")).toBeVisible();
await screenshot(testInfo, page);
});
test("reject weak password", async ({ page }, testInfo) => {
await page.goto("./settings/users");
await login(page);
await screenshot(testInfo, page);
// Click Add User button
await page.getByRole("button", { name: "Add User" }).click();
await expect(page.locator(".modal.show")).toBeVisible();
// Fill in user details with weak password
await page.locator("#username").fill("weakuser");
await page.locator("#password").fill("weak");
// Save the user
await page.getByRole("button", { name: "Save" }).click();
// Verify error message about weak password appears (modal should stay visible on error)
await expect(page.locator(".toast-body, .alert, .error")).toContainText(/too weak|weak/i);
await screenshot(testInfo, page);
});
test("edit existing user", async ({ page }, testInfo) => {
await page.goto("./settings/users");
await login(page);
// First create a user to edit
await page.getByRole("button", { name: "Add User" }).click();
await page.locator("#username").fill("editableuser");
await page.locator("#password").fill("password123");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator(".modal.show")).not.toBeVisible();
await screenshot(testInfo, page);
// Find and click Edit button for the user
const userItem = page.locator(".item").filter({ hasText: "editableuser" });
await userItem.getByRole("button", { name: "Edit" }).click();
await expect(page.locator(".modal.show")).toBeVisible();
// Change username
await page.locator("#username").clear();
await page.locator("#username").fill("editeduserB");
// Save changes
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator(".modal.show")).not.toBeVisible();
// Verify updated username appears
await expect(page.getByText("editeduserB")).toBeVisible();
await screenshot(testInfo, page);
});
test("prevent self-deactivation", async ({ page }, testInfo) => {
await page.goto("./settings/users");
await login(page);
await screenshot(testInfo, page);
// Find and click Edit button for admin user (the logged-in user)
const adminItem = page.locator(".item").filter({ hasText: "admin" });
await adminItem.getByRole("button", { name: "Edit" }).click();
await expect(page.locator(".modal.show")).toBeVisible();
// Try to deactivate the admin account
await page.locator("#active").uncheck();
// Save changes
await page.getByRole("button", { name: "Save" }).click();
// Verify error message appears (modal should stay visible on error)
await expect(page.locator(".toast-body, .alert, .error")).toContainText(/cannot deactivate.*own account/i);
// Close dialog
await page.getByRole("button", { name: "Cancel" }).click();
// Verify admin is still active
await expect(adminItem.locator(".status")).toContainText("Active");
await screenshot(testInfo, page);
});
test("prevent self-deletion", async ({ page }, testInfo) => {
await page.goto("./settings/users");
await login(page);
await screenshot(testInfo, page);
// Try to delete admin user (the logged-in user)
const adminItem = page.locator(".item").filter({ hasText: "admin" });
await adminItem.getByRole("button", { name: "Delete" }).click();
// Confirm deletion
await page.getByRole("button", { name: "Yes" }).click();
// Verify error message appears
await expect(page.locator(".toast-body, .alert, .error")).toContainText(/cannot delete.*own account/i);
await screenshot(testInfo, page);
});
test("delete other user", async ({ page }, testInfo) => {
await page.goto("./settings/users");
await login(page);
// First create a user to delete
await page.getByRole("button", { name: "Add User" }).click();
await page.locator("#username").fill("deletableuser");
await page.locator("#password").fill("password123");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator(".modal.show")).not.toBeVisible();
await screenshot(testInfo, page);
// Find and click Delete button for the user
const userItem = page.locator(".item").filter({ hasText: "deletableuser" });
await userItem.getByRole("button", { name: "Delete" }).click();
// Verify warning message about cascade deletion
await expect(page.locator(".modal, .confirm")).toContainText(/API keys.*orphan.*monitors.*maintenance/i);
await screenshot(testInfo, page);
// Confirm deletion
await page.getByRole("button", { name: "Yes" }).click();
// Verify user is removed from the list
await expect(page.getByText("deletableuser")).not.toBeVisible();
await screenshot(testInfo, page);
});
});