mirror of
https://github.com/docmost/docmost.git
synced 2026-06-14 03:29:56 +00:00
feat: import page permissions
This commit is contained in:
@@ -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;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: c2d2c373c6...72a0342452
Reference in New Issue
Block a user