confirm actions

This commit is contained in:
2026-03-15 18:59:37 -05:00
parent 59754c7657
commit df041254da
28 changed files with 999 additions and 63 deletions

View File

@@ -1,9 +1,17 @@
import type { AdminPermissionOptionDto, AdminRoleDto, AdminRoleInput, AdminUserDto, AdminUserInput } from "@mrp/shared";
import type {
AdminAuthSessionDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
} from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
const emptyUserForm: AdminUserInput = {
email: "",
@@ -21,15 +29,33 @@ const emptyRoleForm: AdminRoleInput = {
};
export function UserManagementPage() {
const { token } = useAuth();
const { token, user: authUser, logout } = useAuth();
const [users, setUsers] = useState<AdminUserDto[]>([]);
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
const [sessions, setSessions] = useState<AdminAuthSessionDto[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>("new");
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
const [status, setStatus] = useState("Loading admin access controls...");
const [isConfirmingAction, setIsConfirmingAction] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "deactivate-user" | "revoke-session";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
userId?: string;
sessionId?: string;
}
| null
>(null);
useEffect(() => {
if (!token) {
@@ -38,14 +64,15 @@ export function UserManagementPage() {
let active = true;
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token)])
.then(([nextUsers, nextRoles, nextPermissions]) => {
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token), api.getAdminSessions(token)])
.then(([nextUsers, nextRoles, nextPermissions, nextSessions]) => {
if (!active) {
return;
}
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setSessions(nextSessions);
setStatus("User management loaded.");
})
.catch((error: Error) => {
@@ -108,19 +135,41 @@ export function UserManagementPage() {
const authToken = token;
async function refreshData(nextStatus: string) {
const [nextUsers, nextRoles, nextPermissions] = await Promise.all([
const [nextUsers, nextRoles, nextPermissions, nextSessions] = await Promise.all([
api.getAdminUsers(authToken),
api.getAdminRoles(authToken),
api.getAdminPermissions(authToken),
api.getAdminSessions(authToken),
]);
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setSessions(nextSessions);
setStatus(nextStatus);
}
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const selectedUser = users.find((entry) => entry.id === selectedUserId);
if (selectedUser && selectedUser.isActive && !userForm.isActive) {
setPendingConfirmation({
kind: "deactivate-user",
title: `Deactivate ${selectedUser.firstName} ${selectedUser.lastName}`,
description: `Disable sign-in for ${selectedUser.email}. Existing active sessions will remain revoked only if you separately revoke them below.`,
impact: "The user will be blocked from new sign-ins as soon as this save completes.",
recovery: "Re-enable the account later if the change was made in error, and revoke live sessions separately if immediate cut-off is required.",
confirmLabel: "Deactivate user",
confirmationLabel: "Type user email to confirm:",
confirmationValue: selectedUser.email,
userId: selectedUser.id,
});
return;
}
await saveUser();
}
async function saveUser() {
if (selectedUserId === "new") {
const createdUser = await api.createAdminUser(authToken, userForm);
await refreshData(`Created user ${createdUser.email}.`);
@@ -165,6 +214,21 @@ export function UserManagementPage() {
}));
}
async function handleSessionRevoke(sessionId: string, isCurrentSession: boolean) {
await api.revokeAdminSession(authToken, sessionId);
if (isCurrentSession) {
await logout();
return;
}
await refreshData("Revoked session. The user must sign in again to restore access unless their account is inactive.");
}
const filteredSessions = sessions.filter((session) => sessionUserFilter === "all" || session.userId === sessionUserFilter);
const activeSessionCount = sessions.filter((session) => session.status === "ACTIVE").length;
const revokedSessionCount = sessions.filter((session) => session.status === "REVOKED").length;
const expiredSessionCount = sessions.filter((session) => session.status === "EXPIRED").length;
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -358,6 +422,159 @@ export function UserManagementPage() {
</div>
</form>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sessions</p>
<h3 className="mt-2 text-lg font-bold text-text">Active sign-ins and revocation control</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Review recent authenticated sessions, see their current state, and revoke stale or risky access without changing the user record.
</p>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Filter by user</span>
<select
value={sessionUserFilter}
onChange={(event) => setSessionUserFilter(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="all">All users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.firstName} {user.lastName}
</option>
))}
</select>
</label>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Active</p>
<p className="mt-2 text-2xl font-bold text-text">{activeSessionCount}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Revoked</p>
<p className="mt-2 text-2xl font-bold text-text">{revokedSessionCount}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Expired</p>
<p className="mt-2 text-2xl font-bold text-text">{expiredSessionCount}</p>
</div>
</div>
<div className="mt-5 grid gap-3">
{filteredSessions.map((session) => (
<div key={session.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-text">{session.userName}</p>
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
{session.status}
</span>
{session.isCurrent ? (
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
Current
</span>
) : null}
</div>
<p className="mt-1 text-sm text-muted">{session.userEmail}</p>
<div className="mt-3 grid gap-2 text-xs text-muted md:grid-cols-2 xl:grid-cols-4">
<p>Started: {new Date(session.createdAt).toLocaleString()}</p>
<p>Last seen: {new Date(session.lastSeenAt).toLocaleString()}</p>
<p>Expires: {new Date(session.expiresAt).toLocaleString()}</p>
<p>IP: {session.ipAddress || "Unknown"}</p>
</div>
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
{session.revokedAt ? (
<p className="mt-2 text-xs text-muted">
Revoked {new Date(session.revokedAt).toLocaleString()}
{session.revokedByName ? ` by ${session.revokedByName}` : ""}.
{session.revokedReason ? ` ${session.revokedReason}` : ""}
</p>
) : null}
</div>
{session.status === "ACTIVE" ? (
<button
type="button"
onClick={() =>
setPendingConfirmation({
kind: "revoke-session",
title: session.isCurrent ? "Revoke current session" : `Revoke session for ${session.userName}`,
description: session.isCurrent
? "Revoke the session you are using right now. Your current browser session will lose access immediately."
: `Revoke the selected active session for ${session.userEmail}.`,
impact: "The selected token becomes unusable immediately.",
recovery: "The user can sign in again unless the account itself is inactive. Review the remaining session list after revocation.",
confirmLabel: "Revoke session",
confirmationLabel: session.isCurrent ? "Type REVOKE to confirm:" : undefined,
confirmationValue: session.isCurrent ? "REVOKE" : undefined,
sessionId: session.id,
})
}
className="rounded-2xl border border-red-300 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
>
Revoke session
</button>
) : null}
</div>
</div>
))}
{filteredSessions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line/70 bg-page/40 px-3 py-6 text-sm text-muted">
No sessions match the current filter.
</div>
) : null}
</div>
</section>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm admin action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={isConfirmingAction}
onClose={() => {
if (!isConfirmingAction) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
setIsConfirmingAction(true);
try {
if (pendingConfirmation.kind === "deactivate-user" && pendingConfirmation.userId) {
await saveUser();
}
if (pendingConfirmation.kind === "revoke-session" && pendingConfirmation.sessionId) {
const isCurrentSession = sessions.find((session) => session.id === pendingConfirmation.sessionId)?.isCurrent ?? false;
await handleSessionRevoke(pendingConfirmation.sessionId, isCurrentSession);
}
if (
pendingConfirmation.kind === "deactivate-user" &&
pendingConfirmation.userId &&
pendingConfirmation.userId === authUser?.id
) {
setStatus("Your own account was deactivated. Sign-in will fail after this session ends unless another admin re-enables the account.");
}
setPendingConfirmation(null);
} finally {
setIsConfirmingAction(false);
}
}}
/>
</div>
);
}