diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 57d39d74..c364e394 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -6066,6 +6066,7 @@ async function refreshUserManagement() { const list = requiredElement("user-list"); try { const users = await listUsers(); + const adminCount = users.filter((user) => user.role === "admin").length; list.replaceChildren(...users.map((user) => { const row = document.createElement("div"); row.className = "sch-row"; @@ -6082,6 +6083,9 @@ async function refreshUserManagement() { 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"); password.type = "password"; password.placeholder = "New password"; @@ -6100,7 +6104,8 @@ async function refreshUserManagement() { 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 () => { 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)); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index f9ee773f..44418eed 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -5070,6 +5070,7 @@ async function refreshUserManagement() { const list = requiredElement("user-list"); try { const users = await listUsers(); + const adminCount = users.filter(user => user.role === "admin").length; list.replaceChildren(...users.map((user) => { const row = document.createElement("div"); row.className = "sch-row"; @@ -5082,6 +5083,9 @@ async function refreshUserManagement() { 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 isOnlyAdmin = user.role === "admin" && adminCount === 1; + role.disabled = isOnlyAdmin; + if (isOnlyAdmin) role.title = "The final administrator cannot be demoted"; const password = document.createElement("input"); 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"; @@ -5091,7 +5095,8 @@ async function refreshUserManagement() { await runUserOperation(() => updateUser(user.username, changes)); }); 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 () => { 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)); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs index 0172cc42..b9215815 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs @@ -1201,11 +1201,31 @@ mod tests { ); let request = aw_test::TestRequest::delete() .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(); assert_eq!( aw_test::call_service(&app, request).await.status(), 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 + ); } }