mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(ee): page verification workflow (#2102)
* feat: page verification workflow * feat: refactor page-verification * sync * fix type * fix * fix * notification icon * use full word * accept .license file * - update templates - update migration and notification * fix copy * update audit labels * sync * add space name
This commit is contained in:
@@ -739,6 +739,93 @@
|
|||||||
"Removed page restriction": "Removed page restriction",
|
"Removed page restriction": "Removed page restriction",
|
||||||
"Added page permission": "Added page permission",
|
"Added page permission": "Added page permission",
|
||||||
"Removed page permission": "Removed page permission",
|
"Removed page permission": "Removed page permission",
|
||||||
|
"day": "day",
|
||||||
|
"days": "days",
|
||||||
|
"week": "week",
|
||||||
|
"weeks": "weeks",
|
||||||
|
"month": "month",
|
||||||
|
"months": "months",
|
||||||
|
"year": "year",
|
||||||
|
"years": "years",
|
||||||
|
"Period": "Period",
|
||||||
|
"Fixed date": "Fixed date",
|
||||||
|
"Indefinitely": "Indefinitely",
|
||||||
|
"Days": "Days",
|
||||||
|
"Weeks": "Weeks",
|
||||||
|
"Months": "Months",
|
||||||
|
"Years": "Years",
|
||||||
|
"Pick a date": "Pick a date",
|
||||||
|
"Maximum is {{max}} {{unit}} for this unit": "Maximum is {{max}} {{unit}} for this unit",
|
||||||
|
"Never expires. Verifiers can re-verify at any time.": "Never expires. Verifiers can re-verify at any time.",
|
||||||
|
"Verified": "Verified",
|
||||||
|
"Review needed": "Review needed",
|
||||||
|
"Verification expired": "Verification expired",
|
||||||
|
"Draft": "Draft",
|
||||||
|
"In Approval": "In Approval",
|
||||||
|
"In approval": "In approval",
|
||||||
|
"Approved": "Approved",
|
||||||
|
"Obsolete": "Obsolete",
|
||||||
|
"Expiring": "Expiring",
|
||||||
|
"Set up verification": "Set up verification",
|
||||||
|
"Verify page": "Verify page",
|
||||||
|
"Page verification": "Page verification",
|
||||||
|
"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",
|
"Verifying your email": "Verifying your email",
|
||||||
"Please wait...": "Please wait...",
|
"Please wait...": "Please wait...",
|
||||||
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
|
"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 WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||||
|
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||||
import TemplateList from "@/ee/template/pages/template-list";
|
import TemplateList from "@/ee/template/pages/template-list";
|
||||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
@@ -119,6 +120,7 @@ export default function App() {
|
|||||||
<Route path={"ai"} element={<AiSettings />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
<Route path={"audit"} element={<AuditLogs />} />
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
|||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||||
|
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
@@ -89,3 +90,11 @@ export const prefetchAuditLogs = () => {
|
|||||||
queryFn: () => getAuditLogs(params),
|
queryFn: () => getAuditLogs(params),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const prefetchVerifiedPages = () => {
|
||||||
|
const params = { limit: 50 };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["verification-list", params],
|
||||||
|
queryFn: () => getVerificationList(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
IconWorld,
|
IconWorld,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
|
IconShieldCheck,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
prefetchAuditLogs,
|
prefetchAuditLogs,
|
||||||
|
prefetchVerifiedPages,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
import AppVersion from "@/components/settings/app-version.tsx";
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
@@ -95,6 +97,12 @@ const groupedData: DataGroup[] = [
|
|||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||||
|
{
|
||||||
|
label: "Verified pages",
|
||||||
|
icon: IconShieldCheck,
|
||||||
|
path: "/settings/verifications",
|
||||||
|
feature: Feature.PAGE_VERIFICATION,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "API management",
|
label: "API management",
|
||||||
icon: IconKey,
|
icon: IconKey,
|
||||||
@@ -210,6 +218,9 @@ export default function SettingsSidebar() {
|
|||||||
case "Audit log":
|
case "Audit log":
|
||||||
prefetchHandler = prefetchAuditLogs;
|
prefetchHandler = prefetchAuditLogs;
|
||||||
break;
|
break;
|
||||||
|
case "Verified pages":
|
||||||
|
prefetchHandler = prefetchVerifiedPages;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ export const auditEventLabels: Record<string, string> = {
|
|||||||
"page.restriction_removed": "Removed page restriction",
|
"page.restriction_removed": "Removed page restriction",
|
||||||
"page.permission_added": "Added page permission",
|
"page.permission_added": "Added page permission",
|
||||||
"page.permission_removed": "Removed 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.created": "Created share link",
|
||||||
"share.deleted": "Deleted 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.restriction_removed", label: "Removed page restriction" },
|
||||||
{ value: "page.permission_added", label: "Added page permission" },
|
{ value: "page.permission_added", label: "Added page permission" },
|
||||||
{ value: "page.permission_removed", label: "Removed 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)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".txt"
|
accept=".txt,.license"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
hidden
|
hidden
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { Group, List, Stack, Table, Text, ThemeIcon } from "@mantine/core";
|
|||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
const enterpriseFeatures = [
|
const enterpriseFeatures = [
|
||||||
"SSO (SAML, OIDC, LDAP)",
|
"AI Integration (Chat, Search & Assistant)",
|
||||||
"AI Integration (Search & Assistant)",
|
|
||||||
"Page-level Permissions",
|
|
||||||
"Audit Logs",
|
|
||||||
"API Keys",
|
|
||||||
"MCP Support",
|
"MCP Support",
|
||||||
|
"SSO (SAML, OIDC, LDAP)",
|
||||||
"Multi-factor Authentication (2FA)",
|
"Multi-factor Authentication (2FA)",
|
||||||
|
"Page-level Permissions",
|
||||||
|
"Page verification & approval workflow",
|
||||||
|
"Audit Logs",
|
||||||
"Enterprise Controls",
|
"Enterprise Controls",
|
||||||
|
"API Keys",
|
||||||
"Advanced Search Engine Support",
|
"Advanced Search Engine Support",
|
||||||
"Full-text Search in Attachments (PDF, DOCX)",
|
"Full-text Search in Attachments (PDF, DOCX)",
|
||||||
"Resolve Comments",
|
"Resolve Comments",
|
||||||
@@ -68,11 +69,31 @@ export default function OssDetails() {
|
|||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Text size="sm" c="dimmed">
|
<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>
|
||||||
|
|
||||||
<Text size="sm" c="dimmed">
|
<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>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</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 React from "react";
|
||||||
import { TitleEditor } from "@/features/editor/title-editor";
|
import { TitleEditor } from "@/features/editor/title-editor";
|
||||||
import PageEditor from "@/features/editor/page-editor";
|
import PageEditor from "@/features/editor/page-editor";
|
||||||
import { Container } from "@mantine/core";
|
import {
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { PageVerificationBadge } from "@/ee/page-verification";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
|
|
||||||
|
type PageCreator = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface FullEditorProps {
|
export interface FullEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
slugId: string;
|
slugId: string;
|
||||||
@@ -16,6 +34,8 @@ export interface FullEditorProps {
|
|||||||
content: string;
|
content: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
creator?: PageCreator;
|
||||||
|
contributors?: IContributor[];
|
||||||
canComment?: boolean;
|
canComment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +46,8 @@ export function FullEditor({
|
|||||||
content,
|
content,
|
||||||
spaceSlug,
|
spaceSlug,
|
||||||
editable,
|
editable,
|
||||||
|
creator,
|
||||||
|
contributors,
|
||||||
canComment,
|
canComment,
|
||||||
}: FullEditorProps) {
|
}: FullEditorProps) {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -44,6 +66,11 @@ export function FullEditor({
|
|||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
|
<PageByline
|
||||||
|
creator={creator}
|
||||||
|
contributors={contributors}
|
||||||
|
readOnly={!editable}
|
||||||
|
/>
|
||||||
<MemoizedPageEditor
|
<MemoizedPageEditor
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
@@ -53,3 +80,91 @@ export function FullEditor({
|
|||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PageBylineProps = {
|
||||||
|
creator?: PageCreator;
|
||||||
|
contributors?: IContributor[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PageByline({
|
||||||
|
creator,
|
||||||
|
contributors,
|
||||||
|
readOnly,
|
||||||
|
}: PageBylineProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const otherContributors = (contributors ?? []).filter(
|
||||||
|
(c) => c.id !== creator?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap="sm"
|
||||||
|
mb="md"
|
||||||
|
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
|
||||||
|
>
|
||||||
|
{creator && (
|
||||||
|
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
|
<UnstyledButton>
|
||||||
|
<Group gap={6}>
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={creator.avatarUrl}
|
||||||
|
name={creator.name}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("By {{name}}", { name: creator.name })}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="sm">
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={creator.avatarUrl}
|
||||||
|
name={creator.name}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{creator.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{otherContributors.length === 0
|
||||||
|
? t("Owner, no contributors")
|
||||||
|
: t("Owner")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{otherContributors.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Text size="xs" fw={500} c="dimmed" tt="uppercase">
|
||||||
|
{t("Contributors")}
|
||||||
|
</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{otherContributors.map((contributor) => (
|
||||||
|
<Group gap="sm" key={contributor.id}>
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={contributor.avatarUrl}
|
||||||
|
name={contributor.name}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
<Text size="sm">{contributor.name}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
<PageVerificationBadge readOnly={readOnly} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconBell,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconPointFilled,
|
IconPointFilled,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { Avatar } from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
import { INotification } from "../types/notification.types";
|
import { INotification } from "../types/notification.types";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
@@ -51,6 +53,16 @@ export function NotificationItem({
|
|||||||
: "<bold>{{name}}</bold> gave you view access to a page";
|
: "<bold>{{name}}</bold> gave you view access to a page";
|
||||||
case "page.updated":
|
case "page.updated":
|
||||||
return "<bold>{{name}}</bold> updated a page";
|
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:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -96,11 +108,17 @@ export function NotificationItem({
|
|||||||
className={classes.notificationItem}
|
className={classes.notificationItem}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||||
|
{notification.actor ? (
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={notification.actor?.avatarUrl}
|
avatarUrl={notification.actor.avatarUrl}
|
||||||
name={notification.actor?.name || "?"}
|
name={notification.actor.name}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar size="sm" color="gray" radius="xl">
|
||||||
|
<IconBell size={14} />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" lineClamp={2}>
|
<Text size="sm" lineClamp={2}>
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ export type NotificationType =
|
|||||||
| "comment.resolved"
|
| "comment.resolved"
|
||||||
| "page.user_mention"
|
| "page.user_mention"
|
||||||
| "page.permission_granted"
|
| "page.permission_granted"
|
||||||
| "page.updated";
|
| "page.updated"
|
||||||
|
| "page.verification_expiring"
|
||||||
|
| "page.verification_expired"
|
||||||
|
| "page.verified"
|
||||||
|
| "page.approval_requested"
|
||||||
|
| "page.approval_rejected";
|
||||||
|
|
||||||
export type INotification = {
|
export type INotification = {
|
||||||
id: string;
|
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 MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import { PageShareModal } from "@/ee/page-permission";
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
|
import {
|
||||||
|
PageVerificationMenuItem,
|
||||||
|
PageVerificationModal,
|
||||||
|
} from "@/ee/page-verification";
|
||||||
import {
|
import {
|
||||||
useFavoriteIds,
|
useFavoriteIds,
|
||||||
useAddFavoriteMutation,
|
useAddFavoriteMutation,
|
||||||
@@ -135,6 +139,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
movePageModalOpened,
|
movePageModalOpened,
|
||||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
|
const [
|
||||||
|
verificationOpened,
|
||||||
|
{ open: openVerificationModal, close: closeVerificationModal },
|
||||||
|
] = useDisclosure(false);
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||||
const favoriteIds = useFavoriteIds("page");
|
const favoriteIds = useFavoriteIds("page");
|
||||||
@@ -261,6 +269,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
{t("Page history")}
|
{t("Page history")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<PageVerificationMenuItem
|
||||||
|
pageId={page?.id}
|
||||||
|
onClick={openVerificationModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -350,6 +365,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
onClose={closeMoveSpaceModal}
|
onClose={closeMoveSpaceModal}
|
||||||
open={movePageModalOpened}
|
open={movePageModalOpened}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PageVerificationModal
|
||||||
|
pageId={page.id}
|
||||||
|
opened={verificationOpened}
|
||||||
|
onClose={closeVerificationModal}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface IPage {
|
|||||||
creator: ICreator;
|
creator: ICreator;
|
||||||
lastUpdatedBy: ILastUpdatedBy;
|
lastUpdatedBy: ILastUpdatedBy;
|
||||||
deletedBy: IDeletedBy;
|
deletedBy: IDeletedBy;
|
||||||
|
contributors?: IContributor[];
|
||||||
space: Partial<ISpace>;
|
space: Partial<ISpace>;
|
||||||
permissions?: {
|
permissions?: {
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
@@ -29,6 +30,12 @@ export interface IPage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IContributor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ICreator {
|
interface ICreator {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VerificationUpdatedEvent = {
|
||||||
|
operation: "verificationUpdated";
|
||||||
|
pageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WebSocketEvent =
|
export type WebSocketEvent =
|
||||||
| InvalidateEvent
|
| InvalidateEvent
|
||||||
| CommentCreatedEvent
|
| CommentCreatedEvent
|
||||||
@@ -96,4 +101,5 @@ export type WebSocketEvent =
|
|||||||
| AddTreeNodeEvent
|
| AddTreeNodeEvent
|
||||||
| MoveTreeNodeEvent
|
| MoveTreeNodeEvent
|
||||||
| DeleteTreeNodeEvent
|
| DeleteTreeNodeEvent
|
||||||
| RefetchRootTreeNodeEvent;
|
| RefetchRootTreeNodeEvent
|
||||||
|
| VerificationUpdatedEvent;
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "verificationUpdated":
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["page-verification-info", data.pageId],
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [queryClient, socket]);
|
}, [queryClient, socket]);
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
slugId={page.slugId}
|
slugId={page.slugId}
|
||||||
spaceSlug={page?.space?.slug}
|
spaceSlug={page?.space?.slug}
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
|
creator={page.creator}
|
||||||
|
contributors={page.contributors}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
/>
|
/>
|
||||||
<MemoizedHistoryModal pageId={page.id} />
|
<MemoizedHistoryModal pageId={page.id} />
|
||||||
|
|||||||
@@ -143,6 +143,18 @@ export function getPageId(documentName: string) {
|
|||||||
return documentName.split('.')[1];
|
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(
|
function stripUnknownNodes(
|
||||||
json: JSONContent,
|
json: JSONContent,
|
||||||
schema: Schema,
|
schema: Schema,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import { WatcherService } from '../../core/watcher/watcher.service';
|
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||||
|
import { isEmptyParagraphDoc } from '../collaboration.util';
|
||||||
|
|
||||||
@Processor(QueueName.HISTORY_QUEUE)
|
@Processor(QueueName.HISTORY_QUEUE)
|
||||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
@@ -55,6 +56,14 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
{ includeContent: true },
|
{ 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 (
|
if (
|
||||||
!lastHistory ||
|
!lastHistory ||
|
||||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export const AuditEvent = {
|
|||||||
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
||||||
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
||||||
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
|
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
|
||||||
|
// Page verification
|
||||||
|
PAGE_VERIFICATION_CREATED: 'page.verification_created',
|
||||||
|
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
|
||||||
|
PAGE_VERIFICATION_REMOVED: 'page.verification_removed',
|
||||||
|
PAGE_VERIFIED: 'page.verified',
|
||||||
|
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||||
|
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||||
|
PAGE_MARKED_OBSOLETE: 'page.marked_obsolete',
|
||||||
|
|
||||||
// Share
|
// Share
|
||||||
SHARE_CREATED: 'share.created',
|
SHARE_CREATED: 'share.created',
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ export const NotificationType = {
|
|||||||
PAGE_USER_MENTION: 'page.user_mention',
|
PAGE_USER_MENTION: 'page.user_mention',
|
||||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||||
PAGE_UPDATED: 'page.updated',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type NotificationType =
|
export type NotificationType =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
|
|||||||
import { NotificationProcessor } from './notification.processor';
|
import { NotificationProcessor } from './notification.processor';
|
||||||
import { CommentNotificationService } from './services/comment.notification';
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
import { PageNotificationService } from './services/page.notification';
|
import { PageNotificationService } from './services/page.notification';
|
||||||
|
import { VerificationNotificationService } from './services/verification.notification';
|
||||||
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
|
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -14,6 +15,7 @@ import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-li
|
|||||||
NotificationProcessor,
|
NotificationProcessor,
|
||||||
CommentNotificationService,
|
CommentNotificationService,
|
||||||
PageNotificationService,
|
PageNotificationService,
|
||||||
|
VerificationNotificationService,
|
||||||
PageUpdateEmailRateLimiter,
|
PageUpdateEmailRateLimiter,
|
||||||
],
|
],
|
||||||
exports: [NotificationService],
|
exports: [NotificationService],
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import {
|
import {
|
||||||
|
IApprovalRejectedNotificationJob,
|
||||||
|
IApprovalRequestedNotificationJob,
|
||||||
ICommentNotificationJob,
|
ICommentNotificationJob,
|
||||||
ICommentResolvedNotificationJob,
|
ICommentResolvedNotificationJob,
|
||||||
IPageMentionNotificationJob,
|
IPageMentionNotificationJob,
|
||||||
IPageUpdateNotificationJob,
|
IPageUpdateNotificationJob,
|
||||||
|
IPageVerifiedNotificationJob,
|
||||||
IPermissionGrantedNotificationJob,
|
IPermissionGrantedNotificationJob,
|
||||||
|
IVerificationExpiringNotificationJob,
|
||||||
|
IVerificationExpiredNotificationJob,
|
||||||
|
IVerificationReconcileJob,
|
||||||
} from '../../integrations/queue/constants/queue.interface';
|
} from '../../integrations/queue/constants/queue.interface';
|
||||||
import { CommentNotificationService } from './services/comment.notification';
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
import { PageNotificationService } from './services/page.notification';
|
import { PageNotificationService } from './services/page.notification';
|
||||||
|
import { VerificationNotificationService } from './services/verification.notification';
|
||||||
import { DomainService } from '../../integrations/environment/domain.service';
|
import { DomainService } from '../../integrations/environment/domain.service';
|
||||||
|
|
||||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||||
@@ -25,7 +33,9 @@ export class NotificationProcessor
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly commentNotificationService: CommentNotificationService,
|
private readonly commentNotificationService: CommentNotificationService,
|
||||||
private readonly pageNotificationService: PageNotificationService,
|
private readonly pageNotificationService: PageNotificationService,
|
||||||
|
private readonly verificationNotificationService: VerificationNotificationService,
|
||||||
private readonly domainService: DomainService,
|
private readonly domainService: DomainService,
|
||||||
|
private readonly moduleRef: ModuleRef,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -37,12 +47,23 @@ export class NotificationProcessor
|
|||||||
| ICommentResolvedNotificationJob
|
| ICommentResolvedNotificationJob
|
||||||
| IPageMentionNotificationJob
|
| IPageMentionNotificationJob
|
||||||
| IPageUpdateNotificationJob
|
| IPageUpdateNotificationJob
|
||||||
| IPermissionGrantedNotificationJob,
|
| IPermissionGrantedNotificationJob
|
||||||
|
| IVerificationExpiringNotificationJob
|
||||||
|
| IVerificationExpiredNotificationJob
|
||||||
|
| IVerificationReconcileJob
|
||||||
|
| IPageVerifiedNotificationJob
|
||||||
|
| IApprovalRequestedNotificationJob
|
||||||
|
| IApprovalRejectedNotificationJob,
|
||||||
void
|
void
|
||||||
>,
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
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);
|
const appUrl = await this.getWorkspaceUrl(workspaceId);
|
||||||
|
|
||||||
switch (job.name) {
|
switch (job.name) {
|
||||||
@@ -92,6 +113,45 @@ export class NotificationProcessor
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case QueueJob.PAGE_VERIFICATION_EXPIRING: {
|
||||||
|
await this.verificationNotificationService.processVerificationExpiring(
|
||||||
|
job.data as IVerificationExpiringNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case QueueJob.PAGE_VERIFICATION_EXPIRED: {
|
||||||
|
await this.verificationNotificationService.processVerificationExpired(
|
||||||
|
job.data as IVerificationExpiredNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case QueueJob.PAGE_VERIFIED_NOTIFICATION: {
|
||||||
|
await this.verificationNotificationService.processPageVerified(
|
||||||
|
job.data as IPageVerifiedNotificationJob,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: {
|
||||||
|
await this.verificationNotificationService.processApprovalRequested(
|
||||||
|
job.data as IApprovalRequestedNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: {
|
||||||
|
await this.verificationNotificationService.processApprovalRejected(
|
||||||
|
job.data as IApprovalRejectedNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||||
}
|
}
|
||||||
@@ -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> {
|
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
|
||||||
const workspace = await this.db
|
const workspace = await this.db
|
||||||
.selectFrom('workspaces')
|
.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)
|
.where('pageId', 'in', pageIdsToMove)
|
||||||
.execute();
|
.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
|
// Update attachments
|
||||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||||
{ spaceId },
|
{ 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 { DB } from '@docmost/db/types/db';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
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()
|
@Injectable()
|
||||||
export class NotificationRepo {
|
export class NotificationRepo {
|
||||||
@@ -43,7 +46,11 @@ export class NotificationRepo {
|
|||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([
|
eb.or([
|
||||||
eb('spaceId', 'is', null),
|
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> {
|
async getUnreadCount(userId: string): Promise<number> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('notifications')
|
.selectFrom('notifications')
|
||||||
@@ -71,7 +86,11 @@ export class NotificationRepo {
|
|||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([
|
eb.or([
|
||||||
eb('spaceId', 'is', null),
|
eb('spaceId', 'is', null),
|
||||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
eb(
|
||||||
|
'spaceId',
|
||||||
|
'in',
|
||||||
|
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -79,14 +98,6 @@ export class NotificationRepo {
|
|||||||
return Number(result?.count ?? 0);
|
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> {
|
async markAsRead(notificationId: string, userId: string): Promise<void> {
|
||||||
await this.db
|
await this.db
|
||||||
.updateTable('notifications')
|
.updateTable('notifications')
|
||||||
@@ -94,12 +105,6 @@ export class NotificationRepo {
|
|||||||
.where('id', '=', notificationId)
|
.where('id', '=', notificationId)
|
||||||
.where('userId', '=', userId)
|
.where('userId', '=', userId)
|
||||||
.where('readAt', 'is', null)
|
.where('readAt', 'is', null)
|
||||||
.where((eb) =>
|
|
||||||
eb.or([
|
|
||||||
eb('spaceId', 'is', null),
|
|
||||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,21 +121,6 @@ export class NotificationRepo {
|
|||||||
.where('id', 'in', notificationIds)
|
.where('id', 'in', notificationIds)
|
||||||
.where('userId', '=', userId)
|
.where('userId', '=', userId)
|
||||||
.where('readAt', 'is', null)
|
.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();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +130,15 @@ export class NotificationRepo {
|
|||||||
.set({ readAt: new Date() })
|
.set({ readAt: new Date() })
|
||||||
.where('userId', '=', userId)
|
.where('userId', '=', userId)
|
||||||
.where('readAt', 'is', null)
|
.where('readAt', 'is', null)
|
||||||
.where((eb) =>
|
.execute();
|
||||||
eb.or([
|
}
|
||||||
eb('spaceId', 'is', null),
|
|
||||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
async markAsEmailed(notificationId: string): Promise<void> {
|
||||||
]),
|
await this.db
|
||||||
)
|
.updateTable('notifications')
|
||||||
|
.set({ emailedAt: new Date() })
|
||||||
|
.where('id', '=', notificationId)
|
||||||
|
.where('emailedAt', 'is', null)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
@@ -400,6 +400,7 @@ export interface Notifications {
|
|||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
spaceId: string | null;
|
spaceId: string | null;
|
||||||
commentId: string | null;
|
commentId: string | null;
|
||||||
|
pageVerificationId: string | null;
|
||||||
data: Json | null;
|
data: Json | null;
|
||||||
readAt: Timestamp | null;
|
readAt: Timestamp | null;
|
||||||
emailedAt: Timestamp | null;
|
emailedAt: Timestamp | null;
|
||||||
@@ -441,6 +442,39 @@ export interface PagePermissions {
|
|||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PageVerifications {
|
||||||
|
id: Generated<string>;
|
||||||
|
pageId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
spaceId: string;
|
||||||
|
type: Generated<string>;
|
||||||
|
status: string | null;
|
||||||
|
mode: string | null;
|
||||||
|
periodAmount: number | null;
|
||||||
|
periodUnit: string | null;
|
||||||
|
verifiedAt: Timestamp | null;
|
||||||
|
verifiedById: string | null;
|
||||||
|
expiresAt: Timestamp | null;
|
||||||
|
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 {
|
export interface Templates {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -519,6 +553,8 @@ export interface DB {
|
|||||||
pageAccess: PageAccess;
|
pageAccess: PageAccess;
|
||||||
pagePermissions: PagePermissions;
|
pagePermissions: PagePermissions;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
|
pageVerifications: PageVerifications;
|
||||||
|
pageVerifiers: PageVerifiers;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Notifications,
|
Notifications,
|
||||||
PageAccess as _PageAccess,
|
PageAccess as _PageAccess,
|
||||||
PagePermissions as _PagePermissions,
|
PagePermissions as _PagePermissions,
|
||||||
|
PageVerifications as _PageVerifications,
|
||||||
|
PageVerifiers as _PageVerifiers,
|
||||||
Pages,
|
Pages,
|
||||||
Spaces,
|
Spaces,
|
||||||
Users,
|
Users,
|
||||||
@@ -182,6 +184,15 @@ export type PagePermission = Selectable<_PagePermissions>;
|
|||||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
||||||
|
|
||||||
|
// Page Verification
|
||||||
|
export type PageVerification = Selectable<_PageVerifications>;
|
||||||
|
export type InsertablePageVerification = Insertable<_PageVerifications>;
|
||||||
|
export type UpdatablePageVerification = Updateable<Omit<_PageVerifications, 'id'>>;
|
||||||
|
|
||||||
|
// Page Verifier
|
||||||
|
export type PageVerifier = Selectable<_PageVerifiers>;
|
||||||
|
export type InsertablePageVerifier = Insertable<_PageVerifiers>;
|
||||||
|
|
||||||
// User Session
|
// User Session
|
||||||
export type UserSession = Selectable<UserSessions>;
|
export type UserSession = Selectable<UserSessions>;
|
||||||
export type InsertableUserSession = Insertable<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_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
||||||
PAGE_UPDATE_DIGEST = 'page-update-digest',
|
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_LOG = 'audit-log',
|
||||||
AUDIT_CLEANUP = 'audit-cleanup',
|
AUDIT_CLEANUP = 'audit-cleanup',
|
||||||
|
|||||||
@@ -76,3 +76,40 @@ export interface IPermissionGrantedNotificationJob {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
role: 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}>
|
<Link href={pageUrl} style={link}>
|
||||||
<strong>{pageTitle}</strong>
|
<strong>{pageTitle}</strong>
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
in <strong>{spaceName}</strong>.
|
in the <strong>{spaceName}</strong> space.
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
<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