mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:14:35 +08:00
feat: page verification workflow
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<PeriodUnit, number> = {
|
||||
day: 1,
|
||||
week: 7,
|
||||
month: 30,
|
||||
year: 365,
|
||||
};
|
||||
|
||||
export const PERIOD_UNIT_MAX_AMOUNT: Record<PeriodUnit, number> = {
|
||||
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 (
|
||||
<div>
|
||||
<Group align="flex-start" gap="xs" wrap="wrap">
|
||||
<Select
|
||||
data={modeOptions}
|
||||
value={mode}
|
||||
onChange={(val) => val && onModeChange(val as ExpirationMode)}
|
||||
variant="filled"
|
||||
allowDeselect={false}
|
||||
style={{ flex: "1 1 140px", minWidth: 140 }}
|
||||
/>
|
||||
|
||||
{mode === "period" && (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
style={{ flex: "1 1 220px", minWidth: 220 }}
|
||||
>
|
||||
<NumberInput
|
||||
value={periodAmount}
|
||||
onChange={(val) => {
|
||||
const n =
|
||||
typeof val === "number" ? val : parseInt(String(val), 10);
|
||||
if (!Number.isNaN(n)) onPeriodAmountChange(n);
|
||||
}}
|
||||
min={PERIOD_AMOUNT_MIN}
|
||||
max={unitMax}
|
||||
clampBehavior="blur"
|
||||
variant="filled"
|
||||
style={{ flex: "0 0 80px" }}
|
||||
hideControls
|
||||
/>
|
||||
<Select
|
||||
data={unitOptions}
|
||||
value={periodUnit}
|
||||
onChange={(val) => val && handleUnitChange(val as PeriodUnit)}
|
||||
variant="filled"
|
||||
allowDeselect={false}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{mode === "fixed" && (
|
||||
<DateInput
|
||||
value={fixedDate || undefined}
|
||||
onChange={(val) => onFixedDateChange(val ?? "")}
|
||||
placeholder={t("Pick a date")}
|
||||
variant="filled"
|
||||
minDate={addDays(1)}
|
||||
clearable
|
||||
style={{ flex: "1 1 200px", minWidth: 180 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{helperText && (
|
||||
<Text size="xs" c={helperError ? "red" : "dimmed"} mt={6}>
|
||||
{helperText}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (info.type === "qms") {
|
||||
return <QmsManageContent pageId={pageId} info={info} onClose={onClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpiringManageContent pageId={pageId} info={info} onClose={onClose} />
|
||||
);
|
||||
}
|
||||
|
||||
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<ExpirationMode>(initialMode);
|
||||
const [periodAmount, setPeriodAmount] = useState<number>(initialPeriodAmount);
|
||||
const [periodUnit, setPeriodUnit] = useState<PeriodUnit>(initialPeriodUnit);
|
||||
const [fixedDate, setFixedDate] = useState<string>(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: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to remove verification from this page?")}
|
||||
</Text>
|
||||
),
|
||||
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 (
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Assigned verifiers must periodically re-verify this page.")}
|
||||
</Text>
|
||||
|
||||
{info.verifiedBy && (
|
||||
<Group gap="sm">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{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,
|
||||
})}
|
||||
</Text>
|
||||
{info.expiresAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
|
||||
date: new Date(info.expiresAt).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{info.verifiers && info.verifiers.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
||||
{t("Verifiers")}
|
||||
</Text>
|
||||
<VerifierList
|
||||
verifiers={info.verifiers}
|
||||
canManage={info.permissions?.canManage}
|
||||
onRemove={
|
||||
info.permissions?.canManage ? handleRemoveVerifier : undefined
|
||||
}
|
||||
/>
|
||||
{info.permissions?.canManage &&
|
||||
info.verifiers.length < MAX_VERIFIERS && (
|
||||
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
|
||||
<VerifierPicker
|
||||
excludeIds={existingVerifierIds}
|
||||
onSelect={(user) => handleAddVerifier(user.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{info.permissions?.canManage && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("Expiration")}
|
||||
</Text>
|
||||
<ExpirationFields
|
||||
mode={mode}
|
||||
periodAmount={periodAmount}
|
||||
periodUnit={periodUnit}
|
||||
fixedDate={fixedDate}
|
||||
onModeChange={setMode}
|
||||
onPeriodAmountChange={setPeriodAmount}
|
||||
onPeriodUnitChange={setPeriodUnit}
|
||||
onFixedDateChange={setFixedDate}
|
||||
baseDate={
|
||||
info.verifiedAt ? new Date(info.verifiedAt) : undefined
|
||||
}
|
||||
/>
|
||||
{hasExpirationChange && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
mt="xs"
|
||||
color="dark"
|
||||
onClick={handleSaveExpiration}
|
||||
loading={updateMutation.isPending}
|
||||
disabled={!canSaveExpiration}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{info.permissions?.canVerify && (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("Confirm")}
|
||||
</Text>
|
||||
<Checkbox
|
||||
label={t("I've reviewed this page for accuracy")}
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
||||
color="dark"
|
||||
/>
|
||||
{storedFixedExpired && (
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{t("The fixed expiration date has passed.")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
{info.permissions?.canManage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={handleRemove}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
{t("Remove verification")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{info.permissions?.canVerify && (
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
disabled={!confirmed || storedFixedExpired}
|
||||
loading={verifyMutation.isPending}
|
||||
color={status === "expired" ? "red" : "dark"}
|
||||
ml="auto"
|
||||
>
|
||||
{t("Verify")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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: (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to mark this page as obsolete? This action cannot be undone.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"To restore this page, you will need to remove verification and set it up again.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
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: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to remove verification from this page?")}
|
||||
</Text>
|
||||
),
|
||||
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 (
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Pages move through draft, approval, and approved stages.")}
|
||||
</Text>
|
||||
|
||||
{status === "draft" && (
|
||||
<>
|
||||
{info.rejectedBy && info.rejectedAt && (
|
||||
<div>
|
||||
<Text size="sm" c="red">
|
||||
{t("Returned by {{name}} {{time}}", {
|
||||
name: info.rejectedBy.name,
|
||||
time: rejectedAtAgo,
|
||||
})}
|
||||
</Text>
|
||||
{info.rejectionComment && (
|
||||
<Text size="sm" c="dimmed" mt={4} fs="italic">
|
||||
“{info.rejectionComment}”
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!info.rejectedBy && (
|
||||
<Text size="sm">{t("No approval has been requested yet.")}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "in_approval" && (
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{t("Submitted by {{name}} {{time}}", {
|
||||
name: info.requestedBy?.name ?? t("Someone"),
|
||||
time: requestedAtAgo,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "approved" && info.verifiedBy && (
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{t("Approved by {{name}} {{time}}", {
|
||||
name: info.verifiedBy.name,
|
||||
time: verifiedAtAgo,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "obsolete" && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("This document has been marked as obsolete.")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{info.verifiers && info.verifiers.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
||||
{t("Verifiers")}
|
||||
</Text>
|
||||
<VerifierList
|
||||
verifiers={info.verifiers}
|
||||
canManage={canManageVerifiers}
|
||||
onRemove={canManageVerifiers ? handleRemoveVerifier : undefined}
|
||||
/>
|
||||
{canManageVerifiers && info.verifiers.length < MAX_VERIFIERS && (
|
||||
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
|
||||
<VerifierPicker
|
||||
excludeIds={existingVerifierIds}
|
||||
onSelect={(user) => handleAddVerifier(user.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "in_approval" && info.permissions?.canVerify && (
|
||||
<>
|
||||
{showRejectForm ? (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("Rejection comment")}
|
||||
</Text>
|
||||
<Textarea
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.currentTarget.value)}
|
||||
placeholder={t("Reason for returning this document...")}
|
||||
minRows={2}
|
||||
variant="filled"
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm" gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
onClick={() => {
|
||||
setShowRejectForm(false);
|
||||
setRejectComment("");
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleReject}
|
||||
loading={rejectMutation.isPending}
|
||||
>
|
||||
{t("Confirm rejection")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Checkbox
|
||||
label={t("I've reviewed this page for accuracy")}
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
||||
color="dark"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
{info.permissions?.canManage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={handleRemove}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
{t("Remove verification")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Group gap="xs" ml="auto">
|
||||
{status === "draft" && info.permissions?.canSubmitForApproval && (
|
||||
<Button
|
||||
onClick={handleSubmitForApproval}
|
||||
loading={submitMutation.isPending}
|
||||
color="dark"
|
||||
>
|
||||
{t("Submit for approval")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === "in_approval" &&
|
||||
info.permissions?.canVerify &&
|
||||
!showRejectForm && (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => setShowRejectForm(true)}
|
||||
>
|
||||
{t("Reject")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
disabled={!confirmed}
|
||||
loading={verifyMutation.isPending}
|
||||
color="dark"
|
||||
>
|
||||
{t("Approve")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "approved" && (
|
||||
<>
|
||||
{info.permissions?.canSubmitForApproval && (
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={handleSubmitForApproval}
|
||||
loading={submitMutation.isPending}
|
||||
>
|
||||
{t("Re-submit for approval")}
|
||||
</Button>
|
||||
)}
|
||||
{info.permissions?.canMarkObsolete && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={handleMarkObsolete}
|
||||
loading={obsoleteMutation.isPending}
|
||||
>
|
||||
{t("Mark obsolete")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
.chooser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
margin-bottom: 2px;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px 16px 12px;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: 10px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-7)
|
||||
);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
120% 90% at 100% 0%,
|
||||
light-dark(rgba(15, 15, 20, 0.035), rgba(255, 255, 255, 0.04)),
|
||||
transparent 55%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-3)
|
||||
);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 1px 0 0
|
||||
light-dark(
|
||||
rgba(15, 15, 20, 0.04),
|
||||
rgba(255, 255, 255, 0.04)
|
||||
),
|
||||
0 18px 36px -22px
|
||||
light-dark(rgba(15, 15, 20, 0.22), rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card:focus-visible {
|
||||
outline: none;
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-3)
|
||||
);
|
||||
box-shadow: 0 0 0 3px
|
||||
light-dark(
|
||||
rgba(15, 15, 20, 0.08),
|
||||
rgba(255, 255, 255, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.iconStamp {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-7),
|
||||
var(--mantine-color-gray-2)
|
||||
);
|
||||
transition:
|
||||
background-color 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card:hover .iconStamp {
|
||||
background: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-1)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-9)
|
||||
);
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-0)
|
||||
);
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.rule {
|
||||
height: 1px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
margin: 10px 0 8px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.metaIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bestFor {
|
||||
font-size: 10.5px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
transition:
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.card:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-0)
|
||||
);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
margin-left: -8px;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
color: light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-gray-0)
|
||||
);
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
.configureHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.configureEyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconRosetteDiscountCheckFilled,
|
||||
IconShieldCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { usePageVerificationInfoQuery } from "@/ee/page-verification/queries/page-verification-query";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
|
||||
import { SetupVerificationForm } from "./setup-verification-form";
|
||||
import { ManageVerificationForm } from "./manage-verification-form";
|
||||
import { getStatusColor, getStatusLabel } from "./verification-status";
|
||||
|
||||
type PageVerificationModalProps = {
|
||||
pageId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function PageVerificationModal({
|
||||
pageId,
|
||||
opened,
|
||||
onClose,
|
||||
}: PageVerificationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: verificationInfo } = usePageVerificationInfoQuery(
|
||||
opened ? pageId : undefined,
|
||||
);
|
||||
|
||||
const status = verificationInfo?.status ?? "none";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck
|
||||
size={20}
|
||||
stroke={1.5}
|
||||
color={
|
||||
status === "verified" || status === "approved"
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: status === "expired"
|
||||
? "var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Text fw={600}>
|
||||
{status === "none" ? t("Set up verification") : t("Verify page")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size={520}
|
||||
>
|
||||
{status === "none" ? (
|
||||
<SetupVerificationForm pageId={pageId} onClose={onClose} />
|
||||
) : (
|
||||
<ManageVerificationForm pageId={pageId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type PageVerificationBadgeProps = {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export function PageVerificationBadge({
|
||||
readOnly,
|
||||
}: PageVerificationBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
|
||||
const { data: verificationInfo, isLoading } = usePageVerificationInfoQuery(
|
||||
isCloudEE ? pageId : undefined,
|
||||
);
|
||||
|
||||
if (!isCloudEE || !pageId) return null;
|
||||
if (isLoading) return null;
|
||||
|
||||
const status = verificationInfo?.status ?? "none";
|
||||
|
||||
if (status === "none" && readOnly) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{status !== "none" ? (
|
||||
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
|
||||
<Group
|
||||
gap={4}
|
||||
onClick={open}
|
||||
style={{ cursor: "pointer" }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<IconRosetteDiscountCheckFilled
|
||||
size={18}
|
||||
color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`}
|
||||
/>
|
||||
<Text size="sm" c={getStatusColor(status)}>
|
||||
{getStatusLabel(status, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
) : !readOnly ? (
|
||||
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={open}>
|
||||
<IconShieldCheck size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<PageVerificationModal
|
||||
pageId={pageId}
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type PageVerificationMenuItemProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function PageVerificationMenuItem({
|
||||
onClick,
|
||||
}: PageVerificationMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
|
||||
if (!isCloudEE) return null;
|
||||
|
||||
return (
|
||||
<Menu.Item leftSection={<IconShieldCheck size={16} />} onClick={onClick}>
|
||||
{t("Page verification")}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCertificate2,
|
||||
IconCheck,
|
||||
IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import classes from "./page-verification-modal.module.css";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { useSetupVerificationMutation } from "@/ee/page-verification/queries/page-verification-query";
|
||||
import {
|
||||
ExpirationMode,
|
||||
PeriodUnit,
|
||||
VerificationType,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import {
|
||||
ExpirationFields,
|
||||
PERIOD_AMOUNT_MIN,
|
||||
PERIOD_UNIT_MAX_AMOUNT,
|
||||
} from "./expiration-fields";
|
||||
import { VerifierPicker } from "./verifier-picker";
|
||||
import { VerifierList } from "./verifier-list";
|
||||
import { MAX_VERIFIERS, UserOptionItem } from "./user-option";
|
||||
|
||||
type WorkflowChooserProps = {
|
||||
onSelect: (type: VerificationType) => void;
|
||||
};
|
||||
|
||||
function WorkflowChooser({ onSelect }: WorkflowChooserProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text className={classes.subhead}>
|
||||
{t("Choose how this page should stay accurate.")}
|
||||
</Text>
|
||||
|
||||
<div className={classes.chooser}>
|
||||
<UnstyledButton
|
||||
component="button"
|
||||
type="button"
|
||||
className={classes.card}
|
||||
onClick={() => onSelect("expiring" as VerificationType)}
|
||||
>
|
||||
<div className={classes.titleRow}>
|
||||
<span className={classes.iconStamp}>
|
||||
<IconRefresh size={15} stroke={1.7} />
|
||||
</span>
|
||||
<h3 className={classes.title}>{t("Recurring verification")}</h3>
|
||||
</div>
|
||||
<p className={classes.description}>
|
||||
{t("Verifiers re-confirm this page on a schedule.")}
|
||||
</p>
|
||||
|
||||
<div className={classes.rule} />
|
||||
|
||||
<div className={classes.meta}>
|
||||
<div className={classes.metaItem}>
|
||||
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
|
||||
{t("Re-verify on a schedule (e.g every 30 days )")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.cardFooter}>
|
||||
<span className={classes.bestFor}>
|
||||
{t("Best for runbooks, FAQs, living documentation")}
|
||||
</span>
|
||||
<span className={classes.arrow}>
|
||||
<IconArrowRight size={16} stroke={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
|
||||
<UnstyledButton
|
||||
component="button"
|
||||
type="button"
|
||||
className={classes.card}
|
||||
onClick={() => onSelect("qms" as VerificationType)}
|
||||
>
|
||||
<div className={classes.titleRow}>
|
||||
<span className={classes.iconStamp}>
|
||||
<IconCertificate2 size={15} stroke={1.7} />
|
||||
</span>
|
||||
<h3 className={classes.title}>{t("Approval workflow")}</h3>
|
||||
</div>
|
||||
<p className={classes.description}>
|
||||
{t("Formal document lifecycle with named approvers.")}
|
||||
</p>
|
||||
|
||||
<div className={classes.rule} />
|
||||
|
||||
<div className={classes.meta}>
|
||||
<div className={classes.metaItem}>
|
||||
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
|
||||
{t("Draft → In approval → Approved → Obsolete")}
|
||||
</div>
|
||||
<div className={classes.metaItem}>
|
||||
<IconCheck size={13} stroke={2.4} className={classes.metaIcon} />
|
||||
{t("Designed for ISO 9001, ISO 13485, and FDA")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.cardFooter}>
|
||||
<span className={classes.bestFor}>
|
||||
{t("Best for SOPs and controlled documents")}
|
||||
</span>
|
||||
<span className={classes.arrow}>
|
||||
<IconArrowRight size={16} stroke={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
type SetupVerificationFormProps = {
|
||||
pageId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SetupVerificationForm({
|
||||
pageId,
|
||||
}: SetupVerificationFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const setupMutation = useSetupVerificationMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [type, setType] = useState<VerificationType | null>(null);
|
||||
const [mode, setMode] = useState<ExpirationMode>("period");
|
||||
const [periodAmount, setPeriodAmount] = useState<number>(1);
|
||||
const [periodUnit, setPeriodUnit] = useState<PeriodUnit>("month");
|
||||
const [fixedDate, setFixedDate] = useState<string>("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [selectedVerifiers, setSelectedVerifiers] = useState<UserOptionItem[]>(
|
||||
[],
|
||||
);
|
||||
const didInitCurrentUser = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitCurrentUser.current && currentUser?.user) {
|
||||
didInitCurrentUser.current = true;
|
||||
const u = currentUser.user;
|
||||
setSelectedVerifiers([
|
||||
{
|
||||
value: u.id,
|
||||
label: u.name,
|
||||
email: u.email,
|
||||
avatarUrl: u.avatarUrl,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
const isQms = type === "qms";
|
||||
const canAddMore = selectedVerifiers.length < MAX_VERIFIERS;
|
||||
|
||||
if (type === null) {
|
||||
return <WorkflowChooser onSelect={setType} />;
|
||||
}
|
||||
|
||||
const handleAddVerifier = (user: UserOptionItem) => {
|
||||
setSelectedVerifiers((prev) =>
|
||||
prev.some((v) => v.value === user.value) ? prev : [...prev, user],
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveVerifier = (userId: string) => {
|
||||
setSelectedVerifiers((prev) => prev.filter((v) => v.value !== userId));
|
||||
};
|
||||
|
||||
const handleSetup = () => {
|
||||
if (selectedVerifiers.length === 0) return;
|
||||
setupMutation.mutate({
|
||||
pageId,
|
||||
type,
|
||||
...(!isQms && {
|
||||
mode,
|
||||
...(mode === "period" && {
|
||||
periodAmount,
|
||||
periodUnit,
|
||||
}),
|
||||
...(mode === "fixed" &&
|
||||
fixedDate && {
|
||||
fixedExpiresAt: new Date(fixedDate).toISOString(),
|
||||
}),
|
||||
}),
|
||||
verifierIds: selectedVerifiers.map((v) => v.value),
|
||||
});
|
||||
};
|
||||
|
||||
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 hasVerifiers = selectedVerifiers.length > 0;
|
||||
|
||||
const canSubmit = isQms
|
||||
? hasVerifiers
|
||||
: hasVerifiers && confirmed && periodValid && fixedDateValid;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.backButton}
|
||||
onClick={() => setType(null)}
|
||||
>
|
||||
<IconArrowLeft size={12} stroke={2.2} />
|
||||
{t("Back")}
|
||||
</button>
|
||||
<div className={classes.configureHeader}>
|
||||
<span className={classes.iconStamp}>
|
||||
{isQms ? (
|
||||
<IconCertificate2 size={16} stroke={1.6} />
|
||||
) : (
|
||||
<IconRefresh size={16} stroke={1.6} />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<span className={classes.configureEyebrow}>
|
||||
{isQms ? t("Quality management") : t("Recurring")}
|
||||
</span>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{isQms
|
||||
? t("Pages move through draft, approval, and approved stages.")
|
||||
: t(
|
||||
"Assigned verifiers must periodically re-verify this page.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
||||
{t("Verifiers")}
|
||||
</Text>
|
||||
{selectedVerifiers.length > 0 && (
|
||||
<div style={{ marginBottom: "var(--mantine-spacing-xs)" }}>
|
||||
<VerifierList
|
||||
verifiers={selectedVerifiers.map((v) => ({
|
||||
id: v.value,
|
||||
name: v.label,
|
||||
email: v.email,
|
||||
avatarUrl: v.avatarUrl,
|
||||
}))}
|
||||
canManage
|
||||
onRemove={handleRemoveVerifier}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canAddMore && (
|
||||
<VerifierPicker
|
||||
excludeIds={selectedVerifiers.map((v) => v.value)}
|
||||
onSelect={handleAddVerifier}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isQms && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{t("Expiration")}
|
||||
</Text>
|
||||
<ExpirationFields
|
||||
mode={mode}
|
||||
periodAmount={periodAmount}
|
||||
periodUnit={periodUnit}
|
||||
fixedDate={fixedDate}
|
||||
onModeChange={setMode}
|
||||
onPeriodAmountChange={setPeriodAmount}
|
||||
onPeriodUnitChange={setPeriodUnit}
|
||||
onFixedDateChange={setFixedDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("Confirm")}
|
||||
</Text>
|
||||
<Checkbox
|
||||
label={t("I've reviewed this page for accuracy")}
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
||||
color="dark"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={handleSetup}
|
||||
disabled={!canSubmit}
|
||||
loading={setupMutation.isPending}
|
||||
color="dark"
|
||||
>
|
||||
{isQms ? t("Set up") : t("Verify")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Group, SelectProps, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { IUser } from "@/features/user/types/user.types";
|
||||
|
||||
export const MAX_VERIFIERS = 5;
|
||||
|
||||
export type UserOptionItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export function toUserOptions(users: IUser[] | undefined): UserOptionItem[] {
|
||||
return (users ?? []).map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
export const renderUserSelectOption: SelectProps["renderOption"] = ({
|
||||
option,
|
||||
}) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={option["avatarUrl"]}
|
||||
size={20}
|
||||
name={option.label}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option["email"] && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{option["email"]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Table, Text, Group, Skeleton, Anchor, Badge } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IVerificationListItem,
|
||||
VerificationStatus,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { format } from "date-fns";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
|
||||
type VerificationListTableProps = {
|
||||
items?: IVerificationListItem[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function statusBadge(status: VerificationStatus | null, t: (s: string) => string) {
|
||||
switch (status) {
|
||||
case "verified":
|
||||
return <Badge color="green" variant="light" size="sm">{t("Verified")}</Badge>;
|
||||
case "expiring":
|
||||
return <Badge color="yellow" variant="light" size="sm">{t("Expiring")}</Badge>;
|
||||
case "expired":
|
||||
return <Badge color="red" variant="light" size="sm">{t("Expired")}</Badge>;
|
||||
case "approved":
|
||||
return <Badge color="green" variant="light" size="sm">{t("Approved")}</Badge>;
|
||||
case "draft":
|
||||
return <Badge color="gray" variant="light" size="sm">{t("Draft")}</Badge>;
|
||||
case "in_approval":
|
||||
return <Badge color="blue" variant="light" size="sm">{t("In approval")}</Badge>;
|
||||
case "obsolete":
|
||||
return <Badge color="red" variant="light" size="sm">{t("Obsolete")}</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string): string {
|
||||
if (item.type === "qms") {
|
||||
if (item.status === "approved") return t("Indefinitely");
|
||||
return "—";
|
||||
}
|
||||
|
||||
if (!item.expiresAt) return t("Indefinitely");
|
||||
|
||||
const expires = new Date(item.expiresAt);
|
||||
const now = new Date();
|
||||
|
||||
if (expires <= now) return t("Expired");
|
||||
return format(expires, "MMM d, yyyy");
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Skeleton height={14} width={160} mb={4} />
|
||||
<Skeleton height={10} width={100} />
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Skeleton circle height={28} />
|
||||
<Skeleton height={14} width={80} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={100} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={20} width={60} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationListTable({
|
||||
items,
|
||||
isLoading,
|
||||
}: VerificationListTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Page")}</Table.Th>
|
||||
<Table.Th>{t("Owner")}</Table.Th>
|
||||
<Table.Th>{t("Verified until")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : items && items.length > 0 ? (
|
||||
items.map((item) => {
|
||||
const primaryVerifier = item.verifiers[0];
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
item.spaceSlug,
|
||||
item.pageSlugId,
|
||||
item.pageTitle ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{ color: "var(--mantine-color-text)" }}
|
||||
component={Link}
|
||||
to={pageUrl}
|
||||
>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{item.pageIcon ? `${item.pageIcon} ` : ""}
|
||||
{item.pageTitle || t("Untitled")}
|
||||
</Text>
|
||||
</Anchor>
|
||||
<Text fz="xs" c="dimmed" lineClamp={1}>
|
||||
{item.spaceName}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{primaryVerifier ? (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={primaryVerifier.avatarUrl}
|
||||
name={primaryVerifier.name}
|
||||
size={28}
|
||||
/>
|
||||
<Text fz="sm" lineClamp={1}>
|
||||
{primaryVerifier.name}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{verifiedUntilText(item, t)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{statusBadge(item.status as VerificationStatus, t)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<NoTableResults colSpan={4} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { VerificationStatus } from "@/ee/page-verification/types/page-verification.types";
|
||||
|
||||
export function getStatusColor(status: VerificationStatus): string {
|
||||
switch (status) {
|
||||
case "verified":
|
||||
case "approved":
|
||||
return "blue.7";
|
||||
case "expiring":
|
||||
case "in_approval":
|
||||
return "orange.8";
|
||||
case "expired":
|
||||
return "red.7";
|
||||
case "draft":
|
||||
case "obsolete":
|
||||
return "gray.6";
|
||||
default:
|
||||
return "gray.6";
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusLabel(
|
||||
status: VerificationStatus,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "verified":
|
||||
return t("Verified");
|
||||
case "expiring":
|
||||
return t("Review needed");
|
||||
case "expired":
|
||||
return t("Verification expired");
|
||||
case "draft":
|
||||
return t("Draft");
|
||||
case "in_approval":
|
||||
return t("In Approval");
|
||||
case "approved":
|
||||
return t("Approved");
|
||||
case "obsolete":
|
||||
return t("Obsolete");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { IVerifier } from "@/ee/page-verification/types/page-verification.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type VerifierListProps = {
|
||||
verifiers: IVerifier[];
|
||||
canManage?: boolean;
|
||||
onRemove?: (userId: string) => void;
|
||||
};
|
||||
|
||||
export function VerifierList({
|
||||
verifiers,
|
||||
canManage,
|
||||
onRemove,
|
||||
}: VerifierListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (verifiers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{verifiers.map((verifier, index) => (
|
||||
<Group
|
||||
key={verifier.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
py={6}
|
||||
style={{
|
||||
borderBottom:
|
||||
index < verifiers.length - 1
|
||||
? "1px solid var(--mantine-color-gray-1)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<CustomAvatar
|
||||
avatarUrl={verifier.avatarUrl}
|
||||
name={verifier.name}
|
||||
size={28}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" truncate="end">
|
||||
{verifier.name}
|
||||
</Text>
|
||||
{verifier.email && (
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{verifier.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{canManage && onRemove && (
|
||||
<Tooltip label={t("Remove")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => onRemove(verifier.id)}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { Select } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||
import {
|
||||
renderUserSelectOption,
|
||||
toUserOptions,
|
||||
UserOptionItem,
|
||||
} from "./user-option";
|
||||
|
||||
type VerifierPickerProps = {
|
||||
excludeIds: string[];
|
||||
disabled?: boolean;
|
||||
onSelect: (user: UserOptionItem) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function VerifierPicker({
|
||||
excludeIds,
|
||||
disabled,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: VerifierPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(searchValue, 300);
|
||||
|
||||
const { data: suggestion } = useSearchSuggestionsQuery({
|
||||
query: debouncedQuery,
|
||||
includeUsers: true,
|
||||
includeGroups: false,
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const excludeSet = new Set(excludeIds);
|
||||
const options = toUserOptions(suggestion?.users).filter(
|
||||
(u) => !excludeSet.has(u.value),
|
||||
);
|
||||
|
||||
const handleChange = (userId: string | null) => {
|
||||
if (!userId) return;
|
||||
const picked = options.find((u) => u.value === userId);
|
||||
if (!picked) return;
|
||||
onSelect(picked);
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={options}
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
renderOption={renderUserSelectOption}
|
||||
placeholder={placeholder ?? t("Add verifier")}
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
filter={({ options }) => options}
|
||||
variant="filled"
|
||||
disabled={disabled}
|
||||
nothingFoundMessage={t("No user found")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./components/page-verification-modal";
|
||||
export * from "./components/verifier-list";
|
||||
export * from "./queries/page-verification-query";
|
||||
export * from "./services/page-verification-service";
|
||||
export * from "./types/page-verification.types";
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import Paginate from "@/components/common/paginate";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
|
||||
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
|
||||
import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
export default function VerifiedPages() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
||||
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
|
||||
const [spaceFilter, setSpaceFilter] = useState<string[]>([]);
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
|
||||
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const spaceOptions = useMemo(
|
||||
() =>
|
||||
spacesData?.items?.map((space) => ({
|
||||
value: space.id,
|
||||
label: space.name,
|
||||
})) ?? [],
|
||||
[spacesData],
|
||||
);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: "expiring", label: t("Expiring") },
|
||||
{ value: "qms", label: t("QMS") },
|
||||
];
|
||||
|
||||
const params: IVerificationListParams = useMemo(
|
||||
() => ({
|
||||
cursor,
|
||||
limit: 50,
|
||||
spaceIds: spaceFilter.length > 0 ? spaceFilter : undefined,
|
||||
type: typeFilter as IVerificationListParams["type"],
|
||||
query: debouncedSearch || undefined,
|
||||
}),
|
||||
[cursor, spaceFilter, typeFilter, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useVerificationListQuery(params);
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSpaceChange = (value: string[]) => {
|
||||
setSpaceFilter(value);
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
const handleTypeChange = (value: string | null) => {
|
||||
setTypeFilter(value);
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.currentTarget.value);
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Verified pages")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<SettingsTitle title={t("Verified pages")} />
|
||||
|
||||
<Group mb="md" gap="sm">
|
||||
<TextInput
|
||||
placeholder={t("Search pages...")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
size="sm"
|
||||
w={220}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
placeholder={t("Filter by space")}
|
||||
data={spaceOptions}
|
||||
value={spaceFilter}
|
||||
onChange={handleSpaceChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder={t("Filter by type")}
|
||||
data={typeOptions}
|
||||
value={typeFilter}
|
||||
onChange={handleTypeChange}
|
||||
clearable
|
||||
w={160}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<VerificationListTable items={data?.items} isLoading={isLoading} />
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
IPageVerificationInfo,
|
||||
ISetupVerification,
|
||||
IUpdateVerification,
|
||||
IVerificationListItem,
|
||||
IVerificationListParams,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import {
|
||||
getVerificationInfo,
|
||||
getVerificationList,
|
||||
markObsolete,
|
||||
rejectApproval,
|
||||
removeVerification,
|
||||
setupVerification,
|
||||
submitForApproval,
|
||||
updateVerification,
|
||||
verifyPage,
|
||||
} from "@/ee/page-verification/services/page-verification-service";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export function usePageVerificationInfoQuery(
|
||||
pageId: string | undefined,
|
||||
): UseQueryResult<IPageVerificationInfo, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
queryFn: () => getVerificationInfo(pageId!),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetupVerificationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, ISetupVerification>({
|
||||
mutationFn: (data) => setupVerification(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", variables.pageId],
|
||||
});
|
||||
notifications.show({ message: t("Verification enabled") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to enable verification"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVerificationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, IUpdateVerification>({
|
||||
mutationFn: (data) => updateVerification(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", variables.pageId],
|
||||
});
|
||||
notifications.show({ message: t("Verification updated") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to update verification"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveVerificationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => removeVerification(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Verification removed") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to remove verification"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerifyPageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => verifyPage(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Page verified") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to verify page"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitForApprovalMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => submitForApproval(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Submitted for approval") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to submit for approval"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectApprovalMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, { pageId: string; comment?: string }>({
|
||||
mutationFn: (data) => rejectApproval(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", variables.pageId],
|
||||
});
|
||||
notifications.show({ message: t("Approval rejected") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to reject approval"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkObsoleteMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (pageId) => markObsolete(pageId),
|
||||
onSuccess: (_, pageId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", pageId],
|
||||
});
|
||||
notifications.show({ message: t("Page marked as obsolete") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to mark as obsolete"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerificationListQuery(
|
||||
params?: IVerificationListParams,
|
||||
): UseQueryResult<IPagination<IVerificationListItem>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["verification-list", params],
|
||||
queryFn: () => getVerificationList(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IPageVerificationInfo,
|
||||
ISetupVerification,
|
||||
IUpdateVerification,
|
||||
IVerificationListItem,
|
||||
IVerificationListParams,
|
||||
} from "@/ee/page-verification/types/page-verification.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export async function getVerificationInfo(
|
||||
pageId: string,
|
||||
): Promise<IPageVerificationInfo> {
|
||||
const req = await api.post<IPageVerificationInfo>(
|
||||
"/page-verification/info",
|
||||
{ pageId },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupVerification(
|
||||
data: ISetupVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/page-verification/setup", data);
|
||||
}
|
||||
|
||||
export async function updateVerification(
|
||||
data: IUpdateVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/page-verification/update", data);
|
||||
}
|
||||
|
||||
export async function removeVerification(pageId: string): Promise<void> {
|
||||
await api.post("/page-verification/remove", { pageId });
|
||||
}
|
||||
|
||||
export async function verifyPage(pageId: string): Promise<void> {
|
||||
await api.post("/page-verification/verify", { pageId });
|
||||
}
|
||||
|
||||
export async function submitForApproval(pageId: string): Promise<void> {
|
||||
await api.post("/page-verification/submit-for-approval", { pageId });
|
||||
}
|
||||
|
||||
export async function rejectApproval(data: {
|
||||
pageId: string;
|
||||
comment?: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/page-verification/reject", data);
|
||||
}
|
||||
|
||||
export async function markObsolete(pageId: string): Promise<void> {
|
||||
await api.post("/page-verification/obsolete", { pageId });
|
||||
}
|
||||
|
||||
export async function getVerificationList(
|
||||
params?: IVerificationListParams,
|
||||
): Promise<IPagination<IVerificationListItem>> {
|
||||
const req = await api.post("/page-verification/list", { ...params });
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export type VerificationType = "expiring" | "qms";
|
||||
|
||||
export type ExpirationMode = "period" | "fixed" | "indefinite";
|
||||
|
||||
export type PeriodUnit = "day" | "week" | "month" | "year";
|
||||
|
||||
export type VerificationStatus =
|
||||
| "verified"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "draft"
|
||||
| "in_approval"
|
||||
| "approved"
|
||||
| "obsolete"
|
||||
| "none";
|
||||
|
||||
export type IUserRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export type IVerifier = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type IPageVerificationInfo = {
|
||||
id?: string;
|
||||
pageId?: string;
|
||||
type?: VerificationType;
|
||||
mode?: ExpirationMode | null;
|
||||
periodAmount?: number | null;
|
||||
periodUnit?: PeriodUnit | null;
|
||||
status: VerificationStatus;
|
||||
verifiedAt?: string | null;
|
||||
verifiedBy?: IUserRef | null;
|
||||
expiresAt?: string | null;
|
||||
requestedAt?: string | null;
|
||||
requestedBy?: IUserRef | null;
|
||||
rejectedAt?: string | null;
|
||||
rejectedBy?: IUserRef | null;
|
||||
rejectionComment?: string | null;
|
||||
verifiers?: IVerifier[];
|
||||
permissions?: IPageVerificationPermissions;
|
||||
};
|
||||
|
||||
export type IPageVerificationPermissions = {
|
||||
canVerify: boolean;
|
||||
canManage: boolean;
|
||||
canSubmitForApproval: boolean;
|
||||
canMarkObsolete: boolean;
|
||||
};
|
||||
|
||||
export type ISetupVerification = {
|
||||
pageId: string;
|
||||
type?: VerificationType;
|
||||
mode?: ExpirationMode;
|
||||
periodAmount?: number;
|
||||
periodUnit?: PeriodUnit;
|
||||
fixedExpiresAt?: string;
|
||||
verifierIds: string[];
|
||||
};
|
||||
|
||||
export type IUpdateVerification = {
|
||||
pageId: string;
|
||||
mode?: ExpirationMode;
|
||||
periodAmount?: number;
|
||||
periodUnit?: PeriodUnit;
|
||||
fixedExpiresAt?: string;
|
||||
verifierIds?: string[];
|
||||
};
|
||||
|
||||
export type IVerificationListItem = {
|
||||
id: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
type: VerificationType;
|
||||
status: VerificationStatus | null;
|
||||
mode: ExpirationMode | null;
|
||||
periodAmount: number | null;
|
||||
periodUnit: PeriodUnit | null;
|
||||
verifiedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
pageTitle: string | null;
|
||||
pageSlugId: string;
|
||||
pageIcon: string | null;
|
||||
spaceName: string;
|
||||
spaceSlug: string;
|
||||
verifiers: IUserRef[];
|
||||
};
|
||||
|
||||
export type IVerificationListParams = {
|
||||
spaceIds?: string[];
|
||||
verifierId?: string;
|
||||
type?: VerificationType;
|
||||
cursor?: string;
|
||||
beforeCursor?: string;
|
||||
limit?: number;
|
||||
query?: string;
|
||||
};
|
||||
@@ -2,13 +2,31 @@ import classes from "@/features/editor/styles/editor.module.css";
|
||||
import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
import {
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { PageVerificationBadge } from "@/ee/page-verification";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
|
||||
type PageCreator = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export interface FullEditorProps {
|
||||
pageId: string;
|
||||
slugId: string;
|
||||
@@ -16,6 +34,8 @@ export interface FullEditorProps {
|
||||
content: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
creator?: PageCreator;
|
||||
contributors?: IContributor[];
|
||||
}
|
||||
|
||||
export function FullEditor({
|
||||
@@ -25,6 +45,8 @@ export function FullEditor({
|
||||
content,
|
||||
spaceSlug,
|
||||
editable,
|
||||
creator,
|
||||
contributors,
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
@@ -42,6 +64,11 @@ export function FullEditor({
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
readOnly={!editable}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
@@ -50,3 +77,91 @@ export function FullEditor({
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
creator?: PageCreator;
|
||||
contributors?: IContributor[];
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({
|
||||
creator,
|
||||
contributors,
|
||||
readOnly,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
mb="md"
|
||||
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
|
||||
>
|
||||
{creator && (
|
||||
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||
<Popover.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={6}>
|
||||
<CustomAvatar
|
||||
avatarUrl={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size={22}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("By {{name}}", { name: creator.name })}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size={36}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{creator.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{otherContributors.length === 0
|
||||
? t("Owner, no contributors")
|
||||
: t("Owner")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{otherContributors.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text size="xs" fw={500} c="dimmed" tt="uppercase">
|
||||
{t("Contributors")}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{otherContributors.map((contributor) => (
|
||||
<Group gap="sm" key={contributor.id}>
|
||||
<CustomAvatar
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
name={contributor.name}
|
||||
size={28}
|
||||
/>
|
||||
<Text size="sm">{contributor.name}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<PageVerificationBadge readOnly={readOnly} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ export function NotificationItem({
|
||||
return notification.data?.role === "writer"
|
||||
? t("gave you edit access to a page")
|
||||
: t("gave you view access to a page");
|
||||
case "page.verification_expiring":
|
||||
return t("Page verification expires soon");
|
||||
case "page.verification_expired":
|
||||
return t("Page verification has expired");
|
||||
case "page.verified":
|
||||
return t("verified a page");
|
||||
case "page.approval_requested":
|
||||
return t("submitted a page for your approval");
|
||||
case "page.approval_rejected":
|
||||
return t("returned a page for revision");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -95,9 +105,13 @@ export function NotificationItem({
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" lineClamp={2}>
|
||||
<Text span fw={600}>
|
||||
{notification.actor?.name}
|
||||
</Text>{" "}
|
||||
{notification.actor?.name ? (
|
||||
<>
|
||||
<Text span fw={600}>
|
||||
{notification.actor.name}
|
||||
</Text>{" "}
|
||||
</>
|
||||
) : null}
|
||||
{getNotificationMessage()}
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ export type NotificationType =
|
||||
| "comment.created"
|
||||
| "comment.resolved"
|
||||
| "page.user_mention"
|
||||
| "page.permission_granted";
|
||||
| "page.permission_granted"
|
||||
| "page.verification_expiring"
|
||||
| "page.verification_expired"
|
||||
| "page.verified"
|
||||
| "page.approval_requested"
|
||||
| "page.approval_rejected";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
|
||||
@@ -40,6 +40,10 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
PageVerificationMenuItem,
|
||||
PageVerificationModal,
|
||||
} from "@/ee/page-verification";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@@ -121,6 +125,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
movePageModalOpened,
|
||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const [
|
||||
verificationOpened,
|
||||
{ open: openVerificationModal, close: closeVerificationModal },
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
|
||||
@@ -200,6 +208,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<PageVerificationMenuItem onClick={openVerificationModal} />
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
{!readOnly && (
|
||||
@@ -289,6 +301,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
onClose={closeMoveSpaceModal}
|
||||
open={movePageModalOpened}
|
||||
/>
|
||||
|
||||
<PageVerificationModal
|
||||
pageId={page.id}
|
||||
opened={verificationOpened}
|
||||
onClose={closeVerificationModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface IPage {
|
||||
creator: ICreator;
|
||||
lastUpdatedBy: ILastUpdatedBy;
|
||||
deletedBy: IDeletedBy;
|
||||
contributors?: IContributor[];
|
||||
space: Partial<ISpace>;
|
||||
permissions?: {
|
||||
canEdit: boolean;
|
||||
@@ -29,6 +30,12 @@ export interface IPage {
|
||||
};
|
||||
}
|
||||
|
||||
export interface IContributor {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface ICreator {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
export type VerificationUpdatedEvent = {
|
||||
operation: "verificationUpdated";
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export type WebSocketEvent =
|
||||
| InvalidateEvent
|
||||
| CommentCreatedEvent
|
||||
@@ -96,4 +101,5 @@ export type WebSocketEvent =
|
||||
| AddTreeNodeEvent
|
||||
| MoveTreeNodeEvent
|
||||
| DeleteTreeNodeEvent
|
||||
| RefetchRootTreeNodeEvent;
|
||||
| RefetchRootTreeNodeEvent
|
||||
| VerificationUpdatedEvent;
|
||||
|
||||
@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "verificationUpdated":
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", data.pageId],
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [queryClient, socket]);
|
||||
|
||||
@@ -104,6 +104,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
slugId={page.slugId}
|
||||
spaceSlug={page?.space?.slug}
|
||||
editable={canEdit}
|
||||
creator={page.creator}
|
||||
contributors={page.contributors}
|
||||
/>
|
||||
<MemoizedHistoryModal pageId={page.id} />
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -47,6 +48,7 @@ try {
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
LoggerModule,
|
||||
NoopAuditModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
|
||||
@@ -59,6 +59,14 @@ export const AuditEvent = {
|
||||
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
||||
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
||||
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
|
||||
// Page verification
|
||||
PAGE_VERIFICATION_CREATED: 'page.verification_created',
|
||||
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
|
||||
PAGE_VERIFICATION_REMOVED: 'page.verification_removed',
|
||||
PAGE_VERIFIED: 'page.verified',
|
||||
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||
PAGE_MARKED_OBSOLETE: 'page.marked_obsolete',
|
||||
|
||||
// Share
|
||||
SHARE_CREATED: 'share.created',
|
||||
|
||||
@@ -20,10 +20,6 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
NoopAuditService,
|
||||
} from '../integrations/audit/audit.service';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@Module({
|
||||
@@ -43,13 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: AUDIT_SERVICE,
|
||||
useClass: NoopAuditService,
|
||||
},
|
||||
],
|
||||
exports: [AUDIT_SERVICE],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@@ -4,6 +4,11 @@ export const NotificationType = {
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||
PAGE_VERIFICATION_EXPIRING: 'page.verification_expiring',
|
||||
PAGE_VERIFICATION_EXPIRED: 'page.verification_expired',
|
||||
PAGE_VERIFIED: 'page.verified',
|
||||
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
@@ -13,6 +14,7 @@ import { PageNotificationService } from './services/page.notification';
|
||||
NotificationProcessor,
|
||||
CommentNotificationService,
|
||||
PageNotificationService,
|
||||
VerificationNotificationService,
|
||||
],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
|
||||
@@ -5,13 +5,19 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import {
|
||||
IApprovalRejectedNotificationJob,
|
||||
IApprovalRequestedNotificationJob,
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
IPageVerifiedNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
import { DomainService } from '../../integrations/environment/domain.service';
|
||||
|
||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||
@@ -24,6 +30,7 @@ export class NotificationProcessor
|
||||
constructor(
|
||||
private readonly commentNotificationService: CommentNotificationService,
|
||||
private readonly pageNotificationService: PageNotificationService,
|
||||
private readonly verificationNotificationService: VerificationNotificationService,
|
||||
private readonly domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
@@ -35,7 +42,12 @@ export class NotificationProcessor
|
||||
| ICommentNotificationJob
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob
|
||||
| IPermissionGrantedNotificationJob,
|
||||
| IPermissionGrantedNotificationJob
|
||||
| IVerificationExpiringNotificationJob
|
||||
| IVerificationExpiredNotificationJob
|
||||
| IPageVerifiedNotificationJob
|
||||
| IApprovalRequestedNotificationJob
|
||||
| IApprovalRejectedNotificationJob,
|
||||
void
|
||||
>,
|
||||
): Promise<void> {
|
||||
@@ -76,6 +88,45 @@ export class NotificationProcessor
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRING: {
|
||||
await this.verificationNotificationService.processVerificationExpiring(
|
||||
job.data as IVerificationExpiringNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRED: {
|
||||
await this.verificationNotificationService.processVerificationExpired(
|
||||
job.data as IVerificationExpiredNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFIED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processPageVerified(
|
||||
job.data as IPageVerifiedNotificationJob,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRequested(
|
||||
job.data as IApprovalRequestedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRejected(
|
||||
job.data as IApprovalRejectedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
IApprovalRejectedNotificationJob,
|
||||
IApprovalRequestedNotificationJob,
|
||||
IPageVerifiedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { VerificationExpiringEmail } from '@docmost/transactional/emails/verification-expiring-email';
|
||||
import { VerificationExpiredEmail } from '@docmost/transactional/emails/verification-expired-email';
|
||||
import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email';
|
||||
import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class VerificationNotificationService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
async processVerificationExpiring(
|
||||
data: IVerificationExpiringNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, expiresAt } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, basePageUrl } = context;
|
||||
|
||||
for (const userId of verifierIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
pageId,
|
||||
spaceId,
|
||||
data: { expiresAt },
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs to be re-verified soon`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiringEmail({
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
expiresAt: new Date(expiresAt).toLocaleDateString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processVerificationExpired(
|
||||
data: IVerificationExpiredNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, basePageUrl } = context;
|
||||
|
||||
for (const userId of verifierIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" verification has expired`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiredEmail({
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processPageVerified(data: IPageVerifiedNotificationJob) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
for (const userId of verifierIds) {
|
||||
await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_VERIFIED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async processApprovalRequested(
|
||||
data: IApprovalRequestedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
for (const userId of verifierIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_APPROVAL_REQUESTED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs your approval`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
ApprovalRequestedEmail({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processApprovalRejected(
|
||||
data: IApprovalRejectedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
|
||||
data;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId: requestedById,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_APPROVAL_REJECTED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" was returned for revision`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
requestedById,
|
||||
notification.id,
|
||||
subject,
|
||||
ApprovalRejectedEmail({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
comment,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async getUserName(userId: string): Promise<string> {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select('name')
|
||||
.where('id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return user?.name ?? 'Someone';
|
||||
}
|
||||
|
||||
private async getPageContext(
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
appUrl: string,
|
||||
) {
|
||||
const [page, space] = await Promise.all([
|
||||
this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title', 'slugId'])
|
||||
.where('id', '=', pageId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id', 'slug'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
if (!page || !space) return null;
|
||||
|
||||
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
return { pageTitle: getPageTitle(page.title), basePageUrl };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('page_verifications')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.notNull().unique().references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.notNull().references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('type', 'varchar', (col) =>
|
||||
col.notNull().defaultTo('expiring'),
|
||||
)
|
||||
.addColumn('status', 'varchar')
|
||||
.addColumn('mode', 'varchar')
|
||||
.addColumn('period_amount', 'integer')
|
||||
.addColumn('period_unit', 'varchar')
|
||||
.addColumn('verified_at', 'timestamptz')
|
||||
.addColumn('verified_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('expires_at', 'timestamptz')
|
||||
.addColumn('notified_at', 'timestamptz')
|
||||
.addColumn('requested_at', 'timestamptz')
|
||||
.addColumn('requested_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('rejected_at', 'timestamptz')
|
||||
.addColumn('rejected_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('rejection_comment', 'text')
|
||||
.addColumn('data', 'jsonb')
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('page_verifiers')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_verification_id', 'uuid', (col) =>
|
||||
col
|
||||
.notNull()
|
||||
.references('page_verifications.id')
|
||||
.onDelete('cascade'),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.notNull().references('users.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('is_primary', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn('added_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('page_verifiers_verification_user_unique', [
|
||||
'page_verification_id',
|
||||
'user_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_verifications_expires_at')
|
||||
.on('page_verifications')
|
||||
.column('expires_at')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_verifiers_user_id')
|
||||
.on('page_verifiers')
|
||||
.column('user_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('page_verifiers').ifExists().execute();
|
||||
await db.schema.dropTable('page_verifications').ifExists().execute();
|
||||
}
|
||||
+36
@@ -429,6 +429,40 @@ export interface PagePermissions {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PageVerifications {
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
spaceId: string;
|
||||
type: Generated<string>;
|
||||
status: string | null;
|
||||
mode: string | null;
|
||||
periodAmount: number | null;
|
||||
periodUnit: string | null;
|
||||
verifiedAt: Timestamp | null;
|
||||
verifiedById: string | null;
|
||||
expiresAt: Timestamp | null;
|
||||
notifiedAt: Timestamp | null;
|
||||
requestedAt: Timestamp | null;
|
||||
requestedById: string | null;
|
||||
rejectedAt: Timestamp | null;
|
||||
rejectedById: string | null;
|
||||
rejectionComment: string | null;
|
||||
data: Json | null;
|
||||
creatorId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PageVerifiers {
|
||||
id: Generated<string>;
|
||||
pageVerificationId: string;
|
||||
userId: string;
|
||||
isPrimary: Generated<boolean>;
|
||||
addedById: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
@@ -445,6 +479,8 @@ export interface DB {
|
||||
pageAccess: PageAccess;
|
||||
pagePermissions: PagePermissions;
|
||||
pageHistory: PageHistory;
|
||||
pageVerifications: PageVerifications;
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Notifications,
|
||||
PageAccess as _PageAccess,
|
||||
PagePermissions as _PagePermissions,
|
||||
PageVerifications as _PageVerifications,
|
||||
PageVerifiers as _PageVerifiers,
|
||||
Pages,
|
||||
Spaces,
|
||||
Users,
|
||||
@@ -157,6 +159,15 @@ export type PagePermission = Selectable<_PagePermissions>;
|
||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
||||
|
||||
// Page Verification
|
||||
export type PageVerification = Selectable<_PageVerifications>;
|
||||
export type InsertablePageVerification = Insertable<_PageVerifications>;
|
||||
export type UpdatablePageVerification = Updateable<Omit<_PageVerifications, 'id'>>;
|
||||
|
||||
// Page Verifier
|
||||
export type PageVerifier = Selectable<_PageVerifiers>;
|
||||
export type InsertablePageVerifier = Insertable<_PageVerifiers>;
|
||||
|
||||
// Audit
|
||||
export type Audit = Selectable<_Audit>;
|
||||
export type InsertableAudit = Insertable<_Audit>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 8b7ae8cf1b...91161ffd90
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AUDIT_SERVICE,
|
||||
useClass: NoopAuditService,
|
||||
},
|
||||
],
|
||||
exports: [AUDIT_SERVICE],
|
||||
})
|
||||
export class NoopAuditModule {}
|
||||
@@ -69,6 +69,11 @@ export enum QueueJob {
|
||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
||||
PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
|
||||
PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
|
||||
PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification',
|
||||
PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification',
|
||||
PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification',
|
||||
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
@@ -68,3 +68,46 @@ export interface IPermissionGrantedNotificationJob {
|
||||
actorId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface IVerificationExpiringNotificationJob {
|
||||
verificationId: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
verifierIds: string[];
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface IVerificationExpiredNotificationJob {
|
||||
verificationId: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
verifierIds: string[];
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface IPageVerifiedNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
verifierIds: string[];
|
||||
}
|
||||
|
||||
export interface IApprovalRequestedNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
verifierIds: string[];
|
||||
}
|
||||
|
||||
export interface IApprovalRejectedNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
requestedById: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export const ApprovalRejectedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
comment,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> returned{' '}
|
||||
<strong>{pageTitle}</strong> for revision.
|
||||
</Text>
|
||||
{comment && (
|
||||
<Text style={{ ...paragraph, fontStyle: 'italic' }}>
|
||||
“{comment}”
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View page
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalRejectedEmail;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const ApprovalRequestedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> submitted{' '}
|
||||
<strong>{pageTitle}</strong> for your approval.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
Review page
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalRequestedEmail;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const VerificationExpiredEmail = ({ pageTitle, pageUrl }: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
The verification for <strong>{pageTitle}</strong> has expired. Please
|
||||
re-verify the page to confirm it is still accurate.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
Re-verify page
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationExpiredEmail;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export const VerificationExpiringEmail = ({
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
expiresAt,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
The page <strong>{pageTitle}</strong> needs to be re-verified. The
|
||||
verification expires on <strong>{expiresAt}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
Review page
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationExpiringEmail;
|
||||
Reference in New Issue
Block a user