Migrate Files surfaces to interface patterns

This commit is contained in:
2026-08-03 09:35:52 +02:00
parent 6baf2a421b
commit d8ae506ff8
9 changed files with 229 additions and 54 deletions
+3
View File
@@ -148,6 +148,9 @@ Connector and collaboration ownership boundaries are documented in
`docs/CONNECTOR_BOUNDARY.md` and `docs/DOCUMENT_COLLABORATION_BOUNDARY.md`. `docs/CONNECTOR_BOUNDARY.md` and `docs/DOCUMENT_COLLABORATION_BOUNDARY.md`.
The role-adaptive user, administration, integration, and operator guide is the The role-adaptive user, administration, integration, and operator guide is the
[Files handbook](docs/FILES_HANDBOOK.md). [Files handbook](docs/FILES_HANDBOOK.md).
The Files route, connector surfaces, consequence classes, and pattern-language
verification are recorded in
[Files interface pattern migration](docs/INTERFACE_PATTERN_MIGRATION.md).
Archive imports use a two-phase preview and confirmation flow for ZIP, TAR, Archive imports use a two-phase preview and confirmation flow for ZIP, TAR,
TAR.GZ, TAR.BZ2, and TAR.XZ. Requests are spooled to bounded temporary files; TAR.GZ, TAR.BZ2, and TAR.XZ. Requests are spooled to bounded temporary files;
+12
View File
@@ -24,6 +24,10 @@ a document collaboration engine, or a records-management system.
| Verify a release or scenario | [Acceptance scenarios](#acceptance-scenarios) | | Verify a release or scenario | [Acceptance scenarios](#acceptance-scenarios) |
| Check whether an idea exists today | [Implemented and planned boundary](#implemented-and-planned-boundary) | | Check whether an idea exists today | [Implemented and planned boundary](#implemented-and-planned-boundary) |
The Files-owned interface archetypes, consequence classes, disabled-state
wording, and verification evidence are recorded in
[Files Interface Pattern Migration](INTERFACE_PATTERN_MIGRATION.md).
## The service contract ## The service contract
A managed file is a tenant-scoped logical asset with exactly one user or group A managed file is a tenant-scoped logical asset with exactly one user or group
@@ -185,6 +189,14 @@ inside the chosen owner space:
Browse, import, and sync never write, rename, or delete the remote source. Browse, import, and sync never write, rename, or delete the remote source.
Connector administration separates endpoint profiles, reusable credentials,
and inherited policy. Ordinary setup uses typed fields and provider discovery;
provider metadata JSON is available only under advanced compatibility options.
Read-only deployment entries explain where they must be changed, and disabled
actions identify the missing permission, target, input, or running operation.
The contextual help icon opens the configured Help Center topic when Docs is
enabled and the hosted GovOPlaN documentation otherwise.
## Process perspective ## Process perspective
### Managed ingestion ### Managed ingestion
+55
View File
@@ -0,0 +1,55 @@
# Files Interface Pattern Migration
This inventory records the Files-owned part of the GovOPlaN interface pattern
language. Core owns the shell and shared components; Files owns the composition
and consequences described here.
## Surface inventory
| Surface | Primary task | Archetype | Consequence | Pattern evidence |
| --- | --- | --- | --- | --- |
| `/files` space and folder panes | Browse managed and connected content without losing location | Directory/explorer | Low for navigation; medium for exposing filenames and provenance | Full-height two-pane workspace, bounded panes, stable selection and contextual Help Center link |
| `/files` toolbar and property filters | Find and act on the current selection | Explorer actions and local filtering | Medium for upload, move, copy, share and synchronization; high for delete | Actions remain beside the affected list, disabled controls explain permission/state/selection blockers, destructive work uses `ConfirmDialog` |
| Upload/archive, transfer, rename and connector-import dialogs | Supply and review one bounded change | Adaptive create/edit or guided import | Medium to high because files, paths and external bytes change | Shared `Dialog`, `FileDropZone`, validation, conflict review, unsaved inputs and explicit confirmation |
| File share dialog | Inspect and change access | Review/decision | High because another actor gains access | Shared dialog, access explanation, stable row actions and destructive confirmation |
| System/tenant/group/user connector surfaces | Compare connections, credentials and effective policy | Administration/configuration | High because endpoints, secrets and inherited policy control external access | Shared `ConnectionTree`, adaptive forms, `ActionBlockerHint`, policy provenance and contextual admin help |
| Connection and credential editors | Create or edit one governed endpoint or secret | Adaptive create/edit | High because a saved change may enable remote access | Relevant fields only, typed credential controls, discovery/test, advanced compatibility section, unsaved-change guard and disabled-save reasons |
| Connector policy card | Narrow inherited connector access | Effective-policy editor | High because deny/allow changes affect lower scopes | Typed reference selectors, deny precedence warning, effective source evidence and permission blocker |
| `files.widget.spaces` | See available managed/connected spaces and open Files | Dashboard widget | Low; names and provider state may still be sensitive | Shared loading/alert/status components, bounded item count, permission-filtered contribution |
| Files chooser capability used by another module | Select a managed snapshot without importing Files internals | Directory chooser | Medium because the exact selected version becomes another module's input | Shared dialog/confirmation, capability boundary and exact file/version evidence |
## State and consequence contract
- Loading, errors, success, empty results, access explanations and confirmation
use Core components. Files does not reproduce the application shell.
- A connector that comes from deployment settings remains visible but read-only;
its action explains that bootstrap configuration and a restart are required.
- Missing permission, target, selection, endpoint, or compatible provider is an
explained disabled state. It is not represented only by color or absence.
- Provider metadata JSON is an expert compatibility escape hatch inside the
collapsed shared advanced-options component. Ordinary connector setup uses
typed provider, endpoint, credential, capability and policy controls.
- Endpoint discovery and credential tests are explicit and report their result;
they do not save the draft. Save remains the only committing action.
- Connector/profile disable and managed-file delete remain confirmed actions and
state their immediate effect. Soft deletion must not be described as purge.
- External connector data, paths and credential references are rendered only in
already-authorized administration or explorer contexts. Secret values are
never returned for rendering.
## Accessibility and responsive evidence
Shared `Dialog` owns focus entry, Escape handling and focus return. Form and
toolbar DOM order is the keyboard order; disabled-action tooltips are themselves
focusable and expose the reason. The connector form uses semantic sections and
labels, status is textual as well as colored, and result alerts are announced by
the shared alert component. The explorer collapses to one column below 1050 px;
connector forms and action rows collapse below 760 px while preserving source
order. Long provider choices scroll inside the segmented control rather than
expanding the page.
The focused structural test guards these contracts, optional-module boundaries,
confirmation, contextual help, advanced-only JSON and responsive rules. Core's
TypeScript build, structural localization audit, module-permutation suite and
full-product bundle check provide the integration gates.
+5
View File
@@ -566,6 +566,11 @@ manifest = ModuleManifest(
"route": "/admin?section=tenant-file-connectors", "route": "/admin?section=tenant-file-connectors",
"screen": "File connections", "screen": "File connections",
"section": "Profiles, credentials, and effective connector policy", "section": "Profiles, credentials, and effective connector policy",
"help_contexts": [
"files.connectors",
"files.connector.credentials",
"files.connector.policy",
],
"security_invariants": [ "security_invariants": [
"New API-managed external secret references fail closed until Files can prove ownership and provider-side deletion.", "New API-managed external secret references fail closed until Files can prove ownership and provider-side deletion.",
"Deletion and destructive retirement scrub Files-owned encrypted connector material before completion and emit non-secret audit evidence.", "Deletion and destructive retirement scrub Files-owned encrypted connector material before completion and emit non-secret audit evidence.",
+8
View File
@@ -127,6 +127,14 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertEqual("reference", topic.metadata["kind"]) self.assertEqual("reference", topic.metadata["kind"])
self.assertEqual("File connections", topic.metadata["screen"]) self.assertEqual("File connections", topic.metadata["screen"])
self.assertTrue(topic.metadata["section"]) self.assertTrue(topic.metadata["section"])
self.assertEqual(
{
"files.connectors",
"files.connector.credentials",
"files.connector.policy",
},
set(topic.metadata["help_contexts"]),
)
self.assertIn("deny rules win", topic.body) self.assertIn("deny rules win", topic.body)
self.assertIn("redact secret values", topic.body) self.assertIn("redact secret values", topic.body)
self.assertIn("same transaction", topic.body) self.assertIn("same transaction", topic.body)
+2 -1
View File
@@ -15,7 +15,8 @@
}, },
"scripts": { "scripts": {
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs", "test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs",
"test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs" "test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs",
"test:interface-pattern-language": "node scripts/test-interface-pattern-language.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
function read(relativePath) {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
}
const connector = read("../src/features/files/FileConnectorSettingsPanel.tsx");
const filesPage = read("../src/features/files/FilesPage.tsx");
const moduleSource = read("../src/module.ts");
const styles = read("../src/styles/file-manager.css");
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
assert.match(connector, /ActionBlockerHint,[\s\S]*AdvancedOptionsPanel,[\s\S]*DocumentationHelpLink/);
assert.match(connector, /ConnectionTree,[\s\S]*ConfirmDialog,[\s\S]*Dialog,[\s\S]*LoadingFrame/);
assert.match(connector, /topicId: "files\.governed-connectors-and-provenance"/);
assert.match(connector, /documentation=\{CONNECTOR_DOCUMENTATION\}/);
assert.match(connector, /disabledReason=\{profileSaveDisabledReason\}/);
assert.match(connector, /disabledReason=\{credentialSaveDisabledReason\}/);
assert.match(connector, /disabledReason: mutationBlocker/);
assert.match(connector, /<AdvancedOptionsPanel title="Connection metadata JSON"/);
assert.match(connector, /<AdvancedOptionsPanel title="Credential metadata JSON"/);
assert.doesNotMatch(connector, /window\.(?:alert|confirm)\(/);
assert.match(filesPage, /DocumentationHelpLink/);
assert.match(filesPage, /topicId: "files\.workflow\.organize-managed-files"/);
assert.match(filesPage, /disabledReason=\{uploadBlocker\}/);
assert.match(filesPage, /disabledReason=\{deleteBlocker\}/);
assert.match(filesPage, /<ConfirmDialog[\s\S]*tone="danger"/);
assert.match(filesPage, /className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen files-page"/);
assert.doesNotMatch(filesPage, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(`${connector}\n${filesPage}\n${moduleSource}`, /@govoplan\/(?:campaign|mail|docs)-webui|govoplan_(?:campaign|mail|docs)/);
assert.match(moduleSource, /"files\.connectors"/);
assert.match(moduleSource, /"files\.fileExplorer"/);
assert.match(styles, /@media \(max-width: 1050px\)[\s\S]*\.files-page \.file-manager-shell[\s\S]*grid-template-columns: 1fr/);
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.file-connector-profile-row[\s\S]*grid-template-columns: 1fr/);
for (const archetype of ["Directory/explorer", "Adaptive create/edit", "Effective-policy editor", "Dashboard widget"]) {
assert.match(migration, new RegExp(archetype.replace("/", "\\/")));
}
assert.match(migration, /Shared `Dialog` owns focus entry, Escape handling and focus return/);
assert.match(migration, /Secret values are[\s\S]*never returned for rendering/);
console.log("Files surfaces satisfy the recorded interface pattern-language contract.");
@@ -6,9 +6,9 @@ import {
ConnectionTree, ConnectionTree,
ConfirmDialog, ConfirmDialog,
CredentialPanel, CredentialPanel,
DisabledActionTooltip,
DismissibleAlert, DismissibleAlert,
Dialog, Dialog,
DocumentationHelpLink,
FormField, FormField,
ActionBlockerHint, ActionBlockerHint,
AdvancedOptionsPanel, AdvancedOptionsPanel,
@@ -149,6 +149,16 @@ const ENDPOINT_PLACEHOLDERS: Record<Provider, string> = {
generic: "https://files.example.test" generic: "https://files.example.test"
}; };
const CONNECTOR_DOCUMENTATION = {
topicId: "files.governed-connectors-and-provenance",
documentationType: "admin"
} as const;
const CONNECTOR_RECOVERY_DOCUMENTATION = {
topicId: "files.reference.integrity-recovery-and-fail-closed-transports",
documentationType: "admin"
} as const;
export default function FileConnectorSettingsPanel({ export default function FileConnectorSettingsPanel({
settings, settings,
scopeType, scopeType,
@@ -796,10 +806,14 @@ export default function FileConnectorSettingsPanel({
const credentialSaveDisabled = !credentialDraft || saving || credentialMissingRequired; const credentialSaveDisabled = !credentialDraft || saving || credentialMissingRequired;
const profileSaveTooltip = requiredFieldsTooltip(profileMissingFields); const profileSaveTooltip = requiredFieldsTooltip(profileMissingFields);
const credentialSaveTooltip = requiredFieldsTooltip(credentialMissingFields); const credentialSaveTooltip = requiredFieldsTooltip(credentialMissingFields);
const profileSaveDisabledReason = saving ? "Wait for the current connector change to finish." : profileSaveTooltip;
const credentialSaveDisabledReason = saving ? "Wait for the current credential change to finish." : credentialSaveTooltip;
const canDiscoverProfileEndpoint = Boolean(draft && (draft.provider === "webdav" || draft.provider === "nextcloud")); const canDiscoverProfileEndpoint = Boolean(draft && (draft.provider === "webdav" || draft.provider === "nextcloud"));
const credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud"); const credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud");
const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported; const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported;
const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported); const credentialTestTooltip = saving ? "Wait for the current connector change to finish." :
testingCredentialLogin ? "The credential test is already running." :
credentialTestHelpText(credentialTestProfile, credentialTestUnsupported);
const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(selectableCredentials, draft) : []; const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(selectableCredentials, draft) : [];
const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : ""; const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : "";
const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [ const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [
@@ -846,18 +860,23 @@ export default function FileConnectorSettingsPanel({
function renderConnectorActions(row: ConnectorTreeRow) { function renderConnectorActions(row: ConnectorTreeRow) {
if (row.kind === "credential") { if (row.kind === "credential") {
const readOnly = row.credential.source_kind !== "database"; const readOnly = row.credential.source_kind !== "database";
const mutationBlocker = connectorMutationBlocker(canWrite, readOnly, saving);
return <TableActionGroup actions={[ 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: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.credential.label }), icon: <Edit3 size={15} />, disabled: Boolean(mutationBlocker), disabledReason: mutationBlocker, 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) } { id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.credential.label }), icon: <Trash2 size={15} />, variant: "danger", disabled: Boolean(mutationBlocker) || !row.credential.enabled, disabledReason: mutationBlocker || (!row.credential.enabled ? "This credential is already disabled." : ""), onClick: () => setPendingCredentialDeactivate(row.credential) }
]} />; ]} />;
} }
const readOnly = row.profile.source_kind !== "database"; const readOnly = row.profile.source_kind !== "database";
const mutationBlocker = connectorMutationBlocker(canWrite, readOnly, saving);
const testBlocker = !row.profile.enabled ? "Enable this connection before testing it." :
saving ? "Wait for the current connector change to finish." :
testingProfileId === row.profile.id ? "This connection test is already running." : "";
return <TableActionGroup actions={[ 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: "test", label: i18nMessage("i18n:govoplan-files.test_value.6a5d10a5", { value0: row.profile.label }), icon: <RefreshCw size={15} />, disabled: Boolean(testBlocker), disabledReason: testBlocker, 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: "add-credential", label: i18nMessage("i18n:govoplan-files.add_credential_for_value.0fa9c1fe", { value0: row.profile.label }), icon: <UserRoundKey size={15} />, variant: "primary", disabled: Boolean(mutationBlocker), disabledReason: mutationBlocker, 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: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.profile.label }), icon: <Edit3 size={15} />, disabled: Boolean(mutationBlocker), disabledReason: mutationBlocker, 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) } { id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.profile.label }), icon: <Trash2 size={15} />, variant: "danger", disabled: Boolean(mutationBlocker) || !row.profile.enabled, disabledReason: mutationBlocker || (!row.profile.enabled ? "This connection is already disabled." : ""), onClick: () => setPendingDeactivate(row.profile) }
]} />; ]} />;
} }
@@ -876,7 +895,8 @@ export default function FileConnectorSettingsPanel({
requiredAction: "Ask an administrator to grant the required permission or make the change at a higher scope.", requiredAction: "Ask an administrator to grant the required permission or make the change at a higher scope.",
actor: "System or tenant administrator", actor: "System or tenant administrator",
target: "Administration > GLOBAL or TENANT > File Connectors" target: "Administration > GLOBAL or TENANT > File Connectors"
}} /> }}
documentation={CONNECTOR_DOCUMENTATION} />
} }
{targetSelectionRequired && !hasSelectableTarget && {targetSelectionRequired && !hasSelectableTarget &&
@@ -888,7 +908,8 @@ export default function FileConnectorSettingsPanel({
requiredAction: "Create or select a user or group first.", requiredAction: "Create or select a user or group first.",
actor: "Access administrator", actor: "Access administrator",
target: targetPluralLabel(scopeType, targetLabel) target: targetPluralLabel(scopeType, targetLabel)
}} /> }}
documentation={CONNECTOR_DOCUMENTATION} />
} }
{targetSelectionRequired && {targetSelectionRequired &&
@@ -911,10 +932,11 @@ export default function FileConnectorSettingsPanel({
actions={ actions={
canWrite ? canWrite ?
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "i18n:govoplan-files.loading.b04ba49f" : "i18n:govoplan-files.reload.cce71553"}</Button> <DocumentationHelpLink reference={CONNECTOR_DOCUMENTATION} />
<Button variant="primary" onClick={startCreate} disabled={saving}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.new_connection.ac979fe4</Button> <Button onClick={() => void loadProfiles()} disabled={loading || saving} disabledReason={loading ? "File connections are already loading." : saving ? "Wait for the current connector change to finish." : undefined}>{loading ? "i18n:govoplan-files.loading.b04ba49f" : "i18n:govoplan-files.reload.cce71553"}</Button>
<Button variant="primary" onClick={startCreate} disabled={saving} disabledReason={saving ? "Wait for the current connector change to finish." : undefined}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.new_connection.ac979fe4</Button>
</div> : </div> :
null <DocumentationHelpLink reference={CONNECTOR_DOCUMENTATION} />
}> }>
<LoadingFrame loading={loading} label="i18n:govoplan-files.loading_file_connections.bd68f224"> <LoadingFrame loading={loading} label="i18n:govoplan-files.loading_file_connections.bd68f224">
@@ -931,10 +953,13 @@ export default function FileConnectorSettingsPanel({
<Card <Card
title={i18nMessage("i18n:govoplan-files.value_connector_policy.0bf7f53b", { value0: scopeLabel(scopeType) })} title={i18nMessage("i18n:govoplan-files.value_connector_policy.0bf7f53b", { value0: scopeLabel(scopeType) })}
actions={canWrite ? actions={canWrite ?
<Button variant="primary" onClick={() => void savePolicyDraft()} disabled={saving || loading}> <div className="button-row compact-actions">
<ShieldCheck size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_policy.77d67ce3"} <DocumentationHelpLink reference={CONNECTOR_DOCUMENTATION} />
</Button> : <Button variant="primary" onClick={() => void savePolicyDraft()} disabled={saving || loading} disabledReason={saving ? "Wait for the current connector change to finish." : loading ? "Wait until the effective connector policy has loaded." : undefined}>
null}> <ShieldCheck size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_policy.77d67ce3"}
</Button>
</div> :
<DocumentationHelpLink reference={CONNECTOR_DOCUMENTATION} />}>
<LoadingFrame loading={loading} label="i18n:govoplan-files.loading_connector_policy.f12ab285"> <LoadingFrame loading={loading} label="i18n:govoplan-files.loading_connector_policy.f12ab285">
<> <>
@@ -1060,11 +1085,9 @@ export default function FileConnectorSettingsPanel({
footer={credentialDraft ? footer={credentialDraft ?
<> <>
<Button onClick={closeCredentialDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button> <Button onClick={closeCredentialDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
<DisabledActionTooltip reason={credentialSaveTooltip}> <Button variant="primary" onClick={() => void saveCredentialDraft()} disabled={credentialSaveDisabled} disabledReason={credentialSaveDisabledReason}>
<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"}
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_credential.2c02a6d2"} </Button>
</Button>
</DisabledActionTooltip>
</> : </> :
null}> null}>
@@ -1105,7 +1128,7 @@ export default function FileConnectorSettingsPanel({
/> />
</FormField> </FormField>
</div> </div>
<FormField label="i18n:govoplan-files.credential_mode.23fdd899"> <FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION}>
<select value={credentialDraft.credentialMode} disabled={saving} onChange={(event) => patchCredentialDraft({ credentialMode: event.target.value as CredentialMode })}> <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="none">i18n:govoplan-files.none.6eef6648</option>
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option> <option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
@@ -1173,11 +1196,9 @@ export default function FileConnectorSettingsPanel({
</div> </div>
} }
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<DisabledActionTooltip reason={credentialTestDisabled ? credentialTestTooltip : ""}> <Button type="button" onClick={() => void testCredentialLogin()} disabled={credentialTestDisabled} disabledReason={credentialTestTooltip}>
<Button type="button" onClick={() => void testCredentialLogin()} disabled={credentialTestDisabled}> <RefreshCw size={16} aria-hidden="true" /> {testingCredentialLogin ? "Testing login" : "Test login"}
<RefreshCw size={16} aria-hidden="true" /> {testingCredentialLogin ? "Testing login" : "Test login"} </Button>
</Button>
</DisabledActionTooltip>
</div> </div>
{credentialLoginResult && {credentialLoginResult &&
<DismissibleAlert tone={credentialLoginResult.ok ? "success" : "warning"} resetKey={credentialLoginResult.message}> <DismissibleAlert tone={credentialLoginResult.ok ? "success" : "warning"} resetKey={credentialLoginResult.message}>
@@ -1192,7 +1213,7 @@ export default function FileConnectorSettingsPanel({
<p>Limit where lower levels may use this credential.</p> <p>Limit where lower levels may use this credential.</p>
</header> </header>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="i18n:govoplan-files.policy.bb9cf141"> <FormField label="i18n:govoplan-files.policy.bb9cf141" documentation={CONNECTOR_DOCUMENTATION}>
<select value={credentialDraft.policyMode} disabled={saving} onChange={(event) => patchCredentialDraft({ policyMode: event.target.value as PolicyMode })}> <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="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="allowed-paths">i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4</option>
@@ -1209,7 +1230,7 @@ export default function FileConnectorSettingsPanel({
</FormField> </FormField>
</div> </div>
<AdvancedOptionsPanel title="Credential metadata JSON" summary="For diagnostics or provider-specific compatibility flags. Normal setup should not need this."> <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"> <FormField label="i18n:govoplan-files.metadata_json.b0e4c283" documentation={CONNECTOR_RECOVERY_DOCUMENTATION}>
<textarea value={credentialDraft.metadataJson} disabled={saving} onChange={(event) => patchCredentialDraft({ metadataJson: event.target.value })} rows={5} spellCheck={false} /> <textarea value={credentialDraft.metadataJson} disabled={saving} onChange={(event) => patchCredentialDraft({ metadataJson: event.target.value })} rows={5} spellCheck={false} />
</FormField> </FormField>
</AdvancedOptionsPanel> </AdvancedOptionsPanel>
@@ -1228,11 +1249,9 @@ export default function FileConnectorSettingsPanel({
footer={draft ? footer={draft ?
<> <>
<Button onClick={closeProfileDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button> <Button onClick={closeProfileDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
<DisabledActionTooltip reason={profileSaveTooltip}> <Button variant="primary" onClick={() => void saveDraft()} disabled={profileSaveDisabled} disabledReason={profileSaveDisabledReason}>
<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"}
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_connection.07796d38"} </Button>
</Button>
</DisabledActionTooltip>
</> : </> :
null}> null}>
@@ -1277,13 +1296,13 @@ export default function FileConnectorSettingsPanel({
<p>Only fields relevant for the selected provider and credential mode are shown.</p> <p>Only fields relevant for the selected provider and credential mode are shown.</p>
</header> </header>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="i18n:govoplan-files.endpoint_url.65aaaa45"> <FormField label="i18n:govoplan-files.endpoint_url.65aaaa45" documentation={CONNECTOR_DOCUMENTATION}>
<input value={draft.endpointUrl} disabled={saving} onChange={(event) => patchDraft({ endpointUrl: event.target.value })} placeholder={ENDPOINT_PLACEHOLDERS[draft.provider]} /> <input value={draft.endpointUrl} disabled={saving} onChange={(event) => patchDraft({ endpointUrl: event.target.value })} placeholder={ENDPOINT_PLACEHOLDERS[draft.provider]} />
</FormField> </FormField>
<FormField label="i18n:govoplan-files.base_path.6a4867ca"> <FormField label="i18n:govoplan-files.base_path.6a4867ca">
<input value={draft.basePath} disabled={saving} onChange={(event) => patchDraft({ basePath: event.target.value })} placeholder="GovOPlaN" /> <input value={draft.basePath} disabled={saving} onChange={(event) => patchDraft({ basePath: event.target.value })} placeholder="GovOPlaN" />
</FormField> </FormField>
<FormField label="i18n:govoplan-files.credential_mode.23fdd899"> <FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION}>
<select value={draft.credentialMode} disabled={saving} onChange={(event) => patchDraft({ credentialMode: event.target.value as CredentialMode })}> <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="none">i18n:govoplan-files.none.6eef6648</option>
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option> <option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
@@ -1300,9 +1319,9 @@ export default function FileConnectorSettingsPanel({
</FormField> </FormField>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button type="button" onClick={() => patchDraft({ endpointUrl: ENDPOINT_PLACEHOLDERS[draft.provider] })} disabled={saving}>Use provider example</Button> <Button type="button" onClick={() => patchDraft({ endpointUrl: ENDPOINT_PLACEHOLDERS[draft.provider] })} disabled={saving} disabledReason={saving ? "Wait for the current connector change to finish." : undefined}>Use provider example</Button>
{canDiscoverProfileEndpoint && {canDiscoverProfileEndpoint &&
<Button type="button" onClick={() => void discoverProfileEndpoint()} disabled={saving || discoveringProfile || !draft.endpointUrl.trim()}> <Button type="button" onClick={() => void discoverProfileEndpoint()} disabled={saving || discoveringProfile || !draft.endpointUrl.trim()} disabledReason={saving ? "Wait for the current connector change to finish." : discoveringProfile ? "Endpoint discovery is already running." : !draft.endpointUrl.trim() ? "Enter a Nextcloud or WebDAV URL before discovery." : undefined}>
<RefreshCw size={16} aria-hidden="true" /> {discoveringProfile ? "Checking endpoint" : "Discover endpoint"} <RefreshCw size={16} aria-hidden="true" /> {discoveringProfile ? "Checking endpoint" : "Discover endpoint"}
</Button> </Button>
} }
@@ -1348,7 +1367,7 @@ export default function FileConnectorSettingsPanel({
<ToggleSwitch label="i18n:govoplan-files.sync.905f6309" checked={draft.canSync} disabled={saving} onChange={(canSync) => patchDraft({ canSync })} /> <ToggleSwitch label="i18n:govoplan-files.sync.905f6309" checked={draft.canSync} disabled={saving} onChange={(canSync) => patchDraft({ canSync })} />
</div> </div>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="i18n:govoplan-files.policy.bb9cf141"> <FormField label="i18n:govoplan-files.policy.bb9cf141" documentation={CONNECTOR_DOCUMENTATION}>
<select value={draft.policyMode} disabled={saving} onChange={(event) => patchDraft({ policyMode: event.target.value as PolicyMode })}> <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="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="allowed-paths">i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4</option>
@@ -1365,7 +1384,7 @@ export default function FileConnectorSettingsPanel({
</FormField> </FormField>
</div> </div>
<AdvancedOptionsPanel title="Connection metadata JSON" summary="For diagnostics or provider-specific compatibility flags. Normal setup should not need this."> <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"> <FormField label="i18n:govoplan-files.metadata_json.b0e4c283" documentation={CONNECTOR_RECOVERY_DOCUMENTATION}>
<textarea value={draft.metadataJson} disabled={saving} onChange={(event) => patchDraft({ metadataJson: event.target.value })} rows={5} spellCheck={false} /> <textarea value={draft.metadataJson} disabled={saving} onChange={(event) => patchDraft({ metadataJson: event.target.value })} rows={5} spellCheck={false} />
</FormField> </FormField>
</AdvancedOptionsPanel> </AdvancedOptionsPanel>
@@ -1485,6 +1504,13 @@ function targetPluralLabel(scopeType: ConnectorScope, fallback: string): string
return fallback; return fallback;
} }
function connectorMutationBlocker(canWrite: boolean, readOnly: boolean, saving: boolean): string {
if (!canWrite) return "Connector administration permission is required for this action.";
if (readOnly) return "This entry is deployment-managed. Change its bootstrap configuration and restart the service.";
if (saving) return "Wait for the current connector change to finish.";
return "";
}
function emptyDraft(scopeType: ConnectorScope): ConnectorProfileDraft { function emptyDraft(scopeType: ConnectorScope): ConnectorProfileDraft {
return { return {
id: `${scopeType}-webdav`, id: `${scopeType}-webdav`,
+31 -12
View File
@@ -3,6 +3,7 @@ import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Li
import { import {
Button, Button,
ConfirmDialog, ConfirmDialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FieldLabel, FieldLabel,
FileDropZone, FileDropZone,
@@ -107,6 +108,10 @@ type FileAccessExplanationTarget = {
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58; const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
const FILE_LIST_OVERSCAN_ROWS = 8; const FILE_LIST_OVERSCAN_ROWS = 8;
const ARCHIVE_FILENAME_PATTERN = /\.(?:zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i; const ARCHIVE_FILENAME_PATTERN = /\.(?:zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
const FILES_WORKFLOW_DOCUMENTATION = {
topicId: "files.workflow.organize-managed-files",
documentationType: "user"
} as const;
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const canDownload = hasScope(auth, "files:download"); const canDownload = hasScope(auth, "files:download");
@@ -2071,31 +2076,45 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
message === "i18n:govoplan-files.unpacking_zip_upload.35019691" || message === "i18n:govoplan-files.unpacking_zip_upload.35019691" ||
message === "i18n:govoplan-files.finalizing_upload.bcce936d" ? "info" : "success"; message === "i18n:govoplan-files.finalizing_upload.bcce936d" ? "info" : "success";
const managedSpaceBlocker = !activeSpace ? "Select a file space first." :
activeSpaceIsConnector ? "This action writes managed storage and is unavailable in a read-only connector space." : "";
const workingBlocker = busy ? "Wait for the current file operation to finish." : "";
const uploadBlocker = workingBlocker || managedSpaceBlocker || (!canUpload ? "File upload permission is required." : "");
const organizeBlocker = workingBlocker || managedSpaceBlocker || (!canOrganize ? "File organization permission is required." : "");
const selectionBlocker = !hasSelection ? "Select at least one file or folder first." : "";
const downloadBlocker = workingBlocker || managedSpaceBlocker || (!canDownload ? "File download permission is required." : "") || (selectedDownloadFileIds.length === 0 ? "Select at least one downloadable file first." : "");
const shareBlocker = workingBlocker || managedSpaceBlocker || (!canShare ? "File sharing permission is required." : "") || (!shareManageableFile ? "Select one manageable file first." : "");
const accessExplanationBlocker = workingBlocker || (activeSpaceIsConnector ? "Access explanations apply to managed files and folders." : "") || (!canExplainResourceAccess ? "Permission to inspect resource access is required." : "") || (!accessExplainableTarget ? "Select one file or folder first." : "");
const deleteBlocker = workingBlocker || managedSpaceBlocker || (!canDelete ? "File deletion permission is required." : "") || selectionBlocker;
const syncBlocker = workingBlocker || (!activeSpace ? "Select a file space first." : "") || (!canUpload ? "File upload permission is required to import synchronized content." : "") || (activeSpaceIsConnector && connectorSpaceSelectedItem?.kind !== "file" ? "Select one remote file to synchronize." : "");
const toolbar = const toolbar =
<div className="file-manager-toolbar" aria-label="i18n:govoplan-files.file_actions.9e1b94c5"> <div className="file-manager-toolbar" aria-label="i18n:govoplan-files.file_actions.9e1b94c5">
<Button variant="primary" onClick={() => openDialog("upload", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canUpload}><UploadCloud size={16} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</Button> <Button variant="primary" onClick={() => openDialog("upload", toolbarTarget())} disabled={Boolean(uploadBlocker)} disabledReason={uploadBlocker}><UploadCloud size={16} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</Button>
<Button <Button
onClick={() => activeSpaceIsConnector ? void syncConnectorSpaceSelection() : void openConnectorSyncDialog(toolbarTarget())} onClick={() => activeSpaceIsConnector ? void syncConnectorSpaceSelection() : void openConnectorSyncDialog(toolbarTarget())}
disabled={busy || !activeSpace || !canUpload || activeSpaceIsConnector && connectorSpaceSelectedItem?.kind !== "file"}> disabled={Boolean(syncBlocker)}
disabledReason={syncBlocker}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.sync.905f6309 <RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.sync.905f6309
</Button> </Button>
<Button onClick={() => void openConnectorSpaceDialog()} disabled={busy || !canOrganize}> <Button onClick={() => void openConnectorSpaceDialog()} disabled={busy || !canOrganize} disabledReason={workingBlocker || (!canOrganize ? "File organization permission is required to add a connector space." : "")}>
<Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4 <Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4
</Button> </Button>
<Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button> <Button onClick={() => void downloadSelection()} disabled={Boolean(downloadBlocker)} disabledReason={downloadBlocker}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
<Button onClick={() => shareManageableFile && setShareDialogFile(shareManageableFile)} disabled={busy || activeSpaceIsConnector || !shareManageableFile}><Share2 size={16} aria-hidden="true" /> Manage shares</Button> <Button onClick={() => shareManageableFile && setShareDialogFile(shareManageableFile)} disabled={Boolean(shareBlocker)} disabledReason={shareBlocker}><Share2 size={16} aria-hidden="true" /> Manage shares</Button>
<Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button> <Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={Boolean(organizeBlocker)} disabledReason={organizeBlocker}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button>
<Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button> <Button onClick={() => openTransferDialog("move")} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
<Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button> <Button onClick={() => openTransferDialog("copy")} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
{hasSelection && <Button onClick={openRenameDialog} disabled={busy || activeSpaceIsConnector || !canOrganize}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>} {hasSelection && <Button onClick={openRenameDialog} disabled={Boolean(organizeBlocker)} disabledReason={organizeBlocker}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>}
<Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={busy || activeSpaceIsConnector || !canExplainResourceAccess || !accessExplainableTarget}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button> <Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
<Button variant="danger" onClick={() => void deleteSelected()} disabled={busy || activeSpaceIsConnector || !canDelete || !hasSelection}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button> <Button variant="danger" onClick={() => void deleteSelected()} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
{activeSpaceIsConnector && {activeSpaceIsConnector &&
<Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace}> <Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace} disabledReason={workingBlocker || (connectorSpaceLoading ? "This connector space is already refreshing." : !activeSpace ? "Select a connector space first." : "")}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc <RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc
</Button> </Button>
} }
<DocumentationHelpLink reference={FILES_WORKFLOW_DOCUMENTATION} />
</div>; </div>;