Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 620fdda2fc | |||
| 58c0441763 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"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.7",
|
||||||
"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",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-files"
|
name = "govoplan-files"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
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.7",
|
||||||
"python-multipart>=0.0.31,<1",
|
"python-multipart>=0.0.31,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.7",
|
||||||
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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
101
tests/test_access_provider.py
Normal file
101
tests/test_access_provider.py
Normal 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()),
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"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.7"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user