mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +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",
|
"Failed to update trash retention": "Failed to update trash retention",
|
||||||
"Removed page restriction": "Removed page restriction",
|
"Removed page restriction": "Removed page restriction",
|
||||||
"Added page permission": "Added page permission",
|
"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 WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||||
|
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -105,6 +106,7 @@ export default function App() {
|
|||||||
<Route path={"ai"} element={<AiSettings />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
<Route path={"audit"} element={<AuditLogs />} />
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
|||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||||
|
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
@@ -89,3 +90,11 @@ export const prefetchAuditLogs = () => {
|
|||||||
queryFn: () => getAuditLogs(params),
|
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,
|
IconWorld,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
|
IconShieldCheck,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
prefetchAuditLogs,
|
prefetchAuditLogs,
|
||||||
|
prefetchVerifiedPages,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
@@ -128,6 +130,15 @@ const groupedData: DataGroup[] = [
|
|||||||
isSelfhosted: true,
|
isSelfhosted: true,
|
||||||
showDisabledInNonEE: 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":
|
case "Audit log":
|
||||||
prefetchHandler = prefetchAuditLogs;
|
prefetchHandler = prefetchAuditLogs;
|
||||||
break;
|
break;
|
||||||
|
case "Verified pages":
|
||||||
|
prefetchHandler = prefetchVerifiedPages;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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 React from "react";
|
||||||
import { TitleEditor } from "@/features/editor/title-editor";
|
import { TitleEditor } from "@/features/editor/title-editor";
|
||||||
import PageEditor from "@/features/editor/page-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 { useAtom } from "jotai";
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
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 MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
|
|
||||||
|
type PageCreator = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface FullEditorProps {
|
export interface FullEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
slugId: string;
|
slugId: string;
|
||||||
@@ -16,6 +34,8 @@ export interface FullEditorProps {
|
|||||||
content: string;
|
content: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
creator?: PageCreator;
|
||||||
|
contributors?: IContributor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FullEditor({
|
export function FullEditor({
|
||||||
@@ -25,6 +45,8 @@ export function FullEditor({
|
|||||||
content,
|
content,
|
||||||
spaceSlug,
|
spaceSlug,
|
||||||
editable,
|
editable,
|
||||||
|
creator,
|
||||||
|
contributors,
|
||||||
}: FullEditorProps) {
|
}: FullEditorProps) {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||||
@@ -42,6 +64,11 @@ export function FullEditor({
|
|||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
|
<PageByline
|
||||||
|
creator={creator}
|
||||||
|
contributors={contributors}
|
||||||
|
readOnly={!editable}
|
||||||
|
/>
|
||||||
<MemoizedPageEditor
|
<MemoizedPageEditor
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
@@ -50,3 +77,91 @@ export function FullEditor({
|
|||||||
</Container>
|
</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"
|
return notification.data?.role === "writer"
|
||||||
? t("gave you edit access to a page")
|
? t("gave you edit access to a page")
|
||||||
: t("gave you view 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:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -95,9 +105,13 @@ export function NotificationItem({
|
|||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" lineClamp={2}>
|
<Text size="sm" lineClamp={2}>
|
||||||
<Text span fw={600}>
|
{notification.actor?.name ? (
|
||||||
{notification.actor?.name}
|
<>
|
||||||
</Text>{" "}
|
<Text span fw={600}>
|
||||||
|
{notification.actor.name}
|
||||||
|
</Text>{" "}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{getNotificationMessage()}
|
{getNotificationMessage()}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ export type NotificationType =
|
|||||||
| "comment.created"
|
| "comment.created"
|
||||||
| "comment.resolved"
|
| "comment.resolved"
|
||||||
| "page.user_mention"
|
| "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 = {
|
export type INotification = {
|
||||||
id: string;
|
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 MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import { PageShareModal } from "@/ee/page-permission";
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
|
import {
|
||||||
|
PageVerificationMenuItem,
|
||||||
|
PageVerificationModal,
|
||||||
|
} from "@/ee/page-verification";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -121,6 +125,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
movePageModalOpened,
|
movePageModalOpened,
|
||||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
|
const [
|
||||||
|
verificationOpened,
|
||||||
|
{ open: openVerificationModal, close: closeVerificationModal },
|
||||||
|
] = useDisclosure(false);
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||||
|
|
||||||
@@ -200,6 +208,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
{t("Page history")}
|
{t("Page history")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<PageVerificationMenuItem onClick={openVerificationModal} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -289,6 +301,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
onClose={closeMoveSpaceModal}
|
onClose={closeMoveSpaceModal}
|
||||||
open={movePageModalOpened}
|
open={movePageModalOpened}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PageVerificationModal
|
||||||
|
pageId={page.id}
|
||||||
|
opened={verificationOpened}
|
||||||
|
onClose={closeVerificationModal}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface IPage {
|
|||||||
creator: ICreator;
|
creator: ICreator;
|
||||||
lastUpdatedBy: ILastUpdatedBy;
|
lastUpdatedBy: ILastUpdatedBy;
|
||||||
deletedBy: IDeletedBy;
|
deletedBy: IDeletedBy;
|
||||||
|
contributors?: IContributor[];
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
permissions?: {
|
permissions?: {
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
@@ -29,6 +30,12 @@ export interface IPage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IContributor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ICreator {
|
interface ICreator {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VerificationUpdatedEvent = {
|
||||||
|
operation: "verificationUpdated";
|
||||||
|
pageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WebSocketEvent =
|
export type WebSocketEvent =
|
||||||
| InvalidateEvent
|
| InvalidateEvent
|
||||||
| CommentCreatedEvent
|
| CommentCreatedEvent
|
||||||
@@ -96,4 +101,5 @@ export type WebSocketEvent =
|
|||||||
| AddTreeNodeEvent
|
| AddTreeNodeEvent
|
||||||
| MoveTreeNodeEvent
|
| MoveTreeNodeEvent
|
||||||
| DeleteTreeNodeEvent
|
| DeleteTreeNodeEvent
|
||||||
| RefetchRootTreeNodeEvent;
|
| RefetchRootTreeNodeEvent
|
||||||
|
| VerificationUpdatedEvent;
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "verificationUpdated":
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-verification-info", data.pageId],
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [queryClient, socket]);
|
}, [queryClient, socket]);
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
slugId={page.slugId}
|
slugId={page.slugId}
|
||||||
spaceSlug={page?.space?.slug}
|
spaceSlug={page?.space?.slug}
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
|
creator={page.creator}
|
||||||
|
contributors={page.contributors}
|
||||||
/>
|
/>
|
||||||
<MemoizedHistoryModal pageId={page.id} />
|
<MemoizedHistoryModal pageId={page.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
|||||||
import KeyvRedis from '@keyv/redis';
|
import KeyvRedis from '@keyv/redis';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +48,7 @@ try {
|
|||||||
middleware: { mount: true },
|
middleware: { mount: true },
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
|
NoopAuditModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export const AuditEvent = {
|
|||||||
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
||||||
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
||||||
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
|
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
|
||||||
SHARE_CREATED: 'share.created',
|
SHARE_CREATED: 'share.created',
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
|||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
import {
|
|
||||||
AUDIT_SERVICE,
|
|
||||||
NoopAuditService,
|
|
||||||
} from '../integrations/audit/audit.service';
|
|
||||||
import { ClsMiddleware } from 'nestjs-cls';
|
import { ClsMiddleware } from 'nestjs-cls';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -43,13 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
|
|||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
],
|
],
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AUDIT_SERVICE,
|
|
||||||
useClass: NoopAuditService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [AUDIT_SERVICE],
|
|
||||||
})
|
})
|
||||||
export class CoreModule implements NestModule {
|
export class CoreModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export const NotificationType = {
|
|||||||
COMMENT_RESOLVED: 'comment.resolved',
|
COMMENT_RESOLVED: 'comment.resolved',
|
||||||
PAGE_USER_MENTION: 'page.user_mention',
|
PAGE_USER_MENTION: 'page.user_mention',
|
||||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type NotificationType =
|
export type NotificationType =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
|
|||||||
import { NotificationProcessor } from './notification.processor';
|
import { NotificationProcessor } from './notification.processor';
|
||||||
import { CommentNotificationService } from './services/comment.notification';
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
import { PageNotificationService } from './services/page.notification';
|
import { PageNotificationService } from './services/page.notification';
|
||||||
|
import { VerificationNotificationService } from './services/verification.notification';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [],
|
||||||
@@ -13,6 +14,7 @@ import { PageNotificationService } from './services/page.notification';
|
|||||||
NotificationProcessor,
|
NotificationProcessor,
|
||||||
CommentNotificationService,
|
CommentNotificationService,
|
||||||
PageNotificationService,
|
PageNotificationService,
|
||||||
|
VerificationNotificationService,
|
||||||
],
|
],
|
||||||
exports: [NotificationService],
|
exports: [NotificationService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import {
|
import {
|
||||||
|
IApprovalRejectedNotificationJob,
|
||||||
|
IApprovalRequestedNotificationJob,
|
||||||
ICommentNotificationJob,
|
ICommentNotificationJob,
|
||||||
ICommentResolvedNotificationJob,
|
ICommentResolvedNotificationJob,
|
||||||
IPageMentionNotificationJob,
|
IPageMentionNotificationJob,
|
||||||
|
IPageVerifiedNotificationJob,
|
||||||
IPermissionGrantedNotificationJob,
|
IPermissionGrantedNotificationJob,
|
||||||
|
IVerificationExpiringNotificationJob,
|
||||||
|
IVerificationExpiredNotificationJob,
|
||||||
} from '../../integrations/queue/constants/queue.interface';
|
} from '../../integrations/queue/constants/queue.interface';
|
||||||
import { CommentNotificationService } from './services/comment.notification';
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
import { PageNotificationService } from './services/page.notification';
|
import { PageNotificationService } from './services/page.notification';
|
||||||
|
import { VerificationNotificationService } from './services/verification.notification';
|
||||||
import { DomainService } from '../../integrations/environment/domain.service';
|
import { DomainService } from '../../integrations/environment/domain.service';
|
||||||
|
|
||||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||||
@@ -24,6 +30,7 @@ export class NotificationProcessor
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly commentNotificationService: CommentNotificationService,
|
private readonly commentNotificationService: CommentNotificationService,
|
||||||
private readonly pageNotificationService: PageNotificationService,
|
private readonly pageNotificationService: PageNotificationService,
|
||||||
|
private readonly verificationNotificationService: VerificationNotificationService,
|
||||||
private readonly domainService: DomainService,
|
private readonly domainService: DomainService,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
) {
|
) {
|
||||||
@@ -35,7 +42,12 @@ export class NotificationProcessor
|
|||||||
| ICommentNotificationJob
|
| ICommentNotificationJob
|
||||||
| ICommentResolvedNotificationJob
|
| ICommentResolvedNotificationJob
|
||||||
| IPageMentionNotificationJob
|
| IPageMentionNotificationJob
|
||||||
| IPermissionGrantedNotificationJob,
|
| IPermissionGrantedNotificationJob
|
||||||
|
| IVerificationExpiringNotificationJob
|
||||||
|
| IVerificationExpiredNotificationJob
|
||||||
|
| IPageVerifiedNotificationJob
|
||||||
|
| IApprovalRequestedNotificationJob
|
||||||
|
| IApprovalRejectedNotificationJob,
|
||||||
void
|
void
|
||||||
>,
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -76,6 +88,45 @@ export class NotificationProcessor
|
|||||||
break;
|
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:
|
default:
|
||||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
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>;
|
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 {
|
export interface DB {
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
@@ -445,6 +479,8 @@ export interface DB {
|
|||||||
pageAccess: PageAccess;
|
pageAccess: PageAccess;
|
||||||
pagePermissions: PagePermissions;
|
pagePermissions: PagePermissions;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
|
pageVerifications: PageVerifications;
|
||||||
|
pageVerifiers: PageVerifiers;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Notifications,
|
Notifications,
|
||||||
PageAccess as _PageAccess,
|
PageAccess as _PageAccess,
|
||||||
PagePermissions as _PagePermissions,
|
PagePermissions as _PagePermissions,
|
||||||
|
PageVerifications as _PageVerifications,
|
||||||
|
PageVerifiers as _PageVerifiers,
|
||||||
Pages,
|
Pages,
|
||||||
Spaces,
|
Spaces,
|
||||||
Users,
|
Users,
|
||||||
@@ -157,6 +159,15 @@ export type PagePermission = Selectable<_PagePermissions>;
|
|||||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
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
|
// Audit
|
||||||
export type Audit = Selectable<_Audit>;
|
export type Audit = Selectable<_Audit>;
|
||||||
export type InsertableAudit = Insertable<_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',
|
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
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_LOG = 'audit-log',
|
||||||
AUDIT_CLEANUP = 'audit-cleanup',
|
AUDIT_CLEANUP = 'audit-cleanup',
|
||||||
|
|||||||
@@ -68,3 +68,46 @@ export interface IPermissionGrantedNotificationJob {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
role: 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