feat(ee): audit logs (#1977)

feat: clickhouse driver
* sync
* updates
This commit is contained in:
Philip Okugbe
2026-03-01 01:29:03 +00:00
committed by GitHub
parent 85ce0d32bf
commit 69d7532c6c
62 changed files with 2600 additions and 191 deletions
+2
View File
@@ -37,6 +37,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
export default function App() {
const { t } = useTranslation();
@@ -102,6 +103,7 @@ export default function App() {
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -11,6 +11,7 @@ import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
@@ -80,3 +81,11 @@ export const prefetchApiKeyManagement = () => {
queryFn: () => getApiKeys({ adminView: true }),
});
};
export const prefetchAuditLogs = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
});
};
@@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconHistory,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -31,6 +32,7 @@ import {
prefetchSpaces,
prefetchSsoProviders,
prefetchWorkspaceMembers,
prefetchAuditLogs,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -116,6 +118,14 @@ const groupedData: DataGroup[] = [
path: "/settings/ai",
isAdmin: true,
},
{
label: "Audit log",
icon: IconHistory,
path: "/settings/audit",
isEnterprise: true,
isAdmin: true,
isSelfhosted: true,
},
],
},
{
@@ -227,6 +237,9 @@ export default function SettingsSidebar() {
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
default:
break;
}
@@ -0,0 +1,301 @@
import { Fragment, useState } from "react";
import { Table, Text, Group, Skeleton, Anchor, Collapse, Box } from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
IconChevronRight,
IconChevronDown,
IconArrowRight,
} from "@tabler/icons-react";
import { IAuditLog } from "@/ee/audit/types/audit.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { getEventLabel } from "@/ee/audit/lib/audit-event-labels";
import { formattedDate } from "@/lib/time";
import NoTableResults from "@/components/common/no-table-results";
import classes from "./audit-logs.module.css";
type AuditLogsTableProps = {
items?: IAuditLog[];
isLoading: boolean;
};
function hasDetails(entry: IAuditLog): boolean {
return !!(entry.changes?.before || entry.changes?.after || entry.metadata);
}
function getResourceUrl(entry: IAuditLog): string | null {
if (!entry.resource) return null;
switch (entry.resourceType) {
case "group":
return `/settings/groups/${entry.resource.id}`;
case "space":
case "space_member":
return entry.resource.slug ? `/s/${entry.resource.slug}` : null;
default:
return null;
}
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function ChangesDiff({ changes }: { changes: IAuditLog["changes"] }) {
const { t } = useTranslation();
if (!changes) return null;
const { before, after } = changes;
const allKeys = new Set([
...Object.keys(before ?? {}),
...Object.keys(after ?? {}),
]);
if (allKeys.size === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Changes")}
</Text>
{[...allKeys].map((key) => {
const hasBefore = before && key in before;
const hasAfter = after && key in after;
return (
<Group key={key} gap={6} mb={2} wrap="nowrap" align="center">
<Text fz="xs" c="dimmed" fw={500} style={{ minWidth: "fit-content" }}>
{key}:
</Text>
{hasBefore && (
<Text fz="xs" component="span">
{formatValue(before[key])}
</Text>
)}
{hasBefore && hasAfter && (
<IconArrowRight size={10} color="var(--mantine-color-dimmed)" />
)}
{hasAfter && (
<Text fz="xs" component="span">
{formatValue(after[key])}
</Text>
)}
</Group>
);
})}
</Box>
);
}
function MetadataDisplay({ metadata }: { metadata: Record<string, any> }) {
const { t } = useTranslation();
const entries = Object.entries(metadata);
if (entries.length === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Metadata")}
</Text>
{entries.map(([key, value]) => (
<Group key={key} gap={6} mb={2} wrap="nowrap">
<Text fz="xs" c="dimmed" fw={500}>
{key}:
</Text>
<Text fz="xs">{formatValue(value)}</Text>
</Group>
))}
</Box>
);
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Skeleton circle height={36} />
<div>
<Skeleton height={14} width={120} mb={4} />
<Skeleton height={10} width={160} />
</div>
</Group>
</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>
))}
</>
);
}
function ResourceCell({ entry }: { entry: IAuditLog }) {
if (!entry.resource?.name) {
return <Text fz="sm" c="dimmed"></Text>;
}
const url = getResourceUrl(entry);
if (url) {
return (
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={url}
>
<div className={classes.resourceLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.resource.name}
</Text>
</div>
</Anchor>
);
}
return (
<Text fz="sm" lineClamp={1}>
{entry.resource.name}
</Text>
);
}
export default function AuditLogsTable({
items,
isLoading,
}: AuditLogsTableProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<Table.ScrollContainer minWidth={700}>
<Table highlightOnHover verticalSpacing="xs" className={classes.table}>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Actor")}</Table.Th>
<Table.Th>{t("Event")}</Table.Th>
<Table.Th>{t("Resource")}</Table.Th>
<Table.Th>{t("Date")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : items && items.length > 0 ? (
items.map((entry) => {
const expandable = hasDetails(entry);
const isExpanded = expanded.has(entry.id);
return (
<Fragment key={entry.id}>
<Table.Tr
onClick={expandable ? () => toggleExpanded(entry.id) : undefined}
style={{ cursor: expandable ? "pointer" : undefined }}
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
{expandable ? (
isExpanded ? (
<IconChevronDown size={16} color="var(--mantine-color-dimmed)" />
) : (
<IconChevronRight size={16} color="var(--mantine-color-dimmed)" />
)
) : (
<Box w={16} />
)}
{entry.actor ? (
<Group gap="sm" wrap="nowrap">
<CustomAvatar
name={entry.actor.name}
size={36}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.actor.name}
</Text>
<Text fz="xs" c="dimmed">
{entry.actor.email}
</Text>
</div>
</Group>
) : (
<Text fz="sm" c="dimmed" fs="italic">
{entry.actorType === "system"
? t("System")
: t("API key")}
</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm">{t(getEventLabel(entry.event))}</Text>
</Table.Td>
<Table.Td>
<ResourceCell entry={entry} />
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formattedDate(new Date(entry.createdAt))}
</Text>
</Table.Td>
</Table.Tr>
{expandable && (
<Table.Tr
className={classes.detailRow}
>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Box px="md" py="sm" className={classes.detailContent}>
<Group gap="xl" align="flex-start">
{entry.changes && <ChangesDiff changes={entry.changes} />}
{entry.metadata && <MetadataDisplay metadata={entry.metadata} />}
</Group>
</Box>
</Collapse>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})
) : (
<NoTableResults colSpan={4} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,33 @@
.table {
--table-border-color: var(--mantine-color-gray-2);
@mixin dark {
--table-border-color: var(--mantine-color-dark-5);
}
}
.resourceLinkText {
width: fit-content;
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
}
.detailRow {
&:hover {
background: none !important;
}
}
.detailContent {
@mixin light {
background: var(--mantine-color-gray-0);
}
@mixin dark {
background: var(--mantine-color-dark-7);
}
}
@@ -0,0 +1,163 @@
type EventOption = {
value: string;
label: string;
};
type EventGroup = {
group: string;
items: EventOption[];
};
export const auditEventLabels: Record<string, string> = {
"workspace.created": "Created workspace",
"workspace.updated": "Updated workspace",
"workspace.invite_created": "Created invitation",
"workspace.invite_resent": "Resent invitation",
"workspace.invite_revoked": "Revoked invitation",
"user.created": "Created user",
"user.deleted": "Deleted user",
"user.login": "Logged in",
"user.logout": "Logged out",
"user.role_changed": "Changed user role",
"user.password_changed": "Changed password",
"user.password_reset": "Reset password",
"user.updated": "Updated user",
"user.mfa_enabled": "Enabled MFA",
"user.mfa_disabled": "Disabled MFA",
"user.mfa_backup_code_generated": "Generated MFA backup codes",
"api_key.created": "Created API key",
"api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key",
"space.created": "Created space",
"space.updated": "Updated space",
"space.deleted": "Deleted space",
"space.member_added": "Added space member",
"space.member_removed": "Removed space member",
"space.member_role_changed": "Changed space member role",
"space.exported": "Exported space",
"group.created": "Created group",
"group.updated": "Updated group",
"group.deleted": "Deleted group",
"group.member_added": "Added group member",
"group.member_removed": "Removed group member",
"comment.deleted": "Deleted comment",
"page.trashed": "Trashed page",
"page.deleted": "Deleted page",
"page.restored": "Restored page",
"page.moved_to_space": "Moved page to space",
"page.duplicated": "Duplicated page",
"page.imported": "Imported page",
"page.exported": "Exported page",
"page.restricted": "Restricted page",
"page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission",
"share.created": "Created share link",
"share.deleted": "Deleted share link",
"sso.provider_created": "Created SSO provider",
"sso.provider_updated": "Updated SSO provider",
"sso.provider_deleted": "Deleted SSO provider",
"license.activated": "Activated license",
"license.removed": "Removed license",
};
export function getEventLabel(event: string): string {
return auditEventLabels[event] ?? event;
}
export const eventFilterOptions: EventGroup[] = [
{
group: "Workspace",
items: [
{ value: "workspace.updated", label: "Updated workspace" },
{ value: "workspace.invite_created", label: "Created invitation" },
{ value: "workspace.invite_revoked", label: "Revoked invitation" },
],
},
{
group: "User",
items: [
{ value: "user.login", label: "Logged in" },
{ value: "user.logout", label: "Logged out" },
{ value: "user.created", label: "Created user" },
{ value: "user.deleted", label: "Deleted user" },
{ value: "user.role_changed", label: "Changed user role" },
{ value: "user.password_changed", label: "Changed password" },
{ value: "user.mfa_enabled", label: "Enabled MFA" },
{ value: "user.mfa_disabled", label: "Disabled MFA" },
],
},
{
group: "Space",
items: [
{ value: "space.created", label: "Created space" },
{ value: "space.updated", label: "Updated space" },
{ value: "space.deleted", label: "Deleted space" },
{ value: "space.member_added", label: "Added space member" },
{ value: "space.member_removed", label: "Removed space member" },
],
},
{
group: "Group",
items: [
{ value: "group.created", label: "Created group" },
{ value: "group.updated", label: "Updated group" },
{ value: "group.deleted", label: "Deleted group" },
{ value: "group.member_added", label: "Added group member" },
{ value: "group.member_removed", label: "Removed group member" },
],
},
{
group: "Page",
items: [
{ value: "page.trashed", label: "Trashed page" },
{ value: "page.deleted", label: "Deleted page" },
{ value: "page.restored", label: "Restored page" },
{ value: "page.moved_to_space", label: "Moved page" },
{ value: "page.imported", label: "Imported page" },
{ value: "page.exported", label: "Exported page" },
{ value: "page.restricted", label: "Restricted page" },
{ value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed page permission" },
],
},
{
group: "Share",
items: [
{ value: "share.created", label: "Created share link" },
{ value: "share.deleted", label: "Deleted share link" },
],
},
{
group: "SSO",
items: [
{ value: "sso.provider_created", label: "Created SSO provider" },
{ value: "sso.provider_updated", label: "Updated SSO provider" },
{ value: "sso.provider_deleted", label: "Deleted SSO provider" },
],
},
{
group: "API key",
items: [
{ value: "api_key.created", label: "Created API key" },
{ value: "api_key.deleted", label: "Deleted API key" },
],
},
{
group: "License",
items: [
{ value: "license.activated", label: "Activated license" },
{ value: "license.removed", label: "Removed license" },
],
},
];
@@ -0,0 +1,223 @@
import { useState, useMemo, useEffect } from "react";
import {
ActionIcon,
Button,
Group,
NumberInput,
Popover,
Select,
Space,
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
useAuditLogsQuery,
useAuditRetentionQuery,
useUpdateAuditRetentionMutation,
} from "@/ee/audit/queries/audit-query";
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
type RetentionUnit = "days" | "months" | "years";
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function AuditLogs() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const { data: retentionData } = useAuditRetentionQuery();
const updateRetention = useUpdateAuditRetentionMutation();
const currentDays = retentionData?.retentionDays ?? 365;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
useEffect(() => {
if (retentionData) {
const { amount, unit } = daysToRetention(retentionData.retentionDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
}
}, [retentionData?.retentionDays]);
const resetRetentionForm = () => {
const { amount, unit } = daysToRetention(currentDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
};
const params: IAuditLogParams = useMemo(
() => ({
cursor,
limit: 50,
event: eventFilter ?? undefined,
}),
[cursor, eventFilter],
);
const { data, isLoading } = useAuditLogsQuery(params);
if (!isAdmin) {
return null;
}
const handleEventChange = (value: string | null) => {
setEventFilter(value);
resetCursor();
};
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} />
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
@@ -0,0 +1,51 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getAuditLogs,
getAuditRetention,
updateAuditRetention,
} from "@/ee/audit/services/audit-service";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useAuditLogsQuery(
params?: IAuditLogParams,
): UseQueryResult<IPagination<IAuditLog>, Error> {
return useQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
placeholderData: keepPreviousData,
});
}
export function useAuditRetentionQuery() {
return useQuery({
queryKey: ["audit-retention"],
queryFn: () => getAuditRetention(),
});
}
export function useUpdateAuditRetentionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: { auditRetentionDays: number }) =>
updateAuditRetention(data),
onSuccess: () => {
notifications.show({ message: t("Audit retention updated") });
queryClient.invalidateQueries({ queryKey: ["audit-retention"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,22 @@
import api from "@/lib/api-client";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
export async function getAuditLogs(
params?: IAuditLogParams,
): Promise<IPagination<IAuditLog>> {
const req = await api.post("/audit", { ...params });
return req.data;
}
export async function getAuditRetention(): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention");
return req.data;
}
export async function updateAuditRetention(data: {
auditRetentionDays: number;
}): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention/update", data);
return req.data;
}
@@ -0,0 +1,39 @@
export type IAuditLog = {
id: string;
workspaceId: string;
actorId?: string;
actorType: string;
event: string;
resourceType: string;
resourceId?: string;
spaceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
ipAddress?: string;
createdAt: string;
actor?: {
id: string;
name: string;
email: string;
};
resource?: {
id: string;
name: string;
slug?: string;
slugId?: string;
};
};
export type IAuditLogParams = {
event?: string;
resourceType?: string;
actorId?: string;
spaceId?: string;
startDate?: string;
endDate?: string;
cursor?: string;
limit?: number;
};
@@ -0,0 +1,138 @@
import { useState, useEffect } from "react";
import {
Group,
Text,
NumberInput,
Select,
Button,
Tooltip,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
type RetentionUnit = "days" | "months" | "years";
const DEFAULT_RETENTION_DAYS = 30;
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function TrashRetention() {
const { t } = useTranslation();
const hasAccess = useEnterpriseAccess();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
const [saving, setSaving] = useState(false);
useEffect(() => {
const days = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const { amount, unit } = daysToRetention(days);
setRetentionAmount(amount);
setRetentionUnit(unit);
}, [workspace?.trashRetentionDays]);
const handleSave = async () => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days === currentDays) return;
setSaving(true);
try {
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
setWorkspace(updatedWorkspace);
notifications.show({
message: t("Trash retention updated"),
});
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message || t("Failed to update trash retention"),
color: "red",
});
const { amount, unit } = daysToRetention(currentDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
} finally {
setSaving(false);
}
};
const isDirty = retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit,
) !== currentDays;
return (
<div>
<Text size="md">{t("Trash retention")}</Text>
<Text size="sm" c="dimmed" mb="sm">
{t("Pages in trash will be permanently deleted after this period.")}
</Text>
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
>
<Group gap="xs" wrap="nowrap" maw={320}>
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
disabled={!hasAccess}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
disabled={!hasAccess}
/>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!hasAccess || !isDirty}
>
{t("Save")}
</Button>
</Group>
</Tooltip>
</div>
);
}
@@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
@@ -42,6 +43,13 @@ export default function Security() {
</>
)}
{!isCloud() && (
<>
<TrashRetention />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
@@ -155,7 +155,9 @@ export function useDeletePageMutation() {
});
},
onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" });
const message =
error["response"]?.data?.message || t("Failed to delete page");
notifications.show({ message, color: "red" });
},
});
}
@@ -31,9 +31,12 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function Trash() {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -108,7 +111,7 @@ export default function Trash() {
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm">
{t("Pages in trash will be permanently deleted after 30 days.")}
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
</Text>
</Alert>
@@ -25,6 +25,7 @@ export interface IWorkspace {
aiSearch?: boolean;
generativeAi?: boolean;
disablePublicSharing?: boolean;
trashRetentionDays?: number;
}
export interface IWorkspaceSettings {