mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 01:04:39 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 834b6f68f7 | |||
| 214daa1ec3 | |||
| 6af74eb3d4 | |||
| e7fff3c9b5 | |||
| 63c1241125 | |||
| 0ae407839f | |||
| d524073d86 | |||
| 5c5fff517c | |||
| 3115c5e097 |
@@ -48,13 +48,6 @@ GOTENBERG_URL=
|
||||
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Allow other sites to embed Docmost in an iframe.
|
||||
IFRAME_EMBED_ALLOWED=false
|
||||
|
||||
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
|
||||
# Example: https://intranet.example.com,https://portal.example.com
|
||||
IFRAME_ALLOWED_ORIGINS=
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
|
||||
|
||||
@@ -936,35 +936,6 @@
|
||||
"Page actions": "Page actions",
|
||||
"Pick emoji": "Pick emoji",
|
||||
"Template menu": "Template menu",
|
||||
"Use": "Use",
|
||||
"Use template": "Use template",
|
||||
"Preview template: {{title}}": "Preview template: {{title}}",
|
||||
"Use a template": "Use a template",
|
||||
"Search templates...": "Search templates...",
|
||||
"Search spaces...": "Search spaces...",
|
||||
"No templates found": "No templates found",
|
||||
"No spaces found": "No spaces found",
|
||||
"Browse all templates": "Browse all templates",
|
||||
"This space": "This space",
|
||||
"All templates": "All templates",
|
||||
"Global": "Global",
|
||||
"New template": "New template",
|
||||
"Edit template": "Edit template",
|
||||
"Are you sure you want to delete this template?": "Are you sure you want to delete this template?",
|
||||
"Template scope updated": "Template scope updated",
|
||||
"Choose which space this template belongs to": "Choose which space this template belongs to",
|
||||
"Scope": "Scope",
|
||||
"Select scope": "Select scope",
|
||||
"Title": "Title",
|
||||
"Saving...": "Saving...",
|
||||
"Saved": "Saved",
|
||||
"Save failed. Retry": "Save failed. Retry",
|
||||
"By {{name}}": "By {{name}}",
|
||||
"Updated {{time}}": "Updated {{time}}",
|
||||
"Choose destination": "Choose destination",
|
||||
"Search pages and spaces...": "Search pages and spaces...",
|
||||
"No results found": "No results found",
|
||||
"You don't have permission to create pages here": "You don't have permission to create pages here",
|
||||
"Chat menu": "Chat menu",
|
||||
"API key menu": "API key menu",
|
||||
"Jump to comment selection": "Jump to comment selection",
|
||||
|
||||
@@ -39,6 +39,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import Webhooks from "@/ee/webhook/pages/webhooks.tsx";
|
||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||
@@ -124,6 +125,7 @@ export default function App() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"webhooks"} element={<Webhooks />} />
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
|
||||
@@ -38,16 +38,6 @@
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
@mixin hover {
|
||||
background-color: transparent;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
|
||||
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconHome,
|
||||
IconClock,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
IconLayoutGrid,
|
||||
IconSettings,
|
||||
IconUserPlus,
|
||||
IconTemplate,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./global-sidebar.module.css";
|
||||
@@ -21,9 +20,12 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
const mainNavItems = [
|
||||
{ label: "Home", icon: IconHome, path: "/home" },
|
||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||
];
|
||||
|
||||
export default function GlobalSidebar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,19 +33,6 @@ export default function GlobalSidebar() {
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const mainNavItems = [
|
||||
{ label: "Home", icon: IconHome, path: "/home" },
|
||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||
{
|
||||
label: "Templates",
|
||||
icon: IconTemplate,
|
||||
path: "/templates",
|
||||
disabled: !hasTemplates,
|
||||
},
|
||||
];
|
||||
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||
@@ -69,37 +58,18 @@ export default function GlobalSidebar() {
|
||||
<div className={classes.navbar}>
|
||||
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||
<div className={classes.section}>
|
||||
{mainNavItems.map((item) =>
|
||||
item.disabled ? (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={upgradeLabel}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<UnstyledButton
|
||||
className={classes.link}
|
||||
data-disabled
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Link
|
||||
key={item.label}
|
||||
className={classes.link}
|
||||
data-active={active === item.path || undefined}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
className={classes.link}
|
||||
data-active={active === item.path || undefined}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getApiKeys } from "@/ee/api-key";
|
||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||
import { getWebhooks } from "@/ee/webhook/services/webhook-service";
|
||||
|
||||
export const prefetchWorkspaceMembers = () => {
|
||||
const params: QueryParams = { limit: 100, query: "" };
|
||||
@@ -106,3 +107,11 @@ export const prefetchScimTokens = () => {
|
||||
queryFn: () => getScimTokens({}),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchWebhooks = () => {
|
||||
const params = { limit: 50 };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["webhook-list", params],
|
||||
queryFn: () => getWebhooks(params),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
IconWebhook,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
prefetchWorkspaceMembers,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
prefetchWebhooks,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -125,6 +127,13 @@ const groupedData: DataGroup[] = [
|
||||
role: "owner",
|
||||
env: "selfhosted",
|
||||
},
|
||||
{
|
||||
label: "Webhooks",
|
||||
icon: IconWebhook,
|
||||
path: "/settings/webhooks",
|
||||
feature: Feature.WEBHOOKS,
|
||||
role: "admin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -222,6 +231,9 @@ export default function SettingsSidebar() {
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Webhooks":
|
||||
prefetchHandler = prefetchWebhooks;
|
||||
break;
|
||||
case "Verified pages":
|
||||
prefetchHandler = prefetchVerifiedPages;
|
||||
break;
|
||||
|
||||
@@ -16,8 +16,6 @@ export function DestinationPickerModal({
|
||||
loading,
|
||||
excludePageId,
|
||||
pageLimit,
|
||||
initialSpaceId,
|
||||
searchSpacesOnly,
|
||||
}: DestinationPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
@@ -48,8 +46,6 @@ export function DestinationPickerModal({
|
||||
onSelectionChange={setSelection}
|
||||
excludePageId={excludePageId}
|
||||
pageLimit={pageLimit}
|
||||
initialSpaceId={initialSpaceId}
|
||||
searchSpacesOnly={searchSpacesOnly}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
transition: background-color 150ms ease;
|
||||
user-select: none;
|
||||
|
||||
@@ -23,11 +22,6 @@
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
@@ -63,7 +57,7 @@
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
@@ -117,7 +111,7 @@
|
||||
}
|
||||
|
||||
.spaceName {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||
import { useState, useCallback } from "react";
|
||||
import { TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
|
||||
import { IconSearch, IconFile } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||
@@ -15,29 +15,23 @@ type DestinationPickerProps = {
|
||||
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
initialSpaceId?: string;
|
||||
searchSpacesOnly?: boolean;
|
||||
};
|
||||
|
||||
export function DestinationPicker({
|
||||
onSelectionChange,
|
||||
excludePageId,
|
||||
pageLimit = 15,
|
||||
initialSpaceId,
|
||||
searchSpacesOnly,
|
||||
}: DestinationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const searchEnabled =
|
||||
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
|
||||
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
|
||||
|
||||
const { data: searchData, isLoading: searchLoading } =
|
||||
useSearchSuggestionsQuery({
|
||||
@@ -48,18 +42,6 @@ export function DestinationPicker({
|
||||
|
||||
const isSearching = !!searchEnabled;
|
||||
|
||||
const filteredSpaces = useMemo(() => {
|
||||
const items = spacesData?.items ?? [];
|
||||
if (!searchSpacesOnly || !debouncedQuery) return items;
|
||||
const fold = (s: string) =>
|
||||
s
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLocaleLowerCase();
|
||||
const term = fold(debouncedQuery);
|
||||
return items.filter((s) => fold(s.name).includes(term));
|
||||
}, [spacesData, searchSpacesOnly, debouncedQuery]);
|
||||
|
||||
const selectedId =
|
||||
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||
|
||||
@@ -105,48 +87,18 @@ export function DestinationPicker({
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
// Pre-select space when initialSpaceId is set and spaces have loaded.
|
||||
// Only runs once: skip if user has already made a selection.
|
||||
useEffect(() => {
|
||||
if (!initialSpaceId || selection) return;
|
||||
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
|
||||
if (match) {
|
||||
updateSelection({ type: "space", spaceId: match.id, space: match });
|
||||
requestAnimationFrame(() => {
|
||||
const el = viewportRef.current?.querySelector<HTMLElement>(
|
||||
`[data-space-id="${match.id}"]`,
|
||||
);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
}, [initialSpaceId, selection, spacesData, updateSelection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={
|
||||
searchSpacesOnly
|
||||
? t("Search spaces...")
|
||||
: t("Search pages and spaces...")
|
||||
}
|
||||
aria-label={
|
||||
searchSpacesOnly
|
||||
? t("Search spaces...")
|
||||
: t("Search pages and spaces...")
|
||||
}
|
||||
placeholder={t("Search pages and spaces...")}
|
||||
variant="filled"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
className={classes.searchInput}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
h="50vh"
|
||||
offsetScrollbars
|
||||
className={classes.scrollArea}
|
||||
viewportRef={viewportRef}
|
||||
>
|
||||
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
|
||||
{isSearching ? (
|
||||
searchLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
@@ -159,28 +111,16 @@ export function DestinationPicker({
|
||||
<div
|
||||
key={page.id}
|
||||
className={classes.searchResult}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSearchResultClick(page)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSearchResultClick(page);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<ActionIcon
|
||||
component="div"
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
size={22}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
<IconFile
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.pageTitle}>
|
||||
@@ -201,14 +141,8 @@ export function DestinationPicker({
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : filteredSpaces.length === 0 ? (
|
||||
<div className={classes.emptyState}>
|
||||
{searchSpacesOnly && debouncedQuery
|
||||
? t("No spaces found")
|
||||
: t("No results found")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSpaces.map((space) => (
|
||||
spacesData?.items?.map((space) => (
|
||||
<SpaceRow
|
||||
key={space.id}
|
||||
space={space}
|
||||
|
||||
@@ -20,6 +20,4 @@ export type DestinationPickerModalProps = {
|
||||
loading?: boolean;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
initialSpaceId?: string;
|
||||
searchSpacesOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { KeyboardEvent, useState } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { IconChevronRight, IconFile } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { PageChildren } from "./page-children";
|
||||
@@ -37,44 +36,23 @@ export function PageRow({
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const handleSelect = () => {
|
||||
if (!isExcluded) onSelect(page);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={rowClasses}
|
||||
style={{ paddingLeft: depth * 20 + 12 }}
|
||||
role="button"
|
||||
tabIndex={isExcluded ? -1 : 0}
|
||||
aria-disabled={isExcluded || undefined}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
onClick={() => !isExcluded && onSelect(page)}
|
||||
>
|
||||
{page.hasChildren ? (
|
||||
<ActionIcon
|
||||
<div
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={expanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
@@ -83,14 +61,10 @@ export function PageRow({
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<ActionIcon
|
||||
component="div"
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
size={22}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
<IconFile
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { KeyboardEvent, useState } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
@@ -42,43 +42,21 @@ export function SpaceRow({
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const handleSelect = () => {
|
||||
if (writable) onSelectSpace(space);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={rowClasses}
|
||||
data-space-id={space.id}
|
||||
role="button"
|
||||
tabIndex={writable ? 0 : -1}
|
||||
aria-disabled={!writable || undefined}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
onClick={() => writable && onSelectSpace(space)}
|
||||
>
|
||||
{writable ? (
|
||||
<ActionIcon
|
||||
<div
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={expanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
@@ -19,4 +19,5 @@ export const Feature = {
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
TEMPLATES: 'templates',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
WEBHOOKS: 'webhooks',
|
||||
} as const;
|
||||
|
||||
@@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() {
|
||||
const [checked, setChecked] = useState(
|
||||
workspace?.settings?.templates?.allowMemberTemplates === true,
|
||||
);
|
||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
||||
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -54,11 +54,15 @@ function AllowMemberTemplatesToggle() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={upgradeLabel} disabled={hasTemplates} refProp="rootRef">
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
disabled={hasSecuritySettings}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasTemplates}
|
||||
disabled={!hasSecuritySettings}
|
||||
aria-label={t("Toggle allow members to create templates")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -137,6 +137,7 @@ export default function Security() {
|
||||
{ max: SCIM_TOKEN_LIMIT },
|
||||
)}
|
||||
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
@mixin hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
@@ -55,27 +50,18 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
padding-top: var(--mantine-spacing-sm);
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.scopeDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.menuTarget {
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
|
||||
.card:hover &,
|
||||
.card:focus-within & {
|
||||
.card:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
|
||||
import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconEdit,
|
||||
@@ -12,7 +12,6 @@ import classes from "./template-card.module.css";
|
||||
type TemplateCardProps = {
|
||||
template: ITemplate;
|
||||
spaceName?: string;
|
||||
onPreview: (template: ITemplate) => void;
|
||||
onUse: (template: ITemplate) => void;
|
||||
onEdit?: (template: ITemplate) => void;
|
||||
onDelete?: (template: ITemplate) => void;
|
||||
@@ -22,7 +21,6 @@ type TemplateCardProps = {
|
||||
export default function TemplateCard({
|
||||
template,
|
||||
spaceName,
|
||||
onPreview,
|
||||
onUse,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -36,17 +34,7 @@ export default function TemplateCard({
|
||||
padding="lg"
|
||||
className={classes.card}
|
||||
style={{ cursor: "pointer" }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("Preview template: {{title}}", { title: template.title })}
|
||||
onClick={() => onPreview(template)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onPreview(template);
|
||||
}
|
||||
}}
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
<div className={classes.cardBody}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
|
||||
@@ -59,17 +47,6 @@ export default function TemplateCard({
|
||||
)}
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="filled"
|
||||
className={classes.menuTarget}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUse(template);
|
||||
}}
|
||||
>
|
||||
{t("Use")}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Menu width={150} shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
@@ -114,7 +91,6 @@ export default function TemplateCard({
|
||||
<div className={classes.title}>{template.title}</div>
|
||||
|
||||
<div className={classes.footer}>
|
||||
<span className={classes.scopeDot} aria-hidden="true" />
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
|
||||
</Text>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
width: 100%;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scope {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 100ms ease;
|
||||
|
||||
.row:hover &,
|
||||
.row:focus-within & {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.useButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--mantine-spacing-sm);
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
|
||||
.row:hover &,
|
||||
.row:focus-within &,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--mantine-spacing-xl);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
TextInput,
|
||||
ScrollArea,
|
||||
Loader,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconSearch,
|
||||
IconFileText,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useGetTemplatesQuery,
|
||||
useUseTemplateMutation,
|
||||
} from "@/ee/template/queries/template-query";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import UseTemplateModal from "@/ee/template/components/use-template-modal";
|
||||
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import classes from "./template-picker-modal.module.css";
|
||||
|
||||
type TemplatePickerModalProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Pre-select this space in the destination picker after a template is chosen. */
|
||||
initialSpaceId?: string;
|
||||
};
|
||||
|
||||
type ScopeFilter = "current" | "all";
|
||||
|
||||
export default function TemplatePickerModal({
|
||||
opened,
|
||||
onClose,
|
||||
initialSpaceId,
|
||||
}: TemplatePickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const useTemplateMutation = useUseTemplateMutation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 200);
|
||||
const [scope, setScope] = useState<ScopeFilter>(
|
||||
initialSpaceId ? "current" : "all",
|
||||
);
|
||||
// Two-stage selection: previewing first, then destination-picker.
|
||||
// `previewTemplate` is set when the user clicks a row in the picker.
|
||||
// `destinationTemplate` is set when they click "Use template" in the preview.
|
||||
const [previewTemplate, setPreviewTemplate] = useState<ITemplate | null>(
|
||||
null,
|
||||
);
|
||||
const [destinationTemplate, setDestinationTemplate] =
|
||||
useState<ITemplate | null>(null);
|
||||
|
||||
const { data, isPending } = useGetTemplatesQuery({
|
||||
spaceId: scope === "current" ? initialSpaceId : undefined,
|
||||
});
|
||||
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const spaceNamesById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
spacesData?.items?.forEach((s) => map.set(s.id, s.name));
|
||||
return map;
|
||||
}, [spacesData]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const all = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const term = debouncedQuery.trim().toLowerCase();
|
||||
if (!term) return all;
|
||||
return all.filter((tpl) => tpl.title.toLowerCase().includes(term));
|
||||
}, [data, debouncedQuery]);
|
||||
|
||||
const createInInitialSpace = async (tpl: ITemplate) => {
|
||||
if (!initialSpaceId) return;
|
||||
try {
|
||||
const page = await useTemplateMutation.mutateAsync({
|
||||
templateId: tpl.id,
|
||||
spaceId: initialSpaceId,
|
||||
});
|
||||
setPreviewTemplate(null);
|
||||
onClose();
|
||||
const space = spacesData?.items?.find((s) => s.id === initialSpaceId);
|
||||
if (page?.slugId && space?.slug) {
|
||||
navigate(buildPageUrl(space.slug, page.slugId, page.title));
|
||||
}
|
||||
} catch {
|
||||
// error notification handled by mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const handlePick = (tpl: ITemplate) => {
|
||||
setPreviewTemplate(tpl);
|
||||
};
|
||||
|
||||
const handleQuickUse = (tpl: ITemplate) => {
|
||||
if (initialSpaceId) {
|
||||
createInInitialSpace(tpl);
|
||||
return;
|
||||
}
|
||||
setDestinationTemplate(tpl);
|
||||
};
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
// Closing preview returns to the picker list (no full unmount).
|
||||
setPreviewTemplate(null);
|
||||
};
|
||||
|
||||
const handlePreviewUse = () => {
|
||||
if (initialSpaceId && previewTemplate) {
|
||||
createInInitialSpace(previewTemplate);
|
||||
return;
|
||||
}
|
||||
// Move from preview into destination-picker stage.
|
||||
setDestinationTemplate(previewTemplate);
|
||||
setPreviewTemplate(null);
|
||||
};
|
||||
|
||||
const handleDestinationClose = () => {
|
||||
setDestinationTemplate(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setQuery("");
|
||||
setScope(initialSpaceId ? "current" : "all");
|
||||
setPreviewTemplate(null);
|
||||
setDestinationTemplate(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened && !previewTemplate && !destinationTemplate}
|
||||
onClose={handleClose}
|
||||
size={550}
|
||||
padding="lg"
|
||||
yOffset="10vh"
|
||||
title={<Text fw={500}>{t("Use a template")}</Text>}
|
||||
>
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t("Search templates...")}
|
||||
variant="filled"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
mb="xs"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{initialSpaceId && (
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
mb="sm"
|
||||
value={scope}
|
||||
onChange={(v) => setScope(v as ScopeFilter)}
|
||||
data={[
|
||||
{ label: t("This space"), value: "current" },
|
||||
{ label: t("All templates"), value: "all" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollArea h="50vh" offsetScrollbars>
|
||||
{isPending ? (
|
||||
<div className={classes.empty}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={classes.empty}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No templates found")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((tpl) => (
|
||||
<UnstyledButton
|
||||
key={tpl.id}
|
||||
className={classes.row}
|
||||
onClick={() => handlePick(tpl)}
|
||||
>
|
||||
<div className={classes.icon}>
|
||||
{tpl.icon ? (
|
||||
<span>{tpl.icon}</span>
|
||||
) : (
|
||||
<IconFileText
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.title}>{tpl.title}</div>
|
||||
<div className={classes.scope}>
|
||||
{tpl.spaceId
|
||||
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
|
||||
: t("Global")}
|
||||
</div>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="filled"
|
||||
className={classes.useButton}
|
||||
loading={useTemplateMutation.isPending}
|
||||
disabled={useTemplateMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickUse(tpl);
|
||||
}}
|
||||
>
|
||||
{t("Use")}
|
||||
</Button>
|
||||
</UnstyledButton>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
component={Link}
|
||||
to="/templates"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t("Browse all templates")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
{previewTemplate && (
|
||||
<TemplatePreviewModal
|
||||
templateId={previewTemplate.id}
|
||||
opened={true}
|
||||
onClose={handlePreviewClose}
|
||||
onUse={handlePreviewUse}
|
||||
useLoading={useTemplateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{destinationTemplate && (
|
||||
<UseTemplateModal
|
||||
template={destinationTemplate}
|
||||
opened={true}
|
||||
onClose={handleDestinationClose}
|
||||
initialSpaceId={initialSpaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ type TemplatePreviewModalProps = {
|
||||
onClose: () => void;
|
||||
onUse: () => void;
|
||||
onEdit?: () => void;
|
||||
useLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function TemplatePreviewModal({
|
||||
@@ -18,7 +17,6 @@ export default function TemplatePreviewModal({
|
||||
onClose,
|
||||
onUse,
|
||||
onEdit,
|
||||
useLoading,
|
||||
}: TemplatePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
|
||||
@@ -39,19 +37,14 @@ export default function TemplatePreviewModal({
|
||||
</Group>
|
||||
</Modal.Title>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={onUse}
|
||||
loading={useLoading}
|
||||
disabled={useLoading}
|
||||
>
|
||||
{t("Use template")}
|
||||
</Button>
|
||||
{onEdit && (
|
||||
<Button size="xs" variant="default" onClick={onEdit}>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" onClick={onUse}>
|
||||
{t("Use template")}
|
||||
</Button>
|
||||
<Modal.CloseButton />
|
||||
</Group>
|
||||
</Modal.Header>
|
||||
|
||||
@@ -10,14 +10,12 @@ type UseTemplateModalProps = {
|
||||
template: ITemplate;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
initialSpaceId?: string;
|
||||
};
|
||||
|
||||
export default function UseTemplateModal({
|
||||
template,
|
||||
opened,
|
||||
onClose,
|
||||
initialSpaceId,
|
||||
}: UseTemplateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -56,8 +54,6 @@ export default function UseTemplateModal({
|
||||
actionLabel={t("Create page")}
|
||||
onSelect={handleSelect}
|
||||
loading={useTemplateMutation.isPending}
|
||||
initialSpaceId={initialSpaceId ?? template.spaceId}
|
||||
searchSpacesOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,18 +75,6 @@ export default function TemplateEditor() {
|
||||
const editor = useEditor({
|
||||
extensions: templateExtensions,
|
||||
content: "",
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate() {
|
||||
if (loadedRef.current) {
|
||||
markDirty();
|
||||
|
||||
@@ -160,8 +160,7 @@ export default function TemplateList() {
|
||||
? spaceNameMap.get(template.spaceId)
|
||||
: undefined
|
||||
}
|
||||
onPreview={handlePreview}
|
||||
onUse={handleUse}
|
||||
onUse={handlePreview}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
canManage={isWorkspaceAdmin}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
UseQueryResult,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import { useAtom, useStore } from "jotai";
|
||||
import {
|
||||
getTemplates,
|
||||
getTemplateById,
|
||||
@@ -19,12 +18,6 @@ import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
|
||||
export function useGetTemplatesQuery(params?: { spaceId?: string }) {
|
||||
const { spaceId } = params ?? {};
|
||||
@@ -156,64 +149,13 @@ export function useDeleteTemplateMutation() {
|
||||
|
||||
export function useUseTemplateMutation() {
|
||||
const { t } = useTranslation();
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
const store = useStore();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
return useMutation<
|
||||
IPage,
|
||||
Error,
|
||||
{ templateId: string; spaceId: string; parentPageId?: string }
|
||||
>({
|
||||
mutationFn: (data) => useTemplate(data),
|
||||
onSuccess: (page) => {
|
||||
// React Query sidebar-pages cache update (same path useCreatePageMutation takes).
|
||||
invalidateOnCreatePage(page);
|
||||
|
||||
const parentId = page.parentPageId ?? null;
|
||||
const newNode: SpaceTreeNode = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Only mutate the tree atom and broadcast if it currently represents
|
||||
// this space. Cross-space template-use (e.g., from the gallery picking
|
||||
// a different space) lets the target space's clients pick up the new
|
||||
// page on their next React Query refetch (focus, navigation, etc.).
|
||||
// Without this guard we'd both pollute the local tree and send a wrong
|
||||
// `index` to remote clients in the target space.
|
||||
const current = store.get(treeDataAtom);
|
||||
const treeIsForThisSpace = current[0]?.spaceId === page.spaceId;
|
||||
if (!treeIsForThisSpace) return;
|
||||
|
||||
const lastIndex =
|
||||
parentId === null
|
||||
? current.length
|
||||
: (treeModel.find(current, parentId)?.children?.length ?? 0);
|
||||
|
||||
setTreeData((prev) =>
|
||||
treeModel.insert(prev, parentId, newNode, lastIndex),
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: page.spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data: newNode,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
},
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
templateId: string;
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
}) => useTemplate(data),
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { ITemplate } from "@/ee/template/types/template.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function getTemplates(params?: {
|
||||
@@ -41,7 +40,7 @@ export async function useTemplate(data: {
|
||||
templateId: string;
|
||||
spaceId: string;
|
||||
parentPageId?: string;
|
||||
}): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/templates/use", data);
|
||||
}): Promise<any> {
|
||||
const req = await api.post("/templates/use", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateWebhookMutation } from "@/ee/webhook/queries/webhook-query";
|
||||
import {
|
||||
EVENT_GROUPS,
|
||||
multiSelectData,
|
||||
} from "@/ee/webhook/lib/webhook-event-labels";
|
||||
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
|
||||
|
||||
interface CreateWebhookModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (signingSecret: string) => void;
|
||||
}
|
||||
|
||||
const allowedEvents: WebhookEvent[] = EVENT_GROUPS.flatMap((g) => g.events);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "URL is required")
|
||||
.refine(
|
||||
(value) => /^https?:\/\//i.test(value),
|
||||
"URL must start with http:// or https://",
|
||||
),
|
||||
subscribedEvents: z
|
||||
.array(z.enum(allowedEvents as [WebhookEvent, ...WebhookEvent[]]))
|
||||
.min(1, "Select at least one event"),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateWebhookModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateWebhookModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const createWebhookMutation = useCreateWebhookMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: "",
|
||||
url: "",
|
||||
subscribedEvents: [],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await createWebhookMutation.mutateAsync({
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
subscribedEvents: values.subscribedEvents,
|
||||
isActive: values.isActive,
|
||||
});
|
||||
form.reset();
|
||||
onClose();
|
||||
onSuccess(result.signingSecret);
|
||||
} catch (_err) {
|
||||
// notification handled inside mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Create webhook")}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("Name")}
|
||||
placeholder={t("e.g. Production alerts")}
|
||||
required
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("URL")}
|
||||
placeholder="https://example.com/webhook"
|
||||
required
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label={t("Events")}
|
||||
placeholder={t("Select events to subscribe to")}
|
||||
data={multiSelectData()}
|
||||
searchable
|
||||
clearable
|
||||
required
|
||||
{...form.getInputProps("subscribedEvents")}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t("Active")}
|
||||
description={t("Deliveries fire only when the webhook is active")}
|
||||
checked={form.values.isActive}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue("isActive", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={createWebhookMutation.isPending}>
|
||||
{t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Drawer,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useRedeliverMutation,
|
||||
useWebhookDeliveries,
|
||||
} from "@/ee/webhook/queries/webhook-query";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import type {
|
||||
IWebhookDelivery,
|
||||
WebhookDeliveryStatus,
|
||||
} from "@/ee/webhook/types/webhook.types";
|
||||
|
||||
interface DeliveryDrawerProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
webhookId: string | null;
|
||||
}
|
||||
|
||||
function statusColor(status: WebhookDeliveryStatus): string {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "green";
|
||||
case "failed":
|
||||
return "red";
|
||||
case "pending":
|
||||
return "yellow";
|
||||
case "skipped_cooldown":
|
||||
case "skipped_inflight":
|
||||
case "skipped_disabled":
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: WebhookDeliveryStatus): string {
|
||||
switch (status) {
|
||||
case "skipped_cooldown":
|
||||
return "skipped (cooldown)";
|
||||
case "skipped_inflight":
|
||||
return "skipped (in-flight)";
|
||||
case "skipped_disabled":
|
||||
return "skipped (disabled)";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function canRedeliver(status: WebhookDeliveryStatus): boolean {
|
||||
return (
|
||||
status === "failed" ||
|
||||
status === "skipped_cooldown" ||
|
||||
status === "skipped_inflight" ||
|
||||
status === "skipped_disabled"
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryRow({
|
||||
delivery,
|
||||
expanded,
|
||||
onToggle,
|
||||
onRedeliver,
|
||||
isRedelivering,
|
||||
}: {
|
||||
delivery: IWebhookDelivery;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onRedeliver: () => void;
|
||||
isRedelivering: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Table.Tr style={{ cursor: "pointer" }} onClick={onToggle}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{expanded ? (
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
) : (
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
)}
|
||||
<Text fz="sm" fw={500}>
|
||||
{delivery.event}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(delivery.status)} variant="light">
|
||||
{statusLabel(delivery.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">
|
||||
{delivery.httpStatus ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">
|
||||
{delivery.durationMs != null ? `${delivery.durationMs} ms` : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formattedDate(new Date(delivery.createdAt))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
{canRedeliver(delivery.status) ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
onClick={onRedeliver}
|
||||
loading={isRedelivering}
|
||||
>
|
||||
{t("Redeliver")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} p={0} style={{ border: "none" }}>
|
||||
<Collapse in={expanded}>
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{ background: "var(--mantine-color-gray-light)" }}
|
||||
>
|
||||
<Text fz="xs" fw={600} mb={4}>
|
||||
{t("Payload")}
|
||||
</Text>
|
||||
<Box
|
||||
component="pre"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
margin: 0,
|
||||
padding: 8,
|
||||
background: "var(--mantine-color-body)",
|
||||
borderRadius: 4,
|
||||
overflowX: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(delivery.payload, null, 2)}
|
||||
</Box>
|
||||
|
||||
{delivery.responseBody && (
|
||||
<>
|
||||
<Text fz="xs" fw={600} mt="sm" mb={4}>
|
||||
{t("Response body")}
|
||||
</Text>
|
||||
<Box
|
||||
component="pre"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
margin: 0,
|
||||
padding: 8,
|
||||
background: "var(--mantine-color-body)",
|
||||
borderRadius: 4,
|
||||
overflowX: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{delivery.responseBody}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{delivery.errorMessage && (
|
||||
<>
|
||||
<Text fz="xs" fw={600} mt="sm" mb={4} c="red">
|
||||
{t("Error")}
|
||||
</Text>
|
||||
<Text fz="xs" c="red">
|
||||
{delivery.errorMessage}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeliveryDrawer({
|
||||
opened,
|
||||
onClose,
|
||||
webhookId,
|
||||
}: DeliveryDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useWebhookDeliveries(opened ? webhookId : null);
|
||||
const redeliverMutation = useRedeliverMutation(webhookId ?? undefined);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRedeliver = async (deliveryId: string) => {
|
||||
setPendingId(deliveryId);
|
||||
try {
|
||||
await redeliverMutation.mutateAsync({ deliveryId });
|
||||
} catch (_err) {
|
||||
// notification handled inside mutation
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Recent deliveries")}
|
||||
position="right"
|
||||
size="xl"
|
||||
>
|
||||
<ScrollArea h="calc(100vh - 80px)">
|
||||
<Table verticalSpacing="xs" striped={false}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Event")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th>{t("HTTP")}</Table.Th>
|
||||
<Table.Th>{t("Duration")}</Table.Th>
|
||||
<Table.Th>{t("Timestamp")}</Table.Th>
|
||||
<Table.Th aria-label={t("Action")} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={120} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={70} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={40} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={60} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={140} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={70} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : data && data.length > 0 ? (
|
||||
data.map((delivery) => (
|
||||
<DeliveryRow
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
expanded={expanded.has(delivery.id)}
|
||||
onToggle={() => toggle(delivery.id)}
|
||||
onRedeliver={() => handleRedeliver(delivery.id)}
|
||||
isRedelivering={
|
||||
pendingId === delivery.id && redeliverMutation.isPending
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={6} text={t("No deliveries yet")} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useRotateSecretMutation,
|
||||
useSendTestMutation,
|
||||
useUpdateWebhookMutation,
|
||||
useWebhook,
|
||||
} from "@/ee/webhook/queries/webhook-query";
|
||||
import {
|
||||
EVENT_GROUPS,
|
||||
multiSelectData,
|
||||
} from "@/ee/webhook/lib/webhook-event-labels";
|
||||
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
|
||||
import { WebhookSecretModal } from "@/ee/webhook/components/webhook-secret-modal";
|
||||
|
||||
interface EditWebhookModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
webhookId: string | null;
|
||||
}
|
||||
|
||||
const allowedEvents: WebhookEvent[] = EVENT_GROUPS.flatMap((g) => g.events);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "URL is required")
|
||||
.refine(
|
||||
(value) => /^https?:\/\//i.test(value),
|
||||
"URL must start with http:// or https://",
|
||||
),
|
||||
subscribedEvents: z
|
||||
.array(z.enum(allowedEvents as [WebhookEvent, ...WebhookEvent[]]))
|
||||
.min(1, "Select at least one event"),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function EditWebhookModal({
|
||||
opened,
|
||||
onClose,
|
||||
webhookId,
|
||||
}: EditWebhookModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: webhook, isLoading } = useWebhook(opened ? webhookId : null);
|
||||
const updateMutation = useUpdateWebhookMutation();
|
||||
const rotateMutation = useRotateSecretMutation();
|
||||
const sendTestMutation = useSendTestMutation();
|
||||
|
||||
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: "",
|
||||
url: "",
|
||||
subscribedEvents: [],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && webhook) {
|
||||
form.setValues({
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
subscribedEvents: webhook.subscribedEvents,
|
||||
isActive: webhook.isActive,
|
||||
});
|
||||
form.resetDirty();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, webhook?.id]);
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
if (!webhookId) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
webhookId,
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
subscribedEvents: values.subscribedEvents,
|
||||
isActive: values.isActive,
|
||||
});
|
||||
onClose();
|
||||
} catch (_err) {
|
||||
// notification handled inside mutation
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async () => {
|
||||
if (!webhookId) return;
|
||||
try {
|
||||
const result = await rotateMutation.mutateAsync({ webhookId });
|
||||
setRevealedSecret(result.signingSecret);
|
||||
} catch (_err) {
|
||||
// notification handled inside mutation
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTest = async () => {
|
||||
if (!webhookId) return;
|
||||
try {
|
||||
await sendTestMutation.mutateAsync({ webhookId });
|
||||
} catch (_err) {
|
||||
// notification handled inside mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Edit webhook")}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("Name")}
|
||||
placeholder={t("e.g. Production alerts")}
|
||||
required
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("URL")}
|
||||
placeholder="https://example.com/webhook"
|
||||
required
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label={t("Events")}
|
||||
placeholder={t("Select events to subscribe to")}
|
||||
data={multiSelectData()}
|
||||
searchable
|
||||
clearable
|
||||
required
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("subscribedEvents")}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t("Active")}
|
||||
description={t(
|
||||
"Deliveries fire only when the webhook is active",
|
||||
)}
|
||||
checked={form.values.isActive}
|
||||
disabled={isLoading}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue("isActive", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t("Signing secret")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<PasswordInput
|
||||
value="dm_wh_••••••••••••••••••••••••••••••••"
|
||||
readOnly
|
||||
visible={false}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRotate}
|
||||
loading={rotateMutation.isPending}
|
||||
>
|
||||
{t("Rotate")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"Rotating generates a new signing secret. The previous secret stops working immediately.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleSendTest}
|
||||
loading={sendTestMutation.isPending}
|
||||
>
|
||||
{t("Send test event")}
|
||||
</Button>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={updateMutation.isPending}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<WebhookSecretModal
|
||||
opened={!!revealedSecret}
|
||||
onClose={() => setRevealedSecret(null)}
|
||||
secret={revealedSecret}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy";
|
||||
|
||||
interface WebhookSecretModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
secret: string | null;
|
||||
}
|
||||
|
||||
export function WebhookSecretModal({
|
||||
opened,
|
||||
onClose,
|
||||
secret,
|
||||
}: WebhookSecretModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!secret) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Save this signing secret")}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("Important")}
|
||||
color="red"
|
||||
>
|
||||
{t(
|
||||
"We won't show it again. Copy it now and store it somewhere safe. You can rotate it later if needed.",
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t("Signing secret")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
flex: 1,
|
||||
wordBreak: "break-all",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{secret}
|
||||
</Code>
|
||||
<CopyTextButton text={secret} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Use this secret to verify the X-Docmost-Signature header on incoming webhook deliveries.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Button fullWidth onClick={onClose} mt="md">
|
||||
{t("I've saved my signing secret")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Group,
|
||||
Menu,
|
||||
Skeleton,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconList,
|
||||
IconSend,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useDeleteWebhookMutation,
|
||||
useSendTestMutation,
|
||||
} from "@/ee/webhook/queries/webhook-query";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import type { IWebhook } from "@/ee/webhook/types/webhook.types";
|
||||
|
||||
interface WebhookTableProps {
|
||||
webhooks: IWebhook[] | undefined;
|
||||
isLoading: boolean;
|
||||
onEdit: (webhook: IWebhook) => void;
|
||||
onViewDeliveries: (webhook: IWebhook) => void;
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
if (value.length <= max) return value;
|
||||
return value.slice(0, max) + "…";
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={140} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={220} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={70} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={70} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={120} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={24} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookTable({
|
||||
webhooks,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onViewDeliveries,
|
||||
}: WebhookTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const deleteMutation = useDeleteWebhookMutation();
|
||||
const sendTestMutation = useSendTestMutation();
|
||||
|
||||
const handleDelete = (webhook: IWebhook) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete webhook"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to delete the webhook {{name}}? This action cannot be undone.",
|
||||
{ name: webhook.name },
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate({ webhookId: webhook.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
<Table.Th>{t("URL")}</Table.Th>
|
||||
<Table.Th>{t("Events")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th>{t("Created")}</Table.Th>
|
||||
<Table.Th aria-label={t("Action")} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : webhooks && webhooks.length > 0 ? (
|
||||
webhooks.map((webhook) => (
|
||||
<Table.Tr key={webhook.id}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onEdit(webhook)}
|
||||
underline="never"
|
||||
style={{ color: "var(--mantine-color-text)" }}
|
||||
>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{webhook.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Tooltip label={webhook.url} withArrow position="top-start">
|
||||
<Text fz="sm" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{truncate(webhook.url, 60)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
label={webhook.subscribedEvents.join(", ")}
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
<Badge variant="light" color="blue">
|
||||
{t("{{count}} events", {
|
||||
count: webhook.subscribedEvents.length,
|
||||
})}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={webhook.isActive ? "green" : "gray"}
|
||||
variant="light"
|
||||
>
|
||||
{webhook.isActive ? t("Active") : t("Inactive")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formattedDate(new Date(webhook.createdAt))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Webhook menu")}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={16} />}
|
||||
onClick={() => onEdit(webhook)}
|
||||
>
|
||||
{t("Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSend size={16} />}
|
||||
onClick={() =>
|
||||
sendTestMutation.mutate({ webhookId: webhook.id })
|
||||
}
|
||||
>
|
||||
{t("Send test event")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconList size={16} />}
|
||||
onClick={() => onViewDeliveries(webhook)}
|
||||
>
|
||||
{t("View deliveries")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={() => handleDelete(webhook)}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults
|
||||
colSpan={6}
|
||||
text={t("No webhooks yet. Add one to start receiving events.")}
|
||||
/>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
|
||||
|
||||
export const EVENT_GROUPS: { group: string; events: WebhookEvent[] }[] = [
|
||||
{
|
||||
group: "Pages",
|
||||
events: [
|
||||
"page.created",
|
||||
"page.updated",
|
||||
"page.moved",
|
||||
"page.deleted",
|
||||
"page.restored",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Comments",
|
||||
events: [
|
||||
"comment.created",
|
||||
"comment.updated",
|
||||
"comment.deleted",
|
||||
"comment.resolved",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Spaces",
|
||||
events: ["space.created", "space.updated", "space.deleted"],
|
||||
},
|
||||
{
|
||||
group: "Attachments",
|
||||
events: ["attachment.uploaded"],
|
||||
},
|
||||
{
|
||||
group: "Members",
|
||||
events: ["user.created", "user.deactivated"],
|
||||
},
|
||||
];
|
||||
|
||||
export const eventLabel = (event: string): string => event;
|
||||
|
||||
export const multiSelectData = () =>
|
||||
EVENT_GROUPS.map(({ group, events }) => ({
|
||||
group,
|
||||
items: events.map((e) => ({ value: e, label: e })),
|
||||
}));
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Group, Space } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 useUserRole from "@/hooks/use-user-role";
|
||||
import { useWebhooks } from "@/ee/webhook/queries/webhook-query";
|
||||
import { WebhookTable } from "@/ee/webhook/components/webhook-table";
|
||||
import { CreateWebhookModal } from "@/ee/webhook/components/create-webhook-modal";
|
||||
import { EditWebhookModal } from "@/ee/webhook/components/edit-webhook-modal";
|
||||
import { WebhookSecretModal } from "@/ee/webhook/components/webhook-secret-modal";
|
||||
import { DeliveryDrawer } from "@/ee/webhook/components/delivery-drawer";
|
||||
import type { IWebhook, IListWebhooksParams } from "@/ee/webhook/types/webhook.types";
|
||||
|
||||
export default function Webhooks() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
|
||||
const [createOpened, setCreateOpened] = useState(false);
|
||||
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
|
||||
const [editingWebhookId, setEditingWebhookId] = useState<string | null>(null);
|
||||
const [deliveryWebhookId, setDeliveryWebhookId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const params: IListWebhooksParams = useMemo(
|
||||
() => ({ cursor, limit: 50 }),
|
||||
[cursor],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useWebhooks(params);
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleEdit = (webhook: IWebhook) => {
|
||||
setEditingWebhookId(webhook.id);
|
||||
};
|
||||
|
||||
const handleViewDeliveries = (webhook: IWebhook) => {
|
||||
setDeliveryWebhookId(webhook.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Webhooks")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<SettingsTitle title={t("Webhooks")} />
|
||||
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button onClick={() => setCreateOpened(true)}>
|
||||
{t("Add webhook")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<WebhookTable
|
||||
webhooks={data?.items}
|
||||
isLoading={isLoading}
|
||||
onEdit={handleEdit}
|
||||
onViewDeliveries={handleViewDeliveries}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateWebhookModal
|
||||
opened={createOpened}
|
||||
onClose={() => setCreateOpened(false)}
|
||||
onSuccess={(signingSecret) => setRevealedSecret(signingSecret)}
|
||||
/>
|
||||
|
||||
<WebhookSecretModal
|
||||
opened={!!revealedSecret}
|
||||
onClose={() => setRevealedSecret(null)}
|
||||
secret={revealedSecret}
|
||||
/>
|
||||
|
||||
<EditWebhookModal
|
||||
opened={!!editingWebhookId}
|
||||
onClose={() => setEditingWebhookId(null)}
|
||||
webhookId={editingWebhookId}
|
||||
/>
|
||||
|
||||
<DeliveryDrawer
|
||||
opened={!!deliveryWebhookId}
|
||||
onClose={() => setDeliveryWebhookId(null)}
|
||||
webhookId={deliveryWebhookId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
getWebhook,
|
||||
getWebhookDeliveries,
|
||||
getWebhooks,
|
||||
redeliverWebhook,
|
||||
rotateWebhookSecret,
|
||||
sendWebhookTest,
|
||||
updateWebhook,
|
||||
} from "@/ee/webhook/services/webhook-service";
|
||||
import {
|
||||
ICreateWebhook,
|
||||
IListWebhooksParams,
|
||||
IUpdateWebhook,
|
||||
IWebhook,
|
||||
IWebhookCreated,
|
||||
IWebhookDelivery,
|
||||
} from "@/ee/webhook/types/webhook.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
const WEBHOOK_LIST_KEY = "webhook-list";
|
||||
const WEBHOOK_INFO_KEY = "webhook-info";
|
||||
const WEBHOOK_DELIVERIES_KEY = "webhook-deliveries";
|
||||
|
||||
export function useWebhooks(
|
||||
params?: IListWebhooksParams,
|
||||
): UseQueryResult<IPagination<IWebhook>, Error> {
|
||||
return useQuery({
|
||||
queryKey: [WEBHOOK_LIST_KEY, params],
|
||||
queryFn: () => getWebhooks(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWebhook(
|
||||
webhookId: string | null | undefined,
|
||||
): UseQueryResult<IWebhook, Error> {
|
||||
return useQuery({
|
||||
queryKey: [WEBHOOK_INFO_KEY, webhookId],
|
||||
queryFn: () => getWebhook(webhookId as string),
|
||||
enabled: !!webhookId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWebhookDeliveries(
|
||||
webhookId: string | null | undefined,
|
||||
): UseQueryResult<IWebhookDelivery[], Error> {
|
||||
return useQuery({
|
||||
queryKey: [WEBHOOK_DELIVERIES_KEY, webhookId],
|
||||
queryFn: () => getWebhookDeliveries(webhookId as string),
|
||||
enabled: !!webhookId,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateLists(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) => item.queryKey[0] === WEBHOOK_LIST_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWebhookMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IWebhookCreated, Error, ICreateWebhook>({
|
||||
mutationFn: (data) => createWebhook(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Webhook created") });
|
||||
invalidateLists(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWebhookMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IWebhook, Error, IUpdateWebhook>({
|
||||
mutationFn: (data) => updateWebhook(data),
|
||||
onSuccess: (data) => {
|
||||
notifications.show({ message: t("Webhook updated") });
|
||||
invalidateLists(queryClient);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [WEBHOOK_INFO_KEY, data.id],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWebhookMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ success: boolean }, Error, { webhookId: string }>({
|
||||
mutationFn: ({ webhookId }) => deleteWebhook(webhookId),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Webhook deleted") });
|
||||
invalidateLists(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRotateSecretMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
{ signingSecret: string },
|
||||
Error,
|
||||
{ webhookId: string }
|
||||
>({
|
||||
mutationFn: ({ webhookId }) => rotateWebhookSecret(webhookId),
|
||||
onSuccess: (_data, variables) => {
|
||||
notifications.show({ message: t("Signing secret rotated") });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [WEBHOOK_INFO_KEY, variables.webhookId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ deliveryId: string }, Error, { webhookId: string }>({
|
||||
mutationFn: ({ webhookId }) => sendWebhookTest(webhookId),
|
||||
onSuccess: (_data, variables) => {
|
||||
notifications.show({ message: t("Test event sent") });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [WEBHOOK_DELIVERIES_KEY, variables.webhookId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRedeliverMutation(webhookId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ deliveryId: string }, Error, { deliveryId: string }>({
|
||||
mutationFn: ({ deliveryId }) => redeliverWebhook(deliveryId),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Redelivery queued") });
|
||||
if (webhookId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [WEBHOOK_DELIVERIES_KEY, webhookId],
|
||||
});
|
||||
} else {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) => item.queryKey[0] === WEBHOOK_DELIVERIES_KEY,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import {
|
||||
ICreateWebhook,
|
||||
IListWebhooksParams,
|
||||
IUpdateWebhook,
|
||||
IWebhook,
|
||||
IWebhookCreated,
|
||||
IWebhookDelivery,
|
||||
} from "@/ee/webhook/types/webhook.types";
|
||||
|
||||
export async function getWebhooks(
|
||||
params?: IListWebhooksParams,
|
||||
): Promise<IPagination<IWebhook>> {
|
||||
const req = await api.post("/webhooks", { ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getWebhook(webhookId: string): Promise<IWebhook> {
|
||||
const req = await api.post<IWebhook>("/webhooks/info", { webhookId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createWebhook(
|
||||
data: ICreateWebhook,
|
||||
): Promise<IWebhookCreated> {
|
||||
const req = await api.post<IWebhookCreated>("/webhooks/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateWebhook(data: IUpdateWebhook): Promise<IWebhook> {
|
||||
const req = await api.post<IWebhook>("/webhooks/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteWebhook(
|
||||
webhookId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
const req = await api.post("/webhooks/delete", { webhookId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function rotateWebhookSecret(
|
||||
webhookId: string,
|
||||
): Promise<{ signingSecret: string }> {
|
||||
const req = await api.post<{ signingSecret: string }>(
|
||||
"/webhooks/rotate-secret",
|
||||
{ webhookId },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function sendWebhookTest(
|
||||
webhookId: string,
|
||||
): Promise<{ deliveryId: string }> {
|
||||
const req = await api.post<{ deliveryId: string }>("/webhooks/test", {
|
||||
webhookId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getWebhookDeliveries(
|
||||
webhookId: string,
|
||||
limit?: number,
|
||||
): Promise<IWebhookDelivery[]> {
|
||||
const req = await api.post<IWebhookDelivery[]>("/webhooks/deliveries", {
|
||||
webhookId,
|
||||
limit,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function redeliverWebhook(
|
||||
deliveryId: string,
|
||||
): Promise<{ deliveryId: string }> {
|
||||
const req = await api.post<{ deliveryId: string }>(
|
||||
"/webhooks/deliveries/redeliver",
|
||||
{ deliveryId },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export type WebhookEvent =
|
||||
| "page.created"
|
||||
| "page.updated"
|
||||
| "page.moved"
|
||||
| "page.deleted"
|
||||
| "page.restored"
|
||||
| "comment.created"
|
||||
| "comment.updated"
|
||||
| "comment.deleted"
|
||||
| "comment.resolved"
|
||||
| "space.created"
|
||||
| "space.updated"
|
||||
| "space.deleted"
|
||||
| "attachment.uploaded"
|
||||
| "user.created"
|
||||
| "user.deactivated";
|
||||
|
||||
export type WebhookDeliveryStatus =
|
||||
| "pending"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "skipped_cooldown"
|
||||
| "skipped_disabled"
|
||||
| "skipped_inflight";
|
||||
|
||||
export interface IWebhook {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
subscribedEvents: WebhookEvent[];
|
||||
isActive: boolean;
|
||||
consecutiveFailureCount: number;
|
||||
disabledAt: string | null;
|
||||
creatorId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IWebhookCreated extends IWebhook {
|
||||
signingSecret: string;
|
||||
}
|
||||
|
||||
export interface IWebhookDelivery {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
event: string;
|
||||
payload: Record<string, unknown>;
|
||||
status: WebhookDeliveryStatus;
|
||||
httpStatus: number | null;
|
||||
responseBody: string | null;
|
||||
errorMessage: string | null;
|
||||
attemptCount: number;
|
||||
durationMs: number | null;
|
||||
deliveredAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ICreateWebhook {
|
||||
name: string;
|
||||
url: string;
|
||||
subscribedEvents: WebhookEvent[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface IUpdateWebhook {
|
||||
webhookId: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
subscribedEvents?: WebhookEvent[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface IListWebhooksParams {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -416,9 +416,7 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
||||
"Video",
|
||||
"File attachment",
|
||||
"Draw.io (diagrams.net)",
|
||||
"Excalidraw (Whiteboard)",
|
||||
"Audio",
|
||||
"Synced block"
|
||||
"Excalidraw diagram",
|
||||
]);
|
||||
|
||||
const TemplateSlashCommand = Command.configure({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
IconSettings,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTemplate,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -54,10 +53,6 @@ import {
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants";
|
||||
import TemplatePickerModal from "@/ee/template/components/template-picker-modal";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
export function SpaceSidebar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -251,11 +246,6 @@ function SpaceMenu({
|
||||
useDisclosure(false);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [
|
||||
templatePickerOpened,
|
||||
{ open: openTemplatePicker, close: closeTemplatePicker },
|
||||
] = useDisclosure(false);
|
||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
||||
|
||||
const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId);
|
||||
const watchMutation = useWatchSpaceMutation();
|
||||
@@ -325,18 +315,6 @@ function SpaceMenu({
|
||||
{isWatching ? t("Stop watching space") : t("Watch space")}
|
||||
</Menu.Item>
|
||||
|
||||
{hasTemplates && canManagePages && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={openTemplatePicker}
|
||||
leftSection={<IconTemplate size={16} />}
|
||||
>
|
||||
{t("Templates")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canManagePages && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
@@ -392,16 +370,6 @@ function SpaceMenu({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasTemplates && templatePickerOpened && (
|
||||
<ErrorBoundary fallbackRender={() => null}>
|
||||
<TemplatePickerModal
|
||||
opened={templatePickerOpened}
|
||||
onClose={closeTemplatePicker}
|
||||
initialSpaceId={spaceId}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getAppName, isCloud } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import ManageHostname from "@/ee/components/manage-hostname.tsx";
|
||||
import { Divider } from "@mantine/core";
|
||||
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,9 +18,6 @@ export default function WorkspaceSettings() {
|
||||
<WorkspaceIcon />
|
||||
<WorkspaceNameForm />
|
||||
|
||||
<Divider my="md" />
|
||||
<AllowMemberTemplates />
|
||||
|
||||
{isCloud() && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@Injectable()
|
||||
export class PersistenceExtension implements Extension {
|
||||
@@ -47,6 +49,7 @@ export class PersistenceExtension implements Extension {
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
@@ -199,6 +202,19 @@ export class PersistenceExtension implements Extension {
|
||||
});
|
||||
|
||||
await this.enqueuePageHistory(page);
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
page.workspaceId,
|
||||
WebhookEvent.PageUpdated,
|
||||
{
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
updatedAt: page.updatedAt,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const Feature = {
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
TEMPLATES: 'templates',
|
||||
PDF_EXPORT: 'export:pdf',
|
||||
WEBHOOKS: 'webhooks',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -2,4 +2,3 @@ export * from './utils';
|
||||
export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
export * from './security-headers';
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export type SecurityHeader = { name: string; value: string };
|
||||
|
||||
export function resolveFrameHeader(
|
||||
iframeEmbedAllowed: boolean,
|
||||
allowedOrigins: string[],
|
||||
): SecurityHeader | null {
|
||||
if (!iframeEmbedAllowed) {
|
||||
return { name: 'X-Frame-Options', value: 'SAMEORIGIN' };
|
||||
}
|
||||
|
||||
if (allowedOrigins.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Content-Security-Policy',
|
||||
value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { createByteCountingStream } from '../../../common/helpers/utils';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
@@ -39,6 +41,7 @@ export class AttachmentService {
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
async uploadFile(opts: {
|
||||
@@ -271,7 +274,7 @@ export class AttachmentService {
|
||||
spaceId,
|
||||
trx,
|
||||
} = opts;
|
||||
return this.attachmentRepo.insertAttachment(
|
||||
const attachment = await this.attachmentRepo.insertAttachment(
|
||||
{
|
||||
id: attachmentId,
|
||||
type: type,
|
||||
@@ -287,6 +290,23 @@ export class AttachmentService {
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspaceId,
|
||||
WebhookEvent.AttachmentUploaded,
|
||||
{
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
fileSize: attachment.fileSize,
|
||||
pageId: attachment.pageId,
|
||||
spaceId: attachment.spaceId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
creatorId: attachment.creatorId,
|
||||
},
|
||||
);
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async handleDeleteAiChatAttachments(aiChatId: string) {
|
||||
|
||||
@@ -42,6 +42,7 @@ function buildWorkspaceOwnerAbility() {
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook);
|
||||
|
||||
return build();
|
||||
}
|
||||
@@ -58,6 +59,7 @@ function buildWorkspaceAdminAbility() {
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook);
|
||||
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum WorkspaceCaslSubject {
|
||||
Attachment = 'attachment',
|
||||
API = 'api_key',
|
||||
Audit = 'audit',
|
||||
Webhook = 'webhook',
|
||||
}
|
||||
|
||||
export type IWorkspaceAbility =
|
||||
@@ -22,4 +23,5 @@ export type IWorkspaceAbility =
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.API]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit];
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Webhook];
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { WsService } from '../../ws/ws.service';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('comments')
|
||||
@@ -44,6 +46,7 @@ export class CommentController {
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly wsService: WsService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -192,5 +195,16 @@ export class CommentController {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
comment.workspaceId,
|
||||
WebhookEvent.CommentDeleted,
|
||||
{
|
||||
id: comment.id,
|
||||
pageId: comment.pageId,
|
||||
spaceId: comment.spaceId,
|
||||
workspaceId: comment.workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
|
||||
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
|
||||
import { WsService } from '../../ws/ws.service';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
@@ -33,6 +35,7 @@ export class CommentService {
|
||||
private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||
private notificationQueue: Queue,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
async findById(commentId: string) {
|
||||
@@ -142,6 +145,21 @@ export class CommentService {
|
||||
comment,
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspaceId,
|
||||
WebhookEvent.CommentCreated,
|
||||
{
|
||||
id: comment.id,
|
||||
pageId: comment.pageId,
|
||||
spaceId: comment.spaceId,
|
||||
workspaceId: comment.workspaceId,
|
||||
type: comment.type,
|
||||
content: comment.content,
|
||||
creatorId: comment.creatorId,
|
||||
createdAt: comment.createdAt,
|
||||
},
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -203,6 +221,22 @@ export class CommentService {
|
||||
comment,
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
comment.workspaceId,
|
||||
WebhookEvent.CommentUpdated,
|
||||
{
|
||||
id: comment.id,
|
||||
pageId: comment.pageId,
|
||||
spaceId: comment.spaceId,
|
||||
workspaceId: comment.workspaceId,
|
||||
type: comment.type,
|
||||
content: comment.content,
|
||||
creatorId: comment.creatorId,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
},
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { getPageTitle } from '../../common/helpers';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -65,6 +67,7 @@ export class PageController {
|
||||
private readonly backlinkService: BacklinkService,
|
||||
private readonly labelService: LabelService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -366,6 +369,18 @@ export class PageController {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspace.id,
|
||||
WebhookEvent.PageDeleted,
|
||||
{
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +421,18 @@ export class PageController {
|
||||
},
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspace.id,
|
||||
WebhookEvent.PageRestored,
|
||||
{
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
|
||||
return this.pageRepo.findById(pageIdDto.pageId, {
|
||||
includeHasChildren: true,
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -73,6 +75,7 @@ export class PageService {
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@@ -156,9 +159,30 @@ export class PageService {
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
);
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
page.workspaceId,
|
||||
WebhookEvent.PageCreated,
|
||||
this.toWebhookPagePayload(page),
|
||||
);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
private toWebhookPagePayload(page: Page) {
|
||||
return {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
parentPageId: page.parentPageId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
creatorId: page.creatorId,
|
||||
createdAt: page.createdAt,
|
||||
updatedAt: page.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||
let pagePosition: string;
|
||||
|
||||
@@ -245,13 +269,21 @@ export class PageService {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.pageRepo.findById(page.id, {
|
||||
const updatedPage = await this.pageRepo.findById(page.id, {
|
||||
includeSpace: true,
|
||||
includeContent: true,
|
||||
includeCreator: true,
|
||||
includeLastUpdatedBy: true,
|
||||
includeContributors: true,
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
updatedPage.workspaceId,
|
||||
WebhookEvent.PageUpdated,
|
||||
this.toWebhookPagePayload(updatedPage),
|
||||
);
|
||||
|
||||
return updatedPage;
|
||||
}
|
||||
|
||||
async updatePageContent(
|
||||
@@ -487,6 +519,18 @@ export class PageService {
|
||||
}
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
rootPage.workspaceId,
|
||||
WebhookEvent.PageMoved,
|
||||
{
|
||||
id: rootPage.id,
|
||||
slugId: rootPage.slugId,
|
||||
fromSpaceId: rootPage.spaceId,
|
||||
toSpaceId: spaceId,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
return { childPageIds };
|
||||
}
|
||||
|
||||
@@ -1015,6 +1059,14 @@ export class PageService {
|
||||
pageIds: pageIds,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
for (const id of pageIds) {
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspaceId,
|
||||
WebhookEvent.PageDeleted,
|
||||
{ id, workspaceId },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceService {
|
||||
@@ -41,6 +43,7 @@ export class SpaceService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
async createSpace(
|
||||
@@ -85,6 +88,17 @@ export class SpaceService {
|
||||
},
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspaceId,
|
||||
WebhookEvent.SpaceCreated,
|
||||
{
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
slug: space.slug,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
return { ...space, memberCount: 1 };
|
||||
}
|
||||
|
||||
@@ -244,6 +258,17 @@ export class SpaceService {
|
||||
spaceId: updateSpaceDto.spaceId,
|
||||
changes: { before, after },
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspaceId,
|
||||
WebhookEvent.SpaceUpdated,
|
||||
{
|
||||
id: updatedSpace.id,
|
||||
name: updatedSpace.name,
|
||||
slug: updatedSpace.slug,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return updatedSpace;
|
||||
@@ -289,5 +314,15 @@ export class SpaceService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspaceId,
|
||||
WebhookEvent.SpaceDeleted,
|
||||
{
|
||||
id: spaceId,
|
||||
name: space.name,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@@ -55,6 +57,7 @@ export class WorkspaceInvitationService {
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
|
||||
@@ -304,6 +307,18 @@ export class WorkspaceInvitationService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspace.id,
|
||||
WebhookEvent.UserCreated,
|
||||
{
|
||||
id: newUser.id,
|
||||
name: newUser.name,
|
||||
email: newUser.email,
|
||||
role: newUser.role,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
|
||||
// notify the inviter
|
||||
const invitedByUser = await this.userRepo.findById(
|
||||
invitation.invitedById,
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
|
||||
import { WebhookEvent } from '@docmost/ee/webhook/constants';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -72,6 +74,7 @@ export class WorkspaceService {
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private readonly webhookDispatcher: WebhookDispatcher,
|
||||
) {}
|
||||
|
||||
async findById(workspaceId: string) {
|
||||
@@ -736,6 +739,17 @@ export class WorkspaceService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.webhookDispatcher.dispatch(
|
||||
workspaceId,
|
||||
WebhookEvent.UserDeactivated,
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async activateUser(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('webhooks')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('url', 'text', (col) => col.notNull())
|
||||
.addColumn('signing_secret', 'text', (col) => col.notNull())
|
||||
.addColumn('subscribed_events', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'[]'::jsonb`),
|
||||
)
|
||||
.addColumn('is_active', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(true),
|
||||
)
|
||||
.addColumn('consecutive_failure_count', 'integer', (col) =>
|
||||
col.notNull().defaultTo(0),
|
||||
)
|
||||
.addColumn('disabled_at', 'timestamptz')
|
||||
.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
|
||||
.createIndex('idx_webhooks_workspace_id')
|
||||
.ifNotExists()
|
||||
.on('webhooks')
|
||||
.columns(['workspace_id', 'id desc'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_webhooks_subscribed_events')
|
||||
.ifNotExists()
|
||||
.on('webhooks')
|
||||
.using('gin')
|
||||
.column('subscribed_events')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('webhook_deliveries')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('webhook_id', 'uuid', (col) =>
|
||||
col.notNull().references('webhooks.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('event', 'varchar', (col) => col.notNull())
|
||||
.addColumn('payload', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('status', 'varchar', (col) =>
|
||||
col.notNull().defaultTo('pending'),
|
||||
)
|
||||
.addColumn('http_status', 'integer')
|
||||
.addColumn('response_body', 'text')
|
||||
.addColumn('error_message', 'text')
|
||||
.addColumn('attempt_count', 'integer', (col) =>
|
||||
col.notNull().defaultTo(0),
|
||||
)
|
||||
.addColumn('duration_ms', 'integer')
|
||||
.addColumn('delivered_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_webhook_deliveries_webhook_id')
|
||||
.ifNotExists()
|
||||
.on('webhook_deliveries')
|
||||
.columns(['webhook_id', 'id desc'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('webhook_deliveries').execute();
|
||||
await db.schema.dropTable('webhooks').execute();
|
||||
}
|
||||
@@ -63,9 +63,11 @@ export class TemplateRepo {
|
||||
|
||||
if (opts?.spaceId) {
|
||||
if (!accessibleSpaceIds.includes(opts.spaceId)) {
|
||||
query = query.where(sql<boolean>`false`);
|
||||
query = query.where('spaceId', 'is', null);
|
||||
} else {
|
||||
query = query.where('spaceId', '=', opts.spaceId);
|
||||
query = query.where((eb) =>
|
||||
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
|
||||
+33
@@ -589,6 +589,37 @@ export interface UserSessions {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Webhooks {
|
||||
id: Generated<string>;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
signingSecret: string;
|
||||
subscribedEvents: Generated<Json>;
|
||||
isActive: Generated<boolean>;
|
||||
consecutiveFailureCount: Generated<number>;
|
||||
disabledAt: Timestamp | null;
|
||||
creatorId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface WebhookDeliveries {
|
||||
id: Generated<string>;
|
||||
webhookId: string;
|
||||
workspaceId: string;
|
||||
event: string;
|
||||
payload: Json;
|
||||
status: Generated<string>;
|
||||
httpStatus: number | null;
|
||||
responseBody: string | null;
|
||||
errorMessage: string | null;
|
||||
attemptCount: Generated<number>;
|
||||
durationMs: number | null;
|
||||
deliveredAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
aiChats: AiChats;
|
||||
aiChatMessages: AiChatMessages;
|
||||
@@ -625,6 +656,8 @@ export interface DB {
|
||||
userSessions: UserSessions;
|
||||
userTokens: UserTokens;
|
||||
watchers: Watchers;
|
||||
webhooks: Webhooks;
|
||||
webhookDeliveries: WebhookDeliveries;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
workspaces: Workspaces;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
Webhooks,
|
||||
WebhookDeliveries,
|
||||
} from './db';
|
||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
|
||||
@@ -238,3 +240,13 @@ export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
|
||||
export type Template = Selectable<Templates>;
|
||||
export type InsertableTemplate = Insertable<Templates>;
|
||||
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
|
||||
|
||||
// Webhook
|
||||
export type Webhook = Selectable<Webhooks>;
|
||||
export type InsertableWebhook = Insertable<Webhooks>;
|
||||
export type UpdatableWebhook = Updateable<Omit<Webhooks, 'id'>>;
|
||||
|
||||
// Webhook delivery
|
||||
export type WebhookDelivery = Selectable<WebhookDeliveries>;
|
||||
export type InsertableWebhookDelivery = Insertable<WebhookDeliveries>;
|
||||
export type UpdatableWebhookDelivery = Updateable<Omit<WebhookDeliveries, 'id'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: b30e92f6a0...190deffa98
@@ -325,19 +325,4 @@ export class EnvironmentService {
|
||||
.toLowerCase();
|
||||
return disabled === 'true';
|
||||
}
|
||||
|
||||
isIframeEmbedAllowed(): boolean {
|
||||
const allowed = this.configService
|
||||
.get<string>('IFRAME_EMBED_ALLOWED', 'false')
|
||||
.toLowerCase();
|
||||
return allowed === 'true';
|
||||
}
|
||||
|
||||
getIframeAllowedOrigins(): string[] {
|
||||
const raw = this.configService.get<string>('IFRAME_ALLOWED_ORIGINS', '');
|
||||
return raw
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum QueueName {
|
||||
HISTORY_QUEUE = '{history-queue}',
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
AUDIT_QUEUE = '{audit-queue}',
|
||||
WEBHOOK_QUEUE = '{webhook-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -83,4 +84,7 @@ export enum QueueJob {
|
||||
|
||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||
|
||||
WEBHOOK_DELIVERY = 'webhook-delivery',
|
||||
WEBHOOK_DELIVERY_CLEANUP = 'webhook-delivery-cleanup',
|
||||
}
|
||||
|
||||
@@ -92,6 +92,18 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
attempts: 3,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.WEBHOOK_QUEUE,
|
||||
defaultJobOptions: {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 10 * 1000,
|
||||
},
|
||||
removeOnComplete: { count: 200 },
|
||||
removeOnFail: { count: 200 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [GeneralQueueProcessor],
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody, getGreetingName } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
recipientName?: string;
|
||||
webhookName: string;
|
||||
webhookUrl: string;
|
||||
settingsUrl: string;
|
||||
}
|
||||
|
||||
export const WebhookDisabledEmail = ({
|
||||
recipientName,
|
||||
webhookName,
|
||||
webhookUrl,
|
||||
settingsUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi {getGreetingName(recipientName)},</Text>
|
||||
<Text style={paragraph}>
|
||||
Your webhook <strong>{webhookName}</strong> to{' '}
|
||||
<strong>{webhookUrl}</strong> has been disabled after too many
|
||||
consecutive delivery failures.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Re-enable it in your workspace settings once the receiving endpoint
|
||||
is healthy again.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={settingsUrl}>Open webhook settings</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebhookDisabledEmail;
|
||||
@@ -12,8 +12,6 @@ import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyIp from 'fastify-ip';
|
||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
||||
import { resolveFrameHeader } from './common/helpers';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -52,28 +50,6 @@ async function bootstrap() {
|
||||
await app.register(fastifyMultipart);
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const environmentService = app.get(EnvironmentService);
|
||||
const frameHeader = resolveFrameHeader(
|
||||
environmentService.isIframeEmbedAllowed(),
|
||||
environmentService.getIframeAllowedOrigins(),
|
||||
);
|
||||
if (frameHeader) {
|
||||
// Skipped routes:
|
||||
// /api/files/ - attachment controller sets its own CSP we'd overwrite
|
||||
// /share/ 0 public share pages are safe to embed
|
||||
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addHook('onSend', (req, reply, payload, done) => {
|
||||
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
|
||||
return done(null, payload);
|
||||
}
|
||||
reply.header(frameHeader.name, frameHeader.value);
|
||||
done(null, payload);
|
||||
});
|
||||
}
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
|
||||
@@ -162,28 +162,6 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Empty callout: delete the whole node so Backspace inside it isn't
|
||||
// a no-op (isolating: true blocks the default join with the block
|
||||
// above).
|
||||
const calloutDepth = $from.depth - 1;
|
||||
if (calloutDepth >= 0) {
|
||||
const calloutNode = $from.node(calloutDepth);
|
||||
if (
|
||||
calloutNode.type === this.type &&
|
||||
calloutNode.childCount === 1 &&
|
||||
calloutNode.firstChild?.content.size === 0
|
||||
) {
|
||||
const calloutPos = $from.before(calloutDepth);
|
||||
const { tr } = state;
|
||||
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(calloutPos), -1),
|
||||
);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const previousPosition = $from.before($from.depth) - 1;
|
||||
|
||||
// If nothing above to join with
|
||||
@@ -229,56 +207,6 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Exit the callout into a fresh paragraph below when the cursor sits
|
||||
// in an empty trailing child. An empty callout (single empty
|
||||
// paragraph) exits on the first Enter and keeps the empty callout
|
||||
// intact; a callout with content needs the double-Enter pattern
|
||||
// (first Enter splits, second Enter on the new trailing empty exits
|
||||
// and removes that trailing paragraph).
|
||||
Enter: ({ editor }) => {
|
||||
const { state, view } = editor;
|
||||
const { selection } = state;
|
||||
if (!selection.empty) return false;
|
||||
|
||||
const { $from } = selection;
|
||||
const calloutDepth = $from.depth - 1;
|
||||
if (calloutDepth < 0) return false;
|
||||
|
||||
const calloutNode = $from.node(calloutDepth);
|
||||
if (calloutNode.type !== this.type) return false;
|
||||
if ($from.parent.content.size !== 0) return false;
|
||||
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paragraphType = state.schema.nodes.paragraph;
|
||||
const containerDepth = calloutDepth - 1;
|
||||
const container = $from.node(containerDepth);
|
||||
const indexAfter = $from.indexAfter(containerDepth);
|
||||
if (
|
||||
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const calloutEnd = $from.after(calloutDepth);
|
||||
const paragraph = paragraphType.create();
|
||||
const { tr } = state;
|
||||
|
||||
if (calloutNode.childCount === 1) {
|
||||
tr.insert(calloutEnd, paragraph);
|
||||
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
|
||||
} else {
|
||||
tr.delete($from.before(), $from.after());
|
||||
const insertPos = tr.mapping.map(calloutEnd);
|
||||
tr.insert(insertPos, paragraph);
|
||||
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user