From 759ce0611d0fab183b6f9ded8da654d341bca467 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:21:43 +0100 Subject: [PATCH] feat: page verification workflow --- .../public/locales/en-US/translation.json | 81 ++- apps/client/src/App.tsx | 2 + .../components/settings/settings-queries.tsx | 9 + .../components/settings/settings-sidebar.tsx | 14 + .../components/expiration-fields.tsx | 232 +++++++ .../components/manage-verification-form.tsx | 632 ++++++++++++++++++ .../page-verification-modal.module.css | 278 ++++++++ .../components/page-verification-modal.tsx | 155 +++++ .../components/setup-verification-form.tsx | 325 +++++++++ .../components/user-option.tsx | 43 ++ .../components/verification-list-table.tsx | 170 +++++ .../components/verification-status.ts | 43 ++ .../components/verifier-list.tsx | 70 ++ .../components/verifier-picker.tsx | 65 ++ apps/client/src/ee/page-verification/index.ts | 5 + .../pages/verified-pages.tsx | 131 ++++ .../queries/page-verification-query.ts | 202 ++++++ .../services/page-verification-service.ts | 61 ++ .../types/page-verification.types.ts | 104 +++ .../src/features/editor/full-editor.tsx | 117 +++- .../components/notification-item.tsx | 20 +- .../notification/types/notification.types.ts | 7 +- .../components/header/page-header-menu.tsx | 18 + .../src/features/page/types/page.types.ts | 7 + .../src/features/websocket/types/types.ts | 8 +- .../websocket/use-query-subscription.ts | 5 + apps/client/src/pages/page/page.tsx | 2 + apps/server/src/app.module.ts | 2 + apps/server/src/common/events/audit-events.ts | 8 + apps/server/src/core/core.module.ts | 11 - .../notification/notification.constants.ts | 5 + .../core/notification/notification.module.ts | 2 + .../notification/notification.processor.ts | 53 +- .../services/verification.notification.ts | 221 ++++++ .../20260419T121647-page-verifications.ts | 97 +++ apps/server/src/database/types/db.d.ts | 36 + .../server/src/database/types/entity.types.ts | 11 + apps/server/src/ee | 2 +- .../src/integrations/audit/audit.module.ts | 14 + .../queue/constants/queue.constants.ts | 5 + .../queue/constants/queue.interface.ts | 43 ++ .../emails/approval-rejected-email.tsx | 50 ++ .../emails/approval-requested-email.tsx | 43 ++ .../emails/verification-expired-email.tsx | 38 ++ .../emails/verification-expiring-email.tsx | 43 ++ 45 files changed, 3470 insertions(+), 20 deletions(-) create mode 100644 apps/client/src/ee/page-verification/components/expiration-fields.tsx create mode 100644 apps/client/src/ee/page-verification/components/manage-verification-form.tsx create mode 100644 apps/client/src/ee/page-verification/components/page-verification-modal.module.css create mode 100644 apps/client/src/ee/page-verification/components/page-verification-modal.tsx create mode 100644 apps/client/src/ee/page-verification/components/setup-verification-form.tsx create mode 100644 apps/client/src/ee/page-verification/components/user-option.tsx create mode 100644 apps/client/src/ee/page-verification/components/verification-list-table.tsx create mode 100644 apps/client/src/ee/page-verification/components/verification-status.ts create mode 100644 apps/client/src/ee/page-verification/components/verifier-list.tsx create mode 100644 apps/client/src/ee/page-verification/components/verifier-picker.tsx create mode 100644 apps/client/src/ee/page-verification/index.ts create mode 100644 apps/client/src/ee/page-verification/pages/verified-pages.tsx create mode 100644 apps/client/src/ee/page-verification/queries/page-verification-query.ts create mode 100644 apps/client/src/ee/page-verification/services/page-verification-service.ts create mode 100644 apps/client/src/ee/page-verification/types/page-verification.types.ts create mode 100644 apps/server/src/core/notification/services/verification.notification.ts create mode 100644 apps/server/src/database/migrations/20260419T121647-page-verifications.ts create mode 100644 apps/server/src/integrations/audit/audit.module.ts create mode 100644 apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx create mode 100644 apps/server/src/integrations/transactional/emails/approval-requested-email.tsx create mode 100644 apps/server/src/integrations/transactional/emails/verification-expired-email.tsx create mode 100644 apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index cd2b7559..bafc5aca 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -693,5 +693,84 @@ "Failed to update trash retention": "Failed to update trash retention", "Removed page restriction": "Removed page restriction", "Added page permission": "Added page permission", - "Removed page permission": "Removed page permission" + "Removed page permission": "Removed page permission", + "day": "day", + "days": "days", + "week": "week", + "weeks": "weeks", + "month": "month", + "months": "months", + "year": "year", + "years": "years", + "Period": "Period", + "Fixed date": "Fixed date", + "Indefinitely": "Indefinitely", + "Days": "Days", + "Weeks": "Weeks", + "Months": "Months", + "Years": "Years", + "Pick a date": "Pick a date", + "Maximum is {{max}} {{unit}} for this unit": "Maximum is {{max}} {{unit}} for this unit", + "Never expires. Verifiers can re-verify at any time.": "Never expires. Verifiers can re-verify at any time.", + "Verified": "Verified", + "Review needed": "Review needed", + "Verification expired": "Verification expired", + "Draft": "Draft", + "In Approval": "In Approval", + "In approval": "In approval", + "Approved": "Approved", + "Obsolete": "Obsolete", + "Expiring": "Expiring", + "Set up verification": "Set up verification", + "Verify page": "Verify page", + "Page verification": "Page verification", + "Choose how this page should stay accurate.": "Choose how this page should stay accurate.", + "Recurring verification": "Recurring verification", + "Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.", + "Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g every 30 days )", + "Page stays editable at all times": "Page stays editable at all times", + "Best for runbooks, FAQs, living documentation": "Best for runbooks, FAQs, living documentation", + "Approval workflow": "Approval workflow", + "Formal document lifecycle with named approvers.": "Formal document lifecycle with named approvers.", + "Draft → In approval → Approved → Obsolete": "Draft → In approval → Approved → Obsolete", + "Locked once approved, with full history": "Locked once approved, with full history", + "Designed for ISO 9001, ISO 13485, and FDA": "Designed for ISO 9001, ISO 13485, and FDA", + "Best for SOPs and controlled documents": "Best for SOPs and controlled documents", + "Back": "Back", + "Quality management": "Quality management", + "Recurring": "Recurring", + "Pages move through draft, approval, and approved stages.": "Pages move through draft, approval, and approved stages.", + "Verifiers": "Verifiers", + "Add verifier": "Add verifier", + "I've reviewed this page for accuracy": "I've reviewed this page for accuracy", + "Set up": "Set up", + "Remove verification": "Remove verification", + "Are you sure you want to remove verification from this page?": "Are you sure you want to remove verification from this page?", + "Assigned verifiers must periodically re-verify this page.": "Assigned verifiers must periodically re-verify this page.", + "Last verified by {{name}} {{time}} (expired)": "Last verified by {{name}} {{time}} (expired)", + "The fixed expiration date has passed.": "The fixed expiration date has passed.", + "Verified by {{name}} {{time}}": "Verified by {{name}} {{time}}", + "Expires {{date}}": "Expires {{date}}", + "Expired {{date}}": "Expired {{date}}", + "Mark as obsolete": "Mark as obsolete", + "Mark obsolete": "Mark obsolete", + "Returned by {{name}} {{time}}": "Returned by {{name}} {{time}}", + "No approval has been requested yet.": "No approval has been requested yet.", + "Submitted by {{name}} {{time}}": "Submitted by {{name}} {{time}}", + "Someone": "Someone", + "Approved by {{name}} {{time}}": "Approved by {{name}} {{time}}", + "This document has been marked as obsolete.": "This document has been marked as obsolete.", + "Rejection comment": "Rejection comment", + "Reason for returning this document...": "Reason for returning this document...", + "Confirm rejection": "Confirm rejection", + "Submit for approval": "Submit for approval", + "Reject": "Reject", + "Approve": "Approve", + "Re-submit for approval": "Re-submit for approval", + "Verified until": "Verified until", + "QMS": "QMS", + "Verified pages": "Verified pages", + "Search pages...": "Search pages...", + "Filter by space": "Filter by space", + "Filter by type": "Filter by type" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c290157c..29a4ddff 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; +import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; export default function App() { const { t } = useTranslation(); @@ -105,6 +106,7 @@ export default function App() { } /> } /> } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index d15bbfa2..e9562a85 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -12,6 +12,7 @@ import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getShares } from "@/features/share/services/share-service.ts"; import { getApiKeys } from "@/ee/api-key"; import { getAuditLogs } from "@/ee/audit/services/audit-service"; +import { getVerificationList } from "@/ee/page-verification/services/page-verification-service"; export const prefetchWorkspaceMembers = () => { const params: QueryParams = { limit: 100, query: "" }; @@ -89,3 +90,11 @@ export const prefetchAuditLogs = () => { queryFn: () => getAuditLogs(params), }); }; + +export const prefetchVerifiedPages = () => { + const params = { limit: 50 }; + queryClient.prefetchQuery({ + queryKey: ["verification-list", params], + queryFn: () => getVerificationList(params), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 6ac3587f..ec30b78a 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -14,6 +14,7 @@ import { IconWorld, IconSparkles, IconHistory, + IconShieldCheck, } from "@tabler/icons-react"; import { Link, useLocation } from "react-router-dom"; import classes from "./settings.module.css"; @@ -33,6 +34,7 @@ import { prefetchSsoProviders, prefetchWorkspaceMembers, prefetchAuditLogs, + prefetchVerifiedPages, } from "@/components/settings/settings-queries.tsx"; import AppVersion from "@/components/settings/app-version.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; @@ -128,6 +130,15 @@ const groupedData: DataGroup[] = [ isSelfhosted: true, showDisabledInNonEE: true, }, + { + label: "Verified pages", + icon: IconShieldCheck, + path: "/settings/verifications", + isCloud: true, + isEnterprise: true, + isAdmin: true, + showDisabledInNonEE: true, + }, ], }, { @@ -244,6 +255,9 @@ export default function SettingsSidebar() { case "Audit log": prefetchHandler = prefetchAuditLogs; break; + case "Verified pages": + prefetchHandler = prefetchVerifiedPages; + break; default: break; } diff --git a/apps/client/src/ee/page-verification/components/expiration-fields.tsx b/apps/client/src/ee/page-verification/components/expiration-fields.tsx new file mode 100644 index 00000000..9dc7a96f --- /dev/null +++ b/apps/client/src/ee/page-verification/components/expiration-fields.tsx @@ -0,0 +1,232 @@ +import { Group, NumberInput, Select, Text } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { useTranslation } from "react-i18next"; +import { + ExpirationMode, + PeriodUnit, +} from "@/ee/page-verification/types/page-verification.types"; + +export const PERIOD_UNIT_DAYS: Record = { + day: 1, + week: 7, + month: 30, + year: 365, +}; + +export const PERIOD_UNIT_MAX_AMOUNT: Record = { + day: 3650, + week: 520, + month: 120, + year: 20, +}; + +export const PERIOD_AMOUNT_MIN = 1; + +export function addDays(days: number, from?: Date): Date { + const date = from ? new Date(from) : new Date(); + date.setDate(date.getDate() + days); + return date; +} + +function formatShortDate(date: Date): string { + const crossesYear = date.getFullYear() !== new Date().getFullYear(); + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + ...(crossesYear && { year: "numeric" }), + }); +} + +function formatLongDate(date: Date): string { + return date.toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }); +} + +export function toLocalDateString(input: Date | string): string { + const d = typeof input === "string" ? new Date(input) : input; + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function pluralizeUnit( + unit: PeriodUnit, + amount: number, + t: (key: string) => string, +): string { + switch (unit) { + case "day": + return amount === 1 ? t("day") : t("days"); + case "week": + return amount === 1 ? t("week") : t("weeks"); + case "month": + return amount === 1 ? t("month") : t("months"); + case "year": + return amount === 1 ? t("year") : t("years"); + } +} + +function buildModeOptions( + t: (key: string) => string, +): { value: ExpirationMode; label: string }[] { + return [ + { value: "period", label: t("Period") }, + { value: "fixed", label: t("Fixed date") }, + { value: "indefinite", label: t("Indefinitely") }, + ]; +} + +function buildUnitOptions( + t: (key: string) => string, +): { value: PeriodUnit; label: string }[] { + return [ + { value: "day", label: t("Days") }, + { value: "week", label: t("Weeks") }, + { value: "month", label: t("Months") }, + { value: "year", label: t("Years") }, + ]; +} + +type ExpirationFieldsProps = { + mode: ExpirationMode; + periodAmount: number; + periodUnit: PeriodUnit; + fixedDate: string; + onModeChange: (mode: ExpirationMode) => void; + onPeriodAmountChange: (amount: number) => void; + onPeriodUnitChange: (unit: PeriodUnit) => void; + onFixedDateChange: (iso: string) => void; + baseDate?: Date; +}; + +export function ExpirationFields({ + mode, + periodAmount, + periodUnit, + fixedDate, + onModeChange, + onPeriodAmountChange, + onPeriodUnitChange, + onFixedDateChange, + baseDate, +}: ExpirationFieldsProps) { + const { t } = useTranslation(); + const modeOptions = buildModeOptions(t); + const unitOptions = buildUnitOptions(t); + + const unitMax = PERIOD_UNIT_MAX_AMOUNT[periodUnit]; + + const handleUnitChange = (nextUnit: PeriodUnit) => { + const nextMax = PERIOD_UNIT_MAX_AMOUNT[nextUnit]; + if (periodAmount > nextMax) { + onPeriodAmountChange(nextMax); + } + onPeriodUnitChange(nextUnit); + }; + + const amountValid = + Number.isInteger(periodAmount) && + periodAmount >= PERIOD_AMOUNT_MIN && + periodAmount <= unitMax; + + const nextDueDate = + mode === "period" && amountValid + ? addDays(periodAmount * PERIOD_UNIT_DAYS[periodUnit], baseDate) + : null; + + const fixedDateObj = fixedDate ? new Date(fixedDate) : null; + + let helperText: string | null = null; + let helperError = false; + if (mode === "period" && !amountValid) { + helperText = t("Maximum is {{max}} {{unit}} for this unit", { + max: unitMax, + unit: pluralizeUnit(periodUnit, unitMax, t), + }); + helperError = true; + } else if (mode === "period" && nextDueDate && amountValid) { + helperText = t( + "Re-verifies every {{amount}} {{unit}} · Next due {{date}}", + { + amount: periodAmount, + unit: pluralizeUnit(periodUnit, periodAmount, t), + date: formatShortDate(nextDueDate), + }, + ); + } else if (mode === "fixed" && fixedDateObj) { + helperText = t( + "Expires on {{date}}. Re-verifying won't change the deadline.", + { date: formatLongDate(fixedDateObj) }, + ); + } else if (mode === "indefinite") { + helperText = t("Never expires. Verifiers can re-verify at any time."); + } + + return ( +
+ + val && handleUnitChange(val as PeriodUnit)} + variant="filled" + allowDeselect={false} + style={{ flex: 1, minWidth: 120 }} + /> + + )} + + {mode === "fixed" && ( + onFixedDateChange(val ?? "")} + placeholder={t("Pick a date")} + variant="filled" + minDate={addDays(1)} + clearable + style={{ flex: "1 1 200px", minWidth: 180 }} + /> + )} + + + {helperText && ( + + {helperText} + + )} +
+ ); +} diff --git a/apps/client/src/ee/page-verification/components/manage-verification-form.tsx b/apps/client/src/ee/page-verification/components/manage-verification-form.tsx new file mode 100644 index 00000000..69ab5e5b --- /dev/null +++ b/apps/client/src/ee/page-verification/components/manage-verification-form.tsx @@ -0,0 +1,632 @@ +import { useState } from "react"; +import { + Button, + Center, + Checkbox, + Divider, + Group, + Loader, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useTranslation } from "react-i18next"; +import { + useMarkObsoleteMutation, + usePageVerificationInfoQuery, + useRejectApprovalMutation, + useRemoveVerificationMutation, + useSubmitForApprovalMutation, + useUpdateVerificationMutation, + useVerifyPageMutation, +} from "@/ee/page-verification/queries/page-verification-query"; +import { + ExpirationMode, + IPageVerificationInfo, + PeriodUnit, +} from "@/ee/page-verification/types/page-verification.types"; +import { useTimeAgo } from "@/hooks/use-time-ago"; +import { VerifierList } from "./verifier-list"; +import { + ExpirationFields, + PERIOD_AMOUNT_MIN, + PERIOD_UNIT_MAX_AMOUNT, + toLocalDateString, +} from "./expiration-fields"; +import { VerifierPicker } from "./verifier-picker"; +import { MAX_VERIFIERS } from "./user-option"; + +type ManageVerificationFormProps = { + pageId: string; + onClose: () => void; +}; + +export function ManageVerificationForm({ + pageId, + onClose, +}: ManageVerificationFormProps) { + const { data: info, isLoading } = usePageVerificationInfoQuery(pageId); + + if (isLoading || !info) { + return ( +
+ +
+ ); + } + + if (info.type === "qms") { + return ; + } + + return ( + + ); +} + +type ManageContentProps = { + pageId: string; + info: IPageVerificationInfo; + onClose: () => void; +}; + +function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) { + const { t } = useTranslation(); + const verifyMutation = useVerifyPageMutation(); + const removeMutation = useRemoveVerificationMutation(); + const updateMutation = useUpdateVerificationMutation(); + const [confirmed, setConfirmed] = useState(false); + + const initialMode: ExpirationMode = (info.mode as ExpirationMode) ?? "period"; + const initialPeriodAmount = info.periodAmount ?? 1; + const initialPeriodUnit: PeriodUnit = + (info.periodUnit as PeriodUnit) ?? "month"; + const initialFixedDate = + initialMode === "fixed" && info.expiresAt + ? toLocalDateString(info.expiresAt) + : ""; + + const [mode, setMode] = useState(initialMode); + const [periodAmount, setPeriodAmount] = useState(initialPeriodAmount); + const [periodUnit, setPeriodUnit] = useState(initialPeriodUnit); + const [fixedDate, setFixedDate] = useState(initialFixedDate); + + const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString()); + + const hasExpirationChange = + mode !== initialMode || + (mode === "period" && + (periodAmount !== initialPeriodAmount || + periodUnit !== initialPeriodUnit)) || + (mode === "fixed" && fixedDate !== initialFixedDate); + + const periodValid = + mode !== "period" || + (Number.isInteger(periodAmount) && + periodAmount >= PERIOD_AMOUNT_MIN && + periodAmount <= PERIOD_UNIT_MAX_AMOUNT[periodUnit]); + const fixedDateValid = + mode !== "fixed" || + (!!fixedDate && new Date(fixedDate).getTime() > Date.now()); + const canSaveExpiration = hasExpirationChange && periodValid && fixedDateValid; + + const storedFixedExpired = + info.mode === "fixed" && + !!info.expiresAt && + new Date(info.expiresAt).getTime() <= Date.now(); + + const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? []; + + const handleVerify = () => { + verifyMutation.mutate(pageId, { + onSuccess: () => { + setConfirmed(false); + onClose(); + }, + }); + }; + + const handleRemove = () => { + modals.openConfirmModal({ + title: t("Remove verification"), + children: ( + + {t("Are you sure you want to remove verification from this page?")} + + ), + labels: { confirm: t("Remove"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }), + }); + }; + + const handleSaveExpiration = () => { + if (!canSaveExpiration) return; + updateMutation.mutate({ + pageId, + mode, + ...(mode === "period" && { + periodAmount, + periodUnit, + }), + ...(mode === "fixed" && + fixedDate && { + fixedExpiresAt: new Date(fixedDate).toISOString(), + }), + }); + }; + + const handleRemoveVerifier = (userId: string) => { + if (!info.verifiers) return; + const remaining = info.verifiers + .filter((v) => v.id !== userId) + .map((v) => v.id); + updateMutation.mutate({ pageId, verifierIds: remaining }); + }; + + const handleAddVerifier = (userId: string) => { + if (!info.verifiers) return; + if (info.verifiers.some((v) => v.id === userId)) return; + const verifierIds = [...info.verifiers.map((v) => v.id), userId]; + updateMutation.mutate({ pageId, verifierIds }); + }; + + const status = info.status; + + return ( + + + {t("Assigned verifiers must periodically re-verify this page.")} + + + {info.verifiedBy && ( + +
+ + {status === "expired" + ? t("Last verified by {{name}} {{time}} (expired)", { + name: info.verifiedBy.name, + time: verifiedAtAgo, + }) + : t("Verified by {{name}} {{time}}", { + name: info.verifiedBy.name, + time: verifiedAtAgo, + })} + + {info.expiresAt && ( + + {t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", { + date: new Date(info.expiresAt).toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }), + })} + + )} +
+
+ )} + + + + {info.verifiers && info.verifiers.length > 0 && ( + <> +
+ + {t("Verifiers")} + + + {info.permissions?.canManage && + info.verifiers.length < MAX_VERIFIERS && ( +
+ handleAddVerifier(user.value)} + /> +
+ )} +
+ + + )} + + {info.permissions?.canManage && ( + <> +
+ + {t("Expiration")} + + + {hasExpirationChange && ( + + )} +
+ + + )} + + {info.permissions?.canVerify && ( +
+ + {t("Confirm")} + + setConfirmed(event.currentTarget.checked)} + color="dark" + /> + {storedFixedExpired && ( + + {t("The fixed expiration date has passed.")} + + )} +
+ )} + + + {info.permissions?.canManage && ( + + )} + + {info.permissions?.canVerify && ( + + )} + +
+ ); +} + +function QmsManageContent({ pageId, info, onClose }: ManageContentProps) { + const { t } = useTranslation(); + const verifyMutation = useVerifyPageMutation(); + const submitMutation = useSubmitForApprovalMutation(); + const rejectMutation = useRejectApprovalMutation(); + const obsoleteMutation = useMarkObsoleteMutation(); + const removeMutation = useRemoveVerificationMutation(); + const updateMutation = useUpdateVerificationMutation(); + const [confirmed, setConfirmed] = useState(false); + const [showRejectForm, setShowRejectForm] = useState(false); + const [rejectComment, setRejectComment] = useState(""); + const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString()); + const requestedAtAgo = useTimeAgo( + info.requestedAt ?? new Date().toISOString(), + ); + const rejectedAtAgo = useTimeAgo(info.rejectedAt ?? new Date().toISOString()); + + const status = info.status; + + const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? []; + + const handleSubmitForApproval = () => { + submitMutation.mutate(pageId, { onSuccess: onClose }); + }; + + const handleVerify = () => { + verifyMutation.mutate(pageId, { + onSuccess: () => { + setConfirmed(false); + onClose(); + }, + }); + }; + + const handleReject = () => { + rejectMutation.mutate( + { pageId, comment: rejectComment || undefined }, + { + onSuccess: () => { + setShowRejectForm(false); + setRejectComment(""); + onClose(); + }, + }, + ); + }; + + const handleMarkObsolete = () => { + modals.openConfirmModal({ + title: t("Mark as obsolete"), + children: ( + + + {t( + "Are you sure you want to mark this page as obsolete? This action cannot be undone.", + )} + + + {t( + "To restore this page, you will need to remove verification and set it up again.", + )} + + + ), + labels: { confirm: t("Mark obsolete"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => + obsoleteMutation.mutate(pageId, { onSuccess: onClose }), + }); + }; + + const handleRemove = () => { + modals.openConfirmModal({ + title: t("Remove verification"), + children: ( + + {t("Are you sure you want to remove verification from this page?")} + + ), + labels: { confirm: t("Remove"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }), + }); + }; + + const handleRemoveVerifier = (userId: string) => { + if (!info.verifiers) return; + const remaining = info.verifiers + .filter((v) => v.id !== userId) + .map((v) => v.id); + updateMutation.mutate({ pageId, verifierIds: remaining }); + }; + + const handleAddVerifier = (userId: string) => { + if (!info.verifiers) return; + if (info.verifiers.some((v) => v.id === userId)) return; + const verifierIds = [...info.verifiers.map((v) => v.id), userId]; + updateMutation.mutate({ pageId, verifierIds }); + }; + + const canManageVerifiers = + info.permissions?.canManage && status !== "obsolete"; + + return ( + + + {t("Pages move through draft, approval, and approved stages.")} + + + {status === "draft" && ( + <> + {info.rejectedBy && info.rejectedAt && ( +
+ + {t("Returned by {{name}} {{time}}", { + name: info.rejectedBy.name, + time: rejectedAtAgo, + })} + + {info.rejectionComment && ( + + “{info.rejectionComment}” + + )} +
+ )} + {!info.rejectedBy && ( + {t("No approval has been requested yet.")} + )} + + )} + + {status === "in_approval" && ( +
+ + {t("Submitted by {{name}} {{time}}", { + name: info.requestedBy?.name ?? t("Someone"), + time: requestedAtAgo, + })} + +
+ )} + + {status === "approved" && info.verifiedBy && ( +
+ + {t("Approved by {{name}} {{time}}", { + name: info.verifiedBy.name, + time: verifiedAtAgo, + })} + +
+ )} + + {status === "obsolete" && ( + + {t("This document has been marked as obsolete.")} + + )} + + + + {info.verifiers && info.verifiers.length > 0 && ( + <> +
+ + {t("Verifiers")} + + + {canManageVerifiers && info.verifiers.length < MAX_VERIFIERS && ( +
+ handleAddVerifier(user.value)} + /> +
+ )} +
+ + + )} + + {status === "in_approval" && info.permissions?.canVerify && ( + <> + {showRejectForm ? ( +
+ + {t("Rejection comment")} + +