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:
Philip Okugbe
2026-04-13 20:20:34 +01:00
committed by GitHub
parent d6068310b4
commit bd68e47e03
50 changed files with 3828 additions and 58 deletions

View File

@@ -2,13 +2,31 @@ import classes from "@/features/editor/styles/editor.module.css";
import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import { Container } from "@mantine/core";
import {
Container,
Divider,
Group,
Popover,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { PageVerificationBadge } from "@/ee/page-verification";
import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
type PageCreator = {
id: string;
name: string;
avatarUrl: string;
};
export interface FullEditorProps {
pageId: string;
slugId: string;
@@ -16,6 +34,8 @@ export interface FullEditorProps {
content: string;
spaceSlug: string;
editable: boolean;
creator?: PageCreator;
contributors?: IContributor[];
canComment?: boolean;
}
@@ -26,6 +46,8 @@ export function FullEditor({
content,
spaceSlug,
editable,
creator,
contributors,
canComment,
}: FullEditorProps) {
const [user] = useAtom(userAtom);
@@ -44,6 +66,11 @@ export function FullEditor({
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
readOnly={!editable}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
@@ -53,3 +80,91 @@ export function FullEditor({
</Container>
);
}
type PageBylineProps = {
creator?: PageCreator;
contributors?: IContributor[];
readOnly?: boolean;
};
function PageByline({
creator,
contributors,
readOnly,
}: PageBylineProps) {
const { t } = useTranslation();
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
);
return (
<Group
gap="sm"
mb="md"
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
>
{creator && (
<Popover position="bottom-start" shadow="md" width={280} withArrow>
<Popover.Target>
<UnstyledButton>
<Group gap={6}>
<CustomAvatar
avatarUrl={creator.avatarUrl}
name={creator.name}
size={22}
/>
<Text size="sm" c="dimmed">
{t("By {{name}}", { name: creator.name })}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Group gap="sm">
<CustomAvatar
avatarUrl={creator.avatarUrl}
name={creator.name}
size={36}
/>
<div>
<Text size="sm" fw={500}>
{creator.name}
</Text>
<Text size="xs" c="dimmed">
{otherContributors.length === 0
? t("Owner, no contributors")
: t("Owner")}
</Text>
</div>
</Group>
{otherContributors.length > 0 && (
<>
<Divider />
<Text size="xs" fw={500} c="dimmed" tt="uppercase">
{t("Contributors")}
</Text>
<Stack gap={6}>
{otherContributors.map((contributor) => (
<Group gap="sm" key={contributor.id}>
<CustomAvatar
avatarUrl={contributor.avatarUrl}
name={contributor.name}
size={28}
/>
<Text size="sm">{contributor.name}</Text>
</Group>
))}
</Stack>
</>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
<PageVerificationBadge readOnly={readOnly} />
</Group>
);
}

View File

@@ -6,10 +6,12 @@ import {
UnstyledButton,
} from "@mantine/core";
import {
IconBell,
IconCheck,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { Avatar } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types";
import { Trans, useTranslation } from "react-i18next";
@@ -51,6 +53,16 @@ export function NotificationItem({
: "<bold>{{name}}</bold> gave you view access to a page";
case "page.updated":
return "<bold>{{name}}</bold> updated a page";
case "page.verified":
return "<bold>{{name}}</bold> verified a page";
case "page.approval_requested":
return "<bold>{{name}}</bold> submitted a page for your approval";
case "page.approval_rejected":
return "<bold>{{name}}</bold> returned a page for revision";
case "page.verification_expiring":
return "Page verification expires soon";
case "page.verification_expired":
return "Page verification has expired";
default:
return "";
}
@@ -96,11 +108,17 @@ export function NotificationItem({
className={classes.notificationItem}
>
<Group wrap="nowrap" align="flex-start" gap="sm">
<CustomAvatar
avatarUrl={notification.actor?.avatarUrl}
name={notification.actor?.name || "?"}
size="sm"
/>
{notification.actor ? (
<CustomAvatar
avatarUrl={notification.actor.avatarUrl}
name={notification.actor.name}
size="sm"
/>
) : (
<Avatar size="sm" color="gray" radius="xl">
<IconBell size={14} />
</Avatar>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={2}>

View File

@@ -4,7 +4,12 @@ export type NotificationType =
| "comment.resolved"
| "page.user_mention"
| "page.permission_granted"
| "page.updated";
| "page.updated"
| "page.verification_expiring"
| "page.verification_expired"
| "page.verified"
| "page.approval_requested"
| "page.approval_rejected";
export type INotification = {
id: string;

View File

@@ -44,6 +44,10 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
PageVerificationMenuItem,
PageVerificationModal,
} from "@/ee/page-verification";
import {
useFavoriteIds,
useAddFavoriteMutation,
@@ -135,6 +139,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [
verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page");
@@ -261,6 +269,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{t("Page history")}
</Menu.Item>
{!readOnly && (
<PageVerificationMenuItem
pageId={page?.id}
onClick={openVerificationModal}
/>
)}
<Menu.Divider />
{!readOnly && (
@@ -350,6 +365,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
<PageVerificationModal
pageId={page.id}
opened={verificationOpened}
onClose={closeVerificationModal}
/>
</>
);
}

View File

@@ -22,6 +22,7 @@ export interface IPage {
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
contributors?: IContributor[];
space: Partial<ISpace>;
permissions?: {
canEdit: boolean;
@@ -29,6 +30,12 @@ export interface IPage {
};
}
export interface IContributor {
id: string;
name: string;
avatarUrl: string;
}
interface ICreator {
id: string;
name: string;

View File

@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
spaceId: string;
};
export type VerificationUpdatedEvent = {
operation: "verificationUpdated";
pageId: string;
};
export type WebSocketEvent =
| InvalidateEvent
| CommentCreatedEvent
@@ -96,4 +101,5 @@ export type WebSocketEvent =
| AddTreeNodeEvent
| MoveTreeNodeEvent
| DeleteTreeNodeEvent
| RefetchRootTreeNodeEvent;
| RefetchRootTreeNodeEvent
| VerificationUpdatedEvent;

View File

@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
});
break;
}
case "verificationUpdated":
queryClient.invalidateQueries({
queryKey: ["page-verification-info", data.pageId],
});
break;
}
});
}, [queryClient, socket]);