mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 01:04:39 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 812d0765b5 | |||
| 234bd13453 | |||
| affd2e6740 | |||
| 37e652ade5 | |||
| 38a847f305 | |||
| 7c80688698 | |||
| 0320e07bb1 | |||
| 1982a0ed1e | |||
| fcb2b08cf3 | |||
| e5ff58a73a | |||
| d3bb105366 | |||
| bbb1b5eb26 | |||
| 3e66aff1e2 | |||
| 89442f5154 | |||
| 12ef426541 | |||
| 93ff3f43b7 | |||
| cbf03fc956 | |||
| 68309247b5 | |||
| 474ff6c629 | |||
| c5e9be6b36 | |||
| 00e8c53852 |
@@ -48,13 +48,6 @@ GOTENBERG_URL=
|
|||||||
|
|
||||||
DISABLE_TELEMETRY=false
|
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)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
|
|||||||
@@ -936,35 +936,6 @@
|
|||||||
"Page actions": "Page actions",
|
"Page actions": "Page actions",
|
||||||
"Pick emoji": "Pick emoji",
|
"Pick emoji": "Pick emoji",
|
||||||
"Template menu": "Template menu",
|
"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",
|
"Chat menu": "Chat menu",
|
||||||
"API key menu": "API key menu",
|
"API key menu": "API key menu",
|
||||||
"Jump to comment selection": "Jump to comment selection",
|
"Jump to comment selection": "Jump to comment selection",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import FavoritesPage from "@/pages/favorites/favorites-page";
|
|||||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||||
import LabelPage from "@/pages/label/label-page";
|
import LabelPage from "@/pages/label/label-page";
|
||||||
|
import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -125,6 +126,10 @@ export default function App() {
|
|||||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
<Route path={"audit"} element={<AuditLogs />} />
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
|
<Route
|
||||||
|
path={"import/confluence"}
|
||||||
|
element={<ConfluenceImportPage />}
|
||||||
|
/>
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -38,16 +38,6 @@
|
|||||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
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 {
|
.linkIcon {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 {
|
import {
|
||||||
IconHome,
|
IconHome,
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserPlus,
|
IconUserPlus,
|
||||||
IconTemplate,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./global-sidebar.module.css";
|
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 { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
const mainNavItems = [
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
{ label: "Home", icon: IconHome, path: "/home" },
|
||||||
|
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||||
|
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function GlobalSidebar() {
|
export default function GlobalSidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,19 +33,6 @@ export default function GlobalSidebar() {
|
|||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobileSidebar = useToggleSidebar(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 { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||||
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const sortedFavoriteSpaces = [...favoriteSpaces]
|
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||||
@@ -69,37 +58,18 @@ export default function GlobalSidebar() {
|
|||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
<ScrollArea w="100%" style={{ flex: 1 }}>
|
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
{mainNavItems.map((item) =>
|
{mainNavItems.map((item) => (
|
||||||
item.disabled ? (
|
<Link
|
||||||
<Tooltip
|
key={item.label}
|
||||||
key={item.label}
|
className={classes.link}
|
||||||
label={upgradeLabel}
|
data-active={active === item.path || undefined}
|
||||||
position="right"
|
to={item.path}
|
||||||
withArrow
|
onClick={handleNavClick}
|
||||||
>
|
>
|
||||||
<UnstyledButton
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
className={classes.link}
|
<span>{t(item.label)}</span>
|
||||||
data-disabled
|
</Link>
|
||||||
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>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider my="xs" />
|
<Divider my="xs" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
|
IconFileImport,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -125,6 +126,13 @@ const groupedData: DataGroup[] = [
|
|||||||
role: "owner",
|
role: "owner",
|
||||||
env: "selfhosted",
|
env: "selfhosted",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Import",
|
||||||
|
icon: IconFileImport,
|
||||||
|
path: "/settings/import/confluence",
|
||||||
|
feature: Feature.CONFLUENCE_IMPORT,
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export function DestinationPickerModal({
|
|||||||
loading,
|
loading,
|
||||||
excludePageId,
|
excludePageId,
|
||||||
pageLimit,
|
pageLimit,
|
||||||
initialSpaceId,
|
|
||||||
searchSpacesOnly,
|
|
||||||
}: DestinationPickerModalProps) {
|
}: DestinationPickerModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||||
@@ -48,8 +46,6 @@ export function DestinationPickerModal({
|
|||||||
onSelectionChange={setSelection}
|
onSelectionChange={setSelection}
|
||||||
excludePageId={excludePageId}
|
excludePageId={excludePageId}
|
||||||
pageLimit={pageLimit}
|
pageLimit={pageLimit}
|
||||||
initialSpaceId={initialSpaceId}
|
|
||||||
searchSpacesOnly={searchSpacesOnly}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
|
||||||
transition: background-color 150ms ease;
|
transition: background-color 150ms ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
@@ -23,11 +22,6 @@
|
|||||||
var(--mantine-color-dark-6)
|
var(--mantine-color-dark-6)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
@@ -63,7 +57,7 @@
|
|||||||
border-radius: var(--mantine-radius-sm);
|
border-radius: var(--mantine-radius-sm);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: transform 150ms ease;
|
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 {
|
@mixin hover {
|
||||||
background-color: light-dark(
|
background-color: light-dark(
|
||||||
@@ -117,7 +111,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.spaceName {
|
.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);
|
font-size: var(--mantine-font-size-xs);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
|
import { TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||||
@@ -15,29 +15,23 @@ type DestinationPickerProps = {
|
|||||||
onSelectionChange: (selection: DestinationSelection | null) => void;
|
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||||
excludePageId?: string;
|
excludePageId?: string;
|
||||||
pageLimit?: number;
|
pageLimit?: number;
|
||||||
initialSpaceId?: string;
|
|
||||||
searchSpacesOnly?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DestinationPicker({
|
export function DestinationPicker({
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
excludePageId,
|
excludePageId,
|
||||||
pageLimit = 15,
|
pageLimit = 15,
|
||||||
initialSpaceId,
|
|
||||||
searchSpacesOnly,
|
|
||||||
}: DestinationPickerProps) {
|
}: DestinationPickerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||||
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchEnabled =
|
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
|
||||||
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
|
|
||||||
|
|
||||||
const { data: searchData, isLoading: searchLoading } =
|
const { data: searchData, isLoading: searchLoading } =
|
||||||
useSearchSuggestionsQuery({
|
useSearchSuggestionsQuery({
|
||||||
@@ -48,18 +42,6 @@ export function DestinationPicker({
|
|||||||
|
|
||||||
const isSearching = !!searchEnabled;
|
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 =
|
const selectedId =
|
||||||
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||||
|
|
||||||
@@ -105,48 +87,18 @@ export function DestinationPicker({
|
|||||||
[updateSelection],
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextInput
|
<TextInput
|
||||||
leftSection={<IconSearch size={16} />}
|
leftSection={<IconSearch size={16} />}
|
||||||
placeholder={
|
placeholder={t("Search pages and spaces...")}
|
||||||
searchSpacesOnly
|
|
||||||
? t("Search spaces...")
|
|
||||||
: t("Search pages and spaces...")
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
searchSpacesOnly
|
|
||||||
? t("Search spaces...")
|
|
||||||
: t("Search pages and spaces...")
|
|
||||||
}
|
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
className={classes.searchInput}
|
className={classes.searchInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
|
||||||
h="50vh"
|
|
||||||
offsetScrollbars
|
|
||||||
className={classes.scrollArea}
|
|
||||||
viewportRef={viewportRef}
|
|
||||||
>
|
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
searchLoading ? (
|
searchLoading ? (
|
||||||
<div className={classes.emptyState}>
|
<div className={classes.emptyState}>
|
||||||
@@ -159,28 +111,16 @@ export function DestinationPicker({
|
|||||||
<div
|
<div
|
||||||
key={page.id}
|
key={page.id}
|
||||||
className={classes.searchResult}
|
className={classes.searchResult}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => handleSearchResultClick(page)}
|
onClick={() => handleSearchResultClick(page)}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSearchResultClick(page);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className={classes.iconWrapper}>
|
<div className={classes.iconWrapper}>
|
||||||
{page.icon ? (
|
{page.icon ? (
|
||||||
page.icon
|
page.icon
|
||||||
) : (
|
) : (
|
||||||
<ActionIcon
|
<IconFile
|
||||||
component="div"
|
size={16}
|
||||||
variant="transparent"
|
color="var(--mantine-color-gray-5)"
|
||||||
c="gray"
|
/>
|
||||||
size={22}
|
|
||||||
>
|
|
||||||
<IconFileDescription size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.pageTitle}>
|
<div className={classes.pageTitle}>
|
||||||
@@ -201,14 +141,8 @@ export function DestinationPicker({
|
|||||||
<div className={classes.emptyState}>
|
<div className={classes.emptyState}>
|
||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
</div>
|
</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
|
<SpaceRow
|
||||||
key={space.id}
|
key={space.id}
|
||||||
space={space}
|
space={space}
|
||||||
|
|||||||
@@ -20,6 +20,4 @@ export type DestinationPickerModalProps = {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
excludePageId?: string;
|
excludePageId?: string;
|
||||||
pageLimit?: number;
|
pageLimit?: number;
|
||||||
initialSpaceId?: string;
|
|
||||||
searchSpacesOnly?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { KeyboardEvent, useState } from "react";
|
import { useState } from "react";
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { IconChevronRight, IconFile } from "@tabler/icons-react";
|
||||||
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IPage } from "@/features/page/types/page.types";
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
import { PageChildren } from "./page-children";
|
import { PageChildren } from "./page-children";
|
||||||
@@ -37,44 +36,23 @@ export function PageRow({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={rowClasses}
|
className={rowClasses}
|
||||||
style={{ paddingLeft: depth * 20 + 12 }}
|
style={{ paddingLeft: depth * 20 + 12 }}
|
||||||
role="button"
|
onClick={() => !isExcluded && onSelect(page)}
|
||||||
tabIndex={isExcluded ? -1 : 0}
|
|
||||||
aria-disabled={isExcluded || undefined}
|
|
||||||
onClick={handleSelect}
|
|
||||||
onKeyDown={handleRowKeyDown}
|
|
||||||
>
|
>
|
||||||
{page.hasChildren ? (
|
{page.hasChildren ? (
|
||||||
<ActionIcon
|
<div
|
||||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconChevronRight size={14} />
|
<IconChevronRight size={14} />
|
||||||
</ActionIcon>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: 20, flexShrink: 0 }} />
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
)}
|
)}
|
||||||
@@ -83,14 +61,10 @@ export function PageRow({
|
|||||||
{page.icon ? (
|
{page.icon ? (
|
||||||
page.icon
|
page.icon
|
||||||
) : (
|
) : (
|
||||||
<ActionIcon
|
<IconFile
|
||||||
component="div"
|
size={16}
|
||||||
variant="transparent"
|
color="var(--mantine-color-gray-5)"
|
||||||
c="gray"
|
/>
|
||||||
size={22}
|
|
||||||
>
|
|
||||||
<IconFileDescription size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { KeyboardEvent, useState } from "react";
|
import { useState } from "react";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { Tooltip } from "@mantine/core";
|
||||||
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ISpace } from "@/features/space/types/space.types";
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
@@ -42,43 +42,21 @@ export function SpaceRow({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.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 = (
|
const rowContent = (
|
||||||
<div
|
<div
|
||||||
className={rowClasses}
|
className={rowClasses}
|
||||||
data-space-id={space.id}
|
onClick={() => writable && onSelectSpace(space)}
|
||||||
role="button"
|
|
||||||
tabIndex={writable ? 0 : -1}
|
|
||||||
aria-disabled={!writable || undefined}
|
|
||||||
onClick={handleSelect}
|
|
||||||
onKeyDown={handleRowKeyDown}
|
|
||||||
>
|
>
|
||||||
{writable ? (
|
{writable ? (
|
||||||
<ActionIcon
|
<div
|
||||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconChevronRight size={14} />
|
<IconChevronRight size={14} />
|
||||||
</ActionIcon>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: 20, flexShrink: 0 }} />
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Progress,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconAlertCircle, IconCheck, IconX } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ConfluenceImportHistoryItem,
|
||||||
|
ConfluenceImportStatus,
|
||||||
|
} from "@/ee/confluence-import/types/confluence-import.types";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { formattedDate } from "@/lib/time";
|
||||||
|
import NoTableResults from "@/components/common/no-table-results";
|
||||||
|
import { useConfluenceImportsQuery } from "@/ee/confluence-import/queries/confluence-import-queries";
|
||||||
|
|
||||||
|
const BADGE_STYLES = {
|
||||||
|
root: { flexShrink: 0 },
|
||||||
|
label: { overflow: "visible" as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
|
||||||
|
if (cancelled) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="gray"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconX size={12} />}
|
||||||
|
styles={BADGE_STYLES}
|
||||||
|
>
|
||||||
|
Cancelled
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "processing") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<Loader size={10} />}
|
||||||
|
styles={BADGE_STYLES}
|
||||||
|
>
|
||||||
|
Running
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="teal"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconCheck size={12} />}
|
||||||
|
styles={BADGE_STYLES}
|
||||||
|
>
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconAlertCircle size={12} />}
|
||||||
|
styles={BADGE_STYLES}
|
||||||
|
>
|
||||||
|
Failed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseLabel(phase: string | null): string {
|
||||||
|
if (!phase) return "—";
|
||||||
|
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressValue(item: ConfluenceImportHistoryItem) {
|
||||||
|
if (item.status === "success") return 100;
|
||||||
|
if (item.totalPages > 0) {
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((item.importedPages / item.totalPages) * 100),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return item.status === "processing" ? 5 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
|
||||||
|
const value = progressValue(item);
|
||||||
|
const color =
|
||||||
|
item.status === "failed"
|
||||||
|
? "red"
|
||||||
|
: item.status === "success"
|
||||||
|
? "teal"
|
||||||
|
: "blue";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Progress value={value} color={color} size="xs" animated={item.status === "processing"} />
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{item.importedPages}/{item.totalPages || "?"} pages
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
· {item.importedSpaces}/{item.totalSpaces || "?"} spaces
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
· {item.importedUsers}/{item.totalUsers || "?"} users
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Table.Tr key={i}>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={180} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={80} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={140} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfluenceImportHistory() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useConfluenceImportsQuery();
|
||||||
|
|
||||||
|
const items = useMemo(() => data?.items ?? [], [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={720}>
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
|
<Table.Th>{t("Site")}</Table.Th>
|
||||||
|
<Table.Th>{t("Phase")}</Table.Th>
|
||||||
|
<Table.Th>{t("Progress")}</Table.Th>
|
||||||
|
<Table.Th>{t("Started by")}</Table.Th>
|
||||||
|
<Table.Th>{t("Started at")}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<Table.Tr key={item.fileTaskId}>
|
||||||
|
<Table.Td>
|
||||||
|
{statusBadge(item.status, item.cancelled)}
|
||||||
|
{item.status === "failed" && item.errorMessage && (
|
||||||
|
<Tooltip label={item.errorMessage} multiline w={320}>
|
||||||
|
<Text fz="xs" c="red" lineClamp={1} maw={180}>
|
||||||
|
{item.errorMessage}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" lineClamp={1} maw={240}>
|
||||||
|
{item.siteUrl}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm">{phaseLabel(item.currentPhase)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<ProgressCell item={item} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{item.creatorName ? (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={item.creatorAvatarUrl}
|
||||||
|
name={item.creatorName}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<Text fz="sm" lineClamp={1}>
|
||||||
|
{item.creatorName}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formattedDate(new Date(item.createdAt))}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<NoTableResults colSpan={6} />
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
PasswordInput,
|
||||||
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Stepper,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconCloudCheck,
|
||||||
|
IconPlug,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
listConfluenceSpaces,
|
||||||
|
startConfluenceImport,
|
||||||
|
testConfluenceConnection,
|
||||||
|
} from "@/ee/confluence-import/services/confluence-import-service";
|
||||||
|
import {
|
||||||
|
ConfluenceAuthType,
|
||||||
|
ConfluenceCredentials,
|
||||||
|
ConfluenceSpaceSummary,
|
||||||
|
} from "@/ee/confluence-import/types/confluence-import.types";
|
||||||
|
import { confluenceImportsQueryKey } from "@/ee/confluence-import/queries/confluence-import-queries";
|
||||||
|
|
||||||
|
type ConfluenceEditionChoice = "cloud" | "server";
|
||||||
|
|
||||||
|
type CredentialsFormValues = {
|
||||||
|
edition: ConfluenceEditionChoice;
|
||||||
|
authType: ConfluenceAuthType;
|
||||||
|
siteUrl: string;
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfluenceImportModal({ opened, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [spaces, setSpaces] = useState<ConfluenceSpaceSummary[]>([]);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||||
|
const [importAll, setImportAll] = useState(true);
|
||||||
|
|
||||||
|
const form = useForm<CredentialsFormValues>({
|
||||||
|
initialValues: {
|
||||||
|
edition: "server",
|
||||||
|
authType: "pat",
|
||||||
|
siteUrl: "",
|
||||||
|
email: "",
|
||||||
|
token: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
siteUrl: (value) =>
|
||||||
|
!value?.trim()
|
||||||
|
? t("Site URL is required")
|
||||||
|
: !/^https?:\/\//i.test(value.trim())
|
||||||
|
? t("Site URL must start with http:// or https://")
|
||||||
|
: null,
|
||||||
|
email: (value, values) =>
|
||||||
|
values.edition === "cloud" && !value?.trim()
|
||||||
|
? t("Email is required")
|
||||||
|
: null,
|
||||||
|
token: (value, values) =>
|
||||||
|
(values.authType === "cloud_token" || values.authType === "pat") &&
|
||||||
|
!value?.trim()
|
||||||
|
? t("API token is required")
|
||||||
|
: null,
|
||||||
|
username: (value, values) =>
|
||||||
|
values.authType === "basic" && !value?.trim()
|
||||||
|
? t("Username is required")
|
||||||
|
: null,
|
||||||
|
password: (value, values) =>
|
||||||
|
values.authType === "basic" && !value?.trim()
|
||||||
|
? t("Password is required")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) {
|
||||||
|
setActive(0);
|
||||||
|
setError(null);
|
||||||
|
setSpaces([]);
|
||||||
|
setSelectedKeys([]);
|
||||||
|
setImportAll(true);
|
||||||
|
setLoading(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const credentials: ConfluenceCredentials = useMemo(() => {
|
||||||
|
const values = form.values;
|
||||||
|
return {
|
||||||
|
siteUrl: values.siteUrl.trim().replace(/\/+$/, ""),
|
||||||
|
authType: values.authType,
|
||||||
|
email: values.email?.trim() || undefined,
|
||||||
|
token: values.token?.trim() || undefined,
|
||||||
|
username: values.username?.trim() || undefined,
|
||||||
|
password: values.password || undefined,
|
||||||
|
};
|
||||||
|
}, [form.values]);
|
||||||
|
|
||||||
|
const handleEditionChange = (edition: ConfluenceEditionChoice) => {
|
||||||
|
form.setFieldValue("edition", edition);
|
||||||
|
if (edition === "cloud") {
|
||||||
|
form.setFieldValue("authType", "cloud_token");
|
||||||
|
} else if (form.values.authType === "cloud_token") {
|
||||||
|
form.setFieldValue("authType", "pat");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextFromCredentials = async () => {
|
||||||
|
if (form.validate().hasErrors) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const test = await testConfluenceConnection(credentials);
|
||||||
|
if (!test.success) {
|
||||||
|
setError(test.error || t("Connection failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = await listConfluenceSpaces(credentials);
|
||||||
|
if (!list.success || !list.spaces) {
|
||||||
|
setError(list.error || t("Failed to load spaces"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSpaces(list.spaces);
|
||||||
|
setSelectedKeys(list.spaces.map((s) => s.key));
|
||||||
|
setImportAll(true);
|
||||||
|
setActive(1);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.message || err?.message || t("Unexpected error"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSpace = (key: string, checked: boolean) => {
|
||||||
|
setSelectedKeys((prev) =>
|
||||||
|
checked ? Array.from(new Set([...prev, key])) : prev.filter((k) => k !== key),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
setImportAll(checked);
|
||||||
|
setSelectedKeys(checked ? spaces.map((s) => s.key) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartImport = async () => {
|
||||||
|
const spaceKeys = importAll ? [] : selectedKeys;
|
||||||
|
if (!importAll && spaceKeys.length === 0) {
|
||||||
|
setError(t("Select at least one space to import"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await startConfluenceImport({
|
||||||
|
...credentials,
|
||||||
|
spaceKeys,
|
||||||
|
});
|
||||||
|
if (!result.success || !result.fileTaskId) {
|
||||||
|
setError(result.error || t("Failed to start import"));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: confluenceImportsQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: t("Confluence import started"),
|
||||||
|
message: t("Track progress below. This runs in the background."),
|
||||||
|
color: "blue",
|
||||||
|
icon: <IconCheck size={18} />,
|
||||||
|
autoClose: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(
|
||||||
|
err?.response?.data?.message || err?.message || t("Unexpected error"),
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelFlow = async () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const editionSegment = (
|
||||||
|
<SegmentedControl
|
||||||
|
value={form.values.edition}
|
||||||
|
onChange={(val) => handleEditionChange(val as ConfluenceEditionChoice)}
|
||||||
|
data={[
|
||||||
|
{ value: "server", label: t("Data Center / Server") },
|
||||||
|
{ value: "cloud", label: t("Cloud") },
|
||||||
|
]}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const authTypeSegment = form.values.edition === "server" && (
|
||||||
|
<SegmentedControl
|
||||||
|
value={form.values.authType}
|
||||||
|
onChange={(val) =>
|
||||||
|
form.setFieldValue("authType", val as ConfluenceAuthType)
|
||||||
|
}
|
||||||
|
data={[
|
||||||
|
{ value: "pat", label: t("Personal Access Token") },
|
||||||
|
{ value: "basic", label: t("Username + password") },
|
||||||
|
]}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCount = importAll ? spaces.length : selectedKeys.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Import from Confluence")}
|
||||||
|
size={720}
|
||||||
|
centered
|
||||||
|
closeOnClickOutside={!loading}
|
||||||
|
closeOnEscape={!loading}
|
||||||
|
>
|
||||||
|
<Stepper active={active} size="sm" mb="md" allowNextStepsSelect={false}>
|
||||||
|
<Stepper.Step
|
||||||
|
label={t("Connect")}
|
||||||
|
description={t("Credentials")}
|
||||||
|
icon={<IconPlug size={18} />}
|
||||||
|
/>
|
||||||
|
<Stepper.Step
|
||||||
|
label={t("Select spaces")}
|
||||||
|
description={t("Choose what to import")}
|
||||||
|
icon={<IconCloudCheck size={18} />}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Enter your Confluence URL and credentials. We'll validate the connection before continuing.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{editionSegment}
|
||||||
|
{authTypeSegment}
|
||||||
|
<TextInput
|
||||||
|
label={t("Site URL")}
|
||||||
|
placeholder={
|
||||||
|
form.values.edition === "cloud"
|
||||||
|
? "https://your-site.atlassian.net/wiki"
|
||||||
|
: "https://confluence.example.com"
|
||||||
|
}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("siteUrl")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.values.edition === "cloud" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t("Email")}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("API token")}
|
||||||
|
description={t(
|
||||||
|
"Create at id.atlassian.com/manage-profile/security/api-tokens",
|
||||||
|
)}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("token")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.values.edition === "server" &&
|
||||||
|
form.values.authType === "pat" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t("Email")}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("Personal Access Token")}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("token")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.values.edition === "server" &&
|
||||||
|
form.values.authType === "basic" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t("Username")}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("username")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("Password")}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("Email (optional)")}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={handleCancelFlow} disabled={loading}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleNextFromCredentials}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t("Test & continue")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Choose the spaces to import. Users, groups and permissions will be imported for the selected spaces.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label={t("Import all spaces ({{count}})", {
|
||||||
|
count: spaces.length,
|
||||||
|
})}
|
||||||
|
checked={importAll}
|
||||||
|
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea h={320} type="auto" offsetScrollbars>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{spaces.map((space) => (
|
||||||
|
<Checkbox
|
||||||
|
key={space.id}
|
||||||
|
label={
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text fw={500}>{space.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
({space.key})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
checked={importAll || selectedKeys.includes(space.key)}
|
||||||
|
disabled={importAll}
|
||||||
|
onChange={(e) =>
|
||||||
|
toggleSpace(space.key, e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{spaces.length === 0 && (
|
||||||
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
|
{t("No spaces found for this account.")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("{{count}} selected", { count: selectedCount })}
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setActive(0)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("Back")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleStartImport}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!importAll && selectedKeys.length === 0}
|
||||||
|
>
|
||||||
|
{t("Start import")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
|
import { ConfluenceIcon } from "@/components/icons/confluence-icon";
|
||||||
|
import ConfluenceImportModal from "@/ee/confluence-import/components/confluence-import-modal";
|
||||||
|
import ConfluenceImportHistory from "@/ee/confluence-import/components/confluence-import-history";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
|
export default function ConfluenceImportPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const hasConfluenceImport = useHasFeature(Feature.CONFLUENCE_IMPORT);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("Import from Confluence")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("Import from Confluence")} />
|
||||||
|
|
||||||
|
<Paper withBorder p="lg" radius="md" mb="lg">
|
||||||
|
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||||||
|
<Group align="flex-start" wrap="nowrap">
|
||||||
|
<ConfluenceIcon size={32} />
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text fw={600}>{t("Confluence API import")}</Text>
|
||||||
|
<Text size="sm" c="dimmed" maw={560}>
|
||||||
|
{t(
|
||||||
|
"Connect to Confluence Cloud or Data Center to import spaces, pages, attachments, comments, users, groups and permissions directly via the API.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Tooltip label={upgradeLabel} disabled={hasConfluenceImport}>
|
||||||
|
<Button onClick={open} disabled={!hasConfluenceImport}>
|
||||||
|
{t("Start import")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Divider my="md" label={t("Import history")} labelPosition="left" />
|
||||||
|
|
||||||
|
<ConfluenceImportHistory />
|
||||||
|
|
||||||
|
<ConfluenceImportModal opened={opened} onClose={close} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { listConfluenceImports } from "@/ee/confluence-import/services/confluence-import-service";
|
||||||
|
|
||||||
|
export const confluenceImportsQueryKey = ["confluence-imports"] as const;
|
||||||
|
|
||||||
|
export function useConfluenceImportsQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: confluenceImportsQueryKey,
|
||||||
|
queryFn: listConfluenceImports,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const hasRunning = query.state.data?.items?.some(
|
||||||
|
(i) => i.status === "processing",
|
||||||
|
);
|
||||||
|
return hasRunning ? 3000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import {
|
||||||
|
ConfluenceCredentials,
|
||||||
|
ImportStatusResponse,
|
||||||
|
ListImportsResponse,
|
||||||
|
ListSpacesResponse,
|
||||||
|
StartImportResponse,
|
||||||
|
TestConnectionResponse,
|
||||||
|
} from "@/ee/confluence-import/types/confluence-import.types";
|
||||||
|
|
||||||
|
export async function testConfluenceConnection(
|
||||||
|
data: ConfluenceCredentials,
|
||||||
|
): Promise<TestConnectionResponse> {
|
||||||
|
const req = await api.post<TestConnectionResponse>(
|
||||||
|
"/confluence-import/test-connection",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConfluenceSpaces(
|
||||||
|
data: ConfluenceCredentials,
|
||||||
|
): Promise<ListSpacesResponse> {
|
||||||
|
const req = await api.post<ListSpacesResponse>(
|
||||||
|
"/confluence-import/spaces",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startConfluenceImport(
|
||||||
|
data: ConfluenceCredentials & { spaceKeys?: string[] },
|
||||||
|
): Promise<StartImportResponse> {
|
||||||
|
const req = await api.post<StartImportResponse>(
|
||||||
|
"/confluence-import/start",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfluenceImportStatus(
|
||||||
|
fileTaskId: string,
|
||||||
|
): Promise<ImportStatusResponse> {
|
||||||
|
const req = await api.post<ImportStatusResponse>(
|
||||||
|
"/confluence-import/status",
|
||||||
|
{ fileTaskId },
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConfluenceImports(): Promise<ListImportsResponse> {
|
||||||
|
const req = await api.post<ListImportsResponse>("/confluence-import/history");
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelConfluenceImport(
|
||||||
|
fileTaskId: string,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const req = await api.post<{ success: boolean }>(
|
||||||
|
"/confluence-import/cancel",
|
||||||
|
{ fileTaskId },
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type ConfluenceAuthType = "cloud_token" | "pat" | "basic";
|
||||||
|
|
||||||
|
export type ConfluenceCredentials = {
|
||||||
|
siteUrl: string;
|
||||||
|
authType: ConfluenceAuthType;
|
||||||
|
email?: string;
|
||||||
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfluenceSpaceSummary = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestConnectionResponse = {
|
||||||
|
success: boolean;
|
||||||
|
edition?: string;
|
||||||
|
spaceCount?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListSpacesResponse = {
|
||||||
|
success: boolean;
|
||||||
|
spaces?: ConfluenceSpaceSummary[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StartImportResponse = {
|
||||||
|
success: boolean;
|
||||||
|
fileTaskId?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfluenceImportStatus = "processing" | "success" | "failed";
|
||||||
|
|
||||||
|
export type ImportStatusResponse = {
|
||||||
|
fileTaskId?: string;
|
||||||
|
status?: ConfluenceImportStatus;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
currentPhase?: string | null;
|
||||||
|
totalSpaces?: number;
|
||||||
|
importedSpaces?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
importedPages?: number;
|
||||||
|
totalUsers?: number;
|
||||||
|
importedUsers?: number;
|
||||||
|
warnings?: string[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfluenceImportHistoryItem = {
|
||||||
|
fileTaskId: string;
|
||||||
|
siteUrl: string;
|
||||||
|
status: ConfluenceImportStatus;
|
||||||
|
errorMessage: string | null;
|
||||||
|
currentPhase: string | null;
|
||||||
|
totalSpaces: number;
|
||||||
|
importedSpaces: number;
|
||||||
|
totalPages: number;
|
||||||
|
importedPages: number;
|
||||||
|
totalUsers: number;
|
||||||
|
importedUsers: number;
|
||||||
|
cancelled: boolean;
|
||||||
|
spaceKeys: string[];
|
||||||
|
warnings: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
creatorId: string | null;
|
||||||
|
creatorName: string | null;
|
||||||
|
creatorAvatarUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListImportsResponse = {
|
||||||
|
items: ConfluenceImportHistoryItem[];
|
||||||
|
};
|
||||||
@@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() {
|
|||||||
const [checked, setChecked] = useState(
|
const [checked, setChecked] = useState(
|
||||||
workspace?.settings?.templates?.allowMemberTemplates === true,
|
workspace?.settings?.templates?.allowMemberTemplates === true,
|
||||||
);
|
);
|
||||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
|
||||||
const upgradeLabel = useUpgradeLabel();
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -54,11 +54,15 @@ function AllowMemberTemplatesToggle() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={upgradeLabel} disabled={hasTemplates} refProp="rootRef">
|
<Tooltip
|
||||||
|
label={upgradeLabel}
|
||||||
|
disabled={hasSecuritySettings}
|
||||||
|
refProp="rootRef"
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={!hasTemplates}
|
disabled={!hasSecuritySettings}
|
||||||
aria-label={t("Toggle allow members to create templates")}
|
aria-label={t("Toggle allow members to create templates")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export default function Security() {
|
|||||||
{ max: SCIM_TOKEN_LIMIT },
|
{ max: SCIM_TOKEN_LIMIT },
|
||||||
)}
|
)}
|
||||||
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
|
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
|
||||||
|
refProp="rootRef"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
|
|||||||
@@ -8,11 +8,6 @@
|
|||||||
@mixin hover {
|
@mixin hover {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardBody {
|
.cardBody {
|
||||||
@@ -55,27 +50,18 @@
|
|||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: space-between;
|
||||||
|
gap: var(--mantine-spacing-xs);
|
||||||
padding-top: var(--mantine-spacing-sm);
|
padding-top: var(--mantine-spacing-sm);
|
||||||
margin-top: var(--mantine-spacing-lg);
|
margin-top: var(--mantine-spacing-lg);
|
||||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
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 {
|
.menuTarget {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 100ms ease;
|
transition: opacity 100ms ease;
|
||||||
|
|
||||||
.card:hover &,
|
.card:hover & {
|
||||||
.card:focus-within & {
|
|
||||||
opacity: 1;
|
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 {
|
import {
|
||||||
IconDots,
|
IconDots,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
@@ -12,7 +12,6 @@ import classes from "./template-card.module.css";
|
|||||||
type TemplateCardProps = {
|
type TemplateCardProps = {
|
||||||
template: ITemplate;
|
template: ITemplate;
|
||||||
spaceName?: string;
|
spaceName?: string;
|
||||||
onPreview: (template: ITemplate) => void;
|
|
||||||
onUse: (template: ITemplate) => void;
|
onUse: (template: ITemplate) => void;
|
||||||
onEdit?: (template: ITemplate) => void;
|
onEdit?: (template: ITemplate) => void;
|
||||||
onDelete?: (template: ITemplate) => void;
|
onDelete?: (template: ITemplate) => void;
|
||||||
@@ -22,7 +21,6 @@ type TemplateCardProps = {
|
|||||||
export default function TemplateCard({
|
export default function TemplateCard({
|
||||||
template,
|
template,
|
||||||
spaceName,
|
spaceName,
|
||||||
onPreview,
|
|
||||||
onUse,
|
onUse,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -36,17 +34,7 @@ export default function TemplateCard({
|
|||||||
padding="lg"
|
padding="lg"
|
||||||
className={classes.card}
|
className={classes.card}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
role="button"
|
onClick={() => onUse(template)}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className={classes.cardBody}>
|
<div className={classes.cardBody}>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
|
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
|
||||||
@@ -59,17 +47,6 @@ export default function TemplateCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="filled"
|
|
||||||
className={classes.menuTarget}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onUse(template);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Use")}
|
|
||||||
</Button>
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Menu width={150} shadow="md" withArrow>
|
<Menu width={150} shadow="md" withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@@ -114,7 +91,6 @@ export default function TemplateCard({
|
|||||||
<div className={classes.title}>{template.title}</div>
|
<div className={classes.title}>{template.title}</div>
|
||||||
|
|
||||||
<div className={classes.footer}>
|
<div className={classes.footer}>
|
||||||
<span className={classes.scopeDot} aria-hidden="true" />
|
|
||||||
<Text size="sm" fw={500} c="dimmed">
|
<Text size="sm" fw={500} c="dimmed">
|
||||||
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
|
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
|
||||||
</Text>
|
</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;
|
onClose: () => void;
|
||||||
onUse: () => void;
|
onUse: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
useLoading?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TemplatePreviewModal({
|
export default function TemplatePreviewModal({
|
||||||
@@ -18,7 +17,6 @@ export default function TemplatePreviewModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onUse,
|
onUse,
|
||||||
onEdit,
|
onEdit,
|
||||||
useLoading,
|
|
||||||
}: TemplatePreviewModalProps) {
|
}: TemplatePreviewModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
|
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
|
||||||
@@ -39,19 +37,14 @@ export default function TemplatePreviewModal({
|
|||||||
</Group>
|
</Group>
|
||||||
</Modal.Title>
|
</Modal.Title>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
onClick={onUse}
|
|
||||||
loading={useLoading}
|
|
||||||
disabled={useLoading}
|
|
||||||
>
|
|
||||||
{t("Use template")}
|
|
||||||
</Button>
|
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button size="xs" variant="default" onClick={onEdit}>
|
<Button size="xs" variant="default" onClick={onEdit}>
|
||||||
{t("Edit")}
|
{t("Edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button size="xs" onClick={onUse}>
|
||||||
|
{t("Use template")}
|
||||||
|
</Button>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ type UseTemplateModalProps = {
|
|||||||
template: ITemplate;
|
template: ITemplate;
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
initialSpaceId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UseTemplateModal({
|
export default function UseTemplateModal({
|
||||||
template,
|
template,
|
||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
initialSpaceId,
|
|
||||||
}: UseTemplateModalProps) {
|
}: UseTemplateModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -56,8 +54,6 @@ export default function UseTemplateModal({
|
|||||||
actionLabel={t("Create page")}
|
actionLabel={t("Create page")}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
loading={useTemplateMutation.isPending}
|
loading={useTemplateMutation.isPending}
|
||||||
initialSpaceId={initialSpaceId ?? template.spaceId}
|
|
||||||
searchSpacesOnly
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,18 +75,6 @@ export default function TemplateEditor() {
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: templateExtensions,
|
extensions: templateExtensions,
|
||||||
content: "",
|
content: "",
|
||||||
editorProps: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
keydown: (_view, event) => {
|
|
||||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
|
||||||
const slashCommand = document.querySelector("#slash-command");
|
|
||||||
if (slashCommand) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onUpdate() {
|
onUpdate() {
|
||||||
if (loadedRef.current) {
|
if (loadedRef.current) {
|
||||||
markDirty();
|
markDirty();
|
||||||
|
|||||||
@@ -160,8 +160,7 @@ export default function TemplateList() {
|
|||||||
? spaceNameMap.get(template.spaceId)
|
? spaceNameMap.get(template.spaceId)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onPreview={handlePreview}
|
onUse={handlePreview}
|
||||||
onUse={handleUse}
|
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
canManage={isWorkspaceAdmin}
|
canManage={isWorkspaceAdmin}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useAtom, useStore } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
getTemplates,
|
getTemplates,
|
||||||
getTemplateById,
|
getTemplateById,
|
||||||
@@ -19,12 +18,6 @@ import { ITemplate } from "@/ee/template/types/template.types";
|
|||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
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 }) {
|
export function useGetTemplatesQuery(params?: { spaceId?: string }) {
|
||||||
const { spaceId } = params ?? {};
|
const { spaceId } = params ?? {};
|
||||||
@@ -156,64 +149,13 @@ export function useDeleteTemplateMutation() {
|
|||||||
|
|
||||||
export function useUseTemplateMutation() {
|
export function useUseTemplateMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [, setTreeData] = useAtom(treeDataAtom);
|
|
||||||
const store = useStore();
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
return useMutation<
|
return useMutation({
|
||||||
IPage,
|
mutationFn: (data: {
|
||||||
Error,
|
templateId: string;
|
||||||
{ templateId: string; spaceId: string; parentPageId?: string }
|
spaceId: string;
|
||||||
>({
|
parentPageId?: string;
|
||||||
mutationFn: (data) => useTemplate(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);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error["response"]?.data?.message;
|
const errorMessage = error["response"]?.data?.message;
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import { ITemplate } from "@/ee/template/types/template.types";
|
import { ITemplate } from "@/ee/template/types/template.types";
|
||||||
import { IPage } from "@/features/page/types/page.types";
|
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export async function getTemplates(params?: {
|
export async function getTemplates(params?: {
|
||||||
@@ -41,7 +40,7 @@ export async function useTemplate(data: {
|
|||||||
templateId: string;
|
templateId: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
parentPageId?: string;
|
parentPageId?: string;
|
||||||
}): Promise<IPage> {
|
}): Promise<any> {
|
||||||
const req = await api.post<IPage>("/templates/use", data);
|
const req = await api.post("/templates/use", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,18 @@ export default function useAuth() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setCurrentUser(RESET);
|
setCurrentUser(RESET);
|
||||||
await logout();
|
await logout();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof indexedDB?.databases === "function") {
|
||||||
|
const dbs = await indexedDB.databases();
|
||||||
|
dbs
|
||||||
|
.filter((db) => db.name?.startsWith("page."))
|
||||||
|
.forEach((db) => indexedDB.deleteDatabase(db.name!));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
@@ -47,6 +49,8 @@ const CommentEditor = forwardRef(
|
|||||||
placeholder: placeholder || t("Reply..."),
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
EmojiCommand,
|
EmojiCommand,
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
|
|||||||
@@ -416,9 +416,7 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
|||||||
"Video",
|
"Video",
|
||||||
"File attachment",
|
"File attachment",
|
||||||
"Draw.io (diagrams.net)",
|
"Draw.io (diagrams.net)",
|
||||||
"Excalidraw (Whiteboard)",
|
"Excalidraw diagram",
|
||||||
"Audio",
|
|
||||||
"Synced block"
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TemplateSlashCommand = Command.configure({
|
const TemplateSlashCommand = Command.configure({
|
||||||
|
|||||||
@@ -238,6 +238,14 @@ export default function PageEditor({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
return editor.view.someProp("handleKeyDown", (f) =>
|
||||||
|
f(editor.view, event)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||||
searchSpotlight.open();
|
searchSpotlight.open();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconTemplate,
|
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -54,10 +53,6 @@ import {
|
|||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import { searchSpotlight } from "@/features/search/constants";
|
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() {
|
export function SpaceSidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -251,11 +246,6 @@ function SpaceMenu({
|
|||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [
|
|
||||||
templatePickerOpened,
|
|
||||||
{ open: openTemplatePicker, close: closeTemplatePicker },
|
|
||||||
] = useDisclosure(false);
|
|
||||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
|
||||||
|
|
||||||
const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId);
|
const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId);
|
||||||
const watchMutation = useWatchSpaceMutation();
|
const watchMutation = useWatchSpaceMutation();
|
||||||
@@ -325,18 +315,6 @@ function SpaceMenu({
|
|||||||
{isWatching ? t("Stop watching space") : t("Watch space")}
|
{isWatching ? t("Stop watching space") : t("Watch space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{hasTemplates && canManagePages && (
|
|
||||||
<>
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item
|
|
||||||
onClick={openTemplatePicker}
|
|
||||||
leftSection={<IconTemplate size={16} />}
|
|
||||||
>
|
|
||||||
{t("Templates")}
|
|
||||||
</Menu.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canManagePages && (
|
{canManagePages && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<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 { Helmet } from "react-helmet-async";
|
||||||
import ManageHostname from "@/ee/components/manage-hostname.tsx";
|
import ManageHostname from "@/ee/components/manage-hostname.tsx";
|
||||||
import { Divider } from "@mantine/core";
|
import { Divider } from "@mantine/core";
|
||||||
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
|
|
||||||
|
|
||||||
export default function WorkspaceSettings() {
|
export default function WorkspaceSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -19,9 +18,6 @@ export default function WorkspaceSettings() {
|
|||||||
<WorkspaceIcon />
|
<WorkspaceIcon />
|
||||||
<WorkspaceNameForm />
|
<WorkspaceNameForm />
|
||||||
|
|
||||||
<Divider my="md" />
|
|
||||||
<AllowMemberTemplates />
|
|
||||||
|
|
||||||
{isCloud() && (
|
{isCloud() && (
|
||||||
<>
|
<>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ['docmost.nz'],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: APP_URL,
|
target: APP_URL,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module';
|
|||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
|
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -53,6 +54,7 @@ try {
|
|||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
EncryptionModule,
|
||||||
RedisModule.forRootAsync({
|
RedisModule.forRootAsync({
|
||||||
useClass: RedisConfigService,
|
useClass: RedisConfigService,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ export * from './utils';
|
|||||||
export * from './nanoid.utils';
|
export * from './nanoid.utils';
|
||||||
export * from './file.helper';
|
export * from './file.helper';
|
||||||
export * from './constants';
|
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(' ')}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -63,9 +63,11 @@ export class TemplateRepo {
|
|||||||
|
|
||||||
if (opts?.spaceId) {
|
if (opts?.spaceId) {
|
||||||
if (!accessibleSpaceIds.includes(opts.spaceId)) {
|
if (!accessibleSpaceIds.includes(opts.spaceId)) {
|
||||||
query = query.where(sql<boolean>`false`);
|
query = query.where('spaceId', 'is', null);
|
||||||
} else {
|
} else {
|
||||||
query = query.where('spaceId', '=', opts.spaceId);
|
query = query.where((eb) =>
|
||||||
|
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Json, Timestamp, Generated } from '@docmost/db/types/db';
|
||||||
|
|
||||||
|
export interface ConfluenceApiImports {
|
||||||
|
id: Generated<string>;
|
||||||
|
fileTaskId: string;
|
||||||
|
siteUrl: string;
|
||||||
|
authType: string;
|
||||||
|
authEmail: string | null;
|
||||||
|
authToken: string | null;
|
||||||
|
authUsername: string | null;
|
||||||
|
totalSpaces: Generated<number>;
|
||||||
|
importedSpaces: Generated<number>;
|
||||||
|
totalPages: Generated<number>;
|
||||||
|
importedPages: Generated<number>;
|
||||||
|
totalUsers: Generated<number>;
|
||||||
|
importedUsers: Generated<number>;
|
||||||
|
totalAttachments: Generated<number>;
|
||||||
|
importedAttachments: Generated<number>;
|
||||||
|
totalLabels: Generated<number>;
|
||||||
|
importedLabels: Generated<number>;
|
||||||
|
idMapping: Generated<Json>;
|
||||||
|
warnings: Generated<Json>;
|
||||||
|
currentPhase: string | null;
|
||||||
|
cancelled: Generated<boolean>;
|
||||||
|
spaceKeys: Generated<Json>;
|
||||||
|
workspaceId: string;
|
||||||
|
creatorId: string | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
|
import { ConfluenceApiImports } from './custom.types';
|
||||||
|
|
||||||
export interface DbInterface extends DB {
|
export interface DbInterface extends DB {
|
||||||
pageEmbeddings: PageEmbeddings;
|
pageEmbeddings: PageEmbeddings;
|
||||||
|
confluenceApiImports: ConfluenceApiImports;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: b30e92f6a0...c2d2c373c6
@@ -0,0 +1,13 @@
|
|||||||
|
export class UnableToInitialize extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`Unable to initialize the encryption service: ${message}`);
|
||||||
|
this.name = 'UnableToInitialize';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnableToDecrypt extends Error {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super(`Unable to decrypt the ciphertext: ${reason}`);
|
||||||
|
this.name = 'UnableToDecrypt';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EncryptionService],
|
||||||
|
exports: [EncryptionService],
|
||||||
|
})
|
||||||
|
export class EncryptionModule {}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||||
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
|
||||||
|
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
|
||||||
|
|
||||||
|
const buildService = (appSecret: string | undefined) => {
|
||||||
|
const env = { getAppSecret: () => appSecret } as EnvironmentService;
|
||||||
|
return new EncryptionService(env);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeEnvelope = (encrypted: string) =>
|
||||||
|
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
|
||||||
|
iv: string;
|
||||||
|
authTag: string;
|
||||||
|
cipherText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodeEnvelope = (envelope: {
|
||||||
|
iv: string;
|
||||||
|
authTag: string;
|
||||||
|
cipherText: string;
|
||||||
|
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
|
||||||
|
|
||||||
|
describe('EncryptionService', () => {
|
||||||
|
let service: EncryptionService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
EncryptionService,
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: { getAppSecret: () => APP_SECRET },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<EncryptionService>(EncryptionService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('compiles via Nest DI', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnableToInitialize when APP_SECRET is missing', () => {
|
||||||
|
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
|
||||||
|
expect(() => buildService('')).toThrow(UnableToInitialize);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encrypt + decrypt round-trip', () => {
|
||||||
|
it('decrypts back to the original plaintext', () => {
|
||||||
|
const plaintext = 'hello world';
|
||||||
|
const encrypted = service.encrypt(plaintext);
|
||||||
|
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
const encrypted = service.encrypt('');
|
||||||
|
expect(service.decrypt(encrypted)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unicode (multi-byte UTF-8)', () => {
|
||||||
|
const plaintext = 'héllo 🔐 世界';
|
||||||
|
const encrypted = service.encrypt(plaintext);
|
||||||
|
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles long plaintext (>1 block)', () => {
|
||||||
|
const plaintext = 'a'.repeat(10_000);
|
||||||
|
const encrypted = service.encrypt(plaintext);
|
||||||
|
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
|
||||||
|
const plaintext = 'same input';
|
||||||
|
const a = service.encrypt(plaintext);
|
||||||
|
const b = service.encrypt(plaintext);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(service.decrypt(a)).toBe(plaintext);
|
||||||
|
expect(service.decrypt(b)).toBe(plaintext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cross-key isolation', () => {
|
||||||
|
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
|
||||||
|
const other = buildService('totally-different-secret-value-9876543210');
|
||||||
|
const encrypted = service.encrypt('secret');
|
||||||
|
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tamper detection', () => {
|
||||||
|
it('rejects modified ciphertext', () => {
|
||||||
|
const encrypted = service.encrypt('hello');
|
||||||
|
const env = decodeEnvelope(encrypted);
|
||||||
|
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
|
||||||
|
tamperedCipher[0] ^= 0x01;
|
||||||
|
const tampered = encodeEnvelope({
|
||||||
|
...env,
|
||||||
|
cipherText: tamperedCipher.toString('base64'),
|
||||||
|
});
|
||||||
|
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects modified auth tag', () => {
|
||||||
|
const encrypted = service.encrypt('hello');
|
||||||
|
const env = decodeEnvelope(encrypted);
|
||||||
|
const tamperedTag = Buffer.from(env.authTag, 'base64');
|
||||||
|
tamperedTag[0] ^= 0x01;
|
||||||
|
const tampered = encodeEnvelope({
|
||||||
|
...env,
|
||||||
|
authTag: tamperedTag.toString('base64'),
|
||||||
|
});
|
||||||
|
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects modified IV', () => {
|
||||||
|
const encrypted = service.encrypt('hello');
|
||||||
|
const env = decodeEnvelope(encrypted);
|
||||||
|
const tamperedIV = Buffer.from(env.iv, 'base64');
|
||||||
|
tamperedIV[0] ^= 0x01;
|
||||||
|
const tampered = encodeEnvelope({
|
||||||
|
...env,
|
||||||
|
iv: tamperedIV.toString('base64'),
|
||||||
|
});
|
||||||
|
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('malformed payloads', () => {
|
||||||
|
it('rejects non-base64 garbage', () => {
|
||||||
|
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
|
||||||
|
UnableToDecrypt,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects base64 of non-JSON', () => {
|
||||||
|
const garbage = Buffer.from('not json at all').toString('base64');
|
||||||
|
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects JSON missing required fields', () => {
|
||||||
|
const partial = encodeEnvelope({
|
||||||
|
iv: Buffer.alloc(12).toString('base64'),
|
||||||
|
authTag: Buffer.alloc(16).toString('base64'),
|
||||||
|
} as never);
|
||||||
|
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong-length IV', () => {
|
||||||
|
const encrypted = service.encrypt('hello');
|
||||||
|
const env = decodeEnvelope(encrypted);
|
||||||
|
const bad = encodeEnvelope({
|
||||||
|
...env,
|
||||||
|
iv: Buffer.alloc(8).toString('base64'),
|
||||||
|
});
|
||||||
|
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong-length auth tag', () => {
|
||||||
|
const encrypted = service.encrypt('hello');
|
||||||
|
const env = decodeEnvelope(encrypted);
|
||||||
|
const bad = encodeEnvelope({
|
||||||
|
...env,
|
||||||
|
authTag: Buffer.alloc(8).toString('base64'),
|
||||||
|
});
|
||||||
|
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('envelope format', () => {
|
||||||
|
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
|
||||||
|
const encrypted = service.encrypt('hello');
|
||||||
|
const env = decodeEnvelope(encrypted);
|
||||||
|
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
|
||||||
|
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
|
||||||
|
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// https://github.com/nhedger/nestjs-encryption - MIT
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||||
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const KEY_DOMAIN = 'docmost:encryption:v1';
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
type AEADPayload<TFormat = string | Buffer> = {
|
||||||
|
iv: TFormat;
|
||||||
|
authTag: TFormat;
|
||||||
|
cipherText: TFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EncryptionService {
|
||||||
|
private readonly key: Buffer;
|
||||||
|
|
||||||
|
constructor(environmentService: EnvironmentService) {
|
||||||
|
const appSecret = environmentService.getAppSecret();
|
||||||
|
if (!appSecret) {
|
||||||
|
throw new UnableToInitialize('APP_SECRET is not set.');
|
||||||
|
}
|
||||||
|
this.key = createHash('sha256')
|
||||||
|
.update(KEY_DOMAIN)
|
||||||
|
.update(appSecret)
|
||||||
|
.digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public encrypt(plaintext: string): string {
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
||||||
|
const cipherText = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
const aead: AEADPayload<string> = {
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
authTag: authTag.toString('base64'),
|
||||||
|
cipherText: cipherText.toString('base64'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Buffer.from(JSON.stringify(aead)).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
public decrypt(encrypted: string): string {
|
||||||
|
try {
|
||||||
|
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(cipherText),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
throw new UnableToDecrypt((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
|
||||||
|
const payload = Buffer.from(encodedPayload, 'base64');
|
||||||
|
|
||||||
|
let deserializedPkg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
deserializedPkg = JSON.parse(payload.toString());
|
||||||
|
} catch {
|
||||||
|
throw new Error('The decoded AEAD payload is not a valid JSON string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ['iv', 'authTag', 'cipherText']) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
|
||||||
|
throw new Error(`The AEAD payload is missing the ${field} field.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
|
||||||
|
if (iv.length !== IV_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
|
||||||
|
if (authTag.length !== AUTH_TAG_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipherText = Buffer.from(
|
||||||
|
deserializedPkg.cipherText as string,
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { iv, authTag, cipherText };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -325,19 +325,4 @@ export class EnvironmentService {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return disabled === 'true';
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
case QueueJob.IMPORT_TASK:
|
case QueueJob.IMPORT_TASK:
|
||||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
|
case QueueJob.CONFLUENCE_API_IMPORT:
|
||||||
|
await this.processConfluenceApiImport(job.data.fileTaskId);
|
||||||
|
break;
|
||||||
case QueueJob.PDF_EXPORT_TASK:
|
case QueueJob.PDF_EXPORT_TASK:
|
||||||
await this.processExportTask(job.data.fileTaskId);
|
await this.processExportTask(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
@@ -49,6 +52,19 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getConfluenceApiImportService() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const mod = require('./../../../ee/confluence-api-import/confluence-api-import.service');
|
||||||
|
return this.moduleRef.get(mod.ConfluenceApiImportService, {
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processConfluenceApiImport(fileTaskId: string): Promise<void> {
|
||||||
|
const service = this.getConfluenceApiImportService();
|
||||||
|
await service.processImport(fileTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||||
const pdfExportService = this.getPdfExportService();
|
const pdfExportService = this.getPdfExportService();
|
||||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||||
@@ -74,6 +90,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
await this.handleFailedImportJob(job);
|
await this.handleFailedImportJob(job);
|
||||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||||
await this.handleFailedExportJob(job);
|
await this.handleFailedExportJob(job);
|
||||||
|
} else if (job.name === QueueJob.CONFLUENCE_API_IMPORT) {
|
||||||
|
await this.handleFailedExportJob(job);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { nodeIdFromConfluenceAnchor } from './confluence-anchor-id';
|
||||||
|
|
||||||
|
describe('nodeIdFromConfluenceAnchor', () => {
|
||||||
|
it('is deterministic for the same (pageId, anchorName)', () => {
|
||||||
|
const a = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
|
||||||
|
const b = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns different ids when the anchor name differs', () => {
|
||||||
|
const a = nodeIdFromConfluenceAnchor('page-1', 'one');
|
||||||
|
const b = nodeIdFromConfluenceAnchor('page-1', 'two');
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns different ids when the pageId differs', () => {
|
||||||
|
const a = nodeIdFromConfluenceAnchor('page-1', 'same');
|
||||||
|
const b = nodeIdFromConfluenceAnchor('page-2', 'same');
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns exactly 12 lowercase a-z characters', () => {
|
||||||
|
const id = nodeIdFromConfluenceAnchor('page-xyz', 'Section · 1');
|
||||||
|
expect(id).toHaveLength(12);
|
||||||
|
expect(id).toMatch(/^[a-z]{12}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty anchor name as a valid input', () => {
|
||||||
|
const id = nodeIdFromConfluenceAnchor('page-1', '');
|
||||||
|
expect(id).toMatch(/^[a-z]{12}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
// Matches the alphabet used by generateNodeId() in
|
||||||
|
// packages/editor-ext/src/lib/utils.ts (customAlphabet from nanoid).
|
||||||
|
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const NODE_ID_LENGTH = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a deterministic 12-character nodeId for a Confluence anchor.
|
||||||
|
* The same (pageId, anchorName) pair always produces the same result, so
|
||||||
|
* cross-page anchor links resolve to the anchor target without a
|
||||||
|
* precomputed map. The output uses the same alphabet and length as
|
||||||
|
* generateNodeId() from @docmost/editor-ext, so it is interchangeable
|
||||||
|
* with editor-generated nodeIds.
|
||||||
|
*/
|
||||||
|
export function nodeIdFromConfluenceAnchor(
|
||||||
|
pageId: string,
|
||||||
|
anchorName: string,
|
||||||
|
): string {
|
||||||
|
const digest = createHash('sha256')
|
||||||
|
.update(`${pageId}#${anchorName}`)
|
||||||
|
.digest();
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < NODE_ID_LENGTH; i++) {
|
||||||
|
out += ALPHABET[digest[i] % ALPHABET.length];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { parseConfluenceEmojiId } from './confluence-emoji';
|
||||||
|
|
||||||
|
describe('parseConfluenceEmojiId', () => {
|
||||||
|
it('parses a single code point id', () => {
|
||||||
|
expect(parseConfluenceEmojiId('1f600')).toBe('😀');
|
||||||
|
expect(parseConfluenceEmojiId('1F600')).toBe('😀');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a country flag (two regional indicator code points)', () => {
|
||||||
|
expect(parseConfluenceEmojiId('1f1f3-1f1ec')).toBe('🇳🇬');
|
||||||
|
expect(parseConfluenceEmojiId('1f1fa-1f1f8')).toBe('🇺🇸');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a ZWJ sequence (three code points)', () => {
|
||||||
|
expect(parseConfluenceEmojiId('1f468-200d-1f4bb')).toBe('👨💻');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a five-component family ZWJ sequence', () => {
|
||||||
|
// 👨👩👧👦 = man, ZWJ, woman, ZWJ, girl, ZWJ, boy
|
||||||
|
expect(parseConfluenceEmojiId('1f468-200d-1f469-200d-1f467-200d-1f466')).toBe(
|
||||||
|
'👨👩👧👦',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for missing input', () => {
|
||||||
|
expect(parseConfluenceEmojiId(undefined)).toBeNull();
|
||||||
|
expect(parseConfluenceEmojiId(null)).toBeNull();
|
||||||
|
expect(parseConfluenceEmojiId('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when any segment is not pure hex', () => {
|
||||||
|
expect(parseConfluenceEmojiId('1f600-NG')).toBeNull();
|
||||||
|
expect(parseConfluenceEmojiId('not-hex')).toBeNull();
|
||||||
|
expect(parseConfluenceEmojiId('1f600--1f1ec')).toBeNull();
|
||||||
|
expect(parseConfluenceEmojiId('1f600 1f1ec')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when a segment parses to a non-positive value', () => {
|
||||||
|
expect(parseConfluenceEmojiId('0')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for code points outside the valid Unicode range', () => {
|
||||||
|
// 0x110000 is one past the highest valid code point.
|
||||||
|
expect(parseConfluenceEmojiId('110000')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Parse a Confluence emoji id (hex code points joined by hyphens) into a
|
||||||
|
* Unicode string. Confluence emits ids in both single- and multi-code-point
|
||||||
|
* forms:
|
||||||
|
*
|
||||||
|
* "1f600" → "😀"
|
||||||
|
* "1f1f3-1f1ec" → "🇳🇬" (flag: Nigeria)
|
||||||
|
* "1f468-200d-1f4bb" → "👨💻" (man technologist, ZWJ sequence)
|
||||||
|
*
|
||||||
|
* Returns null when the input is missing, empty, or doesn't parse cleanly as
|
||||||
|
* hyphen-separated hex code points.
|
||||||
|
*/
|
||||||
|
export function parseConfluenceEmojiId(
|
||||||
|
raw: string | undefined | null,
|
||||||
|
): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split('-');
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
if (!parts.every((p) => /^[0-9a-fA-F]+$/.test(p))) return null;
|
||||||
|
const codePoints = parts.map((p) => parseInt(p, 16));
|
||||||
|
if (codePoints.some((cp) => !Number.isFinite(cp) || cp <= 0)) return null;
|
||||||
|
try {
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
} catch {
|
||||||
|
// Out-of-range code points throw RangeError on String.fromCodePoint.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
|
||||||
|
|
||||||
|
function run(html: string): string {
|
||||||
|
const $ = load(html);
|
||||||
|
applyConfluenceMarginLeftIndent($, $.root());
|
||||||
|
// cheerio's html() includes <html><body>; return the body's inner HTML so
|
||||||
|
// tests can assert on the meaningful portion.
|
||||||
|
return $('body').html() ?? $.html();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyConfluenceMarginLeftIndent', () => {
|
||||||
|
describe('Confluence Cloud (30 px per level, max 6)', () => {
|
||||||
|
it('maps 30/60/90/120/150/180 px to data-indent 1..6', () => {
|
||||||
|
const html =
|
||||||
|
'<p style="margin-left: 30.0px;">L1</p>' +
|
||||||
|
'<p style="margin-left: 60.0px;">L2</p>' +
|
||||||
|
'<p style="margin-left: 90.0px;">L3</p>' +
|
||||||
|
'<p style="margin-left: 120.0px;">L4</p>' +
|
||||||
|
'<p style="margin-left: 150.0px;">L5</p>' +
|
||||||
|
'<p style="margin-left: 180.0px;">L6</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toContain('<p data-indent="1">L1</p>');
|
||||||
|
expect(out).toContain('<p data-indent="2">L2</p>');
|
||||||
|
expect(out).toContain('<p data-indent="3">L3</p>');
|
||||||
|
expect(out).toContain('<p data-indent="4">L4</p>');
|
||||||
|
expect(out).toContain('<p data-indent="5">L5</p>');
|
||||||
|
expect(out).toContain('<p data-indent="6">L6</p>');
|
||||||
|
expect(out).not.toContain('margin-left');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Confluence Data Center (40 px per level, no upper bound)', () => {
|
||||||
|
it('maps 40/80/120/160/200/240 px to data-indent 1..6', () => {
|
||||||
|
const html =
|
||||||
|
'<p style="margin-left: 40.0px;">one</p>' +
|
||||||
|
'<p style="margin-left: 80.0px;">two</p>' +
|
||||||
|
'<p style="margin-left: 120.0px;">three</p>' +
|
||||||
|
'<p style="margin-left: 160.0px;">four</p>' +
|
||||||
|
'<p style="margin-left: 200.0px;">five</p>' +
|
||||||
|
'<p style="margin-left: 240.0px;">six</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toContain('<p data-indent="1">one</p>');
|
||||||
|
expect(out).toContain('<p data-indent="2">two</p>');
|
||||||
|
expect(out).toContain('<p data-indent="3">three</p>');
|
||||||
|
expect(out).toContain('<p data-indent="4">four</p>');
|
||||||
|
expect(out).toContain('<p data-indent="5">five</p>');
|
||||||
|
expect(out).toContain('<p data-indent="6">six</p>');
|
||||||
|
expect(out).not.toContain('margin-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps DC levels above 8 down to 8', () => {
|
||||||
|
const html =
|
||||||
|
'<p style="margin-left: 320.0px;">L8</p>' +
|
||||||
|
'<p style="margin-left: 360.0px;">L9</p>' +
|
||||||
|
'<p style="margin-left: 600.0px;">L15</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toContain('<p data-indent="8">L8</p>');
|
||||||
|
expect(out).toContain('<p data-indent="8">L9</p>');
|
||||||
|
expect(out).toContain('<p data-indent="8">L15</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headings', () => {
|
||||||
|
it('handles indent on h1-h6 the same way as paragraphs', () => {
|
||||||
|
const html =
|
||||||
|
'<h1 style="margin-left: 30px;">a</h1>' +
|
||||||
|
'<h6 style="margin-left: 90px;">b</h6>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toContain('<h1 data-indent="1">a</h1>');
|
||||||
|
expect(out).toContain('<h6 data-indent="3">b</h6>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('style attribute handling', () => {
|
||||||
|
it('strips margin-left but preserves other inline styles', () => {
|
||||||
|
const html =
|
||||||
|
'<p style="color: red; margin-left: 30px; font-weight: bold;">x</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toMatch(/<p style="color: red;\s+font-weight: bold;?" data-indent="1">x<\/p>/);
|
||||||
|
expect(out).not.toContain('margin-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the style attribute entirely when only margin-left was set', () => {
|
||||||
|
// Two values so GCD detection sees a real unit (60 px) instead of
|
||||||
|
// collapsing to the lone value. The point of this test is the style
|
||||||
|
// attribute being stripped, not the level number.
|
||||||
|
const html =
|
||||||
|
'<p style="margin-left: 60px;">x</p>' +
|
||||||
|
'<p style="margin-left: 120px;">y</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toContain('<p data-indent="1">x</p>');
|
||||||
|
expect(out).toContain('<p data-indent="2">y</p>');
|
||||||
|
expect(out).not.toContain('style=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scope and edge cases', () => {
|
||||||
|
it('leaves elements without margin-left untouched', () => {
|
||||||
|
const html = '<p>plain</p><h2>heading</h2>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toBe('<p>plain</p><h2>heading</h2>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch divs, spans, or list items', () => {
|
||||||
|
const html =
|
||||||
|
'<div style="margin-left: 30px;">div</div>' +
|
||||||
|
'<li style="margin-left: 30px;">li</li>' +
|
||||||
|
'<span style="margin-left: 30px;">span</span>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).not.toContain('data-indent');
|
||||||
|
expect(out).toContain('margin-left: 30px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores zero, negative, and unparseable margin-left values', () => {
|
||||||
|
const html =
|
||||||
|
'<p style="margin-left: 0px;">zero</p>' +
|
||||||
|
'<p style="margin-left: -30px;">neg</p>' +
|
||||||
|
'<p style="margin-left: auto;">auto</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).not.toContain('data-indent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors an explicit pxPerLevel override', () => {
|
||||||
|
// Mixed Cloud-and-DC nominal values forced to 40 px/level interpretation.
|
||||||
|
const $ = load(
|
||||||
|
'<p style="margin-left: 40px;">a</p>' +
|
||||||
|
'<p style="margin-left: 80px;">b</p>',
|
||||||
|
);
|
||||||
|
applyConfluenceMarginLeftIndent($, $.root(), { pxPerLevel: 40 });
|
||||||
|
const out = $('body').html() ?? '';
|
||||||
|
expect(out).toContain('<p data-indent="1">a</p>');
|
||||||
|
expect(out).toContain('<p data-indent="2">b</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a no-op when no indented elements are present', () => {
|
||||||
|
const html = '<p>hi</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toBe('<p>hi</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a single ambiguous value by clamping to level 1', () => {
|
||||||
|
// GCD of a single value is the value itself, so 120 / 120 = 1.
|
||||||
|
const html = '<p style="margin-left: 120px;">only</p>';
|
||||||
|
const out = run(html);
|
||||||
|
expect(out).toContain('<p data-indent="1">only</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Cheerio, CheerioAPI } from 'cheerio';
|
||||||
|
|
||||||
|
// Maximum indent level supported by the Indent editor extension (see
|
||||||
|
// packages/editor-ext/src/lib/indent.ts). Values above this clamp down.
|
||||||
|
const MAX_INDENT_LEVEL = 8;
|
||||||
|
const MARGIN_LEFT_RE = /margin-left\s*:\s*(-?\d*\.?\d+)\s*px/i;
|
||||||
|
const MARGIN_LEFT_STRIP_RE = /margin-left\s*:\s*-?\d*\.?\d+\s*px\s*;?/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confluence encodes paragraph indent as inline `style="margin-left: Npx"`.
|
||||||
|
* The per-level pixel value differs by edition: Cloud uses 30 (max 6 levels),
|
||||||
|
* Data Center uses 40 (no upper limit). The HTML-export ZIP path has no
|
||||||
|
* edition information available, so we auto-detect the per-level unit from
|
||||||
|
* the GCD of all margin-left values in the document. The API converter can
|
||||||
|
* pass `pxPerLevel` explicitly when the edition is known.
|
||||||
|
*
|
||||||
|
* Levels are written to `data-indent` for the TipTap Indent extension to
|
||||||
|
* pick up; the margin-left style is stripped from the element so the
|
||||||
|
* normalized indent doesn't double up with the editor's own indent padding.
|
||||||
|
*/
|
||||||
|
export function applyConfluenceMarginLeftIndent(
|
||||||
|
$: CheerioAPI,
|
||||||
|
$root: Cheerio<any>,
|
||||||
|
options?: { pxPerLevel?: number },
|
||||||
|
): void {
|
||||||
|
const $els = $root.find('p, h1, h2, h3, h4, h5, h6');
|
||||||
|
|
||||||
|
const values: number[] = [];
|
||||||
|
$els.each((_, el) => {
|
||||||
|
const style = $(el).attr('style');
|
||||||
|
if (!style) return;
|
||||||
|
const match = MARGIN_LEFT_RE.exec(style);
|
||||||
|
if (!match) return;
|
||||||
|
const px = parseFloat(match[1]);
|
||||||
|
if (Number.isFinite(px) && px > 0) values.push(px);
|
||||||
|
});
|
||||||
|
if (values.length === 0) return;
|
||||||
|
|
||||||
|
const unit = options?.pxPerLevel ?? detectIndentUnit(values);
|
||||||
|
if (!unit || unit <= 0) return;
|
||||||
|
|
||||||
|
$els.each((_, el) => {
|
||||||
|
const $el = $(el);
|
||||||
|
const style = $el.attr('style');
|
||||||
|
if (!style) return;
|
||||||
|
const match = MARGIN_LEFT_RE.exec(style);
|
||||||
|
if (!match) return;
|
||||||
|
const px = parseFloat(match[1]);
|
||||||
|
if (!Number.isFinite(px) || px <= 0) return;
|
||||||
|
const level = Math.min(
|
||||||
|
MAX_INDENT_LEVEL,
|
||||||
|
Math.max(1, Math.round(px / unit)),
|
||||||
|
);
|
||||||
|
$el.attr('data-indent', String(level));
|
||||||
|
const remaining = style.replace(MARGIN_LEFT_STRIP_RE, '').trim();
|
||||||
|
if (remaining) {
|
||||||
|
$el.attr('style', remaining);
|
||||||
|
} else {
|
||||||
|
$el.removeAttr('style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectIndentUnit(values: number[]): number {
|
||||||
|
// Confluence emits floats like "30.0"; round to ints for a clean GCD.
|
||||||
|
const ints = values.map((v) => Math.round(v)).filter((v) => v > 0);
|
||||||
|
if (ints.length === 0) return 0;
|
||||||
|
return ints.reduce((a, b) => gcd(a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gcd(a: number, b: number): number {
|
||||||
|
while (b !== 0) {
|
||||||
|
[a, b] = [b, a % b];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export enum FileImportSource {
|
|||||||
Generic = 'generic',
|
Generic = 'generic',
|
||||||
Notion = 'notion',
|
Notion = 'notion',
|
||||||
Confluence = 'confluence',
|
Confluence = 'confluence',
|
||||||
|
ConfluenceApi = 'confluence-api'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FileTaskStatus {
|
export enum FileTaskStatus {
|
||||||
|
|||||||
@@ -97,14 +97,21 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
|
||||||
|
export { applyConfluenceMarginLeftIndent };
|
||||||
|
|
||||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
normalizeTableColumnWidths($, $root);
|
normalizeTableColumnWidths($, $root);
|
||||||
|
applyConfluenceMarginLeftIndent($, $root);
|
||||||
|
|
||||||
|
// Auto-embed only when the <a> is the sole meaningful child of its parent
|
||||||
|
// block. A link mixed with surrounding text stays an inline link.
|
||||||
$root.find('a[href]').each((_, el) => {
|
$root.find('a[href]').each((_, el) => {
|
||||||
const $el = $(el);
|
const $el = $(el);
|
||||||
const url = $el.attr('href')!;
|
const url = $el.attr('href')!;
|
||||||
const { provider } = getEmbedUrlAndProvider(url);
|
const { provider } = getEmbedUrlAndProvider(url);
|
||||||
if (provider === 'iframe') return;
|
if (provider === 'iframe') return;
|
||||||
|
if (!isSoleMeaningfulChild($el, el)) return;
|
||||||
|
|
||||||
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
||||||
$el.replaceWith(embed);
|
$el.replaceWith(embed);
|
||||||
@@ -120,6 +127,21 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSoleMeaningfulChild(
|
||||||
|
$el: Cheerio<any>,
|
||||||
|
rawEl: any,
|
||||||
|
): boolean {
|
||||||
|
const $parent = $el.parent();
|
||||||
|
if ($parent.length === 0) return true;
|
||||||
|
const others = $parent.contents().toArray().filter((n: any) => {
|
||||||
|
if (n === rawEl) return false;
|
||||||
|
if (n.type === 'text') return (n.data ?? '').trim() !== '';
|
||||||
|
if (n.type === 'tag' && n.name === 'br') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return others.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
const COLUMN_LAYOUTS = [
|
const COLUMN_LAYOUTS = [
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export enum QueueJob {
|
|||||||
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
||||||
|
|
||||||
IMPORT_TASK = 'import-task',
|
IMPORT_TASK = 'import-task',
|
||||||
|
CONFLUENCE_API_IMPORT = 'confluence-api-import-task',
|
||||||
EXPORT_TASK = 'export-task',
|
EXPORT_TASK = 'export-task',
|
||||||
|
|
||||||
SEARCH_INDEX_PAGE = 'search-index-page',
|
SEARCH_INDEX_PAGE = 'search-index-page',
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import fastifyMultipart from '@fastify/multipart';
|
|||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import fastifyIp from 'fastify-ip';
|
import fastifyIp from 'fastify-ip';
|
||||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
|
||||||
import { resolveFrameHeader } from './common/helpers';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@@ -52,28 +50,6 @@ async function bootstrap() {
|
|||||||
await app.register(fastifyMultipart);
|
await app.register(fastifyMultipart);
|
||||||
await app.register(fastifyCookie);
|
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
|
app
|
||||||
.getHttpAdapter()
|
.getHttpAdapter()
|
||||||
.getInstance()
|
.getInstance()
|
||||||
|
|||||||
Reference in New Issue
Block a user