Protect the final administrator
This commit is contained in:
@@ -6066,6 +6066,7 @@ async function refreshUserManagement() {
|
|||||||
const list = requiredElement("user-list");
|
const list = requiredElement("user-list");
|
||||||
try {
|
try {
|
||||||
const users = await listUsers();
|
const users = await listUsers();
|
||||||
|
const adminCount = users.filter((user) => user.role === "admin").length;
|
||||||
list.replaceChildren(...users.map((user) => {
|
list.replaceChildren(...users.map((user) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "sch-row";
|
row.className = "sch-row";
|
||||||
@@ -6082,6 +6083,9 @@ async function refreshUserManagement() {
|
|||||||
option.selected = user.role === value;
|
option.selected = user.role === value;
|
||||||
role.append(option);
|
role.append(option);
|
||||||
}
|
}
|
||||||
|
const isOnlyAdmin = user.role === "admin" && adminCount === 1;
|
||||||
|
role.disabled = isOnlyAdmin;
|
||||||
|
if (isOnlyAdmin) role.title = "The final administrator cannot be demoted";
|
||||||
const password = document.createElement("input");
|
const password = document.createElement("input");
|
||||||
password.type = "password";
|
password.type = "password";
|
||||||
password.placeholder = "New password";
|
password.placeholder = "New password";
|
||||||
@@ -6100,7 +6104,8 @@ async function refreshUserManagement() {
|
|||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "Remove";
|
remove.textContent = "Remove";
|
||||||
remove.className = "danger";
|
remove.className = "danger";
|
||||||
remove.disabled = user.username === authUsername;
|
remove.disabled = user.username === authUsername || isOnlyAdmin;
|
||||||
|
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
|
||||||
remove.addEventListener("click", async () => {
|
remove.addEventListener("click", async () => {
|
||||||
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
||||||
await runUserOperation(() => deleteUser(user.username));
|
await runUserOperation(() => deleteUser(user.username));
|
||||||
|
|||||||
@@ -5070,6 +5070,7 @@ async function refreshUserManagement() {
|
|||||||
const list = requiredElement("user-list");
|
const list = requiredElement("user-list");
|
||||||
try {
|
try {
|
||||||
const users = await listUsers();
|
const users = await listUsers();
|
||||||
|
const adminCount = users.filter(user => user.role === "admin").length;
|
||||||
list.replaceChildren(...users.map((user) => {
|
list.replaceChildren(...users.map((user) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "sch-row";
|
row.className = "sch-row";
|
||||||
@@ -5082,6 +5083,9 @@ async function refreshUserManagement() {
|
|||||||
for (const value of ["user", "admin"] as AuthRole[]) {
|
for (const value of ["user", "admin"] as AuthRole[]) {
|
||||||
const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option);
|
const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option);
|
||||||
}
|
}
|
||||||
|
const isOnlyAdmin = user.role === "admin" && adminCount === 1;
|
||||||
|
role.disabled = isOnlyAdmin;
|
||||||
|
if (isOnlyAdmin) role.title = "The final administrator cannot be demoted";
|
||||||
const password = document.createElement("input");
|
const password = document.createElement("input");
|
||||||
password.type = "password"; password.placeholder = "New password"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8;
|
password.type = "password"; password.placeholder = "New password"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8;
|
||||||
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
|
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
|
||||||
@@ -5091,7 +5095,8 @@ async function refreshUserManagement() {
|
|||||||
await runUserOperation(() => updateUser(user.username, changes));
|
await runUserOperation(() => updateUser(user.username, changes));
|
||||||
});
|
});
|
||||||
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
|
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
|
||||||
remove.disabled = user.username === authUsername;
|
remove.disabled = user.username === authUsername || isOnlyAdmin;
|
||||||
|
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
|
||||||
remove.addEventListener("click", async () => {
|
remove.addEventListener("click", async () => {
|
||||||
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
||||||
await runUserOperation(() => deleteUser(user.username));
|
await runUserOperation(() => deleteUser(user.username));
|
||||||
|
|||||||
@@ -1201,11 +1201,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let request = aw_test::TestRequest::delete()
|
let request = aw_test::TestRequest::delete()
|
||||||
.uri("/auth/users/alice")
|
.uri("/auth/users/alice")
|
||||||
.insert_header((actix_web::http::header::COOKIE, admin_cookie))
|
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
|
||||||
.to_request();
|
.to_request();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
aw_test::call_service(&app, request).await.status(),
|
aw_test::call_service(&app, request).await.status(),
|
||||||
actix_web::http::StatusCode::OK
|
actix_web::http::StatusCode::OK
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Once only one administrator remains, neither API operation may
|
||||||
|
// leave the database without an administrator.
|
||||||
|
let request = aw_test::TestRequest::patch()
|
||||||
|
.uri("/auth/users/admin")
|
||||||
|
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
|
||||||
|
.set_json(serde_json::json!({"role":"user"}))
|
||||||
|
.to_request();
|
||||||
|
assert_eq!(
|
||||||
|
aw_test::call_service(&app, request).await.status(),
|
||||||
|
actix_web::http::StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
let request = aw_test::TestRequest::delete()
|
||||||
|
.uri("/auth/users/admin")
|
||||||
|
.insert_header((actix_web::http::header::COOKIE, admin_cookie))
|
||||||
|
.to_request();
|
||||||
|
assert_eq!(
|
||||||
|
aw_test::call_service(&app, request).await.status(),
|
||||||
|
actix_web::http::StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user