mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
17 Commits
security-471
...
verify
| Author | SHA1 | Date | |
|---|---|---|---|
| 630188e0f7 | |||
| 655c5a724f | |||
| 89f3600548 | |||
| 56017921e7 | |||
| 8fbe802f3c | |||
| 8235980eeb | |||
| b6350f3a2f | |||
| c0c146a42b | |||
| df0cb101b1 | |||
| 6bb84b8b88 | |||
| a97f9047bc | |||
| 0f75f1197e | |||
| 5a071e5e06 | |||
| 37b93fab0a | |||
| 1bd63101d6 | |||
| 77f0aa6483 | |||
| 759ce0611d |
@@ -739,6 +739,93 @@
|
||||
"Removed page restriction": "Removed page restriction",
|
||||
"Added page permission": "Added 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",
|
||||
"Add verification": "Add verification",
|
||||
"Edit verification": "Edit verification",
|
||||
"Search by title": "Search by title",
|
||||
"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",
|
||||
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verified a page",
|
||||
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> submitted a page for your approval",
|
||||
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
|
||||
"Page verification expires soon": "Page verification expires soon",
|
||||
"Page verification has expired": "Page verification has expired",
|
||||
"Verifying your email": "Verifying your email",
|
||||
"Please wait...": "Please wait...",
|
||||
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
|
||||
|
||||
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
@@ -119,6 +120,7 @@ export default function App() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||
import { getShares } from "@/features/share/services/share-service.ts";
|
||||
import { getApiKeys } from "@/ee/api-key";
|
||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||
|
||||
export const prefetchWorkspaceMembers = () => {
|
||||
const params: QueryParams = { limit: 100, query: "" };
|
||||
@@ -89,3 +90,11 @@ export const prefetchAuditLogs = () => {
|
||||
queryFn: () => getAuditLogs(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchVerifiedPages = () => {
|
||||
const params = { limit: 50 };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["verification-list", params],
|
||||
queryFn: () => getVerificationList(params),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
prefetchSsoProviders,
|
||||
prefetchWorkspaceMembers,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -95,6 +97,12 @@ const groupedData: DataGroup[] = [
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||
{
|
||||
label: "Verified pages",
|
||||
icon: IconShieldCheck,
|
||||
path: "/settings/verifications",
|
||||
feature: Feature.PAGE_VERIFICATION,
|
||||
},
|
||||
{
|
||||
label: "API management",
|
||||
icon: IconKey,
|
||||
@@ -210,6 +218,9 @@ export default function SettingsSidebar() {
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
prefetchHandler = prefetchVerifiedPages;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,13 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"page.restriction_removed": "Removed page restriction",
|
||||
"page.permission_added": "Added page permission",
|
||||
"page.permission_removed": "Removed page permission",
|
||||
"page.verification_created": "Created page verification",
|
||||
"page.verification_updated": "Updated page verification",
|
||||
"page.verification_removed": "Removed page verification",
|
||||
"page.verified": "Verified page",
|
||||
"page.approval_requested": "Requested page approval",
|
||||
"page.approval_rejected": "Rejected page approval",
|
||||
"page.marked_obsolete": "Marked page as obsolete",
|
||||
|
||||
"share.created": "Created share link",
|
||||
"share.deleted": "Deleted share link",
|
||||
@@ -136,6 +143,13 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
||||
{ value: "page.permission_added", label: "Added page permission" },
|
||||
{ value: "page.permission_removed", label: "Removed page permission" },
|
||||
{ value: "page.verification_created", label: "Created page verification" },
|
||||
{ value: "page.verification_updated", label: "Updated page verification" },
|
||||
{ value: "page.verification_removed", label: "Removed page verification" },
|
||||
{ value: "page.verified", label: "Verified page" },
|
||||
{ value: "page.approval_requested", label: "Requested page approval" },
|
||||
{ value: "page.approval_rejected", label: "Rejected page approval" },
|
||||
{ value: "page.marked_obsolete", label: "Marked page as obsolete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -87,7 +87,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
accept=".txt,.license"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
hidden
|
||||
|
||||
@@ -2,14 +2,15 @@ import { Group, List, Stack, Table, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
const enterpriseFeatures = [
|
||||
"SSO (SAML, OIDC, LDAP)",
|
||||
"AI Integration (Search & Assistant)",
|
||||
"Page-level Permissions",
|
||||
"Audit Logs",
|
||||
"API Keys",
|
||||
"AI Integration (Chat, Search & Assistant)",
|
||||
"MCP Support",
|
||||
"SSO (SAML, OIDC, LDAP)",
|
||||
"Multi-factor Authentication (2FA)",
|
||||
"Page-level Permissions",
|
||||
"Page verification & approval workflow",
|
||||
"Audit Logs",
|
||||
"Enterprise Controls",
|
||||
"API Keys",
|
||||
"Advanced Search Engine Support",
|
||||
"Full-text Search in Attachments (PDF, DOCX)",
|
||||
"Resolve Comments",
|
||||
@@ -68,11 +69,31 @@ export default function OssDetails() {
|
||||
</List>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Get an enterprise trial key at <a href="https://customers.docmost.com/" target="_blank" rel="noopener noreferrer">customers.docmost.com</a>.
|
||||
Get an enterprise trial key at{" "}
|
||||
<a
|
||||
href="https://customers.docmost.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
customers.docmost.com
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Visit <a href="https://docmost.com/pricing" target="_blank" rel="noopener noreferrer">docmost.com/pricing</a> to purchase an enterprise license.
|
||||
Visit{" "}
|
||||
<a
|
||||
href="https://docmost.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docmost.com/pricing
|
||||
</a>{" "}
|
||||
to purchase an enterprise license.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
For inquiries, contact{" "}
|
||||
<a href="mailto:sales@docmost.com">sales@docmost.com</a>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -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,633 @@
|
||||
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"
|
||||
maxLength={500}
|
||||
/>
|
||||
<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,186 @@
|
||||
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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
import { Feature } from "@/ee/features";
|
||||
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 hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
|
||||
const { data: verificationInfo, isLoading } = usePageVerificationInfoQuery(
|
||||
hasVerificationFeature ? pageId : undefined,
|
||||
);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
if (!pageId) return null;
|
||||
if (!hasVerificationFeature) {
|
||||
if (readOnly) return null;
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${t("Add verification")} — ${upgradeLabel}`}
|
||||
withArrow
|
||||
openDelay={250}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconShieldCheck size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
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 = {
|
||||
pageId?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function PageVerificationMenuItem({
|
||||
pageId,
|
||||
onClick,
|
||||
}: PageVerificationMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasVerificationFeature = useHasFeature(Feature.PAGE_VERIFICATION);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const { data: verificationInfo } = usePageVerificationInfoQuery(
|
||||
hasVerificationFeature ? pageId : undefined,
|
||||
);
|
||||
|
||||
const hasVerification =
|
||||
!!verificationInfo && verificationInfo.status !== "none";
|
||||
const label = hasVerification
|
||||
? t("Edit verification")
|
||||
: t("Add verification");
|
||||
|
||||
const menuItem = (
|
||||
<Menu.Item
|
||||
disabled={!hasVerificationFeature}
|
||||
leftSection={<IconShieldCheck size={16} />}
|
||||
onClick={hasVerificationFeature ? onClick : undefined}
|
||||
>
|
||||
{label}
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
if (!hasVerificationFeature) {
|
||||
return (
|
||||
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
|
||||
{menuItem}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
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,
|
||||
onClose,
|
||||
}: 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),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (!isQms) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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,218 @@
|
||||
import {
|
||||
Table,
|
||||
Text,
|
||||
Group,
|
||||
Skeleton,
|
||||
Anchor,
|
||||
Badge,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
} 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";
|
||||
|
||||
const MAX_VISIBLE_VERIFIERS = 5;
|
||||
|
||||
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="orange" 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={8} wrap="nowrap">
|
||||
<Skeleton circle height={24} />
|
||||
<Skeleton circle height={24} />
|
||||
<Skeleton circle height={24} />
|
||||
</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("Verifiers")}</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 verifiers = item.verifiers ?? [];
|
||||
|
||||
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>
|
||||
{verifiers.length === 1 ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={verifiers[0].avatarUrl}
|
||||
name={verifiers[0].name}
|
||||
/>
|
||||
<Text fz="sm" lineClamp={1}>
|
||||
{verifiers[0].name}
|
||||
</Text>
|
||||
</Group>
|
||||
) : verifiers.length > 1 ? (
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Avatar.Group spacing={8}>
|
||||
{verifiers
|
||||
.slice(0, MAX_VISIBLE_VERIFIERS)
|
||||
.map((verifier) => (
|
||||
<Tooltip
|
||||
key={verifier.id}
|
||||
label={verifier.name}
|
||||
withArrow
|
||||
>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={verifier.avatarUrl}
|
||||
name={verifier.name}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{verifiers.length > MAX_VISIBLE_VERIFIERS && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={verifiers
|
||||
.slice(MAX_VISIBLE_VERIFIERS)
|
||||
.map((v) => (
|
||||
<div key={v.id}>{v.name}</div>
|
||||
))}
|
||||
>
|
||||
<Avatar size="sm" color="gray">
|
||||
+{verifiers.length - MAX_VISIBLE_VERIFIERS}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</Tooltip.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,127 @@
|
||||
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";
|
||||
|
||||
export default function VerifiedPages() {
|
||||
const { t } = useTranslation();
|
||||
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);
|
||||
|
||||
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 by title")}
|
||||
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>(
|
||||
"/pages/verification-info",
|
||||
{ pageId },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function setupVerification(
|
||||
data: ISetupVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/pages/create-verification", data);
|
||||
}
|
||||
|
||||
export async function updateVerification(
|
||||
data: IUpdateVerification,
|
||||
): Promise<void> {
|
||||
await api.post("/pages/update-verification", data);
|
||||
}
|
||||
|
||||
export async function removeVerification(pageId: string): Promise<void> {
|
||||
await api.post("/pages/delete-verification", { pageId });
|
||||
}
|
||||
|
||||
export async function verifyPage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/verify", { pageId });
|
||||
}
|
||||
|
||||
export async function submitForApproval(pageId: string): Promise<void> {
|
||||
await api.post("/pages/submit-for-approval", { pageId });
|
||||
}
|
||||
|
||||
export async function rejectApproval(data: {
|
||||
pageId: string;
|
||||
comment?: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/pages/reject-approval", data);
|
||||
}
|
||||
|
||||
export async function markObsolete(pageId: string): Promise<void> {
|
||||
await api.post("/pages/mark-obsolete", { pageId });
|
||||
}
|
||||
|
||||
export async function getVerificationList(
|
||||
params?: IVerificationListParams,
|
||||
): Promise<IPagination<IVerificationListItem>> {
|
||||
const req = await api.post("/pages/verifications", { ...params });
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export type VerificationType = "expiring" | "qms";
|
||||
|
||||
export type ExpirationMode = "period" | "fixed" | "indefinite";
|
||||
|
||||
export type PeriodUnit = "day" | "week" | "month" | "year";
|
||||
|
||||
export type VerificationStatus =
|
||||
| "verified"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "draft"
|
||||
| "in_approval"
|
||||
| "approved"
|
||||
| "obsolete"
|
||||
| "none";
|
||||
|
||||
export type IUserRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export type IVerifier = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type IPageVerificationInfo = {
|
||||
id?: string;
|
||||
pageId?: string;
|
||||
type?: VerificationType;
|
||||
mode?: ExpirationMode | null;
|
||||
periodAmount?: number | null;
|
||||
periodUnit?: PeriodUnit | null;
|
||||
status: VerificationStatus;
|
||||
verifiedAt?: string | null;
|
||||
verifiedBy?: IUserRef | null;
|
||||
expiresAt?: string | null;
|
||||
requestedAt?: string | null;
|
||||
requestedBy?: IUserRef | null;
|
||||
rejectedAt?: string | null;
|
||||
rejectedBy?: IUserRef | null;
|
||||
rejectionComment?: string | null;
|
||||
verifiers?: IVerifier[];
|
||||
permissions?: IPageVerificationPermissions;
|
||||
};
|
||||
|
||||
export type IPageVerificationPermissions = {
|
||||
canVerify: boolean;
|
||||
canManage: boolean;
|
||||
canSubmitForApproval: boolean;
|
||||
canMarkObsolete: boolean;
|
||||
};
|
||||
|
||||
export type ISetupVerification = {
|
||||
pageId: string;
|
||||
type?: VerificationType;
|
||||
mode?: ExpirationMode;
|
||||
periodAmount?: number;
|
||||
periodUnit?: PeriodUnit;
|
||||
fixedExpiresAt?: string;
|
||||
verifierIds: string[];
|
||||
};
|
||||
|
||||
export type IUpdateVerification = {
|
||||
pageId: string;
|
||||
mode?: ExpirationMode;
|
||||
periodAmount?: number;
|
||||
periodUnit?: PeriodUnit;
|
||||
fixedExpiresAt?: string;
|
||||
verifierIds?: string[];
|
||||
};
|
||||
|
||||
export type IVerificationListItem = {
|
||||
id: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
type: VerificationType;
|
||||
status: VerificationStatus | null;
|
||||
mode: ExpirationMode | null;
|
||||
periodAmount: number | null;
|
||||
periodUnit: PeriodUnit | null;
|
||||
verifiedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
pageTitle: string | null;
|
||||
pageSlugId: string;
|
||||
pageIcon: string | null;
|
||||
spaceName: string;
|
||||
spaceSlug: string;
|
||||
verifiers: IUserRef[];
|
||||
};
|
||||
|
||||
export type IVerificationListParams = {
|
||||
spaceIds?: string[];
|
||||
verifierId?: string;
|
||||
type?: VerificationType;
|
||||
cursor?: string;
|
||||
beforeCursor?: string;
|
||||
limit?: number;
|
||||
query?: string;
|
||||
};
|
||||
@@ -2,13 +2,31 @@ import classes from "@/features/editor/styles/editor.module.css";
|
||||
import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
import {
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { PageVerificationBadge } from "@/ee/page-verification";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
|
||||
type PageCreator = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export interface FullEditorProps {
|
||||
pageId: string;
|
||||
slugId: string;
|
||||
@@ -16,6 +34,8 @@ export interface FullEditorProps {
|
||||
content: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
creator?: PageCreator;
|
||||
contributors?: IContributor[];
|
||||
canComment?: boolean;
|
||||
}
|
||||
|
||||
@@ -26,6 +46,8 @@ export function FullEditor({
|
||||
content,
|
||||
spaceSlug,
|
||||
editable,
|
||||
creator,
|
||||
contributors,
|
||||
canComment,
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -44,6 +66,11 @@ export function FullEditor({
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
readOnly={!editable}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
@@ -53,3 +80,91 @@ export function FullEditor({
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
creator?: PageCreator;
|
||||
contributors?: IContributor[];
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({
|
||||
creator,
|
||||
contributors,
|
||||
readOnly,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
mb="md"
|
||||
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
|
||||
>
|
||||
{creator && (
|
||||
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||
<Popover.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={6}>
|
||||
<CustomAvatar
|
||||
avatarUrl={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size={22}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("By {{name}}", { name: creator.name })}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size={36}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{creator.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{otherContributors.length === 0
|
||||
? t("Owner, no contributors")
|
||||
: t("Owner")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{otherContributors.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text size="xs" fw={500} c="dimmed" tt="uppercase">
|
||||
{t("Contributors")}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{otherContributors.map((contributor) => (
|
||||
<Group gap="sm" key={contributor.id}>
|
||||
<CustomAvatar
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
name={contributor.name}
|
||||
size={28}
|
||||
/>
|
||||
<Text size="sm">{contributor.name}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<PageVerificationBadge readOnly={readOnly} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBell,
|
||||
IconCheck,
|
||||
IconFileDescription,
|
||||
IconPointFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { Avatar } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { INotification } from "../types/notification.types";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -51,6 +53,16 @@ export function NotificationItem({
|
||||
: "<bold>{{name}}</bold> gave you view access to a page";
|
||||
case "page.updated":
|
||||
return "<bold>{{name}}</bold> updated a page";
|
||||
case "page.verified":
|
||||
return "<bold>{{name}}</bold> verified a page";
|
||||
case "page.approval_requested":
|
||||
return "<bold>{{name}}</bold> submitted a page for your approval";
|
||||
case "page.approval_rejected":
|
||||
return "<bold>{{name}}</bold> returned a page for revision";
|
||||
case "page.verification_expiring":
|
||||
return "Page verification expires soon";
|
||||
case "page.verification_expired":
|
||||
return "Page verification has expired";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -96,11 +108,17 @@ export function NotificationItem({
|
||||
className={classes.notificationItem}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={notification.actor?.avatarUrl}
|
||||
name={notification.actor?.name || "?"}
|
||||
size="sm"
|
||||
/>
|
||||
{notification.actor ? (
|
||||
<CustomAvatar
|
||||
avatarUrl={notification.actor.avatarUrl}
|
||||
name={notification.actor.name}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Avatar size="sm" color="gray" radius="xl">
|
||||
<IconBell size={14} />
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" lineClamp={2}>
|
||||
|
||||
@@ -4,7 +4,12 @@ export type NotificationType =
|
||||
| "comment.resolved"
|
||||
| "page.user_mention"
|
||||
| "page.permission_granted"
|
||||
| "page.updated";
|
||||
| "page.updated"
|
||||
| "page.verification_expiring"
|
||||
| "page.verification_expired"
|
||||
| "page.verified"
|
||||
| "page.approval_requested"
|
||||
| "page.approval_rejected";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
|
||||
@@ -44,6 +44,10 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
PageVerificationMenuItem,
|
||||
PageVerificationModal,
|
||||
} from "@/ee/page-verification";
|
||||
import {
|
||||
useFavoriteIds,
|
||||
useAddFavoriteMutation,
|
||||
@@ -135,6 +139,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
movePageModalOpened,
|
||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const [
|
||||
verificationOpened,
|
||||
{ open: openVerificationModal, close: closeVerificationModal },
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
const favoriteIds = useFavoriteIds("page");
|
||||
@@ -261,6 +269,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{t("Page history")}
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<PageVerificationMenuItem
|
||||
pageId={page?.id}
|
||||
onClick={openVerificationModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
{!readOnly && (
|
||||
@@ -350,6 +365,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
onClose={closeMoveSpaceModal}
|
||||
open={movePageModalOpened}
|
||||
/>
|
||||
|
||||
<PageVerificationModal
|
||||
pageId={page.id}
|
||||
opened={verificationOpened}
|
||||
onClose={closeVerificationModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface IPage {
|
||||
creator: ICreator;
|
||||
lastUpdatedBy: ILastUpdatedBy;
|
||||
deletedBy: IDeletedBy;
|
||||
contributors?: IContributor[];
|
||||
space: Partial<ISpace>;
|
||||
permissions?: {
|
||||
canEdit: boolean;
|
||||
@@ -29,6 +30,12 @@ export interface IPage {
|
||||
};
|
||||
}
|
||||
|
||||
export interface IContributor {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
interface ICreator {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
export type VerificationUpdatedEvent = {
|
||||
operation: "verificationUpdated";
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export type WebSocketEvent =
|
||||
| InvalidateEvent
|
||||
| CommentCreatedEvent
|
||||
@@ -96,4 +101,5 @@ export type WebSocketEvent =
|
||||
| AddTreeNodeEvent
|
||||
| MoveTreeNodeEvent
|
||||
| DeleteTreeNodeEvent
|
||||
| RefetchRootTreeNodeEvent;
|
||||
| RefetchRootTreeNodeEvent
|
||||
| VerificationUpdatedEvent;
|
||||
|
||||
@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "verificationUpdated":
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["page-verification-info", data.pageId],
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [queryClient, socket]);
|
||||
|
||||
@@ -107,6 +107,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
slugId={page.slugId}
|
||||
spaceSlug={page?.space?.slug}
|
||||
editable={canEdit}
|
||||
creator={page.creator}
|
||||
contributors={page.contributors}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<MemoizedHistoryModal pageId={page.id} />
|
||||
|
||||
@@ -143,6 +143,18 @@ export function getPageId(documentName: string) {
|
||||
return documentName.split('.')[1];
|
||||
}
|
||||
|
||||
export function isEmptyParagraphDoc(tiptapJson: JSONContent): boolean {
|
||||
if (!tiptapJson || tiptapJson.type !== 'doc') return false;
|
||||
const content = tiptapJson.content;
|
||||
if (!Array.isArray(content) || content.length !== 1) return false;
|
||||
const child = content[0];
|
||||
if (!child || child.type !== 'paragraph') return false;
|
||||
return (
|
||||
!child.content ||
|
||||
(Array.isArray(child.content) && child.content.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
function stripUnknownNodes(
|
||||
json: JSONContent,
|
||||
schema: Schema,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { CollabHistoryService } from '../services/collab-history.service';
|
||||
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||
import { isEmptyParagraphDoc } from '../collaboration.util';
|
||||
|
||||
@Processor(QueueName.HISTORY_QUEUE)
|
||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@@ -55,6 +56,14 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
{ includeContent: true },
|
||||
);
|
||||
|
||||
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
|
||||
this.logger.debug(
|
||||
`Skipping first history for page ${pageId}: empty content`,
|
||||
);
|
||||
await this.collabHistory.clearContributors(pageId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||
|
||||
@@ -59,6 +59,14 @@ export const AuditEvent = {
|
||||
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
||||
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
||||
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
|
||||
// Page verification
|
||||
PAGE_VERIFICATION_CREATED: 'page.verification_created',
|
||||
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
|
||||
PAGE_VERIFICATION_REMOVED: 'page.verification_removed',
|
||||
PAGE_VERIFIED: 'page.verified',
|
||||
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||
PAGE_MARKED_OBSOLETE: 'page.marked_obsolete',
|
||||
|
||||
// Share
|
||||
SHARE_CREATED: 'share.created',
|
||||
|
||||
@@ -5,6 +5,11 @@ export const NotificationType = {
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||
PAGE_UPDATED: 'page.updated',
|
||||
PAGE_VERIFICATION_EXPIRING: 'page.verification_expiring',
|
||||
PAGE_VERIFICATION_EXPIRED: 'page.verification_expired',
|
||||
PAGE_VERIFIED: 'page.verified',
|
||||
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
|
||||
|
||||
@Module({
|
||||
@@ -14,6 +15,7 @@ import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-li
|
||||
NotificationProcessor,
|
||||
CommentNotificationService,
|
||||
PageNotificationService,
|
||||
VerificationNotificationService,
|
||||
PageUpdateEmailRateLimiter,
|
||||
],
|
||||
exports: [NotificationService],
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import {
|
||||
IApprovalRejectedNotificationJob,
|
||||
IApprovalRequestedNotificationJob,
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPageVerifiedNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
IVerificationReconcileJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
import { DomainService } from '../../integrations/environment/domain.service';
|
||||
|
||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||
@@ -25,7 +33,9 @@ export class NotificationProcessor
|
||||
constructor(
|
||||
private readonly commentNotificationService: CommentNotificationService,
|
||||
private readonly pageNotificationService: PageNotificationService,
|
||||
private readonly verificationNotificationService: VerificationNotificationService,
|
||||
private readonly domainService: DomainService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
@@ -37,12 +47,23 @@ export class NotificationProcessor
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob
|
||||
| IPageUpdateNotificationJob
|
||||
| IPermissionGrantedNotificationJob,
|
||||
| IPermissionGrantedNotificationJob
|
||||
| IVerificationExpiringNotificationJob
|
||||
| IVerificationExpiredNotificationJob
|
||||
| IVerificationReconcileJob
|
||||
| IPageVerifiedNotificationJob
|
||||
| IApprovalRequestedNotificationJob
|
||||
| IApprovalRejectedNotificationJob,
|
||||
void
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
|
||||
if (job.name === QueueJob.VERIFICATION_RECONCILE) {
|
||||
await this.runVerificationReconcile();
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId = await this.resolveWorkspaceId(job);
|
||||
const appUrl = await this.getWorkspaceUrl(workspaceId);
|
||||
|
||||
switch (job.name) {
|
||||
@@ -92,6 +113,45 @@ export class NotificationProcessor
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRING: {
|
||||
await this.verificationNotificationService.processVerificationExpiring(
|
||||
job.data as IVerificationExpiringNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRED: {
|
||||
await this.verificationNotificationService.processVerificationExpired(
|
||||
job.data as IVerificationExpiredNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFIED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processPageVerified(
|
||||
job.data as IPageVerifiedNotificationJob,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRequested(
|
||||
job.data as IApprovalRequestedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRejected(
|
||||
job.data as IApprovalRejectedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||
}
|
||||
@@ -102,6 +162,49 @@ export class NotificationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorkspaceId(job: Job): Promise<string> {
|
||||
if (
|
||||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRING ||
|
||||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRED
|
||||
) {
|
||||
const { verificationId } = job.data as { verificationId: string };
|
||||
const row = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.select('workspaceId')
|
||||
.where('id', '=', verificationId)
|
||||
.executeTakeFirst();
|
||||
return row?.workspaceId ?? '';
|
||||
}
|
||||
return (job.data as { workspaceId: string }).workspaceId;
|
||||
}
|
||||
|
||||
private async runVerificationReconcile(): Promise<void> {
|
||||
let eeModule: { PageVerificationSchedulerService?: unknown };
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
eeModule = require('../../ee/page-verification/page-verification-scheduler.service');
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
'VERIFICATION_RECONCILE fired but EE scheduler not bundled in this build',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const schedulerClass = eeModule.PageVerificationSchedulerService as
|
||||
| (new (...args: unknown[]) => { reconcile(): Promise<void> })
|
||||
| undefined;
|
||||
if (!schedulerClass) return;
|
||||
|
||||
const scheduler = this.moduleRef.get(schedulerClass, { strict: false });
|
||||
if (!scheduler) {
|
||||
this.logger.warn(
|
||||
'VERIFICATION_RECONCILE fired but scheduler service not resolvable',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await scheduler.reconcile();
|
||||
}
|
||||
|
||||
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
@Injectable()
|
||||
export class VerificationNotificationService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
private async getAlreadyNotifiedUserIds(
|
||||
pageVerificationId: string,
|
||||
type: string,
|
||||
): Promise<Set<string>> {
|
||||
const rows = await this.db
|
||||
.selectFrom('notifications')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', pageVerificationId)
|
||||
.where('type', '=', type)
|
||||
.execute();
|
||||
return new Set(rows.map((r) => r.userId));
|
||||
}
|
||||
|
||||
private async filterAccessibleRecipients(
|
||||
userIds: string[],
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
): Promise<string[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
const inSpace = await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
);
|
||||
if (inSpace.size === 0) return [];
|
||||
return this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...inSpace,
|
||||
]);
|
||||
}
|
||||
|
||||
async processVerificationExpiring(
|
||||
data: IVerificationExpiringNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const verification = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.selectAll()
|
||||
.where('id', '=', data.verificationId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!verification) return;
|
||||
if (verification.type !== 'expiring') return;
|
||||
if (!verification.expiresAt) return;
|
||||
const expiresAtMs = new Date(verification.expiresAt).getTime();
|
||||
if (expiresAtMs <= Date.now()) return;
|
||||
|
||||
const verifierRows = await this.db
|
||||
.selectFrom('pageVerifiers')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', verification.id)
|
||||
.execute();
|
||||
const verifierIds = verifierRows.map((r) => r.userId);
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
|
||||
verification.id,
|
||||
NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
);
|
||||
const recipients = accessibleVerifierIds.filter(
|
||||
(id) => !alreadyNotified.has(id),
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const expiresAtIso = new Date(verification.expiresAt).toISOString();
|
||||
|
||||
for (const userId of recipients) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId: verification.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
pageId: verification.pageId,
|
||||
spaceId: verification.spaceId,
|
||||
pageVerificationId: verification.id,
|
||||
data: { expiresAt: expiresAtIso },
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs to be re-verified soon`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiringEmail({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processVerificationExpired(
|
||||
data: IVerificationExpiredNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const verification = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.selectAll()
|
||||
.where('id', '=', data.verificationId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!verification) return;
|
||||
if (verification.type !== 'expiring') return;
|
||||
if (!verification.expiresAt) return;
|
||||
if (new Date(verification.expiresAt).getTime() > Date.now()) return;
|
||||
|
||||
const verifierRows = await this.db
|
||||
.selectFrom('pageVerifiers')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', verification.id)
|
||||
.execute();
|
||||
const verifierIds = verifierRows.map((r) => r.userId);
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
|
||||
verification.id,
|
||||
NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
);
|
||||
const recipients = accessibleVerifierIds.filter(
|
||||
(id) => !alreadyNotified.has(id),
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
|
||||
for (const userId of recipients) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId: verification.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
pageId: verification.pageId,
|
||||
spaceId: verification.spaceId,
|
||||
pageVerificationId: verification.id,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" verification has expired`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiredEmail({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processPageVerified(data: IPageVerifiedNotificationJob) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
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 accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
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,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processApprovalRejected(
|
||||
data: IApprovalRejectedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
|
||||
data;
|
||||
|
||||
const recipients = await this.filterAccessibleRecipients(
|
||||
[requestedById],
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, 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,
|
||||
spaceName,
|
||||
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', 'name'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
if (!page || !space) return null;
|
||||
|
||||
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
return { pageTitle: getPageTitle(page.title), spaceName: space.name ?? space.slug, basePageUrl };
|
||||
}
|
||||
}
|
||||
@@ -452,6 +452,20 @@ export class PageService {
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update page verifications
|
||||
await trx
|
||||
.updateTable('pageVerifications')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update notifications — access follows the page after a move
|
||||
await trx
|
||||
.updateTable('notifications')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update attachments
|
||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||
{ spaceId },
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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('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')
|
||||
.ifNotExists()
|
||||
.on('page_verifications')
|
||||
.column('expires_at')
|
||||
.where('expires_at', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_verifications_workspace_id_id')
|
||||
.ifNotExists()
|
||||
.on('page_verifications')
|
||||
.columns(['workspace_id', 'id desc'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_verifications_space_id')
|
||||
.ifNotExists()
|
||||
.on('page_verifications')
|
||||
.column('space_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_verifiers_user_id')
|
||||
.ifNotExists()
|
||||
.on('page_verifiers')
|
||||
.column('user_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('notifications')
|
||||
.addColumn('page_verification_id', 'uuid', (col) =>
|
||||
col.references('page_verifications.id').onDelete('cascade'),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('notifications')
|
||||
.dropColumn('page_verification_id')
|
||||
.execute();
|
||||
await db.schema.dropTable('page_verifiers').ifExists().execute();
|
||||
await db.schema.dropTable('page_verifications').ifExists().execute();
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants';
|
||||
import {
|
||||
NotificationTab,
|
||||
NotificationType,
|
||||
} from '../../../core/notification/notification.constants';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRepo {
|
||||
@@ -43,7 +46,11 @@ export class NotificationRepo {
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
eb(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -62,6 +69,14 @@ export class NotificationRepo {
|
||||
});
|
||||
}
|
||||
|
||||
async insert(notification: InsertableNotification): Promise<Notification> {
|
||||
return this.db
|
||||
.insertInto('notifications')
|
||||
.values(notification)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.selectFrom('notifications')
|
||||
@@ -71,7 +86,11 @@ export class NotificationRepo {
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
eb(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
@@ -79,14 +98,6 @@ export class NotificationRepo {
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async insert(notification: InsertableNotification): Promise<Notification> {
|
||||
return this.db
|
||||
.insertInto('notifications')
|
||||
.values(notification)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('notifications')
|
||||
@@ -94,12 +105,6 @@ export class NotificationRepo {
|
||||
.where('id', '=', notificationId)
|
||||
.where('userId', '=', userId)
|
||||
.where('readAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -116,21 +121,6 @@ export class NotificationRepo {
|
||||
.where('id', 'in', notificationIds)
|
||||
.where('userId', '=', userId)
|
||||
.where('readAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async markAsEmailed(notificationId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('notifications')
|
||||
.set({ emailedAt: new Date() })
|
||||
.where('id', '=', notificationId)
|
||||
.where('emailedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -140,12 +130,15 @@ export class NotificationRepo {
|
||||
.set({ readAt: new Date() })
|
||||
.where('userId', '=', userId)
|
||||
.where('readAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async markAsEmailed(notificationId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('notifications')
|
||||
.set({ emailedAt: new Date() })
|
||||
.where('id', '=', notificationId)
|
||||
.where('emailedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -400,6 +400,7 @@ export interface Notifications {
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
commentId: string | null;
|
||||
pageVerificationId: string | null;
|
||||
data: Json | null;
|
||||
readAt: Timestamp | null;
|
||||
emailedAt: Timestamp | null;
|
||||
@@ -441,6 +442,39 @@ export interface PagePermissions {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PageVerifications {
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
spaceId: string;
|
||||
type: Generated<string>;
|
||||
status: string | null;
|
||||
mode: string | null;
|
||||
periodAmount: number | null;
|
||||
periodUnit: string | null;
|
||||
verifiedAt: Timestamp | null;
|
||||
verifiedById: string | null;
|
||||
expiresAt: Timestamp | null;
|
||||
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 Templates {
|
||||
id: Generated<string>;
|
||||
title: string | null;
|
||||
@@ -519,6 +553,8 @@ export interface DB {
|
||||
pageAccess: PageAccess;
|
||||
pagePermissions: PagePermissions;
|
||||
pageHistory: PageHistory;
|
||||
pageVerifications: PageVerifications;
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Notifications,
|
||||
PageAccess as _PageAccess,
|
||||
PagePermissions as _PagePermissions,
|
||||
PageVerifications as _PageVerifications,
|
||||
PageVerifiers as _PageVerifiers,
|
||||
Pages,
|
||||
Spaces,
|
||||
Users,
|
||||
@@ -182,6 +184,15 @@ export type PagePermission = Selectable<_PagePermissions>;
|
||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
||||
|
||||
// Page Verification
|
||||
export type PageVerification = Selectable<_PageVerifications>;
|
||||
export type InsertablePageVerification = Insertable<_PageVerifications>;
|
||||
export type UpdatablePageVerification = Updateable<Omit<_PageVerifications, 'id'>>;
|
||||
|
||||
// Page Verifier
|
||||
export type PageVerifier = Selectable<_PageVerifiers>;
|
||||
export type InsertablePageVerifier = Insertable<_PageVerifiers>;
|
||||
|
||||
// User Session
|
||||
export type UserSession = Selectable<UserSessions>;
|
||||
export type InsertableUserSession = Insertable<UserSessions>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: e9429b8fdf...a5b5e10eec
@@ -71,6 +71,12 @@ export enum QueueJob {
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
||||
PAGE_UPDATE_DIGEST = 'page-update-digest',
|
||||
PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
|
||||
PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
|
||||
VERIFICATION_RECONCILE = 'verification-reconcile',
|
||||
PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification',
|
||||
PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification',
|
||||
PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification',
|
||||
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
@@ -76,3 +76,40 @@ export interface IPermissionGrantedNotificationJob {
|
||||
actorId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface IVerificationExpiringNotificationJob {
|
||||
verificationId: string;
|
||||
}
|
||||
|
||||
export interface IVerificationExpiredNotificationJob {
|
||||
verificationId: string;
|
||||
}
|
||||
|
||||
export interface IVerificationReconcileJob {
|
||||
// no payload
|
||||
}
|
||||
|
||||
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,41 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export const ApprovalRejectedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl,
|
||||
comment,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> returned{' '}
|
||||
<strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space for revision.
|
||||
</Text>
|
||||
{comment && (
|
||||
<Text style={{ ...paragraph, fontStyle: 'italic' }}>
|
||||
“{comment}”
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalRejectedEmail;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const ApprovalRequestedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> submitted{' '}
|
||||
<strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space for your approval.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>Review page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalRequestedEmail;
|
||||
@@ -27,7 +27,7 @@ export const PageUpdateEmail = ({
|
||||
<Link href={pageUrl} style={link}>
|
||||
<strong>{pageTitle}</strong>
|
||||
</Link>{' '}
|
||||
in <strong>{spaceName}</strong>.
|
||||
in the <strong>{spaceName}</strong> space.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const VerificationExpiredEmail = ({ pageTitle, spaceName, pageUrl }: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
The verification for <strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space has expired. Please re-verify the
|
||||
page to confirm it is still accurate.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>Re-verify page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationExpiredEmail;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export const VerificationExpiringEmail = ({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl,
|
||||
expiresAt,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
The page <strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space needs to be re-verified. The
|
||||
verification expires on <strong>{expiresAt}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>Review page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationExpiringEmail;
|
||||
Reference in New Issue
Block a user