Compare commits

...
3 Commits
Author SHA1 Message Date
zemion 2baa8f2657 feat: promote files to a stable product destination
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 17:58:33 +02:00
zemion 6176e9f40e feat: inventory storage infrastructure dependencies
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 14:56:33 +02:00
zemion 11b9b7c4c6 fix(webui): bind file credentials and deletion to help
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:36:40 +02:00
11 changed files with 214 additions and 29 deletions
+8
View File
@@ -85,6 +85,14 @@ storage, or reinterpret an infrastructure replacement as safe. Use the Files
integrity and Ops checks after deployment and complete migration/recovery review integrity and Ops checks after deployment and complete migration/recovery review
before changing an active backend. before changing an active backend.
The Files infrastructure dependency provider makes that review concrete. Its
authorized, non-secret Ops inventory reports the active runtime binding and
aggregates persisted `FileBlob` rows by storage backend with blob counts and
byte totals. A host apply that changes `files.storage` requires a fresh,
complete inventory from the same installation and shows the migration and
checksum-verification action before any service is replaced. Object keys,
tenant identifiers and storage credentials are not exported.
## User tasks ## User tasks
The Files page is available at `/files`. Actions appear only when the current The Files page is available at `/files`. Actions appear only when the current
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.22", "version": "0.1.25",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -26,7 +26,7 @@
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9", "react-router": ">=8.3.0 <9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"@govoplan/core-webui": "^0.1.18" "@govoplan/core-webui": "^0.1.44"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-files" name = "govoplan-files"
version = "0.1.22" version = "0.1.25"
description = "GovOPlaN files module with backend and WebUI integration." description = "GovOPlaN files module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.20", "govoplan-core>=0.1.44",
"defusedxml>=0.7,<1", "defusedxml>=0.7,<1",
"pyzipper>=0.3.6,<1", "pyzipper>=0.3.6,<1",
"python-multipart>=0.0.31,<1", "python-multipart>=0.0.31,<1",
@@ -6,6 +6,8 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
from sqlalchemy import func, select
from govoplan_core.core.configuration_packages import ( from govoplan_core.core.configuration_packages import (
ConfigurationApplyResult, ConfigurationApplyResult,
ConfigurationDiagnostic, ConfigurationDiagnostic,
@@ -21,19 +23,30 @@ from govoplan_core.core.configuration_packages import (
from govoplan_core.core.infrastructure_capabilities import ( from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapability, InfrastructureCapability,
InfrastructureCapabilityReceipt, InfrastructureCapabilityReceipt,
InfrastructureDependency,
InfrastructureDependencyProvider,
) )
from govoplan_core.db.session import get_database
from govoplan_files.backend.db.models import FileBlob
from govoplan_files.backend.runtime import settings as runtime_settings from govoplan_files.backend.runtime import settings as runtime_settings
FILES_CONFIGURATION_CAPABILITY = "files.configuration" FILES_CONFIGURATION_CAPABILITY = "files.configuration"
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = (
"infrastructure.dependency_inventory.files"
)
MANAGED_STORAGE_FRAGMENT = "managed_storage" MANAGED_STORAGE_FRAGMENT = "managed_storage"
_PAYLOAD_KEYS = frozenset( _PAYLOAD_KEYS = frozenset(
{"capability_id", "expected_backend", "expected_source"} {"capability_id", "expected_backend", "expected_source"}
) )
class FilesConfigurationProvider(ConfigurationProvider): class FilesConfigurationProvider(
ConfigurationProvider,
InfrastructureDependencyProvider,
):
module_id = "files" module_id = "files"
capability_ids = ("files.storage",)
def __init__( def __init__(
self, self,
@@ -166,6 +179,61 @@ class FilesConfigurationProvider(ConfigurationProvider):
item for item in import_result.diagnostics if item.severity == "blocker" item for item in import_result.diagnostics if item.severity == "blocker"
) )
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
active_backend = _normalized_backend(
getattr(self._settings, "file_storage_backend", "local")
)
dependencies = [
InfrastructureDependency(
capability_id="files.storage",
module_id="files",
dependency_type="runtime_storage_binding",
dependency_ref=f"files-storage:{active_backend}",
state="runtime_binding",
scope="system",
summary=(
"The active Files runtime is bound to this deployment storage backend."
),
metrics={},
required_action=(
"Provision and verify the replacement backend before rebinding the Files runtime."
),
)
]
with get_database().session() as session:
rows = session.execute(
select(
FileBlob.storage_backend,
func.count(FileBlob.id),
func.coalesce(func.sum(FileBlob.size_bytes), 0),
)
.group_by(FileBlob.storage_backend)
.order_by(FileBlob.storage_backend)
)
for backend, blob_count, size_bytes in rows:
normalized_backend = _normalized_backend(str(backend or "local"))
dependencies.append(
InfrastructureDependency(
capability_id="files.storage",
module_id="files",
dependency_type="stored_blob_set",
dependency_ref=f"file-blobs:{normalized_backend}",
state="data_present",
scope="all-tenants",
summary=(
"Persisted Files blob metadata references content in this storage backend."
),
metrics={
"blob_count": int(blob_count or 0),
"content_bytes": int(size_bytes or 0),
},
required_action=(
"Copy and checksum-verify every referenced blob, switch the runtime binding, and retain rollback evidence before removing or replacing storage."
),
)
)
return tuple(dependencies)
def _binding_diagnostics( def _binding_diagnostics(
self, self,
fragment: ConfigurationPackageFragment, fragment: ConfigurationPackageFragment,
+38 -5
View File
@@ -37,7 +37,9 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAvailabilityExplanation,
ProductAreaContribution, ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessTool, QuickAccessTool,
RoleTemplate, RoleTemplate,
) )
@@ -55,6 +57,7 @@ from govoplan_core.db.base import Base
from govoplan_files.backend.change_tracking import register_files_change_tracking from govoplan_files.backend.change_tracking import register_files_change_tracking
from govoplan_files.backend.configuration_provider import ( from govoplan_files.backend.configuration_provider import (
FILES_CONFIGURATION_CAPABILITY, FILES_CONFIGURATION_CAPABILITY,
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
) )
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
from govoplan_files.backend.documentation import documentation_topics from govoplan_files.backend.documentation import documentation_topics
@@ -458,7 +461,7 @@ def _dsar_provider(context: ModuleContext) -> object:
manifest = ModuleManifest( manifest = ModuleManifest(
id="files", id="files",
name="Files", name="Files",
version="0.1.22", version="0.1.25",
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -605,6 +608,31 @@ manifest = ModuleManifest(
order=30, order=30,
), ),
), ),
product_surfaces=(
ProductSurfaceContribution(
id="records.files",
module_id="files",
label="i18n:govoplan-core.product_surface.files",
description="i18n:govoplan-core.product_surface.files_description",
icon="folder",
entry_path="/documents",
route_path="/files",
surface_ids=("files.nav.files", "files.route.files"),
presentations=("task", "reader"),
search_source_ids=("files.objects",),
help_context_ids=("files.list",),
documentation_topic_ids=("files.quick-access-and-product-area",),
required_any=("files:file:read",),
order=10,
unavailable=ProductAvailabilityExplanation(
reason="authorization",
title="i18n:govoplan-core.product_surface.unavailable",
description="i18n:govoplan-core.product_surface.unavailable_description",
resolution="i18n:govoplan-core.product_surface.unavailable_resolution",
responsible_role="i18n:govoplan-core.access_administrator",
),
),
),
quick_access_tools=( quick_access_tools=(
QuickAccessTool( QuickAccessTool(
id="files.recent", id="files.recent",
@@ -720,7 +748,8 @@ manifest = ModuleManifest(
"The Files configuration provider validates the files.storage capability against the effective local or S3 runtime. " "The Files configuration provider validates the files.storage capability against the effective local or S3 runtime. "
"It checks the backend, sanitized endpoint, bucket, trust or management marker, durable local path, and presence of referenced environment secrets. " "It checks the backend, sanitized endpoint, bucket, trust or management marker, durable local path, and presence of referenced environment secrets. "
"Matching configuration is already effective and therefore reports skip on every apply. Drift blocks the package; Files never copies secret values, rewrites process environment, " "Matching configuration is already effective and therefore reports skip on every apply. Drift blocks the package; Files never copies secret values, rewrites process environment, "
"or treats a storage replacement as an implicit migration. Run Files integrity and Ops checks after deployment changes." "or treats a storage replacement as an implicit migration. Before files.storage changes, Files reports the active runtime binding plus persisted blob counts and byte totals per backend "
"through the non-secret Ops dependency inventory. Missing, stale, or incomplete inventory blocks host apply. Run Files integrity and Ops checks before and after deployment changes."
), ),
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
@@ -752,7 +781,8 @@ manifest = ModuleManifest(
"Der Files-Konfigurationsprovider prüft die Fähigkeit files.storage gegen die wirksame lokale oder S3-Laufzeitkonfiguration. " "Der Files-Konfigurationsprovider prüft die Fähigkeit files.storage gegen die wirksame lokale oder S3-Laufzeitkonfiguration. "
"Geprüft werden Backend, bereinigter Endpunkt, Bucket, Vertrauens- oder Verwaltungskennzeichen, dauerhafter lokaler Pfad sowie das Vorhandensein referenzierter Umgebungsgeheimnisse. " "Geprüft werden Backend, bereinigter Endpunkt, Bucket, Vertrauens- oder Verwaltungskennzeichen, dauerhafter lokaler Pfad sowie das Vorhandensein referenzierter Umgebungsgeheimnisse. "
"Eine passende Konfiguration ist bereits wirksam und meldet deshalb bei jeder Anwendung skip. Abweichungen blockieren das Paket; Files kopiert keine Geheimwerte, verändert keine Prozessumgebung " "Eine passende Konfiguration ist bereits wirksam und meldet deshalb bei jeder Anwendung skip. Abweichungen blockieren das Paket; Files kopiert keine Geheimwerte, verändert keine Prozessumgebung "
"und behandelt einen Speicherwechsel nicht als stillschweigende Migration. Nach Bereitstellungsänderungen sind die Integritäts- und Ops-Prüfungen auszuführen." "und behandelt einen Speicherwechsel nicht als stillschweigende Migration. Vor einer Änderung von files.storage meldet Files die aktive Laufzeitbindung sowie gespeicherte Blob-Anzahlen und Byte-Summen je Backend "
"im nicht geheimen Ops-Abhängigkeitsinventar. Ein fehlendes, veraltetes oder unvollständiges Inventar blockiert die Host-Anwendung. Die Integritäts- und Ops-Prüfungen sind vor und nach der Bereitstellungsänderung auszuführen."
), ),
} }
}, },
@@ -768,7 +798,8 @@ manifest = ModuleManifest(
title="Files in Records and documents and Quick Access", title="Files in Records and documents and Quick Access",
summary="Use managed files in the Records and documents area and keep a compact file surface beside current work.", summary="Use managed files in the Records and documents area and keep a compact file surface beside current work.",
body=( body=(
"Files contributes its authorized workspace to Records and documents. When Quick Access is enabled, the owner-rendered " "Files contributes its authorized workspace to the stable Files destination at /documents in Records and documents. "
"The owner route /files remains available through All available tools and as a compatible deep link. When Quick Access is enabled, the owner-rendered "
"selector shows at most seven recently changed files and can return one exact file-version reference to the current task. " "selector shows at most seven recently changed files and can return one exact file-version reference to the current task. "
"Accounts with upload permission can launch the full Files upload dialog into a freshly reauthorized managed space. Opening " "Accounts with upload permission can launch the full Files upload dialog into a freshly reauthorized managed space. Opening "
"either path causes Files to re-run its own provider, space, folder, object, and scope checks; completion and cancellation are " "either path causes Files to re-run its own provider, space, folder, object, and scope checks; completion and cancellation are "
@@ -798,7 +829,8 @@ manifest = ModuleManifest(
"title": "Dateien in Akten und Dokumente sowie im Schnellzugriff", "title": "Dateien in Akten und Dokumente sowie im Schnellzugriff",
"summary": "Verwaltete Dateien im Produktbereich Akten und Dokumente und optional neben der aktuellen Arbeit verwenden.", "summary": "Verwaltete Dateien im Produktbereich Akten und Dokumente und optional neben der aktuellen Arbeit verwenden.",
"body": ( "body": (
"Files ordnet seinen berechtigten Arbeitsbereich Akten und Dokumente zu. Ist der Schnellzugriff aktiviert, erscheint " "Files ordnet seinen berechtigten Arbeitsbereich dem stabilen Produktziel Dateien unter /documents in Akten und Dokumente zu. "
"Der Eigentümerpfad /files bleibt unter Alle verfügbaren Werkzeuge und als kompatibler Direktlink erreichbar. Ist der Schnellzugriff aktiviert, erscheint "
"die vom Modul gerenderte Auswahl von höchstens sieben zuletzt geänderten berechtigten Dateien rechts neben der aktuellen " "die vom Modul gerenderte Auswahl von höchstens sieben zuletzt geänderten berechtigten Dateien rechts neben der aktuellen "
"Seite und kann einen exakten Dateiversionsverweis zurückgeben. Mit Upload-Berechtigung lässt sich der vollständige " "Seite und kann einen exakten Dateiversionsverweis zurückgeben. Mit Upload-Berechtigung lässt sich der vollständige "
"Upload-Dialog in einem erneut berechtigungsgeprüften verwalteten Bereich öffnen. Files prüft Anbieter, Bereich, Ordner, " "Upload-Dialog in einem erneut berechtigungsgeprüften verwalteten Bereich öffnen. Files prüft Anbieter, Bereich, Ordner, "
@@ -1784,6 +1816,7 @@ manifest = ModuleManifest(
), ),
capability_factories={ capability_factories={
FILES_CONFIGURATION_CAPABILITY: _configuration_provider, FILES_CONFIGURATION_CAPABILITY: _configuration_provider,
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY: _configuration_provider,
CAPABILITY_FILES_ACCESS: lambda context: __import__( CAPABILITY_FILES_ACCESS: lambda context: __import__(
"govoplan_files.backend.capabilities", fromlist=["access_capability"] "govoplan_files.backend.capabilities", fromlist=["access_capability"]
).access_capability(context), ).access_capability(context),
+69
View File
@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import tempfile
import unittest import unittest
from sqlalchemy import create_engine
from govoplan_core.core.configuration_packages import ( from govoplan_core.core.configuration_packages import (
ConfigurationPackageFragment, ConfigurationPackageFragment,
ConfigurationPreflightContext, ConfigurationPreflightContext,
@@ -10,10 +14,14 @@ from govoplan_core.core.configuration_packages import (
from govoplan_core.core.infrastructure_capabilities import ( from govoplan_core.core.infrastructure_capabilities import (
infrastructure_capability_receipt_from_mapping, infrastructure_capability_receipt_from_mapping,
) )
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_files.backend.configuration_provider import ( from govoplan_files.backend.configuration_provider import (
FILES_CONFIGURATION_CAPABILITY, FILES_CONFIGURATION_CAPABILITY,
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
FilesConfigurationProvider, FilesConfigurationProvider,
) )
from govoplan_files.backend.db.models import FileBlob
from govoplan_files.backend.manifest import manifest from govoplan_files.backend.manifest import manifest
@@ -78,6 +86,67 @@ def _s3_settings(**overrides):
class FilesConfigurationProviderTests(unittest.TestCase): class FilesConfigurationProviderTests(unittest.TestCase):
def test_provider_is_registered(self) -> None: def test_provider_is_registered(self) -> None:
self.assertIn(FILES_CONFIGURATION_CAPABILITY, manifest.capability_factories) self.assertIn(FILES_CONFIGURATION_CAPABILITY, manifest.capability_factories)
self.assertIn(
FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
manifest.capability_factories,
)
def test_inventory_reports_runtime_binding_and_persisted_blob_aggregate(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-files-inventory-") as root:
database_path = Path(root) / "files.sqlite3"
engine = create_engine(f"sqlite:///{database_path}")
Base.metadata.create_all(engine, tables=(FileBlob.__table__,))
configure_database(
f"sqlite:///{database_path}",
engine=engine,
dispose_previous=True,
)
try:
with engine.begin() as connection:
connection.execute(
FileBlob.__table__.insert(),
[
{
"id": "blob-1",
"tenant_id": "tenant-1",
"storage_backend": "local",
"storage_key": "tenant-1/a",
"checksum_sha256": "a" * 64,
"size_bytes": 7,
"protection_discriminator": "plaintext",
"ref_count": 1,
"integrity_status": "unchecked",
},
{
"id": "blob-2",
"tenant_id": "tenant-1",
"storage_backend": "local",
"storage_key": "tenant-1/b",
"checksum_sha256": "b" * 64,
"size_bytes": 11,
"protection_discriminator": "plaintext",
"ref_count": 1,
"integrity_status": "unchecked",
},
],
)
provider = FilesConfigurationProvider(
settings=_local_settings(),
environment={},
)
dependencies = provider.infrastructure_dependencies()
finally:
reset_database()
engine.dispose()
self.assertEqual(
["runtime_storage_binding", "stored_blob_set"],
[item.dependency_type for item in dependencies],
)
blob_set = dependencies[1]
self.assertEqual(2, blob_set.metrics["blob_count"])
self.assertEqual(18, blob_set.metrics["content_bytes"])
def test_matching_local_storage_is_an_idempotent_noop(self) -> None: def test_matching_local_storage_is_an_idempotent_noop(self) -> None:
provider = FilesConfigurationProvider( provider = FilesConfigurationProvider(
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.22", "version": "0.1.25",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -28,7 +28,7 @@
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9", "react-router": ">=8.3.0 <9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"@govoplan/core-webui": "^0.1.18" "@govoplan/core-webui": "^0.1.44"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -1061,7 +1061,7 @@ export default function FileConnectorSettingsPanel({
)} )}
<div className="file-connector-settings-section file-connector-policy-locks"> <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_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_credential_limits.ec2b72bc" helpContextId="files.connector.policy" helpModuleId="files" 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_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_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 })} /> <ToggleSwitch label="i18n:govoplan-files.lower_endpoint_limits.3fd781d5" checked={policyDraft.allowLowerUrlLimits} disabled={saving || !canWrite} onChange={(allowLowerUrlLimits) => patchPolicyDraft({ allowLowerUrlLimits })} />
@@ -1101,14 +1101,14 @@ export default function FileConnectorSettingsPanel({
<p>Choose where this credential can be used and how it signs in.</p> <p>Choose where this credential can be used and how it signs in.</p>
</header> </header>
<FormGrid columns={2} collapseAt="standard" className=""> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="i18n:govoplan-files.credential_id.9432a6e1"> <FormField label="i18n:govoplan-files.credential_id.9432a6e1" helpContextId="files.connector.credentials" helpModuleId="files">
<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`} /> <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>
<FormField label="i18n:govoplan-files.label.74341e3c"> <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" /> <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> </FormField>
{credentialAttachProfileId && {credentialAttachProfileId &&
<FormField label="i18n:govoplan-files.connection_credential.178babe0"> <FormField label="i18n:govoplan-files.connection_credential.178babe0" helpContextId="files.connector.credentials" helpModuleId="files">
<input value={credentialAttachedProfile ? `${credentialAttachedProfile.label} (${credentialAttachedProfile.id})` : credentialAttachProfileId} disabled readOnly /> <input value={credentialAttachedProfile ? `${credentialAttachedProfile.label} (${credentialAttachedProfile.id})` : credentialAttachProfileId} disabled readOnly />
</FormField> </FormField>
} }
@@ -1130,8 +1130,8 @@ export default function FileConnectorSettingsPanel({
/> />
</FormField> </FormField>
</div> </div>
<FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION}> <FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION} helpContextId="files.connector.credentials" helpModuleId="files">
<select value={credentialDraft.credentialMode} disabled={saving} onChange={(event) => patchCredentialDraft({ credentialMode: event.target.value as CredentialMode })}> <select data-help-context-id="files.connector.credentials" data-help-module-id="files" 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>
<option value="basic">i18n:govoplan-files.username_password.e8ba8896</option> <option value="basic">i18n:govoplan-files.username_password.e8ba8896</option>
@@ -1142,8 +1142,8 @@ export default function FileConnectorSettingsPanel({
</FormGrid> </FormGrid>
<div className="file-connector-settings-section"> <div className="file-connector-settings-section">
<ToggleSwitch label="i18n:govoplan-files.enabled.df174a3f" checked={credentialDraft.enabled} disabled={saving} onChange={(enabled) => patchCredentialDraft({ enabled })} /> <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_password.7442260d" helpContextId="files.connector.credentials" helpModuleId="files" 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 })} />} {editingCredentialId && <ToggleSwitch label="i18n:govoplan-files.clear_saved_token.a9c670aa" helpContextId="files.connector.credentials" helpModuleId="files" checked={credentialDraft.clearToken} disabled={saving} onChange={(clearToken) => patchCredentialDraft({ clearToken })} />}
</div> </div>
</section> </section>
@@ -1179,8 +1179,8 @@ export default function FileConnectorSettingsPanel({
disabled={saving} disabled={saving}
showPassword={false} /> showPassword={false} />
<FormGrid columns={2} collapseAt="standard" className=""> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="i18n:govoplan-files.secret_reference.04ed2221"> <FormField label="i18n:govoplan-files.secret_reference.04ed2221" helpContextId="files.connector.credentials" helpModuleId="files">
<input value={credentialDraft.secretRef} disabled={saving} onChange={(event) => patchCredentialDraft({ secretRef: event.target.value })} /> <input data-help-context-id="files.connector.credentials" data-help-module-id="files" value={credentialDraft.secretRef} disabled={saving} onChange={(event) => patchCredentialDraft({ secretRef: event.target.value })} />
</FormField> </FormField>
</FormGrid> </FormGrid>
</> </>
@@ -1304,8 +1304,8 @@ export default function FileConnectorSettingsPanel({
<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" documentation={CONNECTOR_DOCUMENTATION}> <FormField label="i18n:govoplan-files.credential_mode.23fdd899" documentation={CONNECTOR_DOCUMENTATION} helpContextId="files.connector.credentials" helpModuleId="files">
<select value={draft.credentialMode} disabled={saving} onChange={(event) => patchDraft({ credentialMode: event.target.value as CredentialMode })}> <select data-help-context-id="files.connector.credentials" data-help-module-id="files" 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>
<option value="basic">i18n:govoplan-files.username_password.e8ba8896</option> <option value="basic">i18n:govoplan-files.username_password.e8ba8896</option>
@@ -1313,8 +1313,8 @@ export default function FileConnectorSettingsPanel({
<option value="secret_ref">i18n:govoplan-files.secret_reference.04ed2221</option> <option value="secret_ref">i18n:govoplan-files.secret_reference.04ed2221</option>
</select> </select>
</FormField> </FormField>
<FormField label="i18n:govoplan-files.credential.8bede3ea"> <FormField label="i18n:govoplan-files.credential.8bede3ea" helpContextId="files.connector.credentials" helpModuleId="files">
<select value={profileCredentialSelectValue} disabled={saving || profileCredentialOptions.length === 0} onChange={(event) => patchDraft({ credentialProfileId: event.target.value })}> <select data-help-context-id="files.connector.credentials" data-help-module-id="files" 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.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>)} {profileCredentialOptions.map((credential) => <option key={credential.id} value={credential.id}>{credential.label}</option>)}
</select> </select>
+7 -1
View File
@@ -2293,7 +2293,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<Button onClick={() => openTransferDialog("copy")} disabled={Boolean(organizeBlocker || selectionBlocker)} disabledReason={organizeBlocker || selectionBlocker}><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={Boolean(organizeBlocker)} disabledReason={organizeBlocker}>{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={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><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={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button> <Button variant="danger" helpContextId="files.list" helpModuleId="files" onClick={() => void deleteSelected()} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
{activeSpaceIsConnector && {activeSpaceIsConnector &&
<Button <Button
variant="danger" variant="danger"
@@ -2793,8 +2793,12 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
{archivePreview.requires_password && {archivePreview.requires_password &&
<FormField <FormField
label="Archive password" label="Archive password"
helpContextId="files.list"
helpModuleId="files"
help="The password stays in this dialog and is sent only while inspecting or importing this archive."> help="The password stays in this dialog and is sent only while inspecting or importing this archive.">
<PasswordField <PasswordField
helpContextId="files.list"
helpModuleId="files"
value={archivePassword} value={archivePassword}
onValueChange={setArchivePassword} onValueChange={setArchivePassword}
disabled={busy} disabled={busy}
@@ -2857,6 +2861,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
</Button> </Button>
{archivePreview.requires_password && {archivePreview.requires_password &&
<Button <Button
helpContextId="files.list"
helpModuleId="files"
onClick={() => activeDialogTarget && void loadArchivePreview(archiveFile, activeDialogTarget, { preserveSelection: true })} onClick={() => activeDialogTarget && void loadArchivePreview(archiveFile, activeDialogTarget, { preserveSelection: true })}
disabled={busy || !archivePassword}> disabled={busy || !archivePassword}>
<RefreshCw size={15} aria-hidden="true" /> Verify password <RefreshCw size={15} aria-hidden="true" /> Verify password
@@ -264,7 +264,7 @@ export function FileContextMenu({
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button> <button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button>
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button> <button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
<button type="button" role="menuitem" onClick={onExplainAccess} disabled={!canExplainAccess}><KeyRound size={15} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</button> <button type="button" role="menuitem" onClick={onExplainAccess} disabled={!canExplainAccess}><KeyRound size={15} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</button>
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>} {showDelete && <button type="button" role="menuitem" className="danger" data-help-context-id="files.list" data-help-module-id="files" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
</div>); </div>);
} }
+3 -2
View File
@@ -8,6 +8,7 @@ import {
type PlatformWebModule, type PlatformWebModule,
type QuickAccessToolsUiCapability type QuickAccessToolsUiCapability
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { generatedTranslations as productSurfaceTranslations } from "@govoplan/core-webui/outcome-product-surface-translations";
import { FolderTree } from "./features/files/components/FileManagerComponents"; import { FolderTree } from "./features/files/components/FileManagerComponents";
import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel"; import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel";
import ManagedFileChooser from "./features/files/components/ManagedFileChooser"; import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
@@ -22,8 +23,8 @@ const FileIntegrityPanel = lazy(() => import("./features/files/FileIntegrityPane
const fileRead = ["files:file:read"]; const fileRead = ["files:file:read"];
const translations = { const translations = {
en: generatedTranslations.en, en: { ...generatedTranslations.en, ...productSurfaceTranslations.en },
de: generatedTranslations.de de: { ...generatedTranslations.de, ...productSurfaceTranslations.de }
}; };
const fileDashboardWidgets: DashboardWidgetsUiCapability = { const fileDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [ widgets: [