3 Commits

Author SHA1 Message Date
8bf7296bf5 Release v0.1.8 2026-07-11 16:49:02 +02:00
620fdda2fc Release v0.1.7 2026-07-11 02:34:56 +02:00
58c0441763 Add file resource access explanations 2026-07-11 00:39:39 +02:00
19 changed files with 800 additions and 16 deletions

View File

@@ -18,8 +18,8 @@ cd /mnt/DATA/git/govoplan-core
For combined checks, run: For combined checks, run:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan
./scripts/check-focused.sh tools/checks/check-focused.sh
``` ```
For WebUI permutation checks that include files: For WebUI permutation checks that include files:

View File

@@ -1,5 +1,9 @@
# govoplan-files # govoplan-files
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Files is the managed file module. It bundles backend storage APIs and the Files WebUI package so file features can be installed as one module. GovOPlaN Files is the managed file module. It bundles backend storage APIs and the Files WebUI package so file features can be installed as one module.
## Ownership ## Ownership

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-files" name = "govoplan-files"
version = "0.1.6" version = "0.1.8"
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.6", "govoplan-core>=0.1.8",
"defusedxml>=0.7,<1",
"python-multipart>=0.0.31,<1", "python-multipart>=0.0.31,<1",
] ]

View File

@@ -1,8 +1,16 @@
from __future__ import annotations from __future__ import annotations
import base64
import binascii
from typing import Any from typing import Any
from sqlalchemy import or_
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.files import FileAccessProvider
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
from govoplan_files.backend.runtime import configure_runtime from govoplan_files.backend.runtime import configure_runtime
from govoplan_files.backend.storage.campaign_attachments import ( from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files, annotate_built_messages_with_managed_files,
@@ -12,6 +20,21 @@ from govoplan_files.backend.storage.campaign_attachments import (
) )
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
from govoplan_files.backend.storage.files import current_version_and_blob from govoplan_files.backend.storage.files import current_version_and_blob
from govoplan_files.backend.storage.paths import normalize_folder
VIRTUAL_FOLDER_RESOURCE_PREFIX = "virtual-folder:v1"
WRITE_ACTIONS = {
"files:file:upload",
"files:file:organize",
"files:file:share",
"files:file:delete",
"files:upload",
"files:organize",
"files:share",
"files:delete",
}
class FilesCampaignCapability: class FilesCampaignCapability:
@@ -32,3 +55,247 @@ class FilesCampaignCapability:
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability: def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
configure_runtime(registry=context.registry, settings=context.settings) configure_runtime(registry=context.registry, settings=context.settings)
return FilesCampaignCapability() return FilesCampaignCapability()
class FilesAccessService(FileAccessProvider):
def explain_resource_provenance(
self,
session: object,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
normalized_type = resource_type.lower().strip()
if normalized_type in {"file", "file_asset", "files:file"}:
return self._explain_file(session, principal, resource_id=resource_id, action=action)
if normalized_type in {"folder", "file_folder", "files:folder"}:
return self._explain_folder(session, principal, resource_id=resource_id, action=action)
return ()
def _explain_file(
self,
session: object,
principal: PrincipalRef,
*,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
asset = session.get(FileAsset, resource_id) # type: ignore[attr-defined]
if asset is None or (principal.tenant_id and asset.tenant_id != principal.tenant_id):
return (_missing_resource("file", resource_id, principal.tenant_id, source="files.not_found"),)
items = [_file_resource(asset)]
items.extend(_owner_provenance(asset.owner_type, _owner_id(asset.owner_type, asset.owner_user_id, asset.owner_group_id), principal, source="files.owner"))
items.extend(_admin_provenance(principal, "files:file:admin", source="files.admin_scope"))
permission_values = {"write", "manage"} if _requires_write_share(action) else {"read", "write", "manage"}
shares = (
session.query(FileShare) # type: ignore[attr-defined]
.filter(
FileShare.tenant_id == asset.tenant_id,
FileShare.file_asset_id == asset.id,
FileShare.revoked_at.is_(None),
FileShare.permission.in_(sorted(permission_values)),
or_(
(FileShare.target_type == "user") & (FileShare.target_id == principal.membership_id),
(FileShare.target_type == "group") & (FileShare.target_id.in_(sorted(principal.group_ids))),
(FileShare.target_type == "tenant") & (FileShare.target_id == asset.tenant_id),
),
)
.order_by(FileShare.target_type.asc(), FileShare.target_id.asc())
.all()
)
for share in shares:
items.append(_share_provenance(share, source="files.share"))
return tuple(items)
def _explain_folder(
self,
session: object,
principal: PrincipalRef,
*,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
del action
folder = session.get(FileFolder, resource_id) # type: ignore[attr-defined]
if folder is None:
return self._explain_virtual_folder(session, principal, resource_id=resource_id)
if principal.tenant_id and folder.tenant_id != principal.tenant_id:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
return tuple([
AccessDecisionProvenance(
kind="resource",
id=folder.id,
label=folder.path,
tenant_id=folder.tenant_id,
source="files.folder",
details={
"resource_type": "folder",
"path": folder.path,
"owner_type": folder.owner_type,
"deleted": folder.deleted_at is not None,
},
),
*_owner_provenance(folder.owner_type, _owner_id(folder.owner_type, folder.owner_user_id, folder.owner_group_id), principal, source="files.owner"),
*_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
])
def _explain_virtual_folder(
self,
session: object,
principal: PrincipalRef,
*,
resource_id: str,
) -> tuple[AccessDecisionProvenance, ...]:
folder_ref = parse_virtual_folder_resource_id(resource_id)
if folder_ref is None:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
tenant_id, owner_type, owner_id, path = folder_ref
if principal.tenant_id and tenant_id != principal.tenant_id:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
child_prefix = f"{path}/"
owner_filter = FileAsset.owner_user_id == owner_id if owner_type == "user" else FileAsset.owner_group_id == owner_id
child = (
session.query(FileAsset.id) # type: ignore[attr-defined]
.filter(
FileAsset.tenant_id == tenant_id,
FileAsset.owner_type == owner_type,
owner_filter,
FileAsset.deleted_at.is_(None),
FileAsset.display_path.like(f"{child_prefix}%"),
)
.first()
)
if child is None:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
return tuple([
AccessDecisionProvenance(
kind="resource",
id=resource_id,
label=path,
tenant_id=tenant_id,
source="files.virtual_folder",
details={
"resource_type": "folder",
"path": path,
"owner_type": owner_type,
"owner_id": owner_id,
"virtual": True,
"deleted": False,
},
),
*_owner_provenance(owner_type, owner_id, principal, source="files.owner"),
*_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
])
def access_capability(context: ModuleContext) -> FilesAccessService:
configure_runtime(registry=context.registry, settings=context.settings)
return FilesAccessService()
def virtual_folder_resource_id(*, tenant_id: str, owner_type: str, owner_id: str, path: str) -> str:
normalized_path = normalize_folder(path)
encoded_path = base64.urlsafe_b64encode(normalized_path.encode("utf-8")).decode("ascii").rstrip("=")
return f"{VIRTUAL_FOLDER_RESOURCE_PREFIX}:{tenant_id}:{owner_type}:{owner_id}:{encoded_path}"
def parse_virtual_folder_resource_id(resource_id: str) -> tuple[str, str, str, str] | None:
parts = resource_id.split(":", 5)
if len(parts) != 6 or f"{parts[0]}:{parts[1]}" != VIRTUAL_FOLDER_RESOURCE_PREFIX:
return None
_, _, tenant_id, owner_type, owner_id, encoded_path = parts
if owner_type not in {"user", "group"} or not tenant_id or not owner_id or not encoded_path:
return None
padding = "=" * (-len(encoded_path) % 4)
try:
decoded_path = base64.urlsafe_b64decode(f"{encoded_path}{padding}").decode("utf-8")
path = normalize_folder(decoded_path)
except (binascii.Error, UnicodeDecodeError, ValueError):
return None
if not path:
return None
return tenant_id, owner_type, owner_id, path
def _file_resource(asset: FileAsset) -> AccessDecisionProvenance:
return AccessDecisionProvenance(
kind="resource",
id=asset.id,
label=asset.filename,
tenant_id=asset.tenant_id,
source="files.file",
details={
"resource_type": "file",
"display_path": asset.display_path,
"owner_type": asset.owner_type,
"deleted": asset.deleted_at is not None,
},
)
def _missing_resource(resource_type: str, resource_id: str, tenant_id: str | None, *, source: str) -> AccessDecisionProvenance:
return AccessDecisionProvenance(
kind="resource",
id=resource_id,
tenant_id=tenant_id,
source=source,
details={"resource_type": resource_type, "found": False},
)
def _owner_id(owner_type: str, owner_user_id: str | None, owner_group_id: str | None) -> str | None:
return owner_user_id if owner_type == "user" else owner_group_id
def _owner_provenance(owner_type: str, owner_id: str | None, principal: PrincipalRef, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
if owner_id is None:
return ()
matches_user = owner_type == "user" and owner_id == principal.membership_id
matches_group = owner_type == "group" and owner_id in principal.group_ids
if not (matches_user or matches_group):
return ()
return (
AccessDecisionProvenance(
kind="owner",
id=owner_id,
tenant_id=principal.tenant_id,
source=source,
details={"owner_type": owner_type},
),
)
def _admin_provenance(principal: PrincipalRef, required_scope: str, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
if not scopes_grant_compatible(principal.scopes, required_scope):
return ()
return (
AccessDecisionProvenance(
kind="policy",
id=required_scope,
label=required_scope,
tenant_id=principal.tenant_id,
source=source,
details={"grant": "tenant_admin"},
),
)
def _share_provenance(share: FileShare, *, source: str) -> AccessDecisionProvenance:
return AccessDecisionProvenance(
kind="share",
id=share.id,
label=share.permission,
tenant_id=share.tenant_id,
source=source,
details={
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
},
)
def _requires_write_share(action: str) -> bool:
return action in WRITE_ACTIONS

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
FrontendModule, FrontendModule,
@@ -111,10 +112,11 @@ def _files_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="files", id="files",
name="Files", name="Files",
version="0.1.6", version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns",), optional_dependencies=("campaigns",),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="files.access", version="0.1.6"),
ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"), ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"),
), ),
requires_interfaces=( requires_interfaces=(
@@ -172,6 +174,7 @@ manifest = ModuleManifest(
), ),
), ),
capability_factories={ capability_factories={
CAPABILITY_FILES_ACCESS: lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["access_capability"]).access_capability(context),
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), "files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
}, },
) )

