Files
govoplan-files/webui/src/features/files/FileConnectorSettingsPanel.tsx

1762 lines
83 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Edit3, Plus, RefreshCw, Save, ShieldCheck, Trash2, UserRoundKey } from "lucide-react";
import {
Button,
Card,
ConnectionTree,
ConfirmDialog,
CredentialPanel,
DisabledActionTooltip,
DismissibleAlert,
Dialog,
FormField,
ActionBlockerHint,
AdvancedOptionsPanel,
LoadingFrame,
mergeDeltaRows,
SegmentedControl,
StatusBadge,
TableActionGroup,
ToggleSwitch,
useDeltaWatermarks,
useUnsavedDraftGuard,
type ApiSettings,
type ConnectionTreeColumn,
type FileConnectorScope,
type FileConnectorTargetOption,
i18nMessage } from
"@govoplan/core-webui";
import {
browseFileConnectorProfile,
createFileConnectorCredential,
createFileConnectorProfile,
deactivateFileConnectorCredential,
deactivateFileConnectorProfile,
discoverFileConnectorEndpoint,
fetchFileConnectorSettingsDelta,
updateFileConnectorPolicy,
updateFileConnectorCredential,
updateFileConnectorProfile,
type FileConnectorCredential,
type FileConnectorCredentialPayload,
type FileConnectorCredentialUpdatePayload,
type FileConnectorPolicyResponse,
type FileConnectorProfile,
type FileConnectorProfilePayload,
type FileConnectorProfileUpdatePayload } from
"../../api/files";
type ConnectorScope = FileConnectorScope;
type Provider = FileConnectorProfilePayload["provider"];
type CredentialMode = NonNullable<FileConnectorProfilePayload["credential_mode"]>;
type PolicyMode = "allow-provider" | "allowed-paths" | "deny-provider";
type ConnectorTreeRow =
{kind: "profile";id: string;profile: FileConnectorProfile;} |
{kind: "credential";id: string;credential: FileConnectorCredential;profile: FileConnectorProfile;};
type ConnectorProfileDraft = {
id: string;
label: string;
provider: Provider;
endpointUrl: string;
basePath: string;
enabled: boolean;
credentialProfileId: string;
credentialMode: CredentialMode;
username: string;
password: string;
token: string;
secretRef: string;
canBrowse: boolean;
canImport: boolean;
canSync: boolean;
policyMode: PolicyMode;
allowedPaths: string;
deniedPaths: string;
requireSigning: boolean;
authProtocol: string;
browseProtocol: string;
metadataJson: string;
clearPassword: boolean;
clearToken: boolean;
};
type ConnectorCredentialDraft = {
id: string;
label: string;
provider: Provider | "";
enabled: boolean;
credentialMode: CredentialMode;
username: string;
password: string;
token: string;
secretRef: string;
policyMode: PolicyMode;
allowedPaths: string;
deniedPaths: string;
metadataJson: string;
clearPassword: boolean;
clearToken: boolean;
};
type CentralConnectorPolicyDraft = {
allowedConnectors: string;
allowedCredentials: string;
allowedProviders: string;
allowedPaths: string;
allowedUrls: string;
deniedConnectors: string;
deniedCredentials: string;
deniedProviders: string;
deniedPaths: string;
deniedUrls: string;
allowLowerConnectionLimits: boolean;
allowLowerCredentialLimits: boolean;
allowLowerProviderLimits: boolean;
allowLowerPathLimits: boolean;
allowLowerUrlLimits: boolean;
};
const PROVIDERS: Array<{id: Provider;label: string;}> = [
{ id: "webdav", label: "i18n:govoplan-files.webdav.521b4c8b" },
{ id: "nextcloud", label: "i18n:govoplan-files.nextcloud.aab73a3d" },
{ id: "seafile", label: "i18n:govoplan-files.seafile.600192cc" },
{ id: "smb", label: "i18n:govoplan-files.smb.515d2f88" },
{ id: "s3", label: "S3" },
{ id: "sharepoint", label: "SharePoint" },
{ id: "onedrive", label: "OneDrive" },
{ id: "nfs", label: "i18n:govoplan-files.nfs.db121865" },
{ id: "dms", label: "i18n:govoplan-files.dms.477e5652" },
{ id: "generic", label: "i18n:govoplan-files.generic.ff7613e5" }];
const ENDPOINT_PLACEHOLDERS: Record<Provider, string> = {
webdav: "http://127.0.0.1:9083/",
nextcloud: "http://127.0.0.1:9081/remote.php/dav/files/admin/",
seafile: "http://127.0.0.1:9082/",
smb: "smb://127.0.0.1:1445/files",
s3: "http://127.0.0.1:9000",
sharepoint: "https://graph.microsoft.com/v1.0/sites/{site-id}/drives/{drive-id}",
onedrive: "https://graph.microsoft.com/v1.0/drives/{drive-id}",
nfs: "nfs://127.0.0.1/export",
dms: "https://dms.example.test",
generic: "https://files.example.test"
};
export default function FileConnectorSettingsPanel({
settings,
scopeType,
scopeId = null,
targetOptions = [],
targetLabel = "i18n:govoplan-files.target.61ad50a9",
title,
canWrite
}: {settings: ApiSettings;scopeType: ConnectorScope;scopeId?: string | null;targetOptions?: FileConnectorTargetOption[];targetLabel?: string;title?: string;canWrite: boolean;}) {
const [profiles, setProfiles] = useState<FileConnectorProfile[]>([]);
const [credentials, setCredentials] = useState<FileConnectorCredential[]>([]);
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const [policyResponse, setPolicyResponse] = useState<FileConnectorPolicyResponse | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<ConnectorProfileDraft | null>(null);
const [savedDraftKey, setSavedDraftKey] = useState("");
const [credentialDraft, setCredentialDraft] = useState<ConnectorCredentialDraft | null>(null);
const [savedCredentialDraftKey, setSavedCredentialDraftKey] = useState("");
const [credentialAttachProfileId, setCredentialAttachProfileId] = useState<string | null>(null);
const [policyDraft, setPolicyDraft] = useState<CentralConnectorPolicyDraft>(emptyCentralPolicyDraft());
const [savedPolicyDraftKey, setSavedPolicyDraftKey] = useState(centralPolicyDraftKey(emptyCentralPolicyDraft()));
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
const [editingCredentialId, setEditingCredentialId] = useState<string | null>(null);
const [pendingDeactivate, setPendingDeactivate] = useState<FileConnectorProfile | null>(null);
const [pendingCredentialDeactivate, setPendingCredentialDeactivate] = useState<FileConnectorCredential | null>(null);
const [testingProfileId, setTestingProfileId] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, {ok: boolean;message: string;}>>({});
const [discoveringProfile, setDiscoveringProfile] = useState(false);
const [profileDiscoveryResult, setProfileDiscoveryResult] = useState<{ok: boolean;message: string;} | null>(null);
const [testingCredentialLogin, setTestingCredentialLogin] = useState(false);
const [credentialLoginResult, setCredentialLoginResult] = useState<{ok: boolean;message: string;} | null>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const requiresTarget = scopeType === "user" || scopeType === "group";
const targetSelectionRequired = requiresTarget && !scopeId;
const hasSelectableTarget = targetOptions.length > 0;
const activeScopeId = scopeId || selectedTargetId || null;
const deltaKey = `files:connectors:${scopeType}:${activeScopeId ?? ""}`;
const scopeReady = !requiresTarget || Boolean(activeScopeId);
const targetEmptyText = i18nMessage("i18n:govoplan-files.no_value_available", { value0: targetPluralLabel(scopeType, targetLabel) });
const profileDirty = Boolean(draft) && connectorProfileDraftKey(draft) !== savedDraftKey;
const credentialDirty = Boolean(credentialDraft) && connectorCredentialDraftKey(credentialDraft) !== savedCredentialDraftKey;
const policyDirty = scopeReady && centralPolicyDraftKey(policyDraft) !== savedPolicyDraftKey;
useUnsavedDraftGuard({
dirty: profileDirty || credentialDirty || policyDirty,
onSave: saveDirtyDrafts,
onDiscard: discardDirtyDrafts
});
useEffect(() => {
if (scopeId) {
setSelectedTargetId(scopeId);
return;
}
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
setSelectedTargetId(targetOptions[0].id);
return;
}
if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId("");
}, [scopeId, selectedTargetId, targetOptions]);
const scopedProfiles = useMemo(
() => profiles.filter((profile) => connectorBelongsToEditableScope(profile, scopeType, activeScopeId)),
[activeScopeId, profiles, scopeType]
);
const scopedCredentials = useMemo(
() => credentials.filter((credential) => connectorBelongsToEditableScope(credential, scopeType, activeScopeId)),
[activeScopeId, credentials, scopeType]
);
const selectableCredentials = useMemo(
() => credentials.filter((credential) =>
connectorBelongsToEditableScope(credential, scopeType, activeScopeId) ||
credential.source_kind === "credential_envelope" &&
reusableCredentialAvailableAtScope(credential, scopeType, activeScopeId)
),
[activeScopeId, credentials, scopeType]
);
useEffect(() => {
resetDeltaWatermark(deltaKey);
void loadProfiles(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, scopeType, activeScopeId, scopeReady, deltaKey, resetDeltaWatermark]);
async function loadProfiles(forceFull = false) {
setLoading(true);
setError("");
if (!scopeReady) {
setProfiles([]);
setCredentials([]);
setPolicyResponse(null);
setPolicyDraft(emptyCentralPolicyDraft());
setSavedPolicyDraftKey(centralPolicyDraftKey(emptyCentralPolicyDraft()));
resetDeltaWatermark(deltaKey);
setLoading(false);
return;
}
try {
let nextProfiles = forceFull ? [] : profiles;
let nextCredentials = forceFull ? [] : credentials;
let nextPolicyResponse = forceFull ? null : policyResponse;
let nextWatermark = forceFull ? null : getDeltaWatermark(deltaKey);
let hasMore = false;
do {
const response = await fetchFileConnectorSettingsDelta(settings, {
scope_type: scopeType,
scope_id: activeScopeId,
include_disabled: true,
since: nextWatermark
});
nextProfiles = response.full ?
response.profiles :
mergeDeltaRows(nextProfiles, response.profiles, response.deleted, (profile) => profile.id, {
deletedResourceType: "file_connector_profile",
sort: sortConnectorProfiles
});
nextCredentials = response.full ?
response.credentials :
mergeDeltaRows(nextCredentials, response.credentials, response.deleted, (credential) => credential.id, {
deletedResourceType: "file_connector_credential",
sort: sortConnectorCredentials
});
if (response.policy) nextPolicyResponse = response.policy;
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setProfiles(nextProfiles);
setCredentials(nextCredentials);
setPolicyResponse(nextPolicyResponse);
if (nextPolicyResponse) {
const nextPolicyDraft = centralPolicyDraftFromPolicy(nextPolicyResponse.policy);
setPolicyDraft(nextPolicyDraft);
setSavedPolicyDraftKey(centralPolicyDraftKey(nextPolicyDraft));
}
setDeltaWatermark(deltaKey, nextWatermark);
} catch (err) {
resetDeltaWatermark(deltaKey);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
function startCreate() {
setEditingProfileId(null);
const nextDraft = emptyDraft(scopeType);
setDraft(nextDraft);
setSavedDraftKey(connectorProfileDraftKey(nextDraft));
setProfileDiscoveryResult(null);
setMessage("");
setError("");
}
function startCredentialCreate(profile: FileConnectorProfile) {
setEditingCredentialId(null);
setCredentialAttachProfileId(profile.id);
const next = emptyCredentialDraft(scopeType);
next.id = `${profile.id}-credentials`;
next.label = `${profile.label} credentials`;
next.provider = knownProvider(profile.provider);
next.credentialMode = knownCredentialMode(profile.credential_mode);
setCredentialDraft(next);
setSavedCredentialDraftKey(connectorCredentialDraftKey(next));
setCredentialLoginResult(null);
setMessage("");
setError("");
}
function startEdit(profile: FileConnectorProfile) {
setEditingProfileId(profile.id);
const nextDraft = draftFromProfile(profile, scopeType);
setDraft(nextDraft);
setSavedDraftKey(connectorProfileDraftKey(nextDraft));
setProfileDiscoveryResult(null);
setMessage("");
setError("");
}
function startCredentialEdit(credential: FileConnectorCredential) {
setEditingCredentialId(credential.id);
setCredentialAttachProfileId(null);
const nextDraft = credentialDraftFromCredential(credential, scopeType);
setCredentialDraft(nextDraft);
setSavedCredentialDraftKey(connectorCredentialDraftKey(nextDraft));
setCredentialLoginResult(null);
setMessage("");
setError("");
}
function patchDraft(patch: Partial<ConnectorProfileDraft>) {
setDraft((current) => current ? { ...current, ...patch } : current);
}
function patchCredentialDraft(patch: Partial<ConnectorCredentialDraft>) {
setCredentialLoginResult(null);
setCredentialDraft((current) => current ? { ...current, ...patch } : current);
}
function patchCredentialValues(patch: {username?: string | null;password?: string | null;}) {
const next: Partial<ConnectorCredentialDraft> = {};
if (patch.username !== undefined) next.username = String(patch.username ?? "");
if (patch.password !== undefined) next.password = String(patch.password ?? "");
patchCredentialDraft(next);
}
function patchCredentialToken(patch: {password?: string | null;}) {
if (patch.password !== undefined) patchCredentialDraft({ token: String(patch.password ?? "") });
}
function patchPolicyDraft(patch: Partial<CentralConnectorPolicyDraft>) {
setPolicyDraft((current) => ({ ...current, ...patch }));
}
function closeProfileDialog() {
if (saving) return;
setDraft(null);
setEditingProfileId(null);
setProfileDiscoveryResult(null);
}
function closeCredentialDialog() {
if (saving) return;
setCredentialDraft(null);
setEditingCredentialId(null);
setCredentialAttachProfileId(null);
setCredentialLoginResult(null);
}
async function savePolicyDraft(): Promise<boolean> {
if (!canWrite || !scopeReady) return false;
setSaving(true);
setError("");
try {
const response = await updateFileConnectorPolicy(settings, scopeType, centralPolicyFromDraft(policyDraft), activeScopeId);
setPolicyResponse(response);
const savedPolicyDraft = centralPolicyDraftFromPolicy(response.policy);
setPolicyDraft(savedPolicyDraft);
setSavedPolicyDraftKey(centralPolicyDraftKey(savedPolicyDraft));
setMessage(i18nMessage("i18n:govoplan-files.value_connector_policy_saved.430d4eca", { value0: scopeLabel(scopeType) }));
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setSaving(false);
}
}
async function saveDraft(): Promise<boolean> {
if (!draft || !canWrite || !scopeReady) return false;
const label = draft.label.trim();
const id = draft.id.trim();
if (!label || !editingProfileId && !id) {
setError("i18n:govoplan-files.profile_id_and_label_are_required.6ad85df1");
return false;
}
let metadata: Record<string, unknown>;
try {
metadata = metadataFromDraft(draft);
} catch (err) {
setError(errorMessage(err));
return false;
}
const capabilities = [
draft.canBrowse ? "browse" : "",
draft.canImport ? "import" : "",
draft.canSync ? "sync" : ""].
filter(Boolean);
const sharedPayload = {
label,
provider: draft.provider,
endpoint_url: cleanOrNull(draft.endpointUrl),
base_path: cleanOrNull(draft.basePath),
enabled: draft.enabled,
credential_profile_id: cleanOrNull(credentialProfileIdForDraft(draft, selectableCredentials)),
credential_mode: draft.credentialMode,
capabilities,
policy: policyFromDraft(draft),
metadata
};
setSaving(true);
setError("");
try {
if (editingProfileId) {
const payload: FileConnectorProfileUpdatePayload = {
...sharedPayload,
clear_password: draft.clearPassword,
clear_token: draft.clearToken
};
await updateFileConnectorProfile(settings, editingProfileId, payload);
} else {
const payload: FileConnectorProfilePayload = {
id,
scope_type: scopeType,
scope_id: activeScopeId,
...sharedPayload
};
await createFileConnectorProfile(settings, payload);
}
setMessage(editingProfileId ? "i18n:govoplan-files.file_connection_saved.68c16ee9" : "i18n:govoplan-files.file_connection_created.bdf6e1f6");
setDraft(null);
setSavedDraftKey("");
setEditingProfileId(null);
await loadProfiles();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setSaving(false);
}
}
async function saveCredentialDraft(): Promise<boolean> {
if (!credentialDraft || !canWrite || !scopeReady) return false;
if (!editingCredentialId && !credentialAttachProfileId) {
setError("Create credentials from a connection row so the credential is attached to a file connection.");
return false;
}
const label = credentialDraft.label.trim();
const id = credentialDraft.id.trim();
if (!label || !editingCredentialId && !id) {
setError("i18n:govoplan-files.credential_id_and_label_are_required.4cfd1ffd");
return false;
}
let metadata: Record<string, unknown>;
try {
metadata = metadataFromJson(credentialDraft.metadataJson);
} catch (err) {
setError(errorMessage(err));
return false;
}
const credentials = {
username: cleanOrNull(credentialDraft.username),
password: cleanOrUndefined(credentialDraft.password),
token: cleanOrUndefined(credentialDraft.token),
secret_ref: cleanOrNull(credentialDraft.secretRef)
};
const sharedPayload = {
label,
provider: credentialDraft.provider || null,
enabled: credentialDraft.enabled,
credential_mode: credentialDraft.credentialMode,
credentials,
policy: credentialPolicyFromDraft(credentialDraft),
metadata
};
setSaving(true);
setError("");
try {
if (editingCredentialId) {
const payload: FileConnectorCredentialUpdatePayload = {
...sharedPayload,
clear_password: credentialDraft.clearPassword,
clear_token: credentialDraft.clearToken
};
await updateFileConnectorCredential(settings, editingCredentialId, payload);
} else {
const payload: FileConnectorCredentialPayload = {
id,
scope_type: scopeType,
scope_id: activeScopeId,
...sharedPayload
};
await createFileConnectorCredential(settings, payload);
if (credentialAttachProfileId) {
await updateFileConnectorProfile(settings, credentialAttachProfileId, {
credential_profile_id: id,
credential_mode: credentialDraft.credentialMode
});
}
}
setMessage(editingCredentialId ? "i18n:govoplan-files.file_credential_saved.d6a1eef8" : "i18n:govoplan-files.file_credential_created.87c31882");
setCredentialDraft(null);
setSavedCredentialDraftKey("");
setEditingCredentialId(null);
setCredentialAttachProfileId(null);
await loadProfiles();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setSaving(false);
}
}
async function saveDirtyDrafts(): Promise<boolean> {
let ok = true;
if (policyDirty) ok = await savePolicyDraft() && ok;
if (credentialDirty) ok = await saveCredentialDraft() && ok;
if (profileDirty) ok = await saveDraft() && ok;
return ok;
}
function discardDirtyDrafts() {
setDraft(null);
setSavedDraftKey("");
setEditingProfileId(null);
setCredentialDraft(null);
setSavedCredentialDraftKey("");
setEditingCredentialId(null);
setCredentialAttachProfileId(null);
setPolicyDraft(JSON.parse(savedPolicyDraftKey) as CentralConnectorPolicyDraft);
}
async function confirmDeactivate() {
if (!pendingDeactivate) return;
const profile = pendingDeactivate;
setSaving(true);
setError("");
try {
await deactivateFileConnectorProfile(settings, profile.id);
setMessage(i18nMessage("i18n:govoplan-files.file_connection_disabled_value.059a7de9", { value0: profile.label }));
setPendingDeactivate(null);
await loadProfiles();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function confirmCredentialDeactivate() {
if (!pendingCredentialDeactivate) return;
const credential = pendingCredentialDeactivate;
setSaving(true);
setError("");
try {
await deactivateFileConnectorCredential(settings, credential.id);
setMessage(i18nMessage("i18n:govoplan-files.file_credential_disabled_value.947538fd", { value0: credential.label }));
setPendingCredentialDeactivate(null);
await loadProfiles();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
async function testBrowse(profile: FileConnectorProfile) {
setTestingProfileId(profile.id);
setTestResults((current) => ({ ...current, [profile.id]: { ok: true, message: "i18n:govoplan-files.testing.15ccc832" } }));
try {
const response = await browseFileConnectorProfile(settings, profile.id, { path: "" });
setTestResults((current) => ({
...current,
[profile.id]: { ok: true, message: i18nMessage("i18n:govoplan-files.browse_ok_value_item_value.921906fd", { value0: response.items.length, value1: response.items.length === 1 ? "" : "s" }) }
}));
} catch (err) {
setTestResults((current) => ({ ...current, [profile.id]: { ok: false, message: errorMessage(err) } }));
} finally {
setTestingProfileId(null);
}
}
async function discoverProfileEndpoint() {
if (!draft) return;
const endpointUrl = draft.endpointUrl.trim();
if (!endpointUrl) {
setProfileDiscoveryResult({ ok: false, message: "Enter a server URL before running discovery." });
return;
}
setDiscoveringProfile(true);
setProfileDiscoveryResult(null);
try {
const response = await discoverFileConnectorEndpoint(settings, {
provider: draft.provider,
endpoint_url: endpointUrl,
base_path: cleanOrNull(draft.basePath),
credential_mode: draft.credentialMode,
credentials: {
username: cleanOrNull(draft.username),
password: cleanOrUndefined(draft.password),
token: cleanOrUndefined(draft.token),
secret_ref: cleanOrNull(draft.secretRef)
},
metadata: metadataFromDraft(draft)
});
if (response.endpoint_url) {
patchDraft({
endpointUrl: response.endpoint_url,
basePath: response.base_path ?? draft.basePath,
metadataJson: JSON.stringify(response.metadata ?? metadataFromDraft(draft), null, 2)
});
}
setProfileDiscoveryResult({
ok: response.status === "usable" || response.status === "found",
message: response.message
});
} catch (err) {
setProfileDiscoveryResult({ ok: false, message: errorMessage(err) });
} finally {
setDiscoveringProfile(false);
}
}
async function testCredentialLogin() {
if (!credentialDraft) return;
const profile = credentialTestProfile;
if (!profile) {
setCredentialLoginResult({ ok: false, message: "Create or select a file connection before testing this credential." });
return;
}
if (profile.provider !== "webdav" && profile.provider !== "nextcloud") {
setCredentialLoginResult({ ok: false, message: "Credential login testing is currently available for WebDAV and Nextcloud connections." });
return;
}
setTestingCredentialLogin(true);
setCredentialLoginResult(null);
try {
const response = await discoverFileConnectorEndpoint(settings, {
provider: knownProvider(profile.provider),
endpoint_url: profile.endpoint_url || "",
base_path: profile.base_path || "",
credential_mode: credentialDraft.credentialMode,
credentials: {
username: cleanOrNull(credentialDraft.username),
password: cleanOrUndefined(credentialDraft.password),
token: cleanOrUndefined(credentialDraft.token),
secret_ref: cleanOrNull(credentialDraft.secretRef)
},
metadata: profile.metadata ?? {},
require_valid_credentials: true
});
const ok = response.status === "usable" || response.status === "found";
setCredentialLoginResult({
ok,
message: ok ? "Login successful. The credentials can browse the selected connection." : response.message
});
} catch (err) {
setCredentialLoginResult({ ok: false, message: errorMessage(err) });
} finally {
setTestingCredentialLogin(false);
}
}
const panelTitle = title ?? i18nMessage("i18n:govoplan-files.value_file_connections", { value0: scopeLabel(scopeType) });
const selectedCredentialIds = new Set(scopedProfiles.map((profile) => profile.credential_profile_id).filter((value): value is string => Boolean(value)));
const firstCompatibleProfileByCredential = new Map<string, string>();
for (const credential of selectableCredentials) {
const selectedProfile = scopedProfiles.find((profile) => profile.credential_profile_id === credential.id);
const fallbackProfile = selectedProfile ?? scopedProfiles.find((profile) => !selectedCredentialIds.has(credential.id) && credentialMatchesProfile(credential, profile));
if (fallbackProfile) firstCompatibleProfileByCredential.set(credential.id, fallbackProfile.id);
}
const connectorRows: ConnectorTreeRow[] = scopedProfiles.map((profile) => ({ kind: "profile", id: `profile:${profile.id}`, profile }));
const credentialAttachedProfile = credentialAttachProfileId ? scopedProfiles.find((profile) => profile.id === credentialAttachProfileId) ?? null : null;
const credentialTestProfile = credentialDraft ?
credentialAttachedProfile ??
(editingCredentialId ? scopedProfiles.find((profile) => profile.credential_profile_id === editingCredentialId) ?? scopedProfiles.find((profile) => credentialDraft.provider ? profile.provider === credentialDraft.provider : true) ?? null : null) :
null;
const profileMissingFields = profileDraftMissingFields(draft, editingProfileId);
const credentialMissingFields = credentialDraftMissingFields(credentialDraft, editingCredentialId, credentialAttachProfileId);
const profileMissingRequired = profileMissingFields.length > 0;
const credentialMissingRequired = credentialMissingFields.length > 0;
const profileSaveDisabled = !draft || saving || profileMissingRequired;
const credentialSaveDisabled = !credentialDraft || saving || credentialMissingRequired;
const profileSaveTooltip = requiredFieldsTooltip(profileMissingFields);
const credentialSaveTooltip = requiredFieldsTooltip(credentialMissingFields);
const canDiscoverProfileEndpoint = Boolean(draft && (draft.provider === "webdav" || draft.provider === "nextcloud"));
const credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud");
const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported;
const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported);
const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(selectableCredentials, draft) : [];
const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : "";
const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [
{
id: "connection",
header: "i18n:govoplan-files.connection_credential.178babe0",
width: "minmax(260px, 1.5fr)",
render: (row) => row.kind === "profile" ?
<ConnectorProfileCell profile={row.profile} result={testResults[row.profile.id]} /> :
<ConnectorCredentialCell credential={row.credential} selected={row.profile.credential_profile_id === row.credential.id} />
},
{
id: "endpoint",
header: "i18n:govoplan-files.endpoint.92ec6350",
width: "minmax(190px, 1fr)",
render: (row) => row.kind === "profile" ?
<span>{row.profile.endpoint_url || "i18n:govoplan-files.no_endpoint.d0ea68c4"}</span> :
<span>{row.credential.provider ? providerLabel(row.credential.provider) : "i18n:govoplan-files.any_provider.b3fc542f"}</span>
},
{
id: "policy",
header: "i18n:govoplan-files.policy.bb9cf141",
width: "minmax(160px, 0.8fr)",
render: (row) => <span>{connectorPolicySummary(row)}</span>
},
{
id: "status",
header: "i18n:govoplan-files.status.bae7d5be",
width: "120px",
render: (row) => {
const enabled = row.kind === "profile" ? row.profile.enabled : row.credential.enabled;
return <StatusBadge status={enabled ? "success" : "inactive"} label={enabled ? "i18n:govoplan-files.enabled.df174a3f" : "i18n:govoplan-files.disabled.f4f4473d"} />;
}
}];
function connectorChildren(row: ConnectorTreeRow): ConnectorTreeRow[] {
if (row.kind !== "profile") return [];
return selectableCredentials.
filter((credential) => firstCompatibleProfileByCredential.get(credential.id) === row.profile.id).
map((credential) => ({ kind: "credential" as const, id: `credential:${row.profile.id}:${credential.id}`, credential, profile: row.profile }));
}
function renderConnectorActions(row: ConnectorTreeRow) {
if (row.kind === "credential") {
const readOnly = row.credential.source_kind !== "database";
return <TableActionGroup actions={[
{ id: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.credential.label }), icon: <Edit3 size={15} />, disabled: !canWrite || readOnly || saving, onClick: () => startCredentialEdit(row.credential) },
{ id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.credential.label }), icon: <Trash2 size={15} />, variant: "danger", applicable: row.credential.enabled, disabled: !canWrite || readOnly || saving, onClick: () => setPendingCredentialDeactivate(row.credential) }
]} />;
}
const readOnly = row.profile.source_kind !== "database";
return <TableActionGroup actions={[
{ id: "test", label: i18nMessage("i18n:govoplan-files.test_value.6a5d10a5", { value0: row.profile.label }), icon: <RefreshCw size={15} />, applicable: row.profile.enabled, disabled: saving || testingProfileId === row.profile.id, onClick: () => void testBrowse(row.profile) },
{ id: "add-credential", label: i18nMessage("i18n:govoplan-files.add_credential_for_value.0fa9c1fe", { value0: row.profile.label }), icon: <UserRoundKey size={15} />, variant: "primary", disabled: !canWrite || saving, onClick: () => startCredentialCreate(row.profile) },
{ id: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.profile.label }), icon: <Edit3 size={15} />, disabled: !canWrite || readOnly || saving, onClick: () => startEdit(row.profile) },
{ id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.profile.label }), icon: <Trash2 size={15} />, variant: "danger", applicable: row.profile.enabled, disabled: !canWrite || readOnly || saving, onClick: () => setPendingDeactivate(row.profile) }
]} />;
}
return (
<div className="file-connector-settings-panel">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && !error && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
{!canWrite &&
<ActionBlockerHint
tone="info"
reason={{
summary: "You can review file connectors here, but cannot change them from this scope.",
details: "Create, edit, disable, and policy-save actions need a role with connector administration rights.",
requiredAction: "Ask an administrator to grant the required permission or make the change at a higher scope.",
actor: "System or tenant administrator",
target: "Administration > GLOBAL or TENANT > File Connectors"
}} />
}
{targetSelectionRequired && !hasSelectableTarget &&
<ActionBlockerHint
tone="warning"
reason={{
summary: "No target is available for this file connector scope.",
details: "User and group connector settings need a concrete user or group before connections and policies can be loaded.",
requiredAction: "Create or select a user or group first.",
actor: "Access administrator",
target: targetPluralLabel(scopeType, targetLabel)
}} />
}
{targetSelectionRequired &&
<Card title={i18nMessage("i18n:govoplan-files.value_scope", { value0: targetLabel })}>
<div className="settings-target-row">
<FormField label={targetLabel}>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)} disabled={loading || saving || !hasSelectableTarget}>
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? i18nMessage("i18n:govoplan-files.value_value.c189e8bc", { value0: option.label, value1: option.secondary }) : option.label}</option>)}
</select>
</FormField>
</div>
</Card>
}
{scopeReady &&
<>
<Card
title={panelTitle}
actions={
canWrite ?
<div className="button-row compact-actions">
<Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "i18n:govoplan-files.loading.b04ba49f" : "i18n:govoplan-files.reload.cce71553"}</Button>
<Button variant="primary" onClick={startCreate} disabled={saving}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.new_connection.ac979fe4</Button>
</div> :
null
}>
<LoadingFrame loading={loading} label="i18n:govoplan-files.loading_file_connections.bd68f224">
<ConnectionTree
rows={connectorRows}
columns={connectorColumns}
getRowKey={(row) => row.id}
getChildren={connectorChildren}
renderActions={renderConnectorActions}
emptyText="i18n:govoplan-files.no_file_connections_configured.3ef02049" />
</LoadingFrame>
</Card>
<Card
title={i18nMessage("i18n:govoplan-files.value_connector_policy.0bf7f53b", { value0: scopeLabel(scopeType) })}
actions={canWrite ?
<Button variant="primary" onClick={() => void savePolicyDraft()} disabled={saving || loading}>
<ShieldCheck size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_policy.77d67ce3"}
</Button> :
null}>
<LoadingFrame loading={loading} label="i18n:govoplan-files.loading_connector_policy.f12ab285">
<>
<div className="form-grid two">
<FormField label="i18n:govoplan-files.allowed_connection_ids.0df854c8">
<textarea value={policyDraft.allowedConnectors} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedConnectors: event.target.value })} rows={3} placeholder={"tenant-webdav\nseafile-*"} />
</FormField>
<FormField label="i18n:govoplan-files.denied_connection_ids.a95218b4">
<textarea value={policyDraft.deniedConnectors} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedConnectors: event.target.value })} rows={3} placeholder={"legacy-*\nblocked-smb"} />
</FormField>
<FormField label="i18n:govoplan-files.allowed_credential_ids.efcaba2d">
<textarea value={policyDraft.allowedCredentials} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedCredentials: event.target.value })} rows={3} placeholder={"tenant-webdav-credentials\nshared-*"} />
</FormField>
<FormField label="i18n:govoplan-files.denied_credential_ids.b6838bc2">
<textarea value={policyDraft.deniedCredentials} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedCredentials: event.target.value })} rows={3} placeholder={"personal-*\nblocked-*"} />
</FormField>
<FormField label="i18n:govoplan-files.allowed_providers.82a9c23e">
<textarea value={policyDraft.allowedProviders} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedProviders: event.target.value })} rows={3} placeholder={"webdav\nnextcloud\nsmb"} />
</FormField>
<FormField label="i18n:govoplan-files.denied_providers.149b29f4">
<textarea value={policyDraft.deniedProviders} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedProviders: event.target.value })} rows={3} placeholder={"generic\nnfs"} />
</FormField>
<FormField label="i18n:govoplan-files.allowed_paths.c953b4be">
<textarea value={policyDraft.allowedPaths} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedPaths: event.target.value })} rows={3} placeholder={"GovOPlaN\nreports/*"} />
</FormField>
<FormField label="i18n:govoplan-files.denied_paths.d25e4315">
<textarea value={policyDraft.deniedPaths} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedPaths: event.target.value })} rows={3} placeholder={"private\narchive/closed"} />
</FormField>
<FormField label="i18n:govoplan-files.allowed_endpoint_urls.34cfa8e9">
<textarea value={policyDraft.allowedUrls} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedUrls: event.target.value })} rows={3} placeholder={"https://files.example.test/*\nsmb://127.0.0.1:1445/files"} />
</FormField>
<FormField label="i18n:govoplan-files.denied_endpoint_urls.1d8d4369">
<textarea value={policyDraft.deniedUrls} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedUrls: event.target.value })} rows={3} placeholder={"http://*\n*://legacy.example.test/*"} />
</FormField>
</div>
<div className="file-connector-settings-section file-connector-policy-locks">
<ToggleSwitch label="i18n:govoplan-files.lower_connection_limits.4f9838bc" checked={policyDraft.allowLowerConnectionLimits} disabled={saving || !canWrite} onChange={(allowLowerConnectionLimits) => patchPolicyDraft({ allowLowerConnectionLimits })} />
<ToggleSwitch label="i18n:govoplan-files.lower_credential_limits.ec2b72bc" checked={policyDraft.allowLowerCredentialLimits} disabled={saving || !canWrite} onChange={(allowLowerCredentialLimits) => patchPolicyDraft({ allowLowerCredentialLimits })} />
<ToggleSwitch label="i18n:govoplan-files.lower_provider_limits.5816270a" checked={policyDraft.allowLowerProviderLimits} disabled={saving || !canWrite} onChange={(allowLowerProviderLimits) => patchPolicyDraft({ allowLowerProviderLimits })} />
<ToggleSwitch label="i18n:govoplan-files.lower_path_limits.1abcccb1" checked={policyDraft.allowLowerPathLimits} disabled={saving || !canWrite} onChange={(allowLowerPathLimits) => patchPolicyDraft({ allowLowerPathLimits })} />
<ToggleSwitch label="i18n:govoplan-files.lower_endpoint_limits.3fd781d5" checked={policyDraft.allowLowerUrlLimits} disabled={saving || !canWrite} onChange={(allowLowerUrlLimits) => patchPolicyDraft({ allowLowerUrlLimits })} />
</div>
{policyResponse?.effective_policy_sources?.length ?
<div className="file-connector-policy-sources">
i18n:govoplan-files.effective_sources.9a576802 {policyResponse.effective_policy_sources.map((source) => i18nMessage("i18n:govoplan-files.value_value.c189e8bc", { value0: source.label, value1: source.applied_fields.join(", ") || "metadata" })).join(" · ")}
</div> :
<div className="file-connector-policy-sources muted">i18n:govoplan-files.effective_sources_none.9a7c407f</div>
}
</>
</LoadingFrame>
</Card>
<Dialog
open={Boolean(credentialDraft)}
title={editingCredentialId ? "i18n:govoplan-files.edit_file_credential.bc49d44f" : "i18n:govoplan-files.new_file_credential.4c061082"}
onClose={closeCredentialDialog}
className="admin-dialog admin-dialog-wide file-connector-dialog adaptive-config-dialog"
bodyClassName="adaptive-config-dialog-body"
closeDisabled={saving}
footer={credentialDraft ?
<>
<Button onClick={closeCredentialDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
<DisabledActionTooltip reason={credentialSaveTooltip}>
<Button variant="primary" onClick={() => void saveCredentialDraft()} disabled={credentialSaveDisabled}>
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_credential.2c02a6d2"}
</Button>
</DisabledActionTooltip>
</> :
null}>
{credentialDraft &&
<div className="adaptive-config-form">
<section className="adaptive-config-section">
<header>
<h3>Credential</h3>
<p>Choose where this credential can be used and how it signs in.</p>
</header>
<div className="form-grid two">
<FormField label="i18n:govoplan-files.credential_id.9432a6e1">
<input className={!editingCredentialId && !credentialDraft.id.trim() ? "field-input-missing" : undefined} aria-invalid={!editingCredentialId && !credentialDraft.id.trim() || undefined} value={credentialDraft.id} disabled={Boolean(editingCredentialId) || saving} onChange={(event) => patchCredentialDraft({ id: event.target.value })} placeholder={`${scopeType}-webdav-credentials`} />
</FormField>
<FormField label="i18n:govoplan-files.label.74341e3c">
<input className={!credentialDraft.label.trim() ? "field-input-missing" : undefined} aria-invalid={!credentialDraft.label.trim() || undefined} value={credentialDraft.label} disabled={saving} onChange={(event) => patchCredentialDraft({ label: event.target.value })} placeholder="i18n:govoplan-files.govoplan_webdav_credentials.bc696d69" />
</FormField>
{credentialAttachProfileId &&
<FormField label="i18n:govoplan-files.connection_credential.178babe0">
<input value={credentialAttachedProfile ? `${credentialAttachedProfile.label} (${credentialAttachedProfile.id})` : credentialAttachProfileId} disabled readOnly />
</FormField>
}
<div className="file-connector-provider-field">
<FormField label="i18n:govoplan-files.provider.7ceee3f3">
<SegmentedControl
className="file-connector-provider-switch"
role="group"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-files.provider.7ceee3f3"
value={credentialDraft.provider}
disabled={saving || Boolean(credentialAttachProfileId)}
onChange={(provider) => patchCredentialDraft({ provider })}
options={[
{ id: "", label: "i18n:govoplan-files.any_provider.b3fc542f" },
...PROVIDERS.map((provider) => ({ id: provider.id, label: provider.label }))
]}
/>
</FormField>
</div>
<FormField label="i18n:govoplan-files.credential_mode.23fdd899">
<select value={credentialDraft.credentialMode} disabled={saving} onChange={(event) => patchCredentialDraft({ credentialMode: event.target.value as CredentialMode })}>
<option value="none">i18n:govoplan-files.none.6eef6648</option>
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
<option value="basic">i18n:govoplan-files.username_password.e8ba8896</option>
<option value="token">i18n:govoplan-files.token.a1141eb9</option>
<option value="secret_ref">i18n:govoplan-files.secret_reference.04ed2221</option>
</select>
</FormField>
</div>
<div className="file-connector-settings-section">
<ToggleSwitch label="i18n:govoplan-files.enabled.df174a3f" checked={credentialDraft.enabled} disabled={saving} onChange={(enabled) => patchCredentialDraft({ enabled })} />
{editingCredentialId && <ToggleSwitch label="i18n:govoplan-files.clear_saved_password.7442260d" checked={credentialDraft.clearPassword} disabled={saving} onChange={(clearPassword) => patchCredentialDraft({ clearPassword })} />}
{editingCredentialId && <ToggleSwitch label="i18n:govoplan-files.clear_saved_token.a9c670aa" checked={credentialDraft.clearToken} disabled={saving} onChange={(clearToken) => patchCredentialDraft({ clearToken })} />}
</div>
</section>
<section className="adaptive-config-section">
<header>
<h3>Secret</h3>
<p>The fields below change with the selected credential mode.</p>
</header>
{(credentialDraft.credentialMode === "none" || credentialDraft.credentialMode === "anonymous") ?
<ActionBlockerHint
tone="info"
reason={{
summary: "This credential mode does not store a secret.",
details: "Use this only when the provider allows access without a username, password, token, or secret reference.",
requiredAction: "Change the credential mode above if this provider needs authentication.",
actor: "Connector administrator",
target: "Credential mode"
}} /> :
<div className="file-connector-secret-fields">
{credentialDraft.credentialMode === "basic" &&
<CredentialPanel
values={{ username: credentialDraft.username, password: credentialDraft.password }}
onChange={patchCredentialValues}
disabled={saving}
savedPassword={Boolean(editingCredentialId) && !credentialDraft.clearPassword}
savedPasswordPlaceholder="i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e" />
}
{credentialDraft.credentialMode === "secret_ref" &&
<>
<CredentialPanel
values={{ username: credentialDraft.username }}
onChange={patchCredentialValues}
disabled={saving}
showPassword={false} />
<div className="form-grid two">
<FormField label="i18n:govoplan-files.secret_reference.04ed2221">
<input value={credentialDraft.secretRef} disabled={saving} onChange={(event) => patchCredentialDraft({ secretRef: event.target.value })} />
</FormField>
</div>
</>
}
{credentialDraft.credentialMode === "token" &&
<CredentialPanel
values={{ password: credentialDraft.token }}
onChange={patchCredentialToken}
disabled={saving}
showUsername={false}
passwordLabel="i18n:govoplan-files.token.a1141eb9"
savedPassword={Boolean(editingCredentialId) && !credentialDraft.clearToken}
savedPasswordPlaceholder="i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b" />
}
</div>
}
<div className="button-row compact-actions">
<DisabledActionTooltip reason={credentialTestDisabled ? credentialTestTooltip : ""}>
<Button type="button" onClick={() => void testCredentialLogin()} disabled={credentialTestDisabled}>
<RefreshCw size={16} aria-hidden="true" /> {testingCredentialLogin ? "Testing login" : "Test login"}
</Button>
</DisabledActionTooltip>
</div>
{credentialLoginResult &&
<DismissibleAlert tone={credentialLoginResult.ok ? "success" : "warning"} resetKey={credentialLoginResult.message}>
{credentialLoginResult.message}
</DismissibleAlert>
}
</section>
<section className="adaptive-config-section">
<header>
<h3>Policy</h3>
<p>Limit where lower levels may use this credential.</p>
</header>
<div className="form-grid two">
<FormField label="i18n:govoplan-files.policy.bb9cf141">
<select value={credentialDraft.policyMode} disabled={saving} onChange={(event) => patchCredentialDraft({ policyMode: event.target.value as PolicyMode })}>
<option value="allow-provider">i18n:govoplan-files.can_use_this_credential.f4a41971</option>
<option value="allowed-paths">i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4</option>
<option value="deny-provider">i18n:govoplan-files.blocked_by_policy.8d971f86</option>
</select>
</FormField>
{credentialDraft.policyMode === "allowed-paths" &&
<FormField label="i18n:govoplan-files.allowed_paths.c953b4be">
<textarea value={credentialDraft.allowedPaths} disabled={saving} onChange={(event) => patchCredentialDraft({ allowedPaths: event.target.value })} rows={3} placeholder={"GovOPlaN\nreports/*"} />
</FormField>
}
<FormField label="i18n:govoplan-files.denied_paths.d25e4315">
<textarea value={credentialDraft.deniedPaths} disabled={saving} onChange={(event) => patchCredentialDraft({ deniedPaths: event.target.value })} rows={3} placeholder={"private\narchive/closed"} />
</FormField>
</div>
<AdvancedOptionsPanel title="Credential metadata JSON" summary="For diagnostics or provider-specific compatibility flags. Normal setup should not need this.">
<FormField label="i18n:govoplan-files.metadata_json.b0e4c283">
<textarea value={credentialDraft.metadataJson} disabled={saving} onChange={(event) => patchCredentialDraft({ metadataJson: event.target.value })} rows={5} spellCheck={false} />
</FormField>
</AdvancedOptionsPanel>
</section>
</div>
}
</Dialog>
<Dialog
open={Boolean(draft)}
title={editingProfileId ? "i18n:govoplan-files.edit_file_connection.78698154" : "i18n:govoplan-files.new_file_connection.2ec6eb56"}
onClose={closeProfileDialog}
className="admin-dialog admin-dialog-wide file-connector-dialog adaptive-config-dialog"
bodyClassName="adaptive-config-dialog-body"
closeDisabled={saving}
footer={draft ?
<>
<Button onClick={closeProfileDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
<DisabledActionTooltip reason={profileSaveTooltip}>
<Button variant="primary" onClick={() => void saveDraft()} disabled={profileSaveDisabled}>
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_connection.07796d38"}
</Button>
</DisabledActionTooltip>
</> :
null}>
{draft &&
<div className="adaptive-config-form">
<section className="adaptive-config-section">
<header>
<h3>Connection</h3>
<p>Name the file server connection and choose the provider.</p>
</header>
<div className="form-grid two">
<FormField label="i18n:govoplan-files.profile_id.25961d68">
<input className={!editingProfileId && !draft.id.trim() ? "field-input-missing" : undefined} aria-invalid={!editingProfileId && !draft.id.trim() || undefined} value={draft.id} disabled={Boolean(editingProfileId) || saving} onChange={(event) => patchDraft({ id: event.target.value })} placeholder={`${scopeType}-webdav`} />
</FormField>
<FormField label="i18n:govoplan-files.label.74341e3c">
<input className={!draft.label.trim() ? "field-input-missing" : undefined} aria-invalid={!draft.label.trim() || undefined} value={draft.label} disabled={saving} onChange={(event) => patchDraft({ label: event.target.value })} placeholder="i18n:govoplan-files.govoplan_files.b20752ed" />
</FormField>
<div className="file-connector-provider-field">
<FormField label="i18n:govoplan-files.provider.7ceee3f3">
<SegmentedControl
className="file-connector-provider-switch"
role="group"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-files.provider.7ceee3f3"
value={draft.provider}
disabled={saving}
onChange={(provider) => patchDraft({ provider, credentialProfileId: "" })}
options={PROVIDERS}
/>
</FormField>
</div>
<div className="field-block file-connector-settings-toggle-field">
<ToggleSwitch label="i18n:govoplan-files.enabled.df174a3f" checked={draft.enabled} disabled={saving} onChange={(enabled) => patchDraft({ enabled })} />
</div>
</div>
</section>
<section className="adaptive-config-section">
<header>
<h3>Location and credentials</h3>
<p>Only fields relevant for the selected provider and credential mode are shown.</p>
</header>
<div className="form-grid two">
<FormField label="i18n:govoplan-files.endpoint_url.65aaaa45">
<input value={draft.endpointUrl} disabled={saving} onChange={(event) => patchDraft({ endpointUrl: event.target.value })} placeholder={ENDPOINT_PLACEHOLDERS[draft.provider]} />
</FormField>
<FormField label="i18n:govoplan-files.base_path.6a4867ca">
<input value={draft.basePath} disabled={saving} onChange={(event) => patchDraft({ basePath: event.target.value })} placeholder="GovOPlaN" />
</FormField>
<FormField label="i18n:govoplan-files.credential_mode.23fdd899">
<select value={draft.credentialMode} disabled={saving} onChange={(event) => patchDraft({ credentialMode: event.target.value as CredentialMode })}>
<option value="none">i18n:govoplan-files.none.6eef6648</option>
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
<option value="basic">i18n:govoplan-files.username_password.e8ba8896</option>
<option value="token">i18n:govoplan-files.token.a1141eb9</option>
<option value="secret_ref">i18n:govoplan-files.secret_reference.04ed2221</option>
</select>
</FormField>
<FormField label="i18n:govoplan-files.credential.8bede3ea">
<select value={profileCredentialSelectValue} disabled={saving || profileCredentialOptions.length === 0} onChange={(event) => patchDraft({ credentialProfileId: event.target.value })}>
{profileCredentialOptions.length === 0 && <option value="">i18n:govoplan-files.no_saved_credential.30a5a951</option>}
{profileCredentialOptions.map((credential) => <option key={credential.id} value={credential.id}>{credential.label}</option>)}
</select>
</FormField>
</div>
<div className="button-row compact-actions">
<Button type="button" onClick={() => patchDraft({ endpointUrl: ENDPOINT_PLACEHOLDERS[draft.provider] })} disabled={saving}>Use provider example</Button>
{canDiscoverProfileEndpoint &&
<Button type="button" onClick={() => void discoverProfileEndpoint()} disabled={saving || discoveringProfile || !draft.endpointUrl.trim()}>
<RefreshCw size={16} aria-hidden="true" /> {discoveringProfile ? "Checking endpoint" : "Discover endpoint"}
</Button>
}
</div>
{profileDiscoveryResult &&
<DismissibleAlert tone={profileDiscoveryResult.ok ? "success" : "warning"} resetKey={profileDiscoveryResult.message}>
{profileDiscoveryResult.message}
</DismissibleAlert>
}
<AdvancedOptionsPanel title="Protocol and compatibility options" summary="Change these only when the provider or network requires an override.">
<div className="form-grid two">
<FormField label="i18n:govoplan-files.browse_protocol.d1f27c90">
<select value={draft.browseProtocol} disabled={saving} onChange={(event) => patchDraft({ browseProtocol: event.target.value })}>
<option value="">i18n:govoplan-files.native_default.ba32f7b6</option>
<option value="webdav">i18n:govoplan-files.webdav.521b4c8b</option>
</select>
</FormField>
{draft.provider === "smb" &&
<>
<FormField label="i18n:govoplan-files.smb_authentication.96f7cb83">
<select value={draft.authProtocol} disabled={saving} onChange={(event) => patchDraft({ authProtocol: event.target.value })}>
<option value="ntlm">i18n:govoplan-files.ntlm.026eac85</option>
<option value="kerberos">i18n:govoplan-files.kerberos.53c35fb7</option>
</select>
</FormField>
<div className="field-block file-connector-settings-toggle-field">
<ToggleSwitch label="i18n:govoplan-files.require_smb_signing.5168a83d" checked={draft.requireSigning} disabled={saving} onChange={(requireSigning) => patchDraft({ requireSigning })} />
</div>
</>
}
</div>
</AdvancedOptionsPanel>
</section>
<section className="adaptive-config-section">
<header>
<h3>Actions and policy</h3>
<p>Choose which file operations are available and where this connection may be used.</p>
</header>
<div className="file-connector-settings-section">
<ToggleSwitch label="i18n:govoplan-files.browse.2f3b5c55" checked={draft.canBrowse} disabled={saving} onChange={(canBrowse) => patchDraft({ canBrowse })} />
<ToggleSwitch label="i18n:govoplan-files.import.d6fbc9d2" checked={draft.canImport} disabled={saving} onChange={(canImport) => patchDraft({ canImport })} />
<ToggleSwitch label="i18n:govoplan-files.sync.905f6309" checked={draft.canSync} disabled={saving} onChange={(canSync) => patchDraft({ canSync })} />
</div>
<div className="form-grid two">
<FormField label="i18n:govoplan-files.policy.bb9cf141">
<select value={draft.policyMode} disabled={saving} onChange={(event) => patchDraft({ policyMode: event.target.value as PolicyMode })}>
<option value="allow-provider">i18n:govoplan-files.can_use_this_connection.5091a74c</option>
<option value="allowed-paths">i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4</option>
<option value="deny-provider">i18n:govoplan-files.blocked_by_policy.8d971f86</option>
</select>
</FormField>
{draft.policyMode === "allowed-paths" &&
<FormField label="i18n:govoplan-files.allowed_paths.c953b4be">
<textarea value={draft.allowedPaths} disabled={saving} onChange={(event) => patchDraft({ allowedPaths: event.target.value })} rows={3} placeholder={"GovOPlaN\nreports/*"} />
</FormField>
}
<FormField label="i18n:govoplan-files.denied_paths.d25e4315">
<textarea value={draft.deniedPaths} disabled={saving} onChange={(event) => patchDraft({ deniedPaths: event.target.value })} rows={3} placeholder={"private\narchive/closed"} />
</FormField>
</div>
<AdvancedOptionsPanel title="Connection metadata JSON" summary="For diagnostics or provider-specific compatibility flags. Normal setup should not need this.">
<FormField label="i18n:govoplan-files.metadata_json.b0e4c283">
<textarea value={draft.metadataJson} disabled={saving} onChange={(event) => patchDraft({ metadataJson: event.target.value })} rows={5} spellCheck={false} />
</FormField>
</AdvancedOptionsPanel>
</section>
</div>
}
</Dialog>
</>
}
<ConfirmDialog
open={Boolean(pendingDeactivate)}
title="i18n:govoplan-files.disable_file_connection.3fd940ae"
message={i18nMessage("i18n:govoplan-files.disable_value_existing_linked_spaces_will_stop_b.8c1b0ac6", { value0: pendingDeactivate?.label || "i18n:govoplan-files.this_file_connection.edcde997" })}
confirmLabel="i18n:govoplan-files.disable.9a7d4e06"
tone="danger"
busy={saving}
onConfirm={() => void confirmDeactivate()}
onCancel={() => setPendingDeactivate(null)} />
<ConfirmDialog
open={Boolean(pendingCredentialDeactivate)}
title="i18n:govoplan-files.disable_file_credential.49bff614"
message={i18nMessage("i18n:govoplan-files.disable_value_connections_using_it_will_stop_aut.fc3a1708", { value0: pendingCredentialDeactivate?.label || "i18n:govoplan-files.this_file_credential.695efb90" })}
confirmLabel="i18n:govoplan-files.disable.9a7d4e06"
tone="danger"
busy={saving}
onConfirm={() => void confirmCredentialDeactivate()}
onCancel={() => setPendingCredentialDeactivate(null)} />
</div>);
}
function ConnectorProfileCell({
profile,
result
}: {profile: FileConnectorProfile;result?: {ok: boolean;message: string;};}) {
const readOnly = profile.source_kind !== "database";
return (
<div className="connection-tree-main">
<strong>{profile.label}</strong>
<div className="connection-tree-muted-line">
{profile.id} · {providerLabel(profile.provider)} · {profile.base_path || "i18n:govoplan-files.root.e96857c5"}
{profile.credential_profile_label ? i18nMessage("i18n:govoplan-files.value.48afe802", { value0: profile.credential_profile_label }) : ""}
{readOnly ? "i18n:govoplan-files.bootstrap_settings.05e3df38" : ""}
</div>
{result && <div className={result.ok ? "file-connector-test-ok" : "file-connector-test-error"}>{result.message}</div>}
</div>);
}
function ConnectorCredentialCell({
credential,
selected
}: {credential: FileConnectorCredential;selected: boolean;}) {
return (
<div className="connection-tree-main">
<strong>{credential.label}</strong>
<div className="connection-tree-muted-line">
{credential.id} · {credential.credential_mode} · {credential.credentials_configured ? "i18n:govoplan-files.secret_configured.4dc0f095" : "i18n:govoplan-files.no_secret.33c1a339"}
{selected ? "i18n:govoplan-files.selected.840fac38" : "i18n:govoplan-files.available.974948f2"}
</div>
</div>);
}
function connectorBelongsToEditableScope(
item: {scope_type: string;scope_id?: string | null;},
scopeType: ConnectorScope,
scopeId: string | null)
: boolean {
if (item.scope_type !== scopeType) return false;
if (scopeType === "system") return !item.scope_id;
if (scopeType === "tenant") return true;
return Boolean(scopeId) && item.scope_id === scopeId;
}
function credentialMatchesProfile(credential: FileConnectorCredential, profile: FileConnectorProfile): boolean {
return !credential.provider || credential.provider === profile.provider;
}
function connectorPolicySummary(row: ConnectorTreeRow): string {
const item = row.kind === "profile" ? row.profile : row.credential;
const policy = item.policy_sources.find((source) => source.scope_type === item.scope_type && source.scope_id === item.scope_id)?.policy ??
item.policy_sources[0]?.policy ??
{};
const denyProviders = policyRuleList(policy, "deny", "providers");
const denyCredentials = policyRuleList(policy, "deny", "credentials");
const allowPaths = policyRuleList(policy, "allow", "external_paths");
const denyPaths = policyRuleList(policy, "deny", "external_paths");
if (row.kind === "profile" && (denyProviders.includes("*") || denyProviders.includes(row.profile.provider))) return "i18n:govoplan-files.blocked.99613c74";
if (row.kind === "credential" && (denyCredentials.includes("*") || denyCredentials.includes(row.credential.id))) return "i18n:govoplan-files.blocked.99613c74";
if (allowPaths.length > 0) return "i18n:govoplan-files.path_limited.d32cbef0";
if (denyPaths.length > 0) return "i18n:govoplan-files.deny_listed_paths.fcde62cc";
return item.policy_sources.length > 0 ? "i18n:govoplan-files.allowed.77c7b490" : "i18n:govoplan-files.inherited.58823af0";
}
function scopeLabel(scopeType: ConnectorScope): string {
if (scopeType === "system") return "i18n:govoplan-files.system.bc0792d8";
if (scopeType === "tenant") return "i18n:govoplan-files.tenant.3ca93c78";
if (scopeType === "user") return "i18n:govoplan-files.user.9f8a2389";
return "i18n:govoplan-files.group.171a0606";
}
function targetPluralLabel(scopeType: ConnectorScope, fallback: string): string {
if (scopeType === "user") return "i18n:govoplan-files.users";
if (scopeType === "group") return "i18n:govoplan-files.groups";
if (scopeType === "campaign") return "i18n:govoplan-files.campaigns";
return fallback;
}
function emptyDraft(scopeType: ConnectorScope): ConnectorProfileDraft {
return {
id: `${scopeType}-webdav`,
label: "",
provider: "webdav",
endpointUrl: "",
basePath: "",
enabled: true,
credentialProfileId: "",
credentialMode: "basic",
username: "",
password: "",
token: "",
secretRef: "",
canBrowse: true,
canImport: true,
canSync: true,
policyMode: "allow-provider",
allowedPaths: "",
deniedPaths: "",
requireSigning: true,
authProtocol: "ntlm",
browseProtocol: "",
metadataJson: "{}",
clearPassword: false,
clearToken: false
};
}
function connectorProfileDraftKey(draft: ConnectorProfileDraft): string {
return JSON.stringify(draft);
}
function draftFromProfile(profile: FileConnectorProfile, scopeType: ConnectorScope): ConnectorProfileDraft {
const policy = profile.policy_sources.find((source) => source.scope_type === profile.scope_type && source.scope_id === profile.scope_id)?.policy ??
profile.policy_sources[0]?.policy ??
{};
const allowPaths = policyRuleList(policy, "allow", "external_paths");
const denyPaths = policyRuleList(policy, "deny", "external_paths");
const denyProviders = policyRuleList(policy, "deny", "providers");
const metadata = profile.metadata ?? {};
return {
...emptyDraft(scopeType),
id: profile.id,
label: profile.label,
provider: knownProvider(profile.provider),
endpointUrl: profile.endpoint_url ?? "",
basePath: profile.base_path ?? "",
enabled: profile.enabled,
credentialProfileId: profile.credential_profile_id ?? "",
credentialMode: knownCredentialMode(profile.credential_mode),
username: profile.username ?? "",
canBrowse: profile.capabilities.includes("browse"),
canImport: profile.capabilities.includes("import"),
canSync: profile.capabilities.includes("sync"),
policyMode: denyProviders.includes("*") || denyProviders.includes(profile.provider) ? "deny-provider" : allowPaths.length > 0 ? "allowed-paths" : "allow-provider",
allowedPaths: allowPaths.join("\n"),
deniedPaths: denyPaths.join("\n"),
requireSigning: typeof metadata.require_signing === "boolean" ? metadata.require_signing : true,
authProtocol: typeof metadata.auth_protocol === "string" && metadata.auth_protocol ? metadata.auth_protocol : "ntlm",
browseProtocol: typeof metadata.browse_protocol === "string" ? metadata.browse_protocol : "",
metadataJson: JSON.stringify(metadata, null, 2)
};
}
function emptyCredentialDraft(scopeType: ConnectorScope): ConnectorCredentialDraft {
return {
id: `${scopeType}-webdav-credentials`,
label: "",
provider: "",
enabled: true,
credentialMode: "basic",
username: "",
password: "",
token: "",
secretRef: "",
policyMode: "allow-provider",
allowedPaths: "",
deniedPaths: "",
metadataJson: "{}",
clearPassword: false,
clearToken: false
};
}
function connectorCredentialDraftKey(draft: ConnectorCredentialDraft): string {
return JSON.stringify(draft);
}
function credentialOptionsForProfileDraft(credentials: FileConnectorCredential[], draft: ConnectorProfileDraft): FileConnectorCredential[] {
return credentials.filter((credential) =>
credential.id === draft.credentialProfileId ||
credential.enabled &&
(!credential.provider || credential.provider === draft.provider) &&
reusableCredentialAllowsProfile(credential, draft.id)
);
}
function reusableCredentialAvailableAtScope(
credential: FileConnectorCredential,
scopeType: ConnectorScope,
scopeId: string | null
): boolean {
if (connectorBelongsToEditableScope(credential, scopeType, scopeId)) return true;
if (credential.source_kind !== "credential_envelope") return false;
const inherited = credential.metadata?.inherit_to_lower_scopes === true;
if (!inherited) return false;
if (credential.scope_type === "system") return scopeType !== "system";
if (credential.scope_type === "tenant") {
return scopeType === "group" || scopeType === "user";
}
return false;
}
function reusableCredentialAllowsProfile(
credential: FileConnectorCredential,
profileId: string
): boolean {
if (credential.source_kind !== "credential_envelope") return true;
const refs = Array.isArray(credential.metadata?.allowed_server_refs)
? credential.metadata.allowed_server_refs.filter((value): value is string => typeof value === "string")
: [];
return refs.length === 0 || refs.includes(`files:${profileId}`);
}
function credentialProfileIdForDraft(draft: ConnectorProfileDraft, credentials: FileConnectorCredential[]): string {
const options = credentialOptionsForProfileDraft(credentials, draft);
if (draft.credentialProfileId && options.some((credential) => credential.id === draft.credentialProfileId)) {
return draft.credentialProfileId;
}
return options[0]?.id ?? "";
}
function profileDraftMissingFields(draft: ConnectorProfileDraft | null, editingProfileId: string | null): string[] {
if (!draft) return [];
return [
!editingProfileId && !draft.id.trim() ? "connection ID" : "",
!draft.label.trim() ? "label" : ""
].filter(Boolean);
}
function credentialDraftMissingFields(
draft: ConnectorCredentialDraft | null,
editingCredentialId: string | null,
credentialAttachProfileId: string | null)
: string[] {
if (!draft) return [];
return [
!editingCredentialId && !credentialAttachProfileId ? "connection" : "",
!editingCredentialId && !draft.id.trim() ? "credential ID" : "",
!draft.label.trim() ? "label" : ""
].filter(Boolean);
}
function requiredFieldsTooltip(fields: string[]): string {
if (fields.length === 0) return "";
return `Fill in ${humanList(fields)} before saving.`;
}
function credentialTestHelpText(profile: FileConnectorProfile | null, unsupported: boolean): string {
if (!profile) return "Create the credential from a connection row, or attach it to a compatible connection before testing.";
if (unsupported) return "Draft credential testing is currently available for WebDAV and Nextcloud connections.";
return "";
}
function humanList(values: string[]): string {
if (values.length <= 1) return values[0] ?? "";
if (values.length === 2) return `${values[0]} and ${values[1]}`;
return `${values.slice(0, -1).join(", ")}, and ${values[values.length - 1]}`;
}
function emptyCentralPolicyDraft(): CentralConnectorPolicyDraft {
return {
allowedConnectors: "",
allowedCredentials: "",
allowedProviders: "",
allowedPaths: "",
allowedUrls: "",
deniedConnectors: "",
deniedCredentials: "",
deniedProviders: "",
deniedPaths: "",
deniedUrls: "",
allowLowerConnectionLimits: true,
allowLowerCredentialLimits: true,
allowLowerProviderLimits: true,
allowLowerPathLimits: true,
allowLowerUrlLimits: true
};
}
function centralPolicyDraftKey(draft: CentralConnectorPolicyDraft): string {
return JSON.stringify(draft);
}
function centralPolicyDraftFromPolicy(policy: Record<string, unknown> | null | undefined): CentralConnectorPolicyDraft {
const value = policy ?? {};
return {
...emptyCentralPolicyDraft(),
allowedConnectors: policyRuleList(value, "allow", "connectors").join("\n"),
allowedCredentials: policyRuleList(value, "allow", "credentials").join("\n"),
allowedProviders: policyRuleList(value, "allow", "providers").join("\n"),
allowedPaths: policyRuleList(value, "allow", "external_paths").join("\n"),
allowedUrls: policyRuleList(value, "allow", "external_urls").join("\n"),
deniedConnectors: policyRuleList(value, "deny", "connectors").join("\n"),
deniedCredentials: policyRuleList(value, "deny", "credentials").join("\n"),
deniedProviders: policyRuleList(value, "deny", "providers").join("\n"),
deniedPaths: policyRuleList(value, "deny", "external_paths").join("\n"),
deniedUrls: policyRuleList(value, "deny", "external_urls").join("\n"),
allowLowerConnectionLimits: lowerLevelAllowed(value, "connectors"),
allowLowerCredentialLimits: lowerLevelAllowed(value, "credentials"),
allowLowerProviderLimits: lowerLevelAllowed(value, "providers"),
allowLowerPathLimits: lowerLevelAllowed(value, "external_paths"),
allowLowerUrlLimits: lowerLevelAllowed(value, "external_urls")
};
}
function credentialDraftFromCredential(credential: FileConnectorCredential, scopeType: ConnectorScope): ConnectorCredentialDraft {
const policy = credential.policy_sources.find((source) => source.scope_type === credential.scope_type && source.scope_id === credential.scope_id)?.policy ??
credential.policy_sources[0]?.policy ??
{};
const allowPaths = policyRuleList(policy, "allow", "external_paths");
const denyPaths = policyRuleList(policy, "deny", "external_paths");
const denyCredentials = policyRuleList(policy, "deny", "credentials");
return {
...emptyCredentialDraft(scopeType),
id: credential.id,
label: credential.label,
provider: credential.provider ? knownProvider(credential.provider) : "",
enabled: credential.enabled,
credentialMode: knownCredentialMode(credential.credential_mode),
username: credential.username ?? "",
policyMode: denyCredentials.includes("*") || denyCredentials.includes(credential.id) ? "deny-provider" : allowPaths.length > 0 ? "allowed-paths" : "allow-provider",
allowedPaths: allowPaths.join("\n"),
deniedPaths: denyPaths.join("\n"),
metadataJson: JSON.stringify(credential.metadata ?? {}, null, 2)
};
}
function policyFromDraft(draft: ConnectorProfileDraft): Record<string, unknown> {
const denyPaths = lines(draft.deniedPaths);
if (draft.policyMode === "deny-provider") {
return {
deny: {
providers: [draft.provider],
...(denyPaths.length ? { external_paths: denyPaths } : {})
}
};
}
const allowPaths = draft.policyMode === "allowed-paths" ? lines(draft.allowedPaths) : [];
return {
allow: {
providers: [draft.provider],
...(allowPaths.length ? { external_paths: allowPaths } : {})
},
...(denyPaths.length ? { deny: { external_paths: denyPaths } } : {})
};
}
function credentialPolicyFromDraft(draft: ConnectorCredentialDraft): Record<string, unknown> {
const credentialId = draft.id.trim();
const denyPaths = lines(draft.deniedPaths);
const providerRules = draft.provider ? { providers: [draft.provider] } : {};
if (draft.policyMode === "deny-provider") {
return {
deny: {
credentials: credentialId ? [credentialId] : ["*"],
...providerRules,
...(denyPaths.length ? { external_paths: denyPaths } : {})
}
};
}
const allowPaths = draft.policyMode === "allowed-paths" ? lines(draft.allowedPaths) : [];
return {
allow: {
...(credentialId ? { credentials: [credentialId] } : {}),
...providerRules,
...(allowPaths.length ? { external_paths: allowPaths } : {})
},
...(denyPaths.length ? { deny: { external_paths: denyPaths } } : {})
};
}
function centralPolicyFromDraft(draft: CentralConnectorPolicyDraft): Record<string, unknown> {
const allow = compactRuleGroup({
connectors: lines(draft.allowedConnectors),
credentials: lines(draft.allowedCredentials),
providers: lines(draft.allowedProviders),
external_paths: lines(draft.allowedPaths),
external_urls: lines(draft.allowedUrls)
});
const deny = compactRuleGroup({
connectors: lines(draft.deniedConnectors),
credentials: lines(draft.deniedCredentials),
providers: lines(draft.deniedProviders),
external_paths: lines(draft.deniedPaths),
external_urls: lines(draft.deniedUrls)
});
const lower: Record<string, boolean> = {};
setLowerLimit(lower, "connectors", draft.allowLowerConnectionLimits);
setLowerLimit(lower, "credentials", draft.allowLowerCredentialLimits);
setLowerLimit(lower, "providers", draft.allowLowerProviderLimits);
setLowerLimit(lower, "external_paths", draft.allowLowerPathLimits);
setLowerLimit(lower, "external_urls", draft.allowLowerUrlLimits);
return {
...(Object.keys(allow).length ? { allow } : {}),
...(Object.keys(deny).length ? { deny } : {}),
...(Object.keys(lower).length ? { allow_lower_level_limits: lower } : {})
};
}
function metadataFromDraft(draft: ConnectorProfileDraft): Record<string, unknown> {
const metadata = metadataFromJson(draft.metadataJson);
if (draft.browseProtocol) metadata.browse_protocol = draft.browseProtocol;else
delete metadata.browse_protocol;
if (draft.provider === "smb") {
metadata.require_signing = draft.requireSigning;
metadata.auth_protocol = draft.authProtocol || "ntlm";
}
return metadata;
}
function metadataFromJson(value: string): Record<string, unknown> {
const clean = value.trim();
const parsed = clean ? JSON.parse(clean) : {};
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("i18n:govoplan-files.metadata_json_must_be_an_object.48629f13");
}
return { ...parsed } as Record<string, unknown>;
}
function cleanOrNull(value: string): string | null {
const clean = value.trim();
return clean ? clean : null;
}
function cleanOrUndefined(value: string): string | undefined {
const clean = value.trim();
return clean ? clean : undefined;
}
function lines(value: string): string[] {
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
function policyRuleList(policy: Record<string, unknown>, kind: "allow" | "deny", field: "connectors" | "providers" | "credentials" | "external_paths" | "external_urls"): string[] {
const rule = recordValue(policy[kind]) ??
recordValue(policy[kind === "allow" ? "allowlist" : "denylist"]) ??
recordValue(policy[kind === "allow" ? "whitelist" : "blacklist"]) ??
{};
if (field === "connectors") return stringList(rule.connectors ?? rule.connector_ids ?? rule.connector_id);
if (field === "providers") return stringList(rule.providers ?? rule.provider);
if (field === "credentials") return stringList(rule.credentials ?? rule.credential_ids ?? rule.credential_id);
if (field === "external_urls") return stringList(rule.external_urls ?? rule.urls ?? rule.external_url);
return stringList(rule.external_paths ?? rule.paths ?? rule.path_prefixes ?? rule.external_path);
}
function compactRuleGroup(value: Record<string, string[]>): Record<string, string[]> {
return Object.fromEntries(Object.entries(value).filter(([, items]) => items.length > 0));
}
function setLowerLimit(target: Record<string, boolean>, field: string, allowed: boolean) {
if (allowed) return;
target[`allow.${field}`] = false;
target[`deny.${field}`] = false;
}
function lowerLevelAllowed(policy: Record<string, unknown>, field: string): boolean {
const lower = recordValue(policy.allow_lower_level_limits) ?? {};
return lower[`allow.${field}`] !== false && lower[`deny.${field}`] !== false;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function stringList(value: unknown): string[] {
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
return [];
}
function knownProvider(value: string): Provider {
const clean = value.trim().toLowerCase();
return PROVIDERS.some((provider) => provider.id === clean) ? clean as Provider : "generic";
}
function knownCredentialMode(value: string): CredentialMode {
if (["none", "anonymous", "basic", "token", "secret_ref"].includes(value)) return value as CredentialMode;
return "none";
}
function sortConnectorProfiles(left: FileConnectorProfile, right: FileConnectorProfile): number {
return scopeOrder(left.scope_type) - scopeOrder(right.scope_type) || left.label.localeCompare(right.label) || left.id.localeCompare(right.id);
}
function sortConnectorCredentials(left: FileConnectorCredential, right: FileConnectorCredential): number {
return scopeOrder(left.scope_type) - scopeOrder(right.scope_type) || left.label.localeCompare(right.label) || left.id.localeCompare(right.id);
}
function scopeOrder(scopeType: FileConnectorScope): number {
return { system: 0, tenant: 1, user: 2, group: 3, campaign: 4 }[scopeType] ?? 99;
}
function providerLabel(provider: string): string {
return PROVIDERS.find((item) => item.id === provider)?.label ?? provider;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}