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

@@ -8,7 +8,7 @@ interface AuthContextValue {
user: AuthUser | null;
isReady: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
@@ -48,13 +48,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(result.user);
window.localStorage.setItem(tokenKey, result.token);
},
logout() {
async logout() {
if (token) {
try {
await api.logout(token);
} catch {
// Clearing local auth state still signs the user out on the client.
}
}
window.localStorage.removeItem(tokenKey);
setToken(null);
setUser(null);
},
}),
[isReady, token, user]
[token, user, isReady]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -67,4 +74,3 @@ export function useAuth() {
}
return context;
}

View File

@@ -216,7 +216,9 @@ export function AppShell() {
<p className="text-xs text-muted">{user?.email}</p>
<button
type="button"
onClick={logout}
onClick={() => {
void logout();
}}
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
>
Sign out

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
interface ConfirmActionDialogProps {
open: boolean;
title: string;
description: string;
impact?: string;
recovery?: string;
confirmLabel?: string;
cancelLabel?: string;
intent?: "danger" | "primary";
confirmationLabel?: string;
confirmationValue?: string;
isConfirming?: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
export function ConfirmActionDialog({
open,
title,
description,
impact,
recovery,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
intent = "danger",
confirmationLabel,
confirmationValue,
isConfirming = false,
onConfirm,
onClose,
}: ConfirmActionDialogProps) {
const [typedValue, setTypedValue] = useState("");
useEffect(() => {
if (open) {
setTypedValue("");
}
}, [open]);
if (!open) {
return null;
}
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
const confirmButtonClass =
intent === "danger"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-brand text-white hover:brightness-110";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
<div className="w-full max-w-xl rounded-[28px] border border-line/70 bg-surface p-5 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
{impact ? (
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
<span className="mt-1 block">{impact}</span>
</div>
) : null}
{recovery ? (
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
<span className="mt-1 block">{recovery}</span>
</div>
) : null}
{requiresTypedConfirmation ? (
<label className="mt-4 block">
<span className="mb-2 block text-sm font-semibold text-text">
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
</span>
<input
value={typedValue}
onChange={(event) => setTypedValue(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
autoFocus
/>
</label>
) : null}
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
disabled={isConfirming}
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => {
void onConfirm();
}}
disabled={isConfirmDisabled}
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
>
{isConfirming ? "Working..." : confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useAuth } from "../auth/AuthProvider";
import { api, ApiError } from "../lib/api";
import { ConfirmActionDialog } from "./ConfirmActionDialog";
interface FileAttachmentsPanelProps {
ownerType: string;
@@ -41,6 +42,7 @@ export function FileAttachmentsPanel({
const [status, setStatus] = useState("Loading attachments...");
const [isUploading, setIsUploading] = useState(false);
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
@@ -120,12 +122,13 @@ export function FileAttachmentsPanel({
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment deleted.");
setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
setStatus(message);
} finally {
setDeletingAttachmentId(null);
setAttachmentPendingDelete(null);
}
}
@@ -177,7 +180,7 @@ export function FileAttachmentsPanel({
{canWriteFiles ? (
<button
type="button"
onClick={() => handleDelete(attachment)}
onClick={() => setAttachmentPendingDelete(attachment)}
disabled={deletingAttachmentId === attachment.id}
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
>
@@ -189,6 +192,29 @@ export function FileAttachmentsPanel({
))}
</div>
)}
<ConfirmActionDialog
open={attachmentPendingDelete != null}
title="Delete attachment"
description={
attachmentPendingDelete
? `Delete ${attachmentPendingDelete.originalName} from this record.`
: "Delete this attachment."
}
impact="The file link will be removed from this record immediately."
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
confirmLabel="Delete file"
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
onClose={() => {
if (!deletingAttachmentId) {
setAttachmentPendingDelete(null);
}
}}
onConfirm={async () => {
if (attachmentPendingDelete) {
await handleDelete(attachmentPendingDelete);
}
}}
/>
</article>
);
}

View File

@@ -1,5 +1,6 @@
import type {
AdminDiagnosticsDto,
AdminAuthSessionDto,
BackupGuidanceDto,
AdminPermissionOptionDto,
AdminRoleDto,
@@ -15,6 +16,7 @@ import type {
PlanningTimelineDto,
LoginRequest,
LoginResponse,
LogoutResponse,
} from "@mrp/shared";
import type {
CrmContactDto,
@@ -138,6 +140,9 @@ export const api = {
me(token: string) {
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
},
logout(token: string) {
return request<LogoutResponse>("/api/v1/auth/logout", { method: "POST" }, token);
},
getAdminDiagnostics(token: string) {
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
},
@@ -165,6 +170,12 @@ export const api = {
getAdminUsers(token: string) {
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
},
getAdminSessions(token: string) {
return request<AdminAuthSessionDto[]>("/api/v1/admin/sessions", undefined, token);
},
revokeAdminSession(token: string, sessionId: string) {
return request<LogoutResponse>(`/api/v1/admin/sessions/${sessionId}/revoke`, { method: "POST" }, token);
},
createAdminUser(token: string, payload: AdminUserInput) {
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
},

View File

@@ -11,6 +11,7 @@ import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
import { InventoryStatusBadge } from "./InventoryStatusBadge";
@@ -48,6 +49,19 @@ export function InventoryDetailPage() {
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
const [isSavingReservation, setIsSavingReservation] = useState(false);
const [status, setStatus] = useState("Loading inventory item...");
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "transaction" | "transfer" | "reservation";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
@@ -100,8 +114,7 @@ export function InventoryDetailPage() {
setTransferForm((current) => ({ ...current, [key]: value }));
}
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
async function submitTransaction() {
if (!token || !itemId) {
return;
}
@@ -112,7 +125,7 @@ export function InventoryDetailPage() {
try {
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
setItem(nextItem);
setTransactionStatus("Stock transaction recorded.");
setTransactionStatus("Stock transaction recorded. If this was posted in error, create an offsetting stock entry and verify the result in Recent Movements.");
setTransactionForm((current) => ({
...emptyInventoryTransactionInput,
transactionType: current.transactionType,
@@ -127,8 +140,7 @@ export function InventoryDetailPage() {
}
}
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
async function submitTransfer() {
if (!token || !itemId) {
return;
}
@@ -139,7 +151,7 @@ export function InventoryDetailPage() {
try {
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
setItem(nextItem);
setTransferStatus("Transfer recorded.");
setTransferStatus("Transfer recorded. Review stock balances on both locations and post a return transfer if this movement was entered incorrectly.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
setTransferStatus(message);
@@ -148,8 +160,7 @@ export function InventoryDetailPage() {
}
}
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
async function submitReservation() {
if (!token || !itemId) {
return;
}
@@ -160,7 +171,7 @@ export function InventoryDetailPage() {
try {
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
setItem(nextItem);
setReservationStatus("Reservation recorded.");
setReservationStatus("Reservation recorded. Verify available stock and add a compensating reservation change if this demand hold was entered incorrectly.");
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
@@ -170,6 +181,64 @@ export function InventoryDetailPage() {
}
}
function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
const transactionLabel = inventoryTransactionOptions.find((option) => option.value === transactionForm.transactionType)?.label ?? "transaction";
setPendingConfirmation({
kind: "transaction",
title: `Post ${transactionLabel.toLowerCase()}`,
description: `Post a ${transactionLabel.toLowerCase()} of ${transactionForm.quantity} units for ${item.sku} at the selected stock location.`,
impact:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? "This reduces available inventory immediately and affects downstream shortage and readiness calculations."
: "This updates the stock ledger immediately and becomes part of the item transaction history.",
recovery: "If this is incorrect, post an explicit offsetting transaction instead of editing history.",
confirmLabel: `Post ${transactionLabel.toLowerCase()}`,
confirmationLabel:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? "Type item SKU to confirm:"
: undefined,
confirmationValue:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? item.sku
: undefined,
});
}
function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
setPendingConfirmation({
kind: "transfer",
title: "Post inventory transfer",
description: `Move ${transferForm.quantity} units of ${item.sku} between the selected source and destination locations.`,
impact: "This creates paired stock movement entries and changes both source and destination availability immediately.",
recovery: "If the move was entered incorrectly, post a reversing transfer back to the original location.",
confirmLabel: "Post transfer",
});
}
function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
setPendingConfirmation({
kind: "reservation",
title: "Create manual reservation",
description: `Reserve ${reservationForm.quantity} units of ${item.sku}${reservationForm.locationId ? " at the selected location" : ""}.`,
impact: "This reduces available quantity used by planning, purchasing, manufacturing, and readiness views.",
recovery: "Add the correcting reservation entry if this hold should be reduced or removed.",
confirmLabel: "Create reservation",
});
}
if (!item) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
@@ -530,6 +599,41 @@ export function InventoryDetailPage() {
</section>
<InventoryAttachmentsPanel itemId={item.id} />
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm inventory action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "transaction" && isSavingTransaction) ||
(pendingConfirmation?.kind === "transfer" && isSavingTransfer) ||
(pendingConfirmation?.kind === "reservation" && isSavingReservation)
}
onClose={() => {
if (!isSavingTransaction && !isSavingTransfer && !isSavingReservation) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "transaction") {
await submitTransaction();
} else if (pendingConfirmation.kind === "transfer") {
await submitTransfer();
} else {
await submitReservation();
}
setPendingConfirmation(null);
}}
/>
</section>
);
}

View File

@@ -7,6 +7,7 @@ import { Link, useParams } from "react-router-dom";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
@@ -21,6 +22,20 @@ export function WorkOrderDetailPage() {
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isPostingIssue, setIsPostingIssue] = useState(false);
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "issue" | "completion";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus?: WorkOrderStatus;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
@@ -56,7 +71,7 @@ export function WorkOrderDetailPage() {
[issueForm.warehouseId, locationOptions]
);
async function handleStatusChange(nextStatus: WorkOrderStatus) {
async function applyStatusChange(nextStatus: WorkOrderStatus) {
if (!token || !workOrder) {
return;
}
@@ -66,7 +81,7 @@ export function WorkOrderDetailPage() {
try {
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
setWorkOrder(nextWorkOrder);
setStatus("Work-order status updated.");
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
setStatus(message);
@@ -75,8 +90,7 @@ export function WorkOrderDetailPage() {
}
}
async function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
async function submitIssue() {
if (!token || !workOrder) {
return;
}
@@ -91,7 +105,7 @@ export function WorkOrderDetailPage() {
warehouseId: nextWorkOrder.warehouseId,
locationId: nextWorkOrder.locationId,
});
setStatus("Material issue posted.");
setStatus("Material issue posted. This consumed inventory immediately; post a correcting stock movement if the issue quantity was wrong.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
setStatus(message);
@@ -100,8 +114,7 @@ export function WorkOrderDetailPage() {
}
}
async function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
async function submitCompletion() {
if (!token || !workOrder) {
return;
}
@@ -115,7 +128,7 @@ export function WorkOrderDetailPage() {
...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
});
setStatus("Completion posted.");
setStatus("Completion posted. Finished-goods stock has been received; verify the remaining quantity and post a correcting transaction if this completion was overstated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
setStatus(message);
@@ -124,6 +137,64 @@ export function WorkOrderDetailPage() {
}
}
function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!workOrder) {
return;
}
const option = workOrderStatusOptions.find((entry) => entry.value === nextStatus);
setPendingConfirmation({
kind: "status",
title: `Change status to ${option?.label ?? nextStatus}`,
description: `Update work order ${workOrder.workOrderNumber} from ${workOrder.status} to ${nextStatus}.`,
impact:
nextStatus === "CANCELLED"
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
: nextStatus === "COMPLETE"
? "Completing the work order signals execution closure and can change readiness views across the system."
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
recovery: "If this status was selected in error, set the work order back to the correct state immediately after review.",
confirmLabel: `Set ${option?.label ?? nextStatus}`,
confirmationLabel: nextStatus === "CANCELLED" ? "Type work-order number to confirm:" : undefined,
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
nextStatus,
});
}
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!workOrder) {
return;
}
const component = workOrder.materialRequirements.find((requirement) => requirement.componentItemId === issueForm.componentItemId);
setPendingConfirmation({
kind: "issue",
title: "Post material issue",
description: `Issue ${issueForm.quantity} units of ${component?.componentSku ?? "the selected component"} to work order ${workOrder.workOrderNumber}.`,
impact: "This consumes component inventory immediately and updates work-order material history.",
recovery: "If the wrong quantity was issued, post a correcting stock transaction and note the reason on the work order.",
confirmLabel: "Post issue",
confirmationLabel: "Type work-order number to confirm:",
confirmationValue: workOrder.workOrderNumber,
});
}
function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!workOrder) {
return;
}
setPendingConfirmation({
kind: "completion",
title: "Post production completion",
description: `Receive ${completionForm.quantity} finished units into ${workOrder.warehouseCode} / ${workOrder.locationCode}.`,
impact: "This increases finished-goods inventory immediately and advances the execution history for this work order.",
recovery: "If the completion quantity is wrong, post the correcting inventory movement and verify the work-order remaining quantity.",
confirmLabel: "Post completion",
confirmationLabel: completionForm.quantity >= workOrder.dueQuantity ? "Type work-order number to confirm:" : undefined,
confirmationValue: completionForm.quantity >= workOrder.dueQuantity ? workOrder.workOrderNumber : undefined,
});
}
if (!workOrder) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
@@ -375,6 +446,41 @@ export function WorkOrderDetailPage() {
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm manufacturing action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
(pendingConfirmation?.kind === "completion" && isPostingCompletion)
}
onClose={() => {
if (!isUpdatingStatus && !isPostingIssue && !isPostingCompletion) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
await applyStatusChange(pendingConfirmation.nextStatus);
} else if (pendingConfirmation.kind === "issue") {
await submitIssue();
} else if (pendingConfirmation.kind === "completion") {
await submitCompletion();
}
setPendingConfirmation(null);
}}
/>
</section>
);
}

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>
);
}