View File

@@ -0,0 +1,158 @@
"""v0.1.7 files baseline
Revision ID: a7b8c9d0e1f3
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = 'a7b8c9d0e1f3'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('file_connector_credentials',
sa.Column('id', sa.String(length=255), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('credential_mode', sa.String(length=30), nullable=False),
sa.Column('username', sa.String(length=320), nullable=True),
sa.Column('password_encrypted', sa.Text(), nullable=True),
sa.Column('token_encrypted', sa.Text(), nullable=True),
sa.Column('password_env', sa.String(length=255), nullable=True),
sa.Column('token_env', sa.String(length=255), nullable=True),
sa.Column('secret_ref', sa.String(length=1000), nullable=True),
sa.Column('policy', sa.JSON(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_credentials_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_credentials_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_credentials_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_credentials'))
)
op.create_index(op.f('ix_file_connector_credentials_created_by_user_id'), 'file_connector_credentials', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_enabled'), 'file_connector_credentials', ['enabled'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_provider'), 'file_connector_credentials', ['provider'], unique=False)
op.create_index('ix_file_connector_credentials_scope', 'file_connector_credentials', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_scope_id'), 'file_connector_credentials', ['scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_scope_type'), 'file_connector_credentials', ['scope_type'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_tenant_id'), 'file_connector_credentials', ['tenant_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_updated_by_user_id'), 'file_connector_credentials', ['updated_by_user_id'], unique=False)
op.create_table('file_connector_policies',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('policy', sa.JSON(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_policies_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_policies_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_policies_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_policies')),
sa.UniqueConstraint('tenant_id', 'scope_type', 'scope_id', name='uq_file_connector_policies_scope')
)
op.create_index(op.f('ix_file_connector_policies_created_by_user_id'), 'file_connector_policies', ['created_by_user_id'], unique=False)
op.create_index('ix_file_connector_policies_scope', 'file_connector_policies', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_policies_scope_id'), 'file_connector_policies', ['scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_policies_scope_type'), 'file_connector_policies', ['scope_type'], unique=False)
op.create_index(op.f('ix_file_connector_policies_tenant_id'), 'file_connector_policies', ['tenant_id'], unique=False)
op.create_index(op.f('ix_file_connector_policies_updated_by_user_id'), 'file_connector_policies', ['updated_by_user_id'], unique=False)
op.create_table('file_connector_profiles',
sa.Column('id', sa.String(length=255), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('endpoint_url', sa.String(length=1000), nullable=True),
sa.Column('base_path', sa.String(length=1000), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('credential_profile_id', sa.String(length=255), nullable=True),
sa.Column('credential_mode', sa.String(length=30), nullable=False),
sa.Column('username', sa.String(length=320), nullable=True),
sa.Column('password_encrypted', sa.Text(), nullable=True),
sa.Column('token_encrypted', sa.Text(), nullable=True),
sa.Column('password_env', sa.String(length=255), nullable=True),
sa.Column('token_env', sa.String(length=255), nullable=True),
sa.Column('secret_ref', sa.String(length=1000), nullable=True),
sa.Column('capabilities', sa.JSON(), nullable=True),
sa.Column('policy', sa.JSON(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_profiles_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_profiles_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_profiles_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_profiles'))
)
op.create_index(op.f('ix_file_connector_profiles_created_by_user_id'), 'file_connector_profiles', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_credential_profile_id'), 'file_connector_profiles', ['credential_profile_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_enabled'), 'file_connector_profiles', ['enabled'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_provider'), 'file_connector_profiles', ['provider'], unique=False)
op.create_index('ix_file_connector_profiles_scope', 'file_connector_profiles', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_scope_id'), 'file_connector_profiles', ['scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_scope_type'), 'file_connector_profiles', ['scope_type'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_tenant_id'), 'file_connector_profiles', ['tenant_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_updated_by_user_id'), 'file_connector_profiles', ['updated_by_user_id'], unique=False)
op.create_table('file_connector_spaces',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('owner_type', sa.String(length=20), nullable=False),
sa.Column('owner_user_id', sa.String(length=36), nullable=True),
sa.Column('owner_group_id', sa.String(length=36), nullable=True),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('connector_profile_id', sa.String(length=255), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('library_id', sa.String(length=255), nullable=True),
sa.Column('remote_path', sa.String(length=1000), nullable=False),
sa.Column('sync_mode', sa.String(length=30), nullable=False),
sa.Column('read_only', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_spaces_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['owner_group_id'], ['access_groups.id'], name=op.f('fk_file_connector_spaces_owner_group_id_access_groups'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_file_connector_spaces_owner_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_spaces_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_spaces'))
)
op.create_index(op.f('ix_file_connector_spaces_connector_profile_id'), 'file_connector_spaces', ['connector_profile_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_created_by_user_id'), 'file_connector_spaces', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_deleted_at'), 'file_connector_spaces', ['deleted_at'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_is_active'), 'file_connector_spaces', ['is_active'], unique=False)
op.create_index('ix_file_connector_spaces_owner', 'file_connector_spaces', ['tenant_id', 'owner_type', 'owner_user_id', 'owner_group_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_owner_group_id'), 'file_connector_spaces', ['owner_group_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_owner_type'), 'file_connector_spaces', ['owner_type'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_owner_user_id'), 'file_connector_spaces', ['owner_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_provider'), 'file_connector_spaces', ['provider'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_tenant_id'), 'file_connector_spaces', ['tenant_id'], unique=False)
op.create_index('uq_file_connector_spaces_active_group_label', 'file_connector_spaces', ['tenant_id', 'owner_group_id', 'label'], unique=True, sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"))
op.create_index('uq_file_connector_spaces_active_user_label', 'file_connector_spaces', ['tenant_id', 'owner_user_id', 'label'], unique=True, sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"))
def downgrade() -> None:
op.drop_table('file_connector_spaces')
op.drop_table('file_connector_profiles')
op.drop_table('file_connector_policies')
op.drop_table('file_connector_credentials')

View File

@@ -11,6 +11,7 @@ from typing import Any
from urllib.parse import quote, unquote, urljoin, urlsplit from urllib.parse import quote, unquote, urljoin, urlsplit
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from defusedxml import ElementTree as SafeElementTree
import httpx import httpx
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
@@ -70,8 +71,8 @@ def browse_connector_profile(profile: ConnectorProfile, *, path: str | None = No
def parse_webdav_multistatus(*, root_url: str, current_path: str, payload: str | bytes) -> list[ConnectorBrowseItem]: def parse_webdav_multistatus(*, root_url: str, current_path: str, payload: str | bytes) -> list[ConnectorBrowseItem]:
try: try:
root = ET.fromstring(payload) root = SafeElementTree.fromstring(payload)
except ET.ParseError as exc: except SafeElementTree.ParseError as exc:
raise ConnectorBrowseError("Connector returned invalid WebDAV XML") from exc raise ConnectorBrowseError("Connector returned invalid WebDAV XML") from exc
root_path = _url_path_root(root_url) root_path = _url_path_root(root_url)
current = normalize_connector_browse_path(current_path) current = normalize_connector_browse_path(current_path)

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
TENANT_ID = "tenant-1"
USER_ID = "user-1"
OTHER_USER_ID = "user-2"
GROUP_ID = "group-1"
class FilesAccessProviderTests(unittest.TestCase):
def test_file_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
session = _session()
_seed_access_subjects(session)
owned = FileAsset(id="file-owned", tenant_id=TENANT_ID, owner_type="user", owner_user_id=USER_ID, display_path="owned.pdf", filename="owned.pdf")
shared = FileAsset(id="file-shared", tenant_id=TENANT_ID, owner_type="user", owner_user_id=OTHER_USER_ID, display_path="shared.pdf", filename="shared.pdf")
session.add_all([
owned,
shared,
FileShare(id="share-group", tenant_id=TENANT_ID, file_asset_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
])
session.commit()
service = FilesAccessService()
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id=owned.id, action="files:file:read")
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="file", resource_id=shared.id, action="files:file:read")
admin_items = service.explain_resource_provenance(session, _principal(scopes={"files:file:admin"}), resource_type="file", resource_id=shared.id, action="files:file:read")
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id="missing-file", action="files:file:read")
self.assertTrue(any(item.kind == "resource" and item.source == "files.file" and item.id == owned.id for item in owned_items))
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
self.assertTrue(any(item.kind == "policy" and item.id == "files:file:admin" for item in admin_items))
self.assertEqual("files.not_found", missing_items[0].source)
self.assertIs(missing_items[0].details["found"], False)
def test_file_access_provider_explains_persisted_and_virtual_folders(self) -> None:
session = _session()
_seed_access_subjects(session)
persisted = FileFolder(id="folder-persisted", tenant_id=TENANT_ID, owner_type="group", owner_group_id=GROUP_ID, path="records")
virtual_child = FileAsset(
id="file-in-virtual-folder",
tenant_id=TENANT_ID,
owner_type="group",
owner_group_id=GROUP_ID,
display_path="inferred/sub/file.pdf",
filename="file.pdf",
)
session.add_all([persisted, virtual_child])
session.commit()
service = FilesAccessService()
principal = _principal(group_ids={GROUP_ID})
persisted_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=persisted.id, action="files:file:read")
virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/sub")
virtual_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=virtual_id, action="files:file:read")
missing_virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/missing")
missing_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=missing_virtual_id, action="files:file:read")
self.assertTrue(any(item.kind == "resource" and item.source == "files.folder" and item.id == persisted.id for item in persisted_items))
self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in persisted_items))
self.assertTrue(any(item.kind == "resource" and item.source == "files.virtual_folder" and item.id == virtual_id for item in virtual_items))
self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in virtual_items))
self.assertEqual("files.not_found", missing_items[0].source)
def _session():
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, FileAsset.__table__, FileFolder.__table__, FileShare.__table__])
return sessionmaker(bind=engine, future=True)()
def _seed_access_subjects(session) -> None:
session.add_all([
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
])
session.commit()
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
return PrincipalRef(
account_id="account-1",
membership_id=USER_ID,
tenant_id=TENANT_ID,
scopes=frozenset(scopes or {"files:file:read"}),
group_ids=frozenset(group_ids or set()),
)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -21,7 +21,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"@govoplan/core-webui": "^0.1.6" "@govoplan/core-webui": "^0.1.8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

View File

@@ -559,6 +559,55 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;}; export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
export type AccessExplanationUser = {
id: string;
account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details?: Record<string, unknown>;
};
export type ResourceAccessExplanationResponse = {
user: AccessExplanationUser;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export function fetchResourceAccessExplanation(
settings: ApiSettings,
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
: Promise<ResourceAccessExplanationResponse> {
const params = new URLSearchParams({
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action
});
if (options.tenantId) params.set("tenant_id", options.tenantId);
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
}
export function virtualFolderResourceId(options: {tenantId: string;ownerType: "user" | "group";ownerId: string;path: string;}): string {
return `virtual-folder:v1:${options.tenantId}:${options.ownerType}:${options.ownerId}:${base64Url(options.path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""))}`;
}
function base64Url(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise<FileBulkShareResponse> { export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", { return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST", method: "POST",

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react"; import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
import { import {
Button, Button,
ConfirmDialog, ConfirmDialog,
@@ -22,6 +22,7 @@ import {
deleteFolder, deleteFolder,
downloadFile, downloadFile,
downloadFilesAsZip, downloadFilesAsZip,
fetchResourceAccessExplanation,
listFilesDelta, listFilesDelta,
listFileConnectorProfiles, listFileConnectorProfiles,
listFileSpaces, listFileSpaces,
@@ -30,6 +31,7 @@ import {
syncFileConnectorFile, syncFileConnectorFile,
transferFiles, transferFiles,
uploadFiles, uploadFiles,
virtualFolderResourceId,
type ConflictResolution, type ConflictResolution,
type ConflictStrategy, type ConflictStrategy,
type FileConnectorBrowseItem, type FileConnectorBrowseItem,
@@ -38,7 +40,8 @@ import {
type FileFolder, type FileFolder,
type FileSpace, type FileSpace,
type ManagedFile, type ManagedFile,
type RenameResponse } from type RenameResponse,
type ResourceAccessExplanationResponse } from
"../../api/files"; "../../api/files";
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants"; import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents"; import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
@@ -84,6 +87,12 @@ import { useFileDialogs } from "./hooks/useFileDialogs";
import { useFileDragDropState } from "./hooks/useFileDragDropState"; import { useFileDragDropState } from "./hooks/useFileDragDropState";
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing"; type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
type FileAccessExplanationTarget = {
resourceType: "file" | "folder";
resourceId: string;
label: string;
action: string;
};
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;
@@ -133,6 +142,9 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [connectorSpaceError, setConnectorSpaceError] = useState(""); const [connectorSpaceError, setConnectorSpaceError] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
const { const {
dialog, dialog,
@@ -216,6 +228,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
}); });
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]); const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]); const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
const accessExplainableTarget = useMemo(
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
[activeSpaceId, filesBySpace, foldersBySpace, selectedFileIds, selectedFolderPaths]
);
const canExplainResourceAccess =
hasScope(auth, "admin:users:read") ||
hasScope(auth, "admin:roles:read") ||
hasScope(auth, "access:membership:read") ||
hasScope(auth, "access:role:read");
const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]); const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]);
const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null); const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null);
const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null; const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null;
@@ -1456,6 +1477,74 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
await deleteSelected(fileIds, folderPaths, space); await deleteSelected(fileIds, folderPaths, space);
} }
function accessTargetForSelection(fileIds: Set<string>, folderPaths: Set<string>, spaceId: string): FileAccessExplanationTarget | null {
if (fileIds.size === 1 && folderPaths.size === 0) {
const fileId = Array.from(fileIds)[0];
const file = filesInSpace(spaceId).find((item) => item.id === fileId);
return file ? {
resourceType: "file",
resourceId: file.id,
label: file.display_path || file.filename,
action: "files:file:read"
} : null;
}
if (fileIds.size === 0 && folderPaths.size === 1) {
const folderPath = normalizeFolder(Array.from(folderPaths)[0]);
const folder = foldersInSpace(spaceId).find((item) => normalizeFolder(item.path) === folderPath);
if (folder) {
return {
resourceType: "folder",
resourceId: folder.id,
label: folder.path,
action: "files:file:read"
};
}
const space = findSpace(spaceId);
const tenantId = (auth.active_tenant ?? auth.tenant)?.id;
if (!space || !tenantId || !folderPath) return null;
return {
resourceType: "folder",
resourceId: virtualFolderResourceId({ tenantId, ownerType: space.owner_type, ownerId: space.owner_id, path: folderPath }),
label: folderPath,
action: "files:file:read"
};
}
return null;
}
function accessTargetForContext(menu: ContextMenuState | null): FileAccessExplanationTarget | null {
const { fileIds, folderPaths } = selectedSetsForContext(menu);
return accessTargetForSelection(fileIds, folderPaths, menu?.spaceId ?? activeSpaceId);
}
function openAccessExplanationForContext(menu: ContextMenuState | null) {
const target = accessTargetForContext(menu);
setContextMenu(null);
if (target) void openAccessExplanation(target);
}
async function openAccessExplanation(target: FileAccessExplanationTarget): Promise<void> {
if (!auth.user?.id) return;
setAccessExplanationTarget(target);
setResourceAccessExplanation(null);
setResourceAccessLoading(true);
setError("");
try {
setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, {
userId: auth.user.id,
resourceType: target.resourceType,
resourceId: target.resourceId,
action: target.action,
tenantId: (auth.active_tenant ?? auth.tenant)?.id
}));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setAccessExplanationTarget(null);
} finally {
setResourceAccessLoading(false);
}
}
function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) { function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) {
const sets = selectedSetsForContext(menu); const sets = selectedSetsForContext(menu);
const space = spaceForContext(menu); const space = spaceForContext(menu);
@@ -1688,6 +1777,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<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={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> <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>} {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 variant="danger" onClick={() => void deleteSelected()} disabled={busy || activeSpaceIsConnector || !canDelete || !hasSelection}><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}>
@@ -2055,16 +2145,30 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
canDownload={canDownload} canDownload={canDownload}
canOrganize={canOrganize} canOrganize={canOrganize}
canDelete={canDelete} canDelete={canDelete}
canExplainAccess={canExplainResourceAccess && Boolean(accessTargetForContext(contextMenu))}
downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)} downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)}
onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)} onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)}
onUpload={() => openUploadDialogForContext(contextMenu)} onUpload={() => openUploadDialogForContext(contextMenu)}
onDownload={() => void downloadContextSelection(contextMenu)} onDownload={() => void downloadContextSelection(contextMenu)}
onMove={() => openTransferDialogForContext(contextMenu, "move")} onMove={() => openTransferDialogForContext(contextMenu, "move")}
onCopy={() => openTransferDialogForContext(contextMenu, "copy")} onCopy={() => openTransferDialogForContext(contextMenu, "copy")}
onExplainAccess={() => openAccessExplanationForContext(contextMenu)}
onDelete={() => void deleteContextSelection(contextMenu)} /> onDelete={() => void deleteContextSelection(contextMenu)} />
} }
{accessExplanationTarget &&
<FileDialog title="i18n:govoplan-files.access_explanation.75ee7f62" onClose={() => {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}}}>
<ResourceAccessExplanationContent
loading={resourceAccessLoading}
explanation={resourceAccessExplanation}
fallbackResourceLabel={accessExplanationTarget.label} />
<div className="button-row compact-actions align-end">
<Button onClick={() => {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}} disabled={resourceAccessLoading}>i18n:govoplan-files.close.bbfa773e</Button>
</div>
</FileDialog>
}
{dialog === "upload" && {dialog === "upload" &&
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}> <FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
<FileDropZone <FileDropZone
@@ -2350,6 +2454,71 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
} }
function ResourceAccessExplanationContent({
loading,
explanation,
fallbackResourceLabel
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
if (loading) return <p className="muted small-note">i18n:govoplan-files.loading_access_explanation.04a7c934</p>;
if (!explanation) return null;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
return (
<>
<div className="form-grid compact responsive-form-grid">
<div><span className="form-label">i18n:govoplan-files.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-files.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-files.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-files.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
{explanation.provenance.length === 0 ?
<p className="muted small-note">i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e</p> :
<div className="admin-assignment-grid">
{explanation.provenance.map((item, index) =>
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{provenanceKindLabel(item.kind)}</strong>
<div className="muted small-note">
{item.source || "i18n:govoplan-files.no_source.6dcf9723"}
{item.id && <> · <code>{item.id}</code></>}
</div>
{item.label && <p>{item.label}</p>}
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
</div>
)}
</div>
}
</>);
}
function provenanceKindLabel(kind: string): string {
switch (kind) {
case "resource":
return "i18n:govoplan-files.resource.d1c626a9";
case "owner":
return "i18n:govoplan-files.owner.89ff3122";
case "share":
return "i18n:govoplan-files.share.09ca55ca";
case "policy":
return "i18n:govoplan-files.policy.0b779a05";
case "role":
return "i18n:govoplan-files.role.b5b4a5a2";
case "right":
return "i18n:govoplan-files.permission.2f81a22d";
default:
return kind;
}
}
function formatProvenanceDetails(details: Record<string, unknown>): string {
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
}
function formatProvenanceValue(value: unknown): string {
if (value === null || value === undefined) return "i18n:govoplan-files.none.6eef6648";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function FileSearchRow({ function FileSearchRow({
value, value,
caseSensitive, caseSensitive,

View File

@@ -1,5 +1,5 @@
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; import { Copy, Download, FolderOpen, Home, KeyRound, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext, i18nMessage } from "@govoplan/core-webui"; import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext, i18nMessage } from "@govoplan/core-webui";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files"; import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types"; import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
@@ -220,12 +220,14 @@ export function FileContextMenu({
canDownload, canDownload,
canOrganize, canOrganize,
canDelete, canDelete,
canExplainAccess,
downloadLabel, downloadLabel,
onCreateFolder, onCreateFolder,
onUpload, onUpload,
onDownload, onDownload,
onMove, onMove,
onCopy, onCopy,
onExplainAccess,
onDelete onDelete
@@ -242,7 +244,7 @@ export function FileContextMenu({
}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onDelete: () => void;}) { }: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;canExplainAccess: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onExplainAccess: () => void;onDelete: () => void;}) {
const showNewFolder = true; const showNewFolder = true;
const showDelete = menu.target !== "empty"; const showDelete = menu.target !== "empty";
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth; const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
@@ -261,6 +263,7 @@ export function FileContextMenu({
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button> <button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</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={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>
{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" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
</div>); </div>);

View File

@@ -14,6 +14,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths", "i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers", "i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed", "i18n:govoplan-files.allowed.77c7b490": "Allowed",
"i18n:govoplan-files.access_explanation.75ee7f62": "Access explanation",
"i18n:govoplan-files.action.97c89a4d": "Action",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder", "i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and", "i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous", "i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
@@ -24,6 +26,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources", "i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.", "i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available", "i18n:govoplan-files.available.974948f2": " · Available",
"i18n:govoplan-files.close.bbfa773e": "Close",
"i18n:govoplan-files.evidence.8487d192": "Evidence",
"i18n:govoplan-files.explain_access.4d5fac37": "Explain access",
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Loading access explanation...",
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
"i18n:govoplan-files.no_source.6dcf9723": "No source",
"i18n:govoplan-files.owner.89ff3122": "Owner",
"i18n:govoplan-files.permission.2f81a22d": "Permission",
"i18n:govoplan-files.policy.0b779a05": "Policy",
"i18n:govoplan-files.resource.d1c626a9": "Resource",
"i18n:govoplan-files.role.b5b4a5a2": "Role",
"i18n:govoplan-files.share.09ca55ca": "Share",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder", "i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path", "i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy", "i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",
@@ -357,6 +371,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths", "i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers", "i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed", "i18n:govoplan-files.allowed.77c7b490": "Allowed",
"i18n:govoplan-files.access_explanation.75ee7f62": "Zugriffserklärung",
"i18n:govoplan-files.action.97c89a4d": "Aktion",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder", "i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and", "i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous", "i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
@@ -367,6 +383,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources", "i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.", "i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available", "i18n:govoplan-files.available.974948f2": " · Available",
"i18n:govoplan-files.close.bbfa773e": "Schließen",
"i18n:govoplan-files.evidence.8487d192": "Nachweise",
"i18n:govoplan-files.explain_access.4d5fac37": "Zugriff erklären",
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
"i18n:govoplan-files.no_source.6dcf9723": "Keine Quelle",
"i18n:govoplan-files.owner.89ff3122": "Eigentümer",
"i18n:govoplan-files.permission.2f81a22d": "Berechtigung",
"i18n:govoplan-files.policy.0b779a05": "Richtlinie",
"i18n:govoplan-files.resource.d1c626a9": "Ressource",
"i18n:govoplan-files.role.b5b4a5a2": "Rolle",
"i18n:govoplan-files.share.09ca55ca": "Freigabe",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder", "i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path", "i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy", "i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",