diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c8f979b7..3f366e4d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -521,7 +521,7 @@ "Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.", "Verify": "Verify", "Trash": "Trash", - "Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.", + "Pages in trash will be permanently deleted after {{count}} days.": "Pages in trash will be permanently deleted after {{count}} days.", "Deleted": "Deleted", "No pages in trash": "No pages in trash", "Permanently delete page?": "Permanently delete page?", @@ -652,5 +652,13 @@ "Remove access": "Remove access", "Remove all access": "Remove all access", "Are you sure you want to remove this member's access to the page?": "Are you sure you want to remove this member's access to the page?", - "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Are you sure you want to remove all specific access? This will make the page open to everyone in the space." + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.", + "Trash retention": "Trash retention", + "Pages in trash will be permanently deleted after this period.": "Pages in trash will be permanently deleted after this period.", + "Trash retention updated": "Trash retention updated", + "Failed to update trash retention": "Failed to update trash retention", + "Restricted page": "Restricted page", + "Removed page restriction": "Removed page restriction", + "Added page permission": "Added page permission", + "Removed page permission": "Removed page permission" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 438ffde8..3a1eb621 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index 0c68ad90..d15bbfa2 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -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), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1bb30bbd..1c49f918 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -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; } diff --git a/apps/client/src/ee/audit/components/audit-logs-table.tsx b/apps/client/src/ee/audit/components/audit-logs-table.tsx new file mode 100644 index 00000000..9f29d51d --- /dev/null +++ b/apps/client/src/ee/audit/components/audit-logs-table.tsx @@ -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 ( + + + {t("Changes")} + + {[...allKeys].map((key) => { + const hasBefore = before && key in before; + const hasAfter = after && key in after; + + return ( + + + {key}: + + {hasBefore && ( + + {formatValue(before[key])} + + )} + {hasBefore && hasAfter && ( + + )} + {hasAfter && ( + + {formatValue(after[key])} + + )} + + ); + })} + + ); +} + +function MetadataDisplay({ metadata }: { metadata: Record }) { + const { t } = useTranslation(); + const entries = Object.entries(metadata); + if (entries.length === 0) return null; + + return ( + + + {t("Metadata")} + + {entries.map(([key, value]) => ( + + + {key}: + + {formatValue(value)} + + ))} + + ); +} + +function TableSkeleton() { + return ( + <> + {Array.from({ length: 8 }).map((_, i) => ( + + + + +
+ + +
+
+
+ + + + + + + + + +
+ ))} + + ); +} + +function ResourceCell({ entry }: { entry: IAuditLog }) { + if (!entry.resource?.name) { + return ; + } + + const url = getResourceUrl(entry); + + if (url) { + return ( + +
+ + {entry.resource.name} + +
+
+ ); + } + + return ( + + {entry.resource.name} + + ); +} + +export default function AuditLogsTable({ + items, + isLoading, +}: AuditLogsTableProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState>(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 ( + + + + + {t("Actor")} + {t("Event")} + {t("Resource")} + {t("Date")} + + + + + {isLoading ? ( + + ) : items && items.length > 0 ? ( + items.map((entry) => { + const expandable = hasDetails(entry); + const isExpanded = expanded.has(entry.id); + + return ( + + toggleExpanded(entry.id) : undefined} + style={{ cursor: expandable ? "pointer" : undefined }} + > + + + {expandable ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + {entry.actor ? ( + + +
+ + {entry.actor.name} + + + {entry.actor.email} + +
+
+ ) : ( + + {entry.actorType === "system" + ? t("System") + : t("API key")} + + )} +
+
+ + + {t(getEventLabel(entry.event))} + + + + + + + + + {formattedDate(new Date(entry.createdAt))} + + +
+ + {expandable && ( + + + + + + {entry.changes && } + {entry.metadata && } + + + + + + )} +
+ ); + }) + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/client/src/ee/audit/components/audit-logs.module.css b/apps/client/src/ee/audit/components/audit-logs.module.css new file mode 100644 index 00000000..2d095682 --- /dev/null +++ b/apps/client/src/ee/audit/components/audit-logs.module.css @@ -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); + } +} diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts new file mode 100644 index 00000000..11153d19 --- /dev/null +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -0,0 +1,163 @@ +type EventOption = { + value: string; + label: string; +}; + +type EventGroup = { + group: string; + items: EventOption[]; +}; + +export const auditEventLabels: Record = { + "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" }, + ], + }, +]; diff --git a/apps/client/src/ee/audit/pages/audit-logs.tsx b/apps/client/src/ee/audit/pages/audit-logs.tsx new file mode 100644 index 00000000..10e390d8 --- /dev/null +++ b/apps/client/src/ee/audit/pages/audit-logs.tsx @@ -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(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(parsed.amount); + const [retentionUnit, setRetentionUnit] = useState(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 ( + <> + + + {t("Audit log")} - {getAppName()} + + + + + + + { + if (value === "days" || value === "months" || value === "years") { + setRetentionUnit(value); + } + }} + size="sm" + style={{ flex: 1 }} + comboboxProps={{ withinPortal: false }} + /> + + + + + + + + + + + + + + {data?.items && data.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + ); +} diff --git a/apps/client/src/ee/audit/queries/audit-query.ts b/apps/client/src/ee/audit/queries/audit-query.ts new file mode 100644 index 00000000..51888495 --- /dev/null +++ b/apps/client/src/ee/audit/queries/audit-query.ts @@ -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, 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" }); + }, + }); +} diff --git a/apps/client/src/ee/audit/services/audit-service.ts b/apps/client/src/ee/audit/services/audit-service.ts new file mode 100644 index 00000000..f0eb4938 --- /dev/null +++ b/apps/client/src/ee/audit/services/audit-service.ts @@ -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> { + 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; +} diff --git a/apps/client/src/ee/audit/types/audit.types.ts b/apps/client/src/ee/audit/types/audit.types.ts new file mode 100644 index 00000000..0f1c858e --- /dev/null +++ b/apps/client/src/ee/audit/types/audit.types.ts @@ -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; + after?: Record; + }; + metadata?: Record; + 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; +}; diff --git a/apps/client/src/ee/security/components/trash-retention.tsx b/apps/client/src/ee/security/components/trash-retention.tsx new file mode 100644 index 00000000..840e22b1 --- /dev/null +++ b/apps/client/src/ee/security/components/trash-retention.tsx @@ -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(parsed.amount); + const [retentionUnit, setRetentionUnit] = useState(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 ( +
+ {t("Trash retention")} + + {t("Pages in trash will be permanently deleted after this period.")} + + + + + setRetentionAmount(val)} + min={1} + hideControls + size="sm" + w={60} + disabled={!hasAccess} + /> +