mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-12-24 04:30:53 -05:00
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:
commit
f76d3b25cd
4 changed files with 225 additions and 19 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
161
test/e2e/specs/user-management.spec.js
Normal file
161
test/e2e/specs/user-management.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue