confirm actions
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user