1718 lines
83 KiB
TypeScript
1718 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,
|
|
DismissibleAlert,
|
|
Dialog,
|
|
FormField,
|
|
ActionBlockerHint,
|
|
AdvancedOptionsPanel,
|
|
LoadingFrame,
|
|
mergeDeltaRows,
|
|
SegmentedControl,
|
|
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;
|
|
passwordEnv: string;
|
|
tokenEnv: 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;
|
|
passwordEnv: string;
|
|
tokenEnv: 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: "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",
|
|
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]
|
|
);
|
|
|
|
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 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, scopedCredentials)),
|
|
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),
|
|
password_env: cleanOrNull(credentialDraft.passwordEnv),
|
|
token_env: cleanOrNull(credentialDraft.tokenEnv),
|
|
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),
|
|
password_env: cleanOrNull(draft.passwordEnv),
|
|
token_env: cleanOrNull(draft.tokenEnv),
|
|
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),
|
|
password_env: cleanOrNull(credentialDraft.passwordEnv),
|
|
token_env: cleanOrNull(credentialDraft.tokenEnv),
|
|
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 scopedCredentials) {
|
|
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(scopedCredentials, draft) : [];
|
|
const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, scopedCredentials) : "";
|
|
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 <span className={`status-badge ${enabled ? "success" : "neutral"}`}>{enabled ? "i18n:govoplan-files.enabled.df174a3f" : "i18n:govoplan-files.disabled.f4f4473d"}</span>;
|
|
}
|
|
}];
|
|
|
|
|
|
function connectorChildren(row: ConnectorTreeRow): ConnectorTreeRow[] {
|
|
if (row.kind !== "profile") return [];
|
|
return scopedCredentials.
|
|
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 (
|
|
<div className="admin-icon-actions">
|
|
<Button type="button" className="admin-icon-button" title={i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.credential.label })} aria-label={i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.credential.label })} onClick={() => startCredentialEdit(row.credential)} disabled={!canWrite || readOnly || saving}><Edit3 size={15} /></Button>
|
|
<Button type="button" variant="danger" className="admin-icon-button" title={i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.credential.label })} aria-label={i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.credential.label })} onClick={() => setPendingCredentialDeactivate(row.credential)} disabled={!canWrite || readOnly || saving || !row.credential.enabled}><Trash2 size={15} /></Button>
|
|
</div>);
|
|
|
|
}
|
|
const readOnly = row.profile.source_kind !== "database";
|
|
return (
|
|
<div className="admin-icon-actions">
|
|
<Button type="button" className="admin-icon-button" title={i18nMessage("i18n:govoplan-files.test_value.6a5d10a5", { value0: row.profile.label })} aria-label={i18nMessage("i18n:govoplan-files.test_value.6a5d10a5", { value0: row.profile.label })} onClick={() => void testBrowse(row.profile)} disabled={saving || testingProfileId === row.profile.id || !row.profile.enabled}><RefreshCw size={15} /></Button>
|
|
<Button type="button" variant="primary" className="admin-icon-button" title={i18nMessage("i18n:govoplan-files.add_credential_for_value.0fa9c1fe", { value0: row.profile.label })} aria-label={i18nMessage("i18n:govoplan-files.add_credential_for_value.0fa9c1fe", { value0: row.profile.label })} onClick={() => startCredentialCreate(row.profile)} disabled={!canWrite || saving}><UserRoundKey size={15} /></Button>
|
|
<Button type="button" className="admin-icon-button" title={i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.profile.label })} aria-label={i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.profile.label })} onClick={() => startEdit(row.profile)} disabled={!canWrite || readOnly || saving}><Edit3 size={15} /></Button>
|
|
<Button type="button" variant="danger" className="admin-icon-button" title={i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.profile.label })} aria-label={i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.profile.label })} onClick={() => setPendingDeactivate(row.profile)} disabled={!canWrite || readOnly || saving || !row.profile.enabled}><Trash2 size={15} /></Button>
|
|
</div>);
|
|
|
|
}
|
|
|
|
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="mail-profile-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-column-form-grid">
|
|
<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>
|
|
<span className="disabled-action-tooltip" data-tooltip={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>
|
|
</span>
|
|
</> :
|
|
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-column-form-grid">
|
|
<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="form-grid two-column-form-grid">
|
|
{(credentialDraft.credentialMode === "basic" || credentialDraft.credentialMode === "secret_ref") &&
|
|
<FormField label="i18n:govoplan-files.username.84c29015">
|
|
<input value={credentialDraft.username} disabled={saving} onChange={(event) => patchCredentialDraft({ username: event.target.value })} autoComplete="username" />
|
|
</FormField>
|
|
}
|
|
{credentialDraft.credentialMode === "basic" &&
|
|
<FormField label="i18n:govoplan-files.password.8be3c943">
|
|
<input value={credentialDraft.password} disabled={saving} onChange={(event) => patchCredentialDraft({ password: event.target.value })} type="password" autoComplete="new-password" placeholder={editingCredentialId ? "i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e" : ""} />
|
|
</FormField>
|
|
}
|
|
{credentialDraft.credentialMode === "token" &&
|
|
<FormField label="i18n:govoplan-files.token.a1141eb9">
|
|
<input value={credentialDraft.token} disabled={saving} onChange={(event) => patchCredentialDraft({ token: event.target.value })} type="password" placeholder={editingCredentialId ? "i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b" : ""} />
|
|
</FormField>
|
|
}
|
|
{credentialDraft.credentialMode === "secret_ref" &&
|
|
<FormField label="i18n:govoplan-files.secret_reference.04ed2221">
|
|
<input value={credentialDraft.secretRef} disabled={saving} onChange={(event) => patchCredentialDraft({ secretRef: event.target.value })} />
|
|
</FormField>
|
|
}
|
|
</div>
|
|
}
|
|
<div className="button-row compact-actions">
|
|
<span className="disabled-action-tooltip" data-tooltip={credentialTestDisabled ? credentialTestTooltip : ""}>
|
|
<Button type="button" onClick={() => void testCredentialLogin()} disabled={credentialTestDisabled}>
|
|
<RefreshCw size={16} aria-hidden="true" /> {testingCredentialLogin ? "Testing login" : "Test login"}
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
{credentialLoginResult &&
|
|
<DismissibleAlert tone={credentialLoginResult.ok ? "success" : "warning"} resetKey={credentialLoginResult.message}>
|
|
{credentialLoginResult.message}
|
|
</DismissibleAlert>
|
|
}
|
|
<AdvancedOptionsPanel title="Environment and fallback secrets" summary="Use these only when the deployment injects secrets outside the database.">
|
|
<div className="form-grid two-column-form-grid">
|
|
<FormField label="i18n:govoplan-files.password_environment_variable.0e77673f">
|
|
<input value={credentialDraft.passwordEnv} disabled={saving} onChange={(event) => patchCredentialDraft({ passwordEnv: event.target.value })} />
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-files.token_environment_variable.5af4a79e">
|
|
<input value={credentialDraft.tokenEnv} disabled={saving} onChange={(event) => patchCredentialDraft({ tokenEnv: event.target.value })} />
|
|
</FormField>
|
|
</div>
|
|
</AdvancedOptionsPanel>
|
|
</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-column-form-grid">
|
|
<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>
|
|
<span className="disabled-action-tooltip" data-tooltip={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>
|
|
</span>
|
|
</> :
|
|
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-column-form-grid">
|
|
<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-column-form-grid">
|
|
<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-column-form-grid">
|
|
<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-column-form-grid">
|
|
<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: "",
|
|
passwordEnv: "",
|
|
tokenEnv: "",
|
|
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: "",
|
|
passwordEnv: "",
|
|
tokenEnv: "",
|
|
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)
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|