feat(users): admin can permanently delete users, fix set-password redirect bug
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- New DELETE /users/{id}: hard-deletes a user only if they have zero
activity footprint (no tests, evidence, worklogs, audit entries, Jira
links, procedure/template suggestions, reviewed imports) — otherwise
rejects with a clear message to deactivate instead, to keep the audit
trail intact. Cleans up the user's own notifications/password-setup
tokens as part of deletion. Self-delete is blocked. Wired to a trash
icon in the Users page with a confirm dialog.
- Registered the EvaluationImport model in app/models/__init__.py — it
was missing, so its table never existed in the SQLite test DB; only
surfaced once the new delete-user footprint check queried it.
- Fixed a redirect bug: the axios response interceptor's on-401 redirect
to /login didn't exempt /set-password, so the auth provider's routine
mount-time /auth/me check (401 for a logged-out visitor) bounced anyone
opening their emailed set-password link away before they could use it.
This commit is contained in:
@@ -38,6 +38,11 @@ async function tryRefresh(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Routes that are public — a 401 from a background call (e.g. the auth
|
||||
// provider's mount-time /auth/me check) must never bounce someone away
|
||||
// from these while they're using them logged out.
|
||||
const PUBLIC_PATHS = ["/login", "/set-password"];
|
||||
|
||||
// ── Response interceptor ────────────────────────────────────────────
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
@@ -58,8 +63,10 @@ client.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh failed or not applicable — redirect to login
|
||||
if (window.location.pathname !== "/login") {
|
||||
// Refresh failed or not applicable — redirect to login, unless
|
||||
// we're already on a public page (e.g. /set-password) that a
|
||||
// logged-out visitor is legitimately using.
|
||||
if (!PUBLIC_PATHS.includes(window.location.pathname)) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ export async function updateUser(userId: string, payload: UserUpdatePayload): Pr
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Permanently delete a user (admin only). Fails with a 400 if the user has
|
||||
* any activity footprint (tests, evidence, worklogs, audit entries, etc) —
|
||||
* deactivate those instead to preserve the audit trail. */
|
||||
export async function deleteUser(userId: string): Promise<void> {
|
||||
await client.delete(`/users/${userId}`);
|
||||
}
|
||||
|
||||
/** Email a one-time set-password link to a user (admin only). Also used for password resets. */
|
||||
export async function sendPasswordEmail(userId: string): Promise<{ detail: string }> {
|
||||
const { data } = await client.post<{ detail: string }>(`/users/${userId}/send-password-email`);
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
UserCheck,
|
||||
Edit,
|
||||
Mail,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
sendPasswordEmail,
|
||||
type UserOut,
|
||||
type UserCreatePayload,
|
||||
@@ -85,6 +87,26 @@ export default function UsersPage() {
|
||||
onError: (err: Error) => showToast("error", err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
showToast("success", "User deleted");
|
||||
},
|
||||
onError: (err: Error) => showToast("error", err.message),
|
||||
});
|
||||
|
||||
const handleDelete = (user: UserOut) => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Permanently delete ${user.full_name || user.username}? This cannot be undone. ` +
|
||||
`Users with any activity (tests, evidence, worklogs, etc) can't be deleted — deactivate them instead.`,
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserActive = (user: UserOut) => {
|
||||
updateMutation.mutate({
|
||||
userId: user.id,
|
||||
@@ -240,6 +262,14 @@ export default function UsersPage() {
|
||||
<UserCheck className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user)}
|
||||
disabled={deleteMutation.isPending || user.id === currentUser?.id}
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-red-900/50 hover:text-red-400 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-400"
|
||||
title={user.id === currentUser?.id ? "You can't delete your own account" : "Delete permanently"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user