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 pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Cancelled": "Cancelled",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@@ -29,7 +30,9 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Completed": "Completed",
"Confirm": "Confirm",
"Confluence site": "Confluence site",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@@ -55,6 +58,10 @@
"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",
"Edit": "Edit",
"Everyone with access to this space": "Everyone with access to this space",
"Failed": "Failed",
"Import details": "Import details",
"Permissions": "Permissions",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
@@ -130,6 +137,9 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"Restricted pages": "Restricted pages",
"Restrictions": "Restrictions",
"Running": "Running",
"Select version": "Select version",
"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.",
@@ -166,6 +176,7 @@
"Setup workspace": "Setup workspace",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Site": "Site",
"Slug": "Slug",
"Space": "Space",
"Space description": "Space description",
@@ -174,10 +185,13 @@
"Space settings": "Space settings",
"Space slug": "Space slug",
"Spaces": "Spaces",
"spaces": "spaces",
"Spaces you belong to": "Spaces you belong to",
"No space found": "No space found",
"Search for spaces": "Search for spaces",
"Start typing to search...": "Start typing to search...",
"Started at": "Started at",
"Started by": "Started by",
"Status": "Status",
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
@@ -191,6 +205,8 @@
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
"User": "User",
"Users": "Users",
"users": "users",
"Workspace": "Workspace",
"Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings",
@@ -1,8 +1,9 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
Badge,
Group,
Loader,
Modal,
Progress,
Skeleton,
Stack,
@@ -26,7 +27,11 @@ const BADGE_STYLES = {
label: { overflow: "visible" as const },
};
function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
function statusBadge(
status: ConfluenceImportStatus,
cancelled: boolean,
t: (key: string) => string,
) {
if (cancelled) {
return (
<Badge
@@ -35,7 +40,7 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<IconX size={12} />}
styles={BADGE_STYLES}
>
Cancelled
{t("Cancelled")}
</Badge>
);
}
@@ -47,7 +52,7 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<Loader size={10} />}
styles={BADGE_STYLES}
>
Running
{t("Running")}
</Badge>
);
}
@@ -59,7 +64,7 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<IconCheck size={12} />}
styles={BADGE_STYLES}
>
Completed
{t("Completed")}
</Badge>
);
}
@@ -70,14 +75,14 @@ function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) {
leftSection={<IconAlertCircle size={12} />}
styles={BADGE_STYLES}
>
Failed
{t("Failed")}
</Badge>
);
}
function phaseLabel(phase: string | null): string {
function phaseLabel(phase: string | null, t: (key: string) => string): string {
if (!phase) return "—";
return phase.charAt(0).toUpperCase() + phase.slice(1);
return t(phase.charAt(0).toUpperCase() + phase.slice(1));
}
function progressValue(item: ConfluenceImportHistoryItem) {
@@ -92,6 +97,7 @@ function progressValue(item: ConfluenceImportHistoryItem) {
}
function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
const { t } = useTranslation();
const value = progressValue(item);
const color =
item.status === "failed"
@@ -105,19 +111,113 @@ function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
<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
{item.importedPages}/{item.totalPages || "?"} {t("pages")}
</Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedSpaces}/{item.totalSpaces || "?"} spaces
· {item.importedSpaces}/{item.totalSpaces || "?"} {t("spaces")}
</Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedUsers}/{item.totalUsers || "?"} users
· {item.importedUsers}/{item.totalUsers || "?"} {t("users")}
</Text>
</Group>
</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() {
return (
<>
@@ -150,6 +250,8 @@ function TableSkeleton() {
export default function ConfluenceImportHistory() {
const { t } = useTranslation();
const { data, isLoading } = useConfluenceImportsQuery();
const [selectedItem, setSelectedItem] =
useState<ConfluenceImportHistoryItem | null>(null);
const items = useMemo(() => data?.items ?? [], [data]);
@@ -172,9 +274,13 @@ export default function ConfluenceImportHistory() {
<TableSkeleton />
) : items.length > 0 ? (
items.map((item) => (
<Table.Tr key={item.fileTaskId}>
<Table.Tr
key={item.fileTaskId}
onClick={() => setSelectedItem(item)}
style={{ cursor: "pointer" }}
>
<Table.Td>
{statusBadge(item.status, item.cancelled)}
{statusBadge(item.status, item.cancelled, t)}
{item.status === "failed" && item.errorMessage && (
<Tooltip label={item.errorMessage} multiline w={320}>
<Text fz="xs" c="red" lineClamp={1} maw={180}>
@@ -189,7 +295,7 @@ export default function ConfluenceImportHistory() {
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{phaseLabel(item.currentPhase)}</Text>
<Text fz="sm">{phaseLabel(item.currentPhase, t)}</Text>
</Table.Td>
<Table.Td>
<ProgressCell item={item} />
@@ -224,6 +330,11 @@ export default function ConfluenceImportHistory() {
)}
</Table.Tbody>
</Table>
<ImportStatsModal
item={selectedItem}
onClose={() => setSelectedItem(null)}
/>
</Table.ScrollContainer>
);
}
@@ -153,7 +153,9 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
setImportAll(true);
setActive(1);
} catch (err: any) {
setError(err?.response?.data?.message || err?.message || t("Unexpected error"));
setError(
err?.response?.data?.message || err?.message || t("Unexpected error"),
);
} finally {
setLoading(false);
}
@@ -161,7 +163,9 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
const toggleSpace = (key: string, checked: boolean) => {
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">
<Button variant="default" onClick={handleCancelFlow} disabled={loading}>
<Button
variant="default"
onClick={handleCancelFlow}
disabled={loading}
>
{t("Cancel")}
</Button>
<Button
onClick={handleNextFromCredentials}
loading={loading}
>
<Button onClick={handleNextFromCredentials} loading={loading}>
{t("Test & continue")}
</Button>
</Group>
@@ -366,7 +371,7 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
<Stack>
<Text size="sm" c="dimmed">
{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>
@@ -435,7 +440,6 @@ export default function ConfluenceImportModal({ opened, onClose }: Props) {
</Group>
</Stack>
)}
</Modal>
);
}
@@ -49,6 +49,10 @@ export type ImportStatusResponse = {
importedPages?: number;
totalUsers?: number;
importedUsers?: number;
totalGroups?: number;
importedGroups?: number;
totalRestrictedPages?: number;
importedRestrictedPages?: number;
warnings?: string[];
createdAt?: string;
updatedAt?: string;
@@ -67,6 +71,14 @@ export type ConfluenceImportHistoryItem = {
importedPages: number;
totalUsers: number;
importedUsers: number;
totalGroups: number;
importedGroups: number;
totalAttachments: number;
importedAttachments: number;
totalLabels: number;
importedLabels: number;
totalRestrictedPages: number;
importedRestrictedPages: number;
cancelled: boolean;
spaceKeys: string[];
warnings: string[];
@@ -53,7 +53,14 @@ export function PagePermissionItem({
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<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>
</div>
</div>
@@ -17,8 +17,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
/^[a-zA-Z0-9-]+$/,
"Space slug can only contain letters, numbers, and hyphens",
),
description: z.string().max(500),
});
@@ -15,8 +15,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
/^[a-zA-Z0-9-]+$/,
"Space slug can only contain letters, numbers, and hyphens",
),
});
@@ -1,7 +1,7 @@
import {
IsAlphanumeric,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
@@ -20,6 +20,8 @@ export class CreateSpaceDto {
@MinLength(2)
@MaxLength(100)
@IsAlphanumeric()
@Matches(/^[a-zA-Z0-9-]+$/, {
message: 'slug can only contain letters, numbers, and hyphens',
})
slug: string;
}
@@ -18,6 +18,10 @@ export interface ConfluenceApiImports {
importedAttachments: Generated<number>;
totalLabels: Generated<number>;
importedLabels: Generated<number>;
totalGroups: Generated<number>;
importedGroups: Generated<number>;
totalRestrictedPages: Generated<number>;
importedRestrictedPages: Generated<number>;
idMapping: Generated<Json>;
warnings: Generated<Json>;
currentPhase: string | null;