Migrate Files surfaces to interface patterns
This commit is contained in:
@@ -148,6 +148,9 @@ Connector and collaboration ownership boundaries are documented in
|
||||
`docs/CONNECTOR_BOUNDARY.md` and `docs/DOCUMENT_COLLABORATION_BOUNDARY.md`.
|
||||
The role-adaptive user, administration, integration, and operator guide is the
|
||||
[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,
|
||||
TAR.GZ, TAR.BZ2, and TAR.XZ. Requests are spooled to bounded temporary files;
|
||||
|
||||
@@ -24,6 +24,10 @@ a document collaboration engine, or a records-management system.
|
||||
| Verify a release or scenario | [Acceptance scenarios](#acceptance-scenarios) |
|
||||
| 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
### Managed ingestion
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -566,6 +566,11 @@ manifest = ModuleManifest(
|
||||
"route": "/admin?section=tenant-file-connectors",
|
||||
"screen": "File connections",
|
||||
"section": "Profiles, credentials, and effective connector policy",
|
||||
"help_contexts": [
|
||||
"files.connectors",
|
||||
"files.connector.credentials",
|
||||
"files.connector.policy",
|
||||
],
|
||||
"security_invariants": [
|
||||
"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.",
|
||||
|
||||
@@ -127,6 +127,14 @@ class FilesManifestDocumentationTests(unittest.TestCase):
|
||||
self.assertEqual("reference", topic.metadata["kind"])
|
||||
self.assertEqual("File connections", topic.metadata["screen"])
|
||||
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("redact secret values", topic.body)
|
||||
self.assertIn("same transaction", topic.body)
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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": {
|
||||
"@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,
|
||||
ConfirmDialog,
|
||||
CredentialPanel,
|
||||
DisabledActionTooltip,
|
||||
DismissibleAlert,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
ActionBlockerHint,
|
||||
AdvancedOptionsPanel,
|
||||
@@ -149,6 +149,16 @@ const ENDPOINT_PLACEHOLDERS: Record<Provider, string> = {
|
||||
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({
|
||||
settings,
|
||||
scopeType,
|
||||
@@ -796,10 +806,14 @@ export default function FileConnectorSettingsPanel({
|
||||
const credentialSaveDisabled = !credentialDraft || saving || credentialMissingRequired;
|
||||
const profileSaveTooltip = requiredFieldsTooltip(profileMissingFields);
|
||||
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 credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud");
|
||||
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 profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : "";
|
||||
const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [
|
||||
@@ -846,18 +860,23 @@ export default function FileConnectorSettingsPanel({
|
||||
function renderConnectorActions(row: ConnectorTreeRow) {
|
||||
if (row.kind === "credential") {
|
||||
const readOnly = row.credential.source_kind !== "database";
|
||||
const mutationBlocker = connectorMutationBlocker(canWrite, readOnly, saving);
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.credential.label }), icon: <Edit3 size={15} />, disabled: !canWrite || readOnly || saving, onClick: () => startCredentialEdit(row.credential) },
|
||||
{ id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.credential.label }), icon: <Trash2 size={15} />, variant: "danger", applicable: row.credential.enabled, disabled: !canWrite || readOnly || saving, onClick: () => setPendingCredentialDeactivate(row.credential) }
|
||||
{ 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", 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 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={[
|
||||
{ id: "test", label: i18nMessage("i18n:govoplan-files.test_value.6a5d10a5", { value0: row.profile.label }), icon: <RefreshCw size={15} />, applicable: row.profile.enabled, disabled: saving || testingProfileId === row.profile.id, onClick: () => void testBrowse(row.profile) },
|
||||
{ id: "add-credential", label: i18nMessage("i18n:govoplan-files.add_credential_for_value.0fa9c1fe", { value0: row.profile.label }), icon: <UserRoundKey size={15} />, variant: "primary", disabled: !canWrite || saving, onClick: () => startCredentialCreate(row.profile) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-files.edit_value.fad75899", { value0: row.profile.label }), icon: <Edit3 size={15} />, disabled: !canWrite || readOnly || saving, onClick: () => startEdit(row.profile) },
|
||||
{ id: "disable", label: i18nMessage("i18n:govoplan-files.disable_value.09485f7f", { value0: row.profile.label }), icon: <Trash2 size={15} />, variant: "danger", applicable: row.profile.enabled, disabled: !canWrite || readOnly || saving, onClick: () => setPendingDeactivate(row.profile) }
|
||||
{ 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: 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: 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", 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.",
|
||||
actor: "System or tenant administrator",
|
||||
target: "Administration > GLOBAL or TENANT > File Connectors"
|
||||
}} />
|
||||
}}
|
||||
documentation={CONNECTOR_DOCUMENTATION} />
|
||||
}
|
||||
|
||||
{targetSelectionRequired && !hasSelectableTarget &&
|
||||
@@ -888,7 +908,8 @@ export default function FileConnectorSettingsPanel({
|
||||
requiredAction: "Create or select a user or group first.",
|
||||
actor: "Access administrator",
|
||||
target: targetPluralLabel(scopeType, targetLabel)
|
||||
}} />
|
||||
}}
|
||||
documentation={CONNECTOR_DOCUMENTATION} />
|
||||
}
|
||||
|
||||
{targetSelectionRequired &&
|
||||
@@ -911,10 +932,11 @@ export default function FileConnectorSettingsPanel({
|
||||
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>
|
||||
<DocumentationHelpLink reference={CONNECTOR_DOCUMENTATION} />
|
||||
<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> :
|
||||
null
|
||||
<DocumentationHelpLink reference={CONNECTOR_DOCUMENTATION} />
|
||||
}>
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-files.loading_file_connections.bd68f224">
|
||||
@@ -931,10 +953,13 @@ export default function FileConnectorSettingsPanel({
|
||||
<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}>
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={CONNECTOR_DOCUMENTATION} />
|
||||
<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}>
|
||||
<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">
|
||||
<>
|
||||
@@ -1060,11 +1085,9 @@ export default function FileConnectorSettingsPanel({
|
||||
footer={credentialDraft ?
|
||||
<>
|
||||
<Button onClick={closeCredentialDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
|
||||
<DisabledActionTooltip reason={credentialSaveTooltip}>
|
||||
<Button variant="primary" onClick={() => void saveCredentialDraft()} disabled={credentialSaveDisabled}>
|
||||
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_credential.2c02a6d2"}
|
||||
</Button>
|
||||
</DisabledActionTooltip>
|
||||
<Button variant="primary" onClick={() => void saveCredentialDraft()} disabled={credentialSaveDisabled} disabledReason={credentialSaveDisabledReason}>
|
||||
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_credential.2c02a6d2"}
|
||||
</Button>
|
||||
</> :
|
||||
null}>
|
||||
|
||||
@@ -1105,7 +1128,7 @@ export default function FileConnectorSettingsPanel({
|
||||
/>
|
||||
</FormField>
|
||||
</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 })}>
|
||||
<option value="none">i18n:govoplan-files.none.6eef6648</option>
|
||||
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
|
||||
@@ -1173,11 +1196,9 @@ export default function FileConnectorSettingsPanel({
|
||||
</div>
|
||||
}
|
||||
<div className="button-row compact-actions">
|
||||
<DisabledActionTooltip reason={credentialTestDisabled ? credentialTestTooltip : ""}>
|
||||
<Button type="button" onClick={() => void testCredentialLogin()} disabled={credentialTestDisabled}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> {testingCredentialLogin ? "Testing login" : "Test login"}
|
||||
</Button>
|
||||
</DisabledActionTooltip>
|
||||
<Button type="button" onClick={() => void testCredentialLogin()} disabled={credentialTestDisabled} disabledReason={credentialTestTooltip}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> {testingCredentialLogin ? "Testing login" : "Test login"}
|
||||
</Button>
|
||||
</div>
|
||||
{credentialLoginResult &&
|
||||
<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>
|
||||
</header>
|
||||
<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 })}>
|
||||
<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>
|
||||
@@ -1209,7 +1230,7 @@ export default function FileConnectorSettingsPanel({
|
||||
</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">
|
||||
<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} />
|
||||
</FormField>
|
||||
</AdvancedOptionsPanel>
|
||||
@@ -1228,11 +1249,9 @@ export default function FileConnectorSettingsPanel({
|
||||
footer={draft ?
|
||||
<>
|
||||
<Button onClick={closeProfileDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
|
||||
<DisabledActionTooltip reason={profileSaveTooltip}>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={profileSaveDisabled}>
|
||||
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_connection.07796d38"}
|
||||
</Button>
|
||||
</DisabledActionTooltip>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={profileSaveDisabled} disabledReason={profileSaveDisabledReason}>
|
||||
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_connection.07796d38"}
|
||||
</Button>
|
||||
</> :
|
||||
null}>
|
||||
|
||||
@@ -1277,13 +1296,13 @@ export default function FileConnectorSettingsPanel({
|
||||
<p>Only fields relevant for the selected provider and credential mode are shown.</p>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="i18n:govoplan-files.endpoint_url.65aaaa45">
|
||||
<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]} />
|
||||
</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">
|
||||
<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 })}>
|
||||
<option value="none">i18n:govoplan-files.none.6eef6648</option>
|
||||
<option value="anonymous">i18n:govoplan-files.anonymous.9bed5104</option>
|
||||
@@ -1300,9 +1319,9 @@ export default function FileConnectorSettingsPanel({
|
||||
</FormField>
|
||||
</div>
|
||||
<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 &&
|
||||
<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"}
|
||||
</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 })} />
|
||||
</div>
|
||||
<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 })}>
|
||||
<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>
|
||||
@@ -1365,7 +1384,7 @@ export default function FileConnectorSettingsPanel({
|
||||
</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">
|
||||
<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} />
|
||||
</FormField>
|
||||
</AdvancedOptionsPanel>
|
||||
@@ -1485,6 +1504,13 @@ function targetPluralLabel(scopeType: ConnectorScope, fallback: string): string
|
||||
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 {
|
||||
return {
|
||||
id: `${scopeType}-webdav`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Li
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FieldLabel,
|
||||
FileDropZone,
|
||||
@@ -107,6 +108,10 @@ type FileAccessExplanationTarget = {
|
||||
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
|
||||
const FILE_LIST_OVERSCAN_ROWS = 8;
|
||||
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;}) {
|
||||
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.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 =
|
||||
<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
|
||||
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
|
||||
</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
|
||||
</Button>
|
||||
<Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><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={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><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("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><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>}
|
||||
<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 variant="danger" onClick={() => void deleteSelected()} disabled={busy || activeSpaceIsConnector || !canDelete || !hasSelection}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
|
||||
<Button onClick={() => void downloadSelection()} disabled={Boolean(downloadBlocker)} disabledReason={downloadBlocker}><Download size={16} aria-hidden="true" /> {downloadLabel}</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={Boolean(organizeBlocker)} disabledReason={organizeBlocker}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</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={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</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={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</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 &&
|
||||
<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
|
||||
</Button>
|
||||
}
|
||||
<DocumentationHelpLink reference={FILES_WORKFLOW_DOCUMENTATION} />
|
||||
</div>;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user