feat: import page permissions

This commit is contained in:
Philipinho
2026-06-12 12:59:31 +01:00
parent 812d0765b5
commit 79df1229cb
10 changed files with 187 additions and 31 deletions
@@ -22,6 +22,7 @@
"Can view": "Can view", "Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.", "Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel", "Cancel": "Cancel",
"Cancelled": "Cancelled",
"Change email": "Change email", "Change email": "Change email",
"Change password": "Change password", "Change password": "Change password",
"Change photo": "Change photo", "Change photo": "Change photo",
@@ -29,7 +30,9 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.", "Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Completed": "Completed",
"Confirm": "Confirm", "Confirm": "Confirm",
"Confluence site": "Confluence site",
"Copy as Markdown": "Copy as Markdown", "Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Create": "Create", "Create": "Create",
@@ -55,6 +58,10 @@
"e.g Space for product team": "e.g Space for product team", "e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate", "e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Edit": "Edit", "Edit": "Edit",
"Everyone with access to this space": "Everyone with access to this space",
"Failed": "Failed",
"Import details": "Import details",
"Permissions": "Permissions",
"Read": "Read", "Read": "Read",
"Edit group": "Edit group", "Edit group": "Edit group",
"Email": "Email", "Email": "Email",
@@ -130,6 +137,9 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"Restricted pages": "Restricted pages",
"Restrictions": "Restrictions",
"Running": "Running",
"Select version": "Select version", "Select version": "Select version",
"Highlight changes": "Highlight changes", "Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -166,6 +176,7 @@
"Setup workspace": "Setup workspace", "Setup workspace": "Setup workspace",
"Sign In": "Sign In", "Sign In": "Sign In",
"Sign Up": "Sign Up", "Sign Up": "Sign Up",
"Site": "Site",
"Slug": "Slug", "Slug": "Slug",
"Space": "Space", "Space": "Space",
"Space description": "Space description", "Space description": "Space description",
@@ -174,10 +185,13 @@
"Space settings": "Space settings", "Space settings": "Space settings",
"Space slug": "Space slug", "Space slug": "Space slug",
"Spaces": "Spaces", "Spaces": "Spaces",
"spaces": "spaces",
"Spaces you belong to": "Spaces you belong to", "Spaces you belong to": "Spaces you belong to",
"No space found": "No space found", "No space found": "No space found",
"Search for spaces": "Search for spaces", "Search for spaces": "Search for spaces",
"Start typing to search...": "Start typing to search...", "Start typing to search...": "Start typing to search...",
"Started at": "Started at",
"Started by": "Started by",
"Status": "Status", "Status": "Status",
"Successfully imported": "Successfully imported", "Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored", "Successfully restored": "Successfully restored",
@@ -191,6 +205,8 @@
"Untitled": "Untitled", "Untitled": "Untitled",
"Updated successfully": "Updated successfully", "Updated successfully": "Updated successfully",
"User": "User", "User": "User",
"Users": "Users",
"users": "users",
"Workspace": "Workspace", "Workspace": "Workspace",
"Workspace Name": "Workspace Name", "Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings", "Workspace settings": "Workspace settings",
@@ -1,8 +1,9 @@
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { import {
Badge, Badge,
Group, Group,
Loader, Loader,
Modal,
Progress, Progress,
Skeleton, Skeleton,
Stack, Stack,
@@ -26,7 +27,11 @@ const BADGE_STYLES = {
label: { overflow: "visible" as const }, label: { overflow: "visible" as const },
}; };
function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) { function statusBadge(
status: ConfluenceImportStatus,
cancelled: boolean,
t: (key: string) => string,
) {
if (cancelled) { if (cancelled) {
return ( return (
<Badge <Badge
@@ -35,7 +40,7 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<IconX size={12} />} leftSection={<IconX size={12} />}
styles={BADGE_STYLES} styles={BADGE_STYLES}
> >
Cancelled {t("Cancelled")}
</Badge> </Badge>
); );
} }
@@ -47,7 +52,7 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<Loader size={10} />} leftSection={<Loader size={10} />}
styles={BADGE_STYLES} styles={BADGE_STYLES}
> >
Running {t("Running")}
</Badge> </Badge>
); );
} }
@@ -59,7 +64,7 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<IconCheck size={12} />} leftSection={<IconCheck size={12} />}
styles={BADGE_STYLES} styles={BADGE_STYLES}
> >
Completed {t("Completed")}
</Badge> </Badge>
); );
} }
@@ -70,14 +75,14 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<IconAlertCircle size={12} />} leftSection={<IconAlertCircle size={12} />}
styles={BADGE_STYLES} styles={BADGE_STYLES}
> >
Failed {t("Failed")}
</Badge> </Badge>
); );
} }
function phaseLabel(phase: string | null): string { function phaseLabel(phase: string | null, t: (key: string) => string): string {
if (!phase) return "—"; if (!phase) return "—";
return phase.charAt(0).toUpperCase() + phase.slice(1); return t(phase.charAt(0).toUpperCase() + phase.slice(1));
} }
function progressValue(item: ConfluenceImportHistoryItem) { function progressValue(item: ConfluenceImportHistoryItem) {
@@ -92,6 +97,7 @@ function progressValue(item: ConfluenceImportHistoryItem) {
} }
function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) { function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
const { t } = useTranslation();
const value = progressValue(item); const value = progressValue(item);
const color = const color =
item.status === "failed" item.status === "failed"
@@ -105,19 +111,113 @@ function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
<Progress value={value} color={color} size="xs" animated={item.status === "processing"} /> <Progress value={value} color={color} size="xs" animated={item.status === "processing"} />
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> <Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{item.importedPages}/{item.totalPages || "?"} pages {item.importedPages}/{item.totalPages || "?"} {t("pages")}
</Text> </Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> <Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedSpaces}/{item.totalSpaces || "?"} spaces · {item.importedSpaces}/{item.totalSpaces || "?"} {t("spaces")}
</Text> </Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> <Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedUsers}/{item.totalUsers || "?"} users · {item.importedUsers}/{item.totalUsers || "?"} {t("users")}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>
); );
} }
function ImportStatsModal({
item,
onClose,
}: {
item: ConfluenceImportHistoryItem | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const stats = item
? [
{
label: t("Spaces"),
imported: item.importedSpaces,
total: item.totalSpaces,
},
{
label: t("Pages"),
imported: item.importedPages,
total: item.totalPages,
},
{
label: t("Users"),
imported: item.importedUsers,
total: item.totalUsers,
},
{
label: t("Groups"),
imported: item.importedGroups,
total: item.totalGroups,
},
{
label: t("Attachments"),
imported: item.importedAttachments,
total: item.totalAttachments,
},
{
label: t("Labels"),
imported: item.importedLabels,
total: item.totalLabels,
},
{
label: t("Restricted pages"),
imported: item.importedRestrictedPages,
total: item.totalRestrictedPages,
},
]
: [];
return (
<Modal
opened={!!item}
onClose={onClose}
title={t("Import details")}
size="md"
>
{item && (
<Stack gap="sm">
<div>
<Text fz="sm" c="dimmed">
{t("Confluence site")}
</Text>
<Text fz="sm" fw={500} lineClamp={1}>
{item.siteUrl}
</Text>
</div>
<div>
<Text fz="sm" c="dimmed">
{t("Started at")}
</Text>
<Text fz="sm">{formattedDate(new Date(item.createdAt))}</Text>
</div>
<Table verticalSpacing="xs" fz="sm">
<Table.Tbody>
{stats.map((stat) => (
<Table.Tr key={stat.label}>
<Table.Td>
<Text fz="sm">{stat.label}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ta="right">
{stat.imported} / {stat.total}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
)}
</Modal>
);
}
function TableSkeleton() { function TableSkeleton() {
return ( return (
<> <>
@@ -150,6 +250,8 @@ function TableSkeleton() {
export default function ConfluenceImportHistory() { export default function ConfluenceImportHistory() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useConfluenceImportsQuery(); const { data, isLoading } = useConfluenceImportsQuery();
const [selectedItem, setSelectedItem] =
useState<ConfluenceImportHistoryItem | null>(null);
const items = useMemo(() => data?.items ?? [], [data]); const items = useMemo(() => data?.items ?? [], [data]);
@@ -172,9 +274,13 @@ export default function ConfluenceImportHistory() {
<TableSkeleton /> <TableSkeleton />
) : items.length > 0 ? ( ) : items.length > 0 ? (
items.map((item) => ( items.map((item) => (
<Table.Tr key={item.fileTaskId}> <Table.Tr
key={item.fileTaskId}
onClick={() => setSelectedItem(item)}
style={{ cursor: "pointer" }}
>
<Table.Td> <Table.Td>
{statusBadge(item.status, item.cancelled)} {statusBadge(item.status, item.cancelled, t)}
{item.status === "failed" && item.errorMessage && ( {item.status === "failed" && item.errorMessage && (
<Tooltip label={item.errorMessage} multiline w={320}> <Tooltip label={item.errorMessage} multiline w={320}>
<Text fz="xs" c="red" lineClamp={1} maw={180}> <Text fz="xs" c="red" lineClamp={1} maw={180}>
@@ -189,7 +295,7 @@ export default function ConfluenceImportHistory() {
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text fz="sm">{phaseLabel(item.currentPhase)}</Text> <Text fz="sm">{phaseLabel(item.currentPhase, t)}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ProgressCell item={item} /> <ProgressCell item={item} />
@@ -224,6 +330,11 @@ export default function ConfluenceImportHistory() {
)} )}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<ImportStatsModal
item={selectedItem}
onClose={() => setSelectedItem(null)}
/>
</Table.ScrollContainer> </Table.ScrollContainer>
); );
} }
@@ -153,7 +153,9 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
setImportAll(true); setImportAll(true);
setActive(1); setActive(1);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.message || err?.message || t("Unexpected error")); setError(
err?.response?.data?.message || err?.message || t("Unexpected error"),
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -161,7 +163,9 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
const toggleSpace = (key: string, checked: boolean) => { const toggleSpace = (key: string, checked: boolean) => {
setSelectedKeys((prev) => setSelectedKeys((prev) =>
checked ? Array.from(new Set([...prev, key])) : prev.filter((k) => k !== key), checked
? Array.from(new Set([...prev, key]))
: prev.filter((k) => k !== key),
); );
}; };
@@ -349,13 +353,14 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
)} )}
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={handleCancelFlow} disabled={loading}> <Button
variant="default"
onClick={handleCancelFlow}
disabled={loading}
>
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button <Button onClick={handleNextFromCredentials} loading={loading}>
onClick={handleNextFromCredentials}
loading={loading}
>
{t("Test & continue")} {t("Test & continue")}
</Button> </Button>
</Group> </Group>
@@ -366,7 +371,7 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
<Stack> <Stack>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t( {t(
"Choose the spaces to import. Users, groups and permissions will be imported for the selected spaces.", "Pages, comments, page labels, users, groups, spaces and permissions will be imported.",
)} )}
</Text> </Text>
@@ -435,7 +440,6 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
</Group> </Group>
</Stack> </Stack>
)} )}
</Modal> </Modal>
); );
} }
@@ -49,6 +49,10 @@ export type ImportStatusResponse = {
importedPages?: number; importedPages?: number;
totalUsers?: number; totalUsers?: number;
importedUsers?: number; importedUsers?: number;
totalGroups?: number;
importedGroups?: number;
totalRestrictedPages?: number;
importedRestrictedPages?: number;
warnings?: string[]; warnings?: string[];
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@@ -67,6 +71,14 @@ export type ConfluenceImportHistoryItem = {
importedPages: number; importedPages: number;
totalUsers: number; totalUsers: number;
importedUsers: number; importedUsers: number;
totalGroups: number;
importedGroups: number;
totalAttachments: number;
importedAttachments: number;
totalLabels: number;
importedLabels: number;
totalRestrictedPages: number;
importedRestrictedPages: number;
cancelled: boolean; cancelled: boolean;
spaceKeys: string[]; spaceKeys: string[];
warnings: string[]; warnings: string[];
@@ -53,7 +53,14 @@ export function PagePermissionItem({
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>} {isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText> </AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed"> <AutoTooltipText fz="xs" c="dimmed">
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)} {member.type === "user"
? member.email
: member.isDefault
? // Page access still requires space membership, so the
// workspace-wide member count would overstate who can
// actually see the page.
t("Everyone with access to this space")
: formatMemberCount(member.memberCount, t)}
</AutoTooltipText> </AutoTooltipText>
</div> </div>
</div> </div>
@@ -17,8 +17,8 @@ const formSchema = z.object({
.min(2) .min(2)
.max(100) .max(100)
.regex( .regex(
/^[a-zA-Z0-9]+$/, /^[a-zA-Z0-9-]+$/,
"Space slug must be alphanumeric. No special characters", "Space slug can only contain letters, numbers, and hyphens",
), ),
description: z.string().max(500), description: z.string().max(500),
}); });
@@ -15,8 +15,8 @@ const formSchema = z.object({
.min(2) .min(2)
.max(100) .max(100)
.regex( .regex(
/^[a-zA-Z0-9]+$/, /^[a-zA-Z0-9-]+$/,
"Space slug must be alphanumeric. No special characters", "Space slug can only contain letters, numbers, and hyphens",
), ),
}); });
@@ -1,7 +1,7 @@
import { import {
IsAlphanumeric,
IsOptional, IsOptional,
IsString, IsString,
Matches,
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
@@ -20,6 +20,8 @@ export class CreateSpaceDto {
@MinLength(2) @MinLength(2)
@MaxLength(100) @MaxLength(100)
@IsAlphanumeric() @Matches(/^[a-zA-Z0-9-]+$/, {
message: 'slug can only contain letters, numbers, and hyphens',
})
slug: string; slug: string;
} }
@@ -18,6 +18,10 @@ export interface ConfluenceApiImports {
importedAttachments: Generated<number>; importedAttachments: Generated<number>;
totalLabels: Generated<number>; totalLabels: Generated<number>;
importedLabels: Generated<number>; importedLabels: Generated<number>;
totalGroups: Generated<number>;
importedGroups: Generated<number>;
totalRestrictedPages: Generated<number>;
importedRestrictedPages: Generated<number>;
idMapping: Generated<Json>; idMapping: Generated<Json>;
warnings: Generated<Json>; warnings: Generated<Json>;
currentPhase: string | null; currentPhase: string | null;