mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Improve UX around source folders in Processor (#7101)
# Description of Changes Adds implicitly defined folders to the list of locations that folder sources can look in, including the legacy watchedFolder folders, and the server storage location (if enabled). Also adds a settings UI for defining the list of allowed folders instead of having to manually edit `settings.yml` (please excuse the styling, that's the standard styling of the Processor, hoping it gets fixed by one of the styling PRs). <img width="888" height="786" alt="image" src="https://github.com/user-attachments/assets/cf6d0705-adcf-463c-8e80-6901a652068b" /> <img width="1103" height="713" alt="image" src="https://github.com/user-attachments/assets/b7244592-249a-4149-994f-3a2b750f25f9" />
This commit is contained in:
@@ -1099,6 +1099,25 @@ label = "Regenerate on Startup"
|
||||
description = "Number of days the certificate will be valid"
|
||||
label = "Certificate Validity (days)"
|
||||
|
||||
[admin.settings.folderAccess]
|
||||
description = "Directories that folder sources and folder outputs are allowed to read from and write to. This is a security boundary: automations can never be pointed at a server path outside this list."
|
||||
securityNote = "Leave this empty to disable folder sources and outputs entirely. Stirling's own configuration directory is always off-limits."
|
||||
title = "Folder Access"
|
||||
|
||||
[admin.settings.folderAccess.implied]
|
||||
description = "These Stirling-managed directories are always permitted and can't be changed here."
|
||||
serverStorage = "Server file storage"
|
||||
title = "Always allowed"
|
||||
watchedFolder = "Pipeline watched folder"
|
||||
|
||||
[admin.settings.folderAccess.roots]
|
||||
add = "Add"
|
||||
empty = "No folders allowed. Folder sources and outputs are currently disabled."
|
||||
hint = "Enter absolute paths, for example /data/inbox."
|
||||
label = "Allowed folder roots"
|
||||
placeholder = "/data/inbox"
|
||||
remove = "Remove folder root"
|
||||
|
||||
[admin.settings.general]
|
||||
description = "Configure system-wide application settings including branding and default behavior."
|
||||
system = "System"
|
||||
@@ -8506,6 +8525,11 @@ editTitle = "Edit source"
|
||||
enabled = "Enabled"
|
||||
save = "Save changes"
|
||||
|
||||
[portal.sources.builder.folderAccess]
|
||||
description = "Folder automations can only use folders an administrator has allowed. Add it under Folder Access settings, then try again."
|
||||
openSettings = "Folder Access settings"
|
||||
title = "This folder isn't allowed"
|
||||
|
||||
[portal.sources.delete]
|
||||
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
|
||||
cancel = "Cancel"
|
||||
@@ -9437,6 +9461,7 @@ advanced = "Advanced"
|
||||
database = "Database"
|
||||
endpoints = "Endpoints"
|
||||
features = "Features"
|
||||
folderAccess = "Folder Access"
|
||||
mcp = "MCP Server"
|
||||
storageSharing = "File Storage & Sharing"
|
||||
systemSettings = "System Settings"
|
||||
|
||||
@@ -470,6 +470,11 @@
|
||||
"title": "Admin Storage Sharing Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminFolderAccess": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Folder Access Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminMcp": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Mcp Settings - Stirling PDF",
|
||||
@@ -673,6 +678,7 @@
|
||||
"/settings/adminUsage": "/settings/adminUsage",
|
||||
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
||||
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
||||
"/settings/adminFolderAccess": "/settings/adminFolderAccess",
|
||||
"/settings/adminMcp": "/settings/adminMcp",
|
||||
"/settings/adminAiGeneral": "/settings/adminAiGeneral",
|
||||
"/settings/adminAiModels": "/settings/adminAiModels",
|
||||
|
||||
@@ -471,6 +471,11 @@
|
||||
"title": "Admin Storage Sharing Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminFolderAccess": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Folder Access Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminMcp": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Mcp Settings - Stirling PDF",
|
||||
@@ -686,6 +691,7 @@
|
||||
"/settings/adminUsage": "/settings/adminUsage",
|
||||
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
||||
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
||||
"/settings/adminFolderAccess": "/settings/adminFolderAccess",
|
||||
"/settings/adminMcp": "/settings/adminMcp",
|
||||
"/settings/adminAiGeneral": "/settings/adminAiGeneral",
|
||||
"/settings/adminAiModels": "/settings/adminAiModels",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminUsage",
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"adminFolderAccess",
|
||||
"adminMcp",
|
||||
"adminAiGeneral",
|
||||
"adminAiModels",
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { apiClient, HttpError } from "@portal/api/http";
|
||||
|
||||
/**
|
||||
* Sources service layer: the backend contract.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend marker (on the error body) for a folder source rejected because its
|
||||
* directory falls outside the allowed roots - the one folder-access failure an
|
||||
* admin can fix in the Folder Access settings. Mirrors
|
||||
* SourceController.FOLDER_ACCESS_DENIED_CODE.
|
||||
*/
|
||||
export const FOLDER_ACCESS_DENIED_CODE = "folderAccessDenied";
|
||||
|
||||
/**
|
||||
* True when a source save failed specifically because the folder is outside the
|
||||
* allowed roots (so the caller can point the admin at settings), as opposed to
|
||||
* any other 400 (blank directory, SaaS mode, the protected config dir).
|
||||
*/
|
||||
export function isFolderAccessDeniedError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof HttpError &&
|
||||
typeof error.body === "object" &&
|
||||
error.body !== null &&
|
||||
(error.body as { code?: unknown }).code === FOLDER_ACCESS_DENIED_CODE
|
||||
);
|
||||
}
|
||||
|
||||
/** Overview row status: referenced and enabled, enabled-but-orphaned, or disabled. */
|
||||
export type SourceStatus = "active" | "unused" | "disabled";
|
||||
|
||||
|
||||
@@ -7,10 +7,19 @@ import {
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { SourceBuilder } from "@portal/views/SourceBuilder";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
|
||||
// SourceBuilder reads useUI() to open settings, so wrap in its provider.
|
||||
const Providers = ({ children }: { children: ReactNode }) => (
|
||||
<MantineProvider>
|
||||
<UIProvider>{children}</UIProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
baseRender(ui, { wrapper: Providers });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -22,10 +31,12 @@ vi.mock("react-i18next", () => ({
|
||||
const createSource = vi.fn();
|
||||
const fetchSource = vi.fn();
|
||||
const deleteSource = vi.fn();
|
||||
const isFolderAccessDeniedError = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
createSource: (s: unknown) => createSource(s),
|
||||
fetchSource: (id: string) => fetchSource(id),
|
||||
deleteSource: (id: string) => deleteSource(id),
|
||||
isFolderAccessDeniedError: (e: unknown) => isFolderAccessDeniedError(e),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
@@ -55,6 +66,8 @@ describe("SourceBuilder", () => {
|
||||
fetchSource.mockReset();
|
||||
deleteSource.mockReset();
|
||||
deleteSource.mockResolvedValue(undefined);
|
||||
isFolderAccessDeniedError.mockReset();
|
||||
isFolderAccessDeniedError.mockReturnValue(false);
|
||||
fetchS3Connections.mockReset();
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
});
|
||||
@@ -86,6 +99,54 @@ describe("SourceBuilder", () => {
|
||||
expect(await screen.findByText("sources list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a Folder Access settings link when the folder is outside allowed roots", async () => {
|
||||
createSource.mockRejectedValue(
|
||||
new Error("outside the allowed folder roots"),
|
||||
);
|
||||
isFolderAccessDeniedError.mockReturnValue(true);
|
||||
renderBuilder("/processor/sources/new");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
|
||||
target: { value: "Claims intake" },
|
||||
});
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.folder\.fields\.directory\.label/,
|
||||
),
|
||||
{ target: { value: "/etc" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.sources.builder.create"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("portal.sources.builder.folderAccess.title"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("portal.sources.builder.folderAccess.openSettings"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a plain error banner (no settings link) for other save failures", async () => {
|
||||
createSource.mockRejectedValue(new Error("boom"));
|
||||
isFolderAccessDeniedError.mockReturnValue(false);
|
||||
renderBuilder("/processor/sources/new");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
|
||||
target: { value: "Claims intake" },
|
||||
});
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.folder\.fields\.directory\.label/,
|
||||
),
|
||||
{ target: { value: "/data/incoming" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.sources.builder.create"));
|
||||
|
||||
expect(await screen.findByText("boom")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("portal.sources.builder.folderAccess.openSettings"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates the s3 type on a chosen connection", async () => {
|
||||
renderBuilder("/processor/sources/new");
|
||||
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
createSource,
|
||||
deleteSource,
|
||||
fetchSource,
|
||||
isFolderAccessDeniedError,
|
||||
type Source,
|
||||
} from "@portal/api/sources";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
@@ -69,6 +71,7 @@ function optionsFor(
|
||||
export function SourceBuilder() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { openSettings } = useUI();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const listPath = toPortalPath(VIEW_PATHS.sources);
|
||||
@@ -87,6 +90,9 @@ export function SourceBuilder() {
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A folder-outside-allowed-roots failure: the error banner offers a link to
|
||||
// the Folder Access settings instead of leaving the admin at a dead end.
|
||||
const [folderAccessDenied, setFolderAccessDenied] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [reveal, setReveal] = useState<{
|
||||
@@ -137,6 +143,7 @@ export function SourceBuilder() {
|
||||
if (!canSave) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setFolderAccessDenied(false);
|
||||
try {
|
||||
const saved = await createSource({
|
||||
id: isEdit ? id : undefined,
|
||||
@@ -156,6 +163,7 @@ export function SourceBuilder() {
|
||||
navigate(listPath);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
setFolderAccessDenied(isFolderAccessDeniedError(e));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
@@ -357,7 +365,34 @@ export function SourceBuilder() {
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
{error &&
|
||||
(folderAccessDenied ? (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t(
|
||||
"portal.sources.builder.folderAccess.title",
|
||||
"This folder isn't allowed",
|
||||
)}
|
||||
description={t(
|
||||
"portal.sources.builder.folderAccess.description",
|
||||
"Folder automations can only use folders an administrator has allowed. Add it under Folder Access settings, then try again.",
|
||||
)}
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openSettings("adminFolderAccess")}
|
||||
>
|
||||
{t(
|
||||
"portal.sources.builder.folderAccess.openSettings",
|
||||
"Folder Access settings",
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Banner tone="danger" description={error} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -24,6 +24,7 @@ import AdminAiLimitsSection from "@app/components/shared/config/configSections/A
|
||||
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
|
||||
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
||||
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
||||
import AdminFolderAccessSection from "@app/components/shared/config/configSections/AdminFolderAccessSection";
|
||||
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
|
||||
import AccountSection from "@app/components/shared/config/configSections/AccountSection";
|
||||
import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding";
|
||||
@@ -135,6 +136,14 @@ export const useConfigNavSections = (
|
||||
badge: t("toolPanel.alpha", "Alpha"),
|
||||
badgeColor: "orange",
|
||||
},
|
||||
{
|
||||
key: "adminFolderAccess",
|
||||
label: t("settings.configuration.folderAccess", "Folder Access"),
|
||||
icon: "folder-rounded",
|
||||
component: <AdminFolderAccessSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminEndpoints",
|
||||
label: t("settings.configuration.endpoints", "Endpoints"),
|
||||
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Code,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
interface FolderAccessSettingsData {
|
||||
allowedFolderRoots?: string[];
|
||||
}
|
||||
|
||||
interface ImpliedFolderRoot {
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export default function AdminFolderAccessSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
|
||||
useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<FolderAccessSettingsData>({ sectionName: "policies" });
|
||||
|
||||
const [newRoot, setNewRoot] = useState("");
|
||||
const [impliedRoots, setImpliedRoots] = useState<ImpliedFolderRoot[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginEnabled) return;
|
||||
apiClient
|
||||
.get<ImpliedFolderRoot[]>(
|
||||
"/api/v1/admin/settings/policies/implied-folder-roots",
|
||||
)
|
||||
.then((res) => setImpliedRoots(res.data ?? []))
|
||||
.catch(() => setImpliedRoots([]));
|
||||
}, [loginEnabled]);
|
||||
|
||||
const roots = settings.allowedFolderRoots ?? [];
|
||||
|
||||
const reasonLabel = (reason: string) => {
|
||||
switch (reason) {
|
||||
case "serverStorage":
|
||||
return t(
|
||||
"admin.settings.folderAccess.implied.serverStorage",
|
||||
"Server file storage",
|
||||
);
|
||||
case "watchedFolder":
|
||||
return t(
|
||||
"admin.settings.folderAccess.implied.watchedFolder",
|
||||
"Pipeline watched folder",
|
||||
);
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
};
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const setRoots = useCallback(
|
||||
(next: string[]) => {
|
||||
setSettings({ ...settings, allowedFolderRoots: next });
|
||||
},
|
||||
[settings, setSettings],
|
||||
);
|
||||
|
||||
const addRoot = useCallback(() => {
|
||||
const value = newRoot.trim();
|
||||
if (!value) return;
|
||||
if (roots.includes(value)) {
|
||||
setNewRoot("");
|
||||
return;
|
||||
}
|
||||
setRoots([...roots, value]);
|
||||
setNewRoot("");
|
||||
}, [newRoot, roots, setRoots]);
|
||||
|
||||
const removeRoot = useCallback(
|
||||
(value: string) => {
|
||||
setRoots(roots.filter((root) => root !== value));
|
||||
},
|
||||
[roots, setRoots],
|
||||
);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setSettings(resetToSnapshot());
|
||||
setNewRoot("");
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveSettings();
|
||||
markSaved();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loginEnabled && loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<div className="settings-section-content">
|
||||
<Stack gap="sm">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.folderAccess.title", "Folder Access")}
|
||||
</Text>
|
||||
{isFieldPending("allowedFolderRoots") && (
|
||||
<PendingBadge show={true} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.folderAccess.description",
|
||||
"Directories that folder sources and folder outputs are allowed to read from and write to. This is a security boundary: automations can never be pointed at a server path outside this list.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Alert variant="light" color="blue">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
"admin.settings.folderAccess.securityNote",
|
||||
"Leave this empty to disable folder sources and outputs entirely. Stirling's own configuration directory is always off-limits, and folder access is always disabled in hosted (SaaS) mode.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.folderAccess.roots.label",
|
||||
"Allowed folder roots",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.folderAccess.roots.hint",
|
||||
"Enter absolute paths, for example /data/inbox.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{roots.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" fs="italic">
|
||||
{t(
|
||||
"admin.settings.folderAccess.roots.empty",
|
||||
"No folders allowed. Folder sources and outputs are currently disabled.",
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{roots.map((root) => (
|
||||
<Group
|
||||
key={root}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
>
|
||||
<Code style={{ wordBreak: "break-all" }}>{root}</Code>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
aria-label={t(
|
||||
"admin.settings.folderAccess.roots.remove",
|
||||
"Remove folder root",
|
||||
)}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => removeRoot(root)}
|
||||
disabled={!loginEnabled}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
value={newRoot}
|
||||
onChange={(e) => setNewRoot(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addRoot();
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"admin.settings.folderAccess.roots.placeholder",
|
||||
"/data/inbox",
|
||||
)}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={addRoot}
|
||||
disabled={!loginEnabled || newRoot.trim().length === 0}
|
||||
>
|
||||
{t("admin.settings.folderAccess.roots.add", "Add")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{impliedRoots.length > 0 && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.folderAccess.implied.title",
|
||||
"Always allowed",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.folderAccess.implied.description",
|
||||
"These Stirling-managed directories are always permitted and can't be changed here.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
{impliedRoots.map((root) => (
|
||||
<Group
|
||||
key={root.path}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
align="center"
|
||||
>
|
||||
<Code style={{ wordBreak: "break-all" }}>
|
||||
{root.path}
|
||||
</Code>
|
||||
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{reasonLabel(root.reason)}
|
||||
</Text>
|
||||
<LocalIcon icon="lock" width="1rem" height="1rem" />
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -121,8 +121,8 @@ export default function AdminStorageSharingSection() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
markSaved();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
|
||||
Reference in New Issue
Block a user