feat: add personal and group view ownership

This commit is contained in:
2026-07-28 22:13:15 +02:00
parent 34221b633b
commit 3de8d4aa5f
12 changed files with 833 additions and 213 deletions

View File

@@ -34,6 +34,12 @@ Administrators can:
revisions revisions
- inspect and remove references to surfaces retired by a module - inspect and remove references to surfaces retired by a module
Accounts with the corresponding definition permission can also design personal
Views, or reusable Views owned by one of their groups, from **Settings >
Views**. Publishing an owned View automatically makes it available to its
owner; administrators can still distribute it more broadly through explicit
assignments.
Required assignments are validated by the backend. They must retain the Views Required assignments are validated by the backend. They must retain the Views
selector, the Access administration route, and the administration section able selector, the Access administration route, and the administration section able
to remove the assignment. If an installed-module change leaves an active View to remove the assignment. If an installed-module change leaves an active View

View File

@@ -31,6 +31,10 @@ MODULE_VERSION = "0.1.0"
DEFINITION_READ_SCOPE = "views:definition:read" DEFINITION_READ_SCOPE = "views:definition:read"
DEFINITION_WRITE_SCOPE = "views:definition:write" DEFINITION_WRITE_SCOPE = "views:definition:write"
GROUP_DEFINITION_READ_SCOPE = "views:group_definition:read"
GROUP_DEFINITION_WRITE_SCOPE = "views:group_definition:write"
PERSONAL_DEFINITION_READ_SCOPE = "views:personal_definition:read"
PERSONAL_DEFINITION_WRITE_SCOPE = "views:personal_definition:write"
ASSIGNMENT_READ_SCOPE = "views:assignment:read" ASSIGNMENT_READ_SCOPE = "views:assignment:read"
ASSIGNMENT_WRITE_SCOPE = "views:assignment:write" ASSIGNMENT_WRITE_SCOPE = "views:assignment:write"
SELECTION_READ_SCOPE = "views:selection:read" SELECTION_READ_SCOPE = "views:selection:read"
@@ -72,6 +76,26 @@ PERMISSIONS = (
"Manage tenant Views", "Manage tenant Views",
"Create, revise, publish, and archive tenant View definitions.", "Create, revise, publish, and archive tenant View definitions.",
), ),
_permission(
GROUP_DEFINITION_READ_SCOPE,
"View group Views",
"Read View definitions owned by groups the account belongs to.",
),
_permission(
GROUP_DEFINITION_WRITE_SCOPE,
"Manage group Views",
"Create, revise, publish, and archive Views owned by the account's groups.",
),
_permission(
PERSONAL_DEFINITION_READ_SCOPE,
"View personal Views",
"Read View definitions owned by the current account.",
),
_permission(
PERSONAL_DEFINITION_WRITE_SCOPE,
"Manage personal Views",
"Create, revise, publish, and archive Views owned by the current account.",
),
_permission( _permission(
ASSIGNMENT_READ_SCOPE, ASSIGNMENT_READ_SCOPE,
"View tenant View assignments", "View tenant View assignments",
@@ -126,17 +150,39 @@ ROLE_TEMPLATES = (
permissions=( permissions=(
DEFINITION_READ_SCOPE, DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE, DEFINITION_WRITE_SCOPE,
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
ASSIGNMENT_READ_SCOPE, ASSIGNMENT_READ_SCOPE,
ASSIGNMENT_WRITE_SCOPE, ASSIGNMENT_WRITE_SCOPE,
SELECTION_READ_SCOPE, SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE, SELECTION_WRITE_SCOPE,
), ),
), ),
RoleTemplate(
slug="view_designer",
name="View designer",
description="Design personal Views and reusable Views for assigned groups.",
permissions=(
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE,
),
),
RoleTemplate( RoleTemplate(
slug="view_user", slug="view_user",
name="View user", name="View user",
description="Use and select Views made available to the account.", description="Use available Views and design Views for the current account.",
permissions=(SELECTION_READ_SCOPE, SELECTION_WRITE_SCOPE), permissions=(
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE,
),
default_authenticated=True, default_authenticated=True,
), ),
) )
@@ -203,6 +249,14 @@ manifest = ModuleManifest(
description="Edit tenant, group, and user Views and assignments.", description="Edit tenant, group, and user Views and assignments.",
order=30, order=30,
), ),
ViewSurface(
id="views.settings.personal",
module_id=MODULE_ID,
kind="section",
label="Personal and group Views",
description="Design reusable Views owned by the account or its groups.",
order=40,
),
), ),
), ),
route_factory=_router, route_factory=_router,
@@ -269,6 +323,10 @@ __all__ = [
"ASSIGNMENT_WRITE_SCOPE", "ASSIGNMENT_WRITE_SCOPE",
"DEFINITION_READ_SCOPE", "DEFINITION_READ_SCOPE",
"DEFINITION_WRITE_SCOPE", "DEFINITION_WRITE_SCOPE",
"GROUP_DEFINITION_READ_SCOPE",
"GROUP_DEFINITION_WRITE_SCOPE",
"PERSONAL_DEFINITION_READ_SCOPE",
"PERSONAL_DEFINITION_WRITE_SCOPE",
"MODULE_ID", "MODULE_ID",
"MODULE_VERSION", "MODULE_VERSION",
"SELECTION_READ_SCOPE", "SELECTION_READ_SCOPE",

View File

@@ -12,6 +12,10 @@ from govoplan_views.backend.manifest import (
ASSIGNMENT_WRITE_SCOPE, ASSIGNMENT_WRITE_SCOPE,
DEFINITION_READ_SCOPE, DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE, DEFINITION_WRITE_SCOPE,
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
SELECTION_READ_SCOPE, SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE, SELECTION_WRITE_SCOPE,
SYSTEM_ASSIGNMENT_READ_SCOPE, SYSTEM_ASSIGNMENT_READ_SCOPE,
@@ -82,7 +86,15 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
) )
def _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None: def _has_any_scope(principal: ApiPrincipal, *scopes: str) -> bool:
return any(has_scope(principal, scope) for scope in scopes)
def _require_definition_read(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None = None,
) -> None:
if scope_type == "system": if scope_type == "system":
_require_any_scope( _require_any_scope(
principal, principal,
@@ -90,22 +102,98 @@ def _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None:
SYSTEM_DEFINITION_WRITE_SCOPE, SYSTEM_DEFINITION_WRITE_SCOPE,
) )
return return
_require_any_scope( if scope_type == "tenant":
principal, _require_any_scope(
DEFINITION_READ_SCOPE, principal,
DEFINITION_WRITE_SCOPE, DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
)
return
if _has_any_scope(principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE):
return
if scope_type == "group":
if not scope_id or scope_id not in principal.group_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Group Views can only be managed by members of that group.",
)
_require_any_scope(
principal,
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
)
return
if scope_type == "user":
if scope_id != principal.account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Personal Views can only be managed by their owner.",
)
_require_any_scope(
principal,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
)
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported View definition scope: {scope_type}",
) )
def _require_definition_write(principal: ApiPrincipal, scope_type: str) -> None: def _require_definition_write(
_require_any_scope( principal: ApiPrincipal,
principal, scope_type: str,
SYSTEM_DEFINITION_WRITE_SCOPE scope_id: str | None = None,
if scope_type == "system" ) -> None:
else DEFINITION_WRITE_SCOPE, if scope_type == "system":
_require_any_scope(principal, SYSTEM_DEFINITION_WRITE_SCOPE)
return
if scope_type == "tenant":
_require_any_scope(principal, DEFINITION_WRITE_SCOPE)
return
if has_scope(principal, DEFINITION_WRITE_SCOPE):
return
if scope_type == "group":
if not scope_id or scope_id not in principal.group_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Group Views can only be managed by members of that group.",
)
_require_any_scope(principal, GROUP_DEFINITION_WRITE_SCOPE)
return
if scope_type == "user":
if scope_id != principal.account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Personal Views can only be managed by their owner.",
)
_require_any_scope(principal, PERSONAL_DEFINITION_WRITE_SCOPE)
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported View definition scope: {scope_type}",
) )
def _definition_scope_id(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
) -> str | None:
if scope_type in {"system", "tenant"}:
return scope_id
if scope_type == "user":
return (scope_id or principal.account_id).strip()
target_id = (scope_id or "").strip()
if not target_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Group View definitions require a target id.",
)
return target_id
def _require_assignment_read(principal: ApiPrincipal, scope_type: str) -> None: def _require_assignment_read(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system": if scope_type == "system":
_require_any_scope( _require_any_scope(
@@ -279,6 +367,10 @@ def api_view_surfaces(
principal, principal,
DEFINITION_READ_SCOPE, DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE, DEFINITION_WRITE_SCOPE,
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
SYSTEM_DEFINITION_READ_SCOPE, SYSTEM_DEFINITION_READ_SCOPE,
SYSTEM_DEFINITION_WRITE_SCOPE, SYSTEM_DEFINITION_WRITE_SCOPE,
) )
@@ -313,16 +405,22 @@ def api_view_surfaces(
@router.get("/definitions", response_model=ViewDefinitionListResponse) @router.get("/definitions", response_model=ViewDefinitionListResponse)
def api_list_definitions( def api_list_definitions(
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"), scope_type: str = Query(
default="tenant",
pattern="^(system|tenant|group|user)$",
),
scope_id: str | None = Query(default=None, max_length=255),
include_inherited: bool = True, include_inherited: bool = True,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionListResponse: ) -> ViewDefinitionListResponse:
_require_definition_read(principal, scope_type) target_id = _definition_scope_id(principal, scope_type, scope_id)
_require_definition_read(principal, scope_type, target_id)
definitions = list_definitions( definitions = list_definitions(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
scope_type=scope_type, scope_type=scope_type,
scope_id=target_id,
include_inherited=include_inherited, include_inherited=include_inherited,
) )
return ViewDefinitionListResponse( return ViewDefinitionListResponse(
@@ -330,7 +428,16 @@ def api_list_definitions(
_definition_response( _definition_response(
session, session,
definition, definition,
readonly=(scope_type == "tenant" and definition.scope_type == "system"), readonly=(
(scope_type == "tenant" and definition.scope_type == "system")
or (
scope_type in {"group", "user"}
and (
definition.scope_type != scope_type
or definition.scope_id != target_id
)
)
),
) )
for definition in definitions for definition in definitions
] ]
@@ -347,12 +454,18 @@ def api_create_definition(
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionResponse: ) -> ViewDefinitionResponse:
_require_definition_write(principal, payload.scope_type) target_id = _definition_scope_id(
principal,
payload.scope_type,
payload.scope_id,
)
_require_definition_write(principal, payload.scope_type, target_id)
try: try:
definition = create_definition( definition = create_definition(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
scope_type=payload.scope_type, scope_type=payload.scope_type,
scope_id=target_id,
definition_key=payload.definition_key, definition_key=payload.definition_key,
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
@@ -391,7 +504,11 @@ def api_update_definition(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
definition_id=definition_id, definition_id=definition_id,
) )
_require_definition_write(principal, definition.scope_type) _require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
update_definition( update_definition(
session, session,
definition, definition,
@@ -430,7 +547,11 @@ def api_list_revisions(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
definition_id=definition_id, definition_id=definition_id,
) )
_require_definition_read(principal, definition.scope_type) _require_definition_read(
principal,
definition.scope_type,
definition.scope_id,
)
return [ return [
ViewRevisionResponse.model_validate(revision) ViewRevisionResponse.model_validate(revision)
for revision in definition_revisions( for revision in definition_revisions(
@@ -458,7 +579,11 @@ def api_create_revision(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
definition_id=definition_id, definition_id=definition_id,
) )
_require_definition_write(principal, definition.scope_type) _require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
revision = create_revision( revision = create_revision(
session, session,
definition, definition,
@@ -500,7 +625,11 @@ def api_publish_revision(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
definition_id=definition_id, definition_id=definition_id,
) )
_require_definition_write(principal, definition.scope_type) _require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
revision = get_revision( revision = get_revision(
session, session,
definition_id=definition.id, definition_id=definition.id,
@@ -543,7 +672,11 @@ def api_archive_definition(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
definition_id=definition_id, definition_id=definition_id,
) )
_require_definition_write(principal, definition.scope_type) _require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
archive_definition( archive_definition(
session, session,
definition, definition,

View File

@@ -6,7 +6,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
ViewScopeType = Literal["system", "tenant"] ViewScopeType = Literal["system", "tenant", "group", "user"]
AssignmentScopeType = Literal["system", "tenant", "group", "user"] AssignmentScopeType = Literal["system", "tenant", "group", "user"]
AssignmentMode = Literal["available", "default", "required"] AssignmentMode = Literal["available", "default", "required"]
@@ -68,6 +68,7 @@ class ViewDefinitionListResponse(BaseModel):
class ViewDefinitionCreateRequest(BaseModel): class ViewDefinitionCreateRequest(BaseModel):
scope_type: ViewScopeType = "tenant" scope_type: ViewScopeType = "tenant"
scope_id: str | None = Field(default=None, max_length=255)
definition_key: str | None = Field(default=None, max_length=120) definition_key: str | None = Field(default=None, max_length=120)
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=4000) description: str | None = Field(default=None, max_length=4000)

View File

@@ -27,7 +27,7 @@ from govoplan_views.backend.db.models import (
ASSIGNMENT_MODES = frozenset({"available", "default", "required"}) ASSIGNMENT_MODES = frozenset({"available", "default", "required"})
ASSIGNMENT_SCOPES = frozenset({"system", "tenant", "group", "user"}) ASSIGNMENT_SCOPES = frozenset({"system", "tenant", "group", "user"})
DEFINITION_SCOPES = frozenset({"system", "tenant"}) DEFINITION_SCOPES = frozenset({"system", "tenant", "group", "user"})
LOCKOUT_BASE_SURFACE_IDS = ( LOCKOUT_BASE_SURFACE_IDS = (
"access.module", "access.module",
"access.nav.admin", "access.nav.admin",
@@ -91,11 +91,27 @@ def _scope_values(
scope_type: str, scope_type: str,
*, *,
tenant_id: str, tenant_id: str,
scope_id: str | None = None,
) -> tuple[str | None, str | None, str]: ) -> tuple[str | None, str | None, str]:
if scope_type == "system": if scope_type == "system":
if scope_id:
raise ViewsValidationError(
"System View definitions cannot declare a target id"
)
return None, None, "system" return None, None, "system"
if scope_type == "tenant": if scope_type == "tenant":
if scope_id and scope_id != tenant_id:
raise ViewsValidationError(
"Tenant View definitions must target the active tenant"
)
return tenant_id, tenant_id, f"tenant:{tenant_id}" return tenant_id, tenant_id, f"tenant:{tenant_id}"
if scope_type in {"group", "user"}:
target_id = (scope_id or "").strip()
if not target_id:
raise ViewsValidationError(
f"{scope_type.title()} View definitions require a target id"
)
return tenant_id, target_id, f"{scope_type}:{tenant_id}:{target_id}"
raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}") raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}")
@@ -158,6 +174,7 @@ def list_definitions(
*, *,
tenant_id: str, tenant_id: str,
scope_type: str, scope_type: str,
scope_id: str | None = None,
include_inherited: bool = True, include_inherited: bool = True,
) -> list[ViewDefinition]: ) -> list[ViewDefinition]:
if scope_type not in DEFINITION_SCOPES: if scope_type not in DEFINITION_SCOPES:
@@ -168,12 +185,45 @@ def list_definitions(
ViewDefinition.scope_type == "system", ViewDefinition.scope_type == "system",
ViewDefinition.deleted_at.is_(None), ViewDefinition.deleted_at.is_(None),
) )
elif scope_type == "tenant":
if not include_inherited:
query = session.query(ViewDefinition).filter(
ViewDefinition.tenant_id == tenant_id,
ViewDefinition.scope_type == "tenant",
ViewDefinition.deleted_at.is_(None),
)
else:
query = _definition_query_for_actor(
session,
tenant_id=tenant_id,
include_system=True,
)
else: else:
query = _definition_query_for_actor( target_id = (scope_id or "").strip()
session, if not target_id:
tenant_id=tenant_id, raise ViewsValidationError(
include_system=include_inherited, f"{scope_type.title()} View definition listings require a target id"
)
exact_target = (
(ViewDefinition.tenant_id == tenant_id)
& (ViewDefinition.scope_type == scope_type)
& (ViewDefinition.scope_id == target_id)
) )
query = session.query(ViewDefinition).filter(
ViewDefinition.deleted_at.is_(None)
)
if include_inherited:
query = query.filter(
or_(
exact_target,
(ViewDefinition.tenant_id == tenant_id)
& (ViewDefinition.scope_type == "tenant"),
(ViewDefinition.tenant_id.is_(None))
& (ViewDefinition.scope_type == "system"),
)
)
else:
query = query.filter(exact_target)
return query.order_by( return query.order_by(
ViewDefinition.scope_type.asc(), ViewDefinition.scope_type.asc(),
ViewDefinition.name.asc(), ViewDefinition.name.asc(),
@@ -291,6 +341,7 @@ def create_definition(
*, *,
tenant_id: str, tenant_id: str,
scope_type: str, scope_type: str,
scope_id: str | None = None,
definition_key: str | None, definition_key: str | None,
name: str, name: str,
description: str | None, description: str | None,
@@ -303,6 +354,7 @@ def create_definition(
row_tenant_id, scope_id, scope_key = _scope_values( row_tenant_id, scope_id, scope_key = _scope_values(
scope_type, scope_type,
tenant_id=tenant_id, tenant_id=tenant_id,
scope_id=scope_id,
) )
clean_name = name.strip() clean_name = name.strip()
if not clean_name: if not clean_name:
@@ -489,9 +541,65 @@ def publish_revision(
definition.updated_by = actor_id definition.updated_by = actor_id
definition.updated_at = _now() definition.updated_at = _now()
session.flush() session.flush()
ensure_owner_available_assignment(
session,
definition,
catalogue=catalogue,
actor_id=actor_id,
)
return definition return definition
def ensure_owner_available_assignment(
session: Session,
definition: ViewDefinition,
*,
catalogue: Iterable[ViewSurface],
actor_id: str | None,
) -> ViewAssignment | None:
if definition.scope_type not in {"group", "user"}:
return None
if not definition.tenant_id or not definition.scope_id:
raise ViewsValidationError(
"Owned View definitions require tenant and target identifiers"
)
target_key = f"{definition.scope_type}:{definition.tenant_id}:{definition.scope_id}"
assignment = (
session.query(ViewAssignment)
.filter(
ViewAssignment.target_key == target_key,
ViewAssignment.definition_id == definition.id,
ViewAssignment.mode == "available",
)
.first()
)
if assignment is None:
return create_assignment(
session,
tenant_id=definition.tenant_id,
scope_type=definition.scope_type,
scope_id=definition.scope_id,
definition=definition,
revision_id=None,
mode="available",
priority=0,
is_active=True,
metadata={"source": "definition_owner"},
catalogue=catalogue,
actor_id=actor_id,
)
assignment.revision_id = None
assignment.is_active = True
assignment.metadata_ = {
**dict(assignment.metadata_ or {}),
"source": "definition_owner",
}
assignment.updated_by = actor_id
assignment.updated_at = _now()
session.flush()
return assignment
def archive_definition( def archive_definition(
session: Session, session: Session,
definition: ViewDefinition, definition: ViewDefinition,
@@ -1181,6 +1289,7 @@ __all__ = [
"get_assignment", "get_assignment",
"get_definition", "get_definition",
"get_revision", "get_revision",
"ensure_owner_available_assignment",
"list_assignments", "list_assignments",
"list_definitions", "list_definitions",
"lockout_required_surface_ids", "lockout_required_surface_ids",

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import unittest
from fastapi import HTTPException
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_views.backend.router import (
_require_definition_read,
_require_definition_write,
)
def principal(
*,
account_id: str = "account-1",
scopes: tuple[str, ...],
group_ids: tuple[str, ...] = (),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id="membership-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
group_ids=frozenset(group_ids),
),
account=object(),
user=object(),
)
class ViewDefinitionAuthorizationTests(unittest.TestCase):
def test_personal_writer_cannot_edit_another_accounts_view(self) -> None:
actor = principal(scopes=("views:personal_definition:write",))
_require_definition_write(actor, "user", "account-1")
with self.assertRaises(HTTPException) as caught:
_require_definition_write(actor, "user", "account-2")
self.assertEqual(403, caught.exception.status_code)
def test_group_reader_is_limited_to_current_memberships(self) -> None:
actor = principal(
scopes=("views:group_definition:read",),
group_ids=("group-1",),
)
_require_definition_read(actor, "group", "group-1")
with self.assertRaises(HTTPException) as caught:
_require_definition_read(actor, "group", "group-2")
self.assertEqual(403, caught.exception.status_code)
def test_tenant_manager_can_manage_owned_views(self) -> None:
actor = principal(scopes=("views:definition:write",))
_require_definition_write(actor, "group", "group-2")
_require_definition_write(actor, "user", "account-2")
if __name__ == "__main__":
unittest.main()

View File

@@ -15,6 +15,7 @@ from govoplan_views.backend.service import (
create_definition, create_definition,
create_revision, create_revision,
get_revision, get_revision,
list_definitions,
normalize_visible_surface_ids, normalize_visible_surface_ids,
publish_revision, publish_revision,
resolve_effective_view, resolve_effective_view,
@@ -163,6 +164,56 @@ class ViewsServiceTests(unittest.TestCase):
self.assertIn("views.selector", normalized) self.assertIn("views.selector", normalized)
self.assertIn("views.module", normalized) self.assertIn("views.module", normalized)
def test_published_group_view_is_available_to_group_members(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
scope_type="group",
scope_id="group-1",
definition_key=None,
name="Group workspace",
description=None,
visible_surface_ids=ordinary_surface_ids(),
catalogue=self.catalogue,
actor_id="account-designer",
)
revision = get_revision(self.session, definition_id=definition.id)
publish_revision(
self.session,
definition,
revision,
catalogue=self.catalogue,
actor_id="account-designer",
)
member = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-member",
group_ids=("group-1",),
)
outsider = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-outsider",
group_ids=(),
)
self.assertEqual([definition.id], [view.id for view in member.available_views])
self.assertEqual((), outsider.available_views)
self.assertEqual(
[definition.id],
[
item.id
for item in list_definitions(
self.session,
tenant_id="tenant-1",
scope_type="group",
scope_id="group-1",
include_inherited=False,
)
],
)
def test_normalization_requires_navigation_and_route(self) -> None: def test_normalization_requires_navigation_and_route(self) -> None:
with self.assertRaisesRegex(ViewsValidationError, "navigation"): with self.assertRaisesRegex(ViewsValidationError, "navigation"):
normalize_visible_surface_ids( normalize_visible_surface_ids(
@@ -333,9 +384,7 @@ class ViewsServiceTests(unittest.TestCase):
actor_id="account-admin", actor_id="account-admin",
) )
catalogue_without_admin_route = tuple( catalogue_without_admin_route = tuple(
surface surface for surface in self.catalogue if surface.id != "access.route.admin"
for surface in self.catalogue
if surface.id != "access.route.admin"
) )
state = resolve_effective_view( state = resolve_effective_view(

View File

@@ -5,7 +5,7 @@ import {
type EffectiveViewProjection type EffectiveViewProjection
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
export type ViewScopeType = "system" | "tenant"; export type ViewScopeType = "system" | "tenant" | "group" | "user";
export type ViewAssignmentScopeType = "system" | "tenant" | "group" | "user"; export type ViewAssignmentScopeType = "system" | "tenant" | "group" | "user";
export type ViewAssignmentMode = "available" | "default" | "required"; export type ViewAssignmentMode = "available" | "default" | "required";
@@ -148,12 +148,14 @@ export async function selectEffectiveView(
export async function fetchViewDefinitions( export async function fetchViewDefinitions(
settings: ApiSettings, settings: ApiSettings,
scopeType: ViewScopeType scopeType: ViewScopeType,
scopeId?: string | null
): Promise<ViewDefinition[]> { ): Promise<ViewDefinition[]> {
const response = await apiFetch<{ definitions: ViewDefinition[] }>( const response = await apiFetch<{ definitions: ViewDefinition[] }>(
settings, settings,
apiPath("/api/v1/views/definitions", { apiPath("/api/v1/views/definitions", {
scope_type: scopeType, scope_type: scopeType,
scope_id: scopeId || undefined,
include_inherited: scopeType === "tenant" include_inherited: scopeType === "tenant"
}) })
); );
@@ -164,6 +166,7 @@ export function createViewDefinition(
settings: ApiSettings, settings: ApiSettings,
payload: { payload: {
scope_type: ViewScopeType; scope_type: ViewScopeType;
scope_id?: string | null;
name: string; name: string;
description?: string | null; description?: string | null;
visible_surface_ids: string[]; visible_surface_ids: string[];

View File

@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from "react";
import {
FormField,
hasScope,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import ViewsAdminPanel from "./ViewsAdminPanel";
import type { ViewScopeType } from "../../api/views";
type OwnerOption = {
key: string;
scopeType: Extract<ViewScopeType, "group" | "user">;
scopeId: string;
label: string;
description: string;
canWrite: boolean;
};
export default function PersonalViewsPanel({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const options = useMemo(() => ownerOptions(auth), [auth]);
const [ownerKey, setOwnerKey] = useState(options[0]?.key ?? "");
useEffect(() => {
if (!options.some((option) => option.key === ownerKey)) {
setOwnerKey(options[0]?.key ?? "");
}
}, [options, ownerKey]);
const owner = options.find((option) => option.key === ownerKey) ?? options[0];
if (!owner) {
return (
<p className="muted">
You do not have access to personal or group View definitions.
</p>
);
}
return (
<div className="views-personal-panel">
{options.length > 1 && (
<div className="views-owner-toolbar">
<FormField label="View owner">
<select
value={owner.key}
onChange={(event) => setOwnerKey(event.target.value)}
>
{options.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</FormField>
</div>
)}
<ViewsAdminPanel
key={owner.key}
settings={settings}
scopeType={owner.scopeType}
scopeId={owner.scopeId}
canWriteDefinitions={owner.canWrite}
canWriteAssignments={false}
showAssignments={false}
embedded
title={owner.label}
description={owner.description}
/>
</div>
);
}
function ownerOptions(auth: AuthInfo): OwnerOption[] {
const tenantManager = hasScope(auth, "views:definition:write");
const options: OwnerOption[] = [];
if (
tenantManager ||
hasScope(auth, "views:personal_definition:read") ||
hasScope(auth, "views:personal_definition:write")
) {
options.push({
key: `user:${auth.user.account_id}`,
scopeType: "user",
scopeId: auth.user.account_id,
label: "My Views",
description:
"Design personal interface projections. Publishing a View makes it available to this account.",
canWrite:
tenantManager ||
hasScope(auth, "views:personal_definition:write")
});
}
const canReadGroups =
tenantManager ||
hasScope(auth, "views:group_definition:read") ||
hasScope(auth, "views:group_definition:write");
if (canReadGroups) {
for (const group of auth.groups) {
options.push({
key: `group:${group.id}`,
scopeType: "group",
scopeId: group.id,
label: `Group: ${group.name}`,
description:
"Design reusable interface projections for this group. Publishing a View makes it available to all current group members.",
canWrite:
tenantManager ||
hasScope(auth, "views:group_definition:write")
});
}
}
return options;
}

View File

@@ -1,13 +1,16 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Archive, Archive,
Check, CheckSquare2,
ChevronDown,
ChevronRight, ChevronRight,
MinusSquare,
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Save, Save,
Send, Send,
Square,
Trash2 Trash2
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -16,8 +19,11 @@ import {
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
ExplorerTree,
FormField, FormField,
IconButton, IconButton,
SelectionList,
SelectionListItem,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
adminErrorMessage, adminErrorMessage,
@@ -77,13 +83,23 @@ const LOCKOUT_SURFACES = new Set([
export default function ViewsAdminPanel({ export default function ViewsAdminPanel({
settings, settings,
scopeType, scopeType,
scopeId,
canWriteDefinitions, canWriteDefinitions,
canWriteAssignments canWriteAssignments,
showAssignments = scopeType === "system" || scopeType === "tenant",
embedded = false,
title: titleOverride,
description: descriptionOverride
}: { }: {
settings: ApiSettings; settings: ApiSettings;
scopeType: ViewScopeType; scopeType: ViewScopeType;
scopeId?: string | null;
canWriteDefinitions: boolean; canWriteDefinitions: boolean;
canWriteAssignments: boolean; canWriteAssignments: boolean;
showAssignments?: boolean;
embedded?: boolean;
title?: string;
description?: string;
}) { }) {
const surfaces = useViewSurfaces(); const surfaces = useViewSurfaces();
const { requestDiscard } = useUnsavedChanges(); const { requestDiscard } = useUnsavedChanges();
@@ -133,8 +149,10 @@ export default function ViewsAdminPanel({
setError(""); setError("");
try { try {
const [nextDefinitions, nextAssignments] = await Promise.all([ const [nextDefinitions, nextAssignments] = await Promise.all([
fetchViewDefinitions(settings, scopeType), fetchViewDefinitions(settings, scopeType, scopeId),
fetchViewAssignments(settings, scopeType) showAssignments && (scopeType === "system" || scopeType === "tenant")
? fetchViewAssignments(settings, scopeType)
: Promise.resolve([])
]); ]);
setDefinitions(nextDefinitions); setDefinitions(nextDefinitions);
setAssignments(nextAssignments); setAssignments(nextAssignments);
@@ -161,6 +179,8 @@ export default function ViewsAdminPanel({
void load(); void load();
}, [ }, [
scopeType, scopeType,
scopeId,
showAssignments,
settings.accessToken, settings.accessToken,
settings.apiBaseUrl, settings.apiBaseUrl,
settings.apiKey settings.apiKey
@@ -268,6 +288,7 @@ export default function ViewsAdminPanel({
.map((surface) => surface.id); .map((surface) => surface.id);
const created = await createViewDefinition(settings, { const created = await createViewDefinition(settings, {
scope_type: scopeType, scope_type: scopeType,
scope_id: scopeId || null,
name: createDraft.name.trim(), name: createDraft.name.trim(),
description: createDraft.description.trim() || null, description: createDraft.description.trim() || null,
visible_surface_ids: visibleSurfaceIds visible_surface_ids: visibleSurfaceIds
@@ -411,11 +432,26 @@ export default function ViewsAdminPanel({
} }
} }
const title = scopeType === "system" ? "System Views" : "Tenant Views"; const title =
titleOverride ??
({
system: "System Views",
tenant: "Tenant Views",
group: "Group Views",
user: "My Views"
} satisfies Record<ViewScopeType, string>)[scopeType];
const description = const description =
scopeType === "system" descriptionOverride ??
? "Publish reusable interface projections and assign instance-wide defaults or requirements." ({
: "Tailor the visible interface for this tenant, its groups, and individual users."; system:
"Publish reusable interface projections and assign instance-wide defaults or requirements.",
tenant:
"Tailor the visible interface for this tenant, its groups, and individual users.",
group:
"Design reusable interface projections for members of this group.",
user:
"Design personal interface projections that remain available to this account."
} satisfies Record<ViewScopeType, string>)[scopeType];
const canCreate = const canCreate =
canWriteDefinitions && canWriteDefinitions &&
surfaces.some((surface) => surface.kind === "navigation") && surfaces.some((surface) => surface.kind === "navigation") &&
@@ -426,6 +462,7 @@ export default function ViewsAdminPanel({
<AdminPageLayout <AdminPageLayout
title={title} title={title}
description={description} description={description}
className={embedded ? "views-admin-page embedded" : "views-admin-page"}
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
@@ -453,42 +490,42 @@ export default function ViewsAdminPanel({
</> </>
} }
> >
<div className="views-admin-shell"> <div className="views-management-layout">
<div className="views-admin-shell">
<aside className="views-definition-pane" aria-label="View definitions"> <aside className="views-definition-pane" aria-label="View definitions">
<div className="views-pane-heading"> <div className="views-pane-heading">
<strong>Definitions</strong> <strong>Definitions</strong>
<span>{definitions.length}</span> <span>{definitions.length}</span>
</div> </div>
<div className="views-definition-list"> <SelectionList
className="views-definition-list"
label="View definitions"
>
{definitions.map((definition) => ( {definitions.map((definition) => (
<button <SelectionListItem
key={definition.id} key={definition.id}
type="button" className="views-definition-item"
className={ selected={definition.id === selectedId}
definition.id === selectedId
? "views-definition-item active"
: "views-definition-item"
}
onClick={() => selectDefinition(definition.id)} onClick={() => selectDefinition(definition.id)}
> >
<span className="views-definition-item-main"> <span className="views-definition-item-main">
<strong>{definition.name}</strong> <strong>{definition.name}</strong>
<small> <small>
{definition.scope_type === "system" ? "System" : "Tenant"} {scopeLabel(definition)}
{" · "} {" · "}
revision {definition.current_revision} revision {definition.current_revision}
</small> </small>
</span> </span>
<StatusBadge status={definition.status} /> <StatusBadge status={definition.status} />
<ChevronRight size={16} aria-hidden="true" /> <ChevronRight size={16} aria-hidden="true" />
</button> </SelectionListItem>
))} ))}
{!definitions.length && ( {!definitions.length && (
<div className="views-empty-list"> <div className="views-empty-list">
No Views have been defined in this scope. No Views have been defined in this scope.
</div> </div>
)} )}
</div> </SelectionList>
</aside> </aside>
<main className="views-editor-pane"> <main className="views-editor-pane">
@@ -610,17 +647,6 @@ export default function ViewsAdminPanel({
/> />
</section> </section>
<AssignmentsSection
assignments={assignments}
definitions={definitions}
scopeType={scopeType}
busy={busy}
canWrite={canWriteAssignments}
onCreate={openCreateAssignment}
onEdit={openEditAssignment}
onToggle={toggleAssignment}
onDelete={setDeleteAssignmentTarget}
/>
</> </>
) : ( ) : (
<div className="views-empty-editor"> <div className="views-empty-editor">
@@ -629,6 +655,21 @@ export default function ViewsAdminPanel({
</div> </div>
)} )}
</main> </main>
</div>
{showAssignments &&
(scopeType === "system" || scopeType === "tenant") && (
<AssignmentsSection
assignments={assignments}
definitions={definitions}
scopeType={scopeType}
busy={busy}
canWrite={canWriteAssignments}
onCreate={openCreateAssignment}
onEdit={openEditAssignment}
onToggle={toggleAssignment}
onDelete={setDeleteAssignmentTarget}
/>
)}
</div> </div>
</AdminPageLayout> </AdminPageLayout>
@@ -1026,6 +1067,7 @@ function SurfaceSelector({
onChange: (surfaceIds: string[]) => void; onChange: (surfaceIds: string[]) => void;
}) { }) {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const byId = useMemo( const byId = useMemo(
() => new Map(surfaces.map((surface) => [surface.id, surface])), () => new Map(surfaces.map((surface) => [surface.id, surface])),
[surfaces] [surfaces]
@@ -1098,6 +1140,26 @@ function SurfaceSelector({
} }
const normalizedFilter = filter.trim().toLowerCase(); const normalizedFilter = filter.trim().toLowerCase();
const visibleModules = modules.filter((moduleSurface) => {
if (!normalizedFilter) return true;
return [moduleSurface, ...descendants(moduleSurface.id)]
.map((surface) =>
typeof surface === "string" ? byId.get(surface) : surface
)
.filter((surface): surface is PlatformViewSurface => Boolean(surface))
.some((surface) =>
`${surface.label} ${surface.id} ${surface.description ?? ""}`
.toLowerCase()
.includes(normalizedFilter)
);
});
const effectiveExpandedIds = normalizedFilter
? new Set(
surfaces
.filter((surface) => (childrenByParent.get(surface.id) ?? []).length > 0)
.map((surface) => surface.id)
)
: expandedIds;
return ( return (
<div className="views-surface-selector"> <div className="views-surface-selector">
@@ -1110,94 +1172,80 @@ function SurfaceSelector({
/> />
</div> </div>
<div className="views-surface-modules"> <div className="views-surface-modules">
{modules.map((moduleSurface) => { <ExplorerTree
const moduleSurfaces = [ nodes={visibleModules}
moduleSurface, getNodeId={(surface) => surface.id}
...descendants(moduleSurface.id) getNodeLabel={(surface) => surface.label}
.map((id) => byId.get(id)) getNodeChildren={(surface) => childrenByParent.get(surface.id) ?? []}
.filter((item): item is PlatformViewSurface => Boolean(item)) expandedIds={effectiveExpandedIds}
]; depth={0}
const matches = !normalizedFilter || moduleSurfaces.some((surface) => className="views-surface-tree"
`${surface.label} ${surface.id} ${surface.description ?? ""}` onToggle={(surface) => {
.toLowerCase() setExpandedIds((current) => {
.includes(normalizedFilter) const next = new Set(current);
); if (next.has(surface.id)) next.delete(surface.id);
if (!matches) return null; else next.add(surface.id);
const childIds = moduleSurfaces.slice(1).map((surface) => surface.id); return next;
const selectedChildren = childIds.filter((id) => selectedSet.has(id)).length; });
return ( }}
<details key={moduleSurface.id} open={Boolean(normalizedFilter)}> onOpen={(surface) => {
<summary> if (disabled || requiredIds.has(surface.id)) return;
<SurfaceCheckbox toggle(surface.id, !selectedSet.has(surface.id));
checked={selectedSet.has(moduleSurface.id)} }}
indeterminate={ renderToggleIcon={(_surface, context) =>
selectedChildren > 0 && selectedChildren < childIds.length context.hasChildren ? (
} context.expanded ? (
disabled={disabled || requiredIds.has(moduleSurface.id)} <ChevronDown size={16} aria-hidden="true" />
label={moduleSurface.label} ) : (
secondary={`${selectedChildren}/${childIds.length}`} <ChevronRight size={16} aria-hidden="true" />
onChange={(checked) => toggle(moduleSurface.id, checked)} )
/> ) : null
</summary> }
<div className="views-surface-children"> getNodeButtonClassName={(surface) =>
{moduleSurfaces.slice(1).map((surface) => ( disabled || requiredIds.has(surface.id)
<SurfaceCheckbox ? "views-surface-node disabled"
key={surface.id} : "views-surface-node"
checked={selectedSet.has(surface.id)} }
disabled={disabled || requiredIds.has(surface.id)} renderNodeContent={(surface) => {
label={surface.label} const childIds = descendants(surface.id);
secondary={`${surface.kind} · ${surface.description ?? surface.id}`} const selectedChildren = childIds.filter((id) =>
onChange={(checked) => toggle(surface.id, checked)} selectedSet.has(id)
/> ).length;
))} const checked = selectedSet.has(surface.id);
</div> const indeterminate =
</details> selectedChildren > 0 && selectedChildren < childIds.length;
); const secondary =
})} surface.kind === "module"
? `${selectedChildren}/${childIds.length} surfaces`
: `${surface.kind} · ${surface.description ?? surface.id}`;
return (
<>
<span className="views-surface-check" aria-hidden="true">
{indeterminate ? (
<MinusSquare size={17} />
) : checked ? (
<CheckSquare2 size={17} />
) : (
<Square size={17} />
)}
</span>
<span className="explorer-tree-node-content">
<strong>{surface.label}</strong>
<small>{secondary}</small>
</span>
</>
);
}}
/>
{!visibleModules.length && (
<div className="views-empty-list">No matching surfaces.</div>
)}
</div> </div>
</div> </div>
); );
} }
function SurfaceCheckbox({
checked,
indeterminate = false,
disabled,
label,
secondary,
onChange
}: {
checked: boolean;
indeterminate?: boolean;
disabled: boolean;
label: string;
secondary: string;
onChange: (checked: boolean) => void;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<label className={disabled ? "views-surface-row disabled" : "views-surface-row"}>
<input
ref={ref}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span>
<strong>{label}</strong>
<small>{secondary}</small>
</span>
{checked && <Check size={15} aria-hidden="true" />}
</label>
);
}
function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft { function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
return { return {
scopeType: scopeType === "system" ? "system" : "tenant", scopeType: scopeType === "system" ? "system" : "tenant",
@@ -1211,6 +1259,19 @@ function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft {
} }
function scopeLabel(definition: ViewDefinition): string {
const label = {
system: "System",
tenant: "Tenant",
group: "Group",
user: "User"
} satisfies Record<ViewScopeType, string>;
return definition.scope_id && definition.scope_type !== "tenant"
? `${label[definition.scope_type]} · ${definition.scope_id}`
: label[definition.scope_type];
}
function definitionDraftKey(draft: DefinitionDraft): string { function definitionDraftKey(draft: DefinitionDraft): string {
return JSON.stringify({ return JSON.stringify({
name: draft.name.trim(), name: draft.name.trim(),

View File

@@ -3,6 +3,7 @@ import {
hasScope, hasScope,
type AdminSectionsUiCapability, type AdminSectionsUiCapability,
type PlatformWebModule, type PlatformWebModule,
type SettingsSectionsUiCapability,
type ViewsRuntimeUiCapability type ViewsRuntimeUiCapability
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import ViewSelector from "./components/ViewSelector"; import ViewSelector from "./components/ViewSelector";
@@ -13,6 +14,9 @@ import "./styles/views.css";
const ViewsAdminPanel = lazy( const ViewsAdminPanel = lazy(
() => import("./features/views/ViewsAdminPanel") () => import("./features/views/ViewsAdminPanel")
); );
const PersonalViewsPanel = lazy(
() => import("./features/views/PersonalViewsPanel")
);
const viewsAdminSections: AdminSectionsUiCapability = { const viewsAdminSections: AdminSectionsUiCapability = {
sections: [ sections: [
@@ -63,6 +67,28 @@ const viewsRuntime: ViewsRuntimeUiCapability = {
Selector: ViewSelector Selector: ViewSelector
}; };
const viewsSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "views",
label: "Views",
group: "ui",
order: 35,
surfaceId: "views.settings.personal",
anyOf: [
"views:definition:read",
"views:definition:write",
"views:group_definition:read",
"views:group_definition:write",
"views:personal_definition:read",
"views:personal_definition:write"
],
render: ({ settings, auth }) =>
createElement(PersonalViewsPanel, { settings, auth })
}
]
};
export const viewsModule: PlatformWebModule = { export const viewsModule: PlatformWebModule = {
id: "views", id: "views",
label: "Views", label: "Views",
@@ -90,13 +116,20 @@ export const viewsModule: PlatformWebModule = {
kind: "section", kind: "section",
label: "Tenant Views administration", label: "Tenant Views administration",
order: 30 order: 30
},
{
id: "views.settings.personal",
moduleId: "views",
kind: "section",
label: "Personal and group Views",
order: 40
} }
], ],
uiCapabilities: { uiCapabilities: {
"admin.sections": viewsAdminSections, "admin.sections": viewsAdminSections,
"settings.sections": viewsSettingsSections,
"views.runtime": viewsRuntime "views.runtime": viewsRuntime
} }
}; };
export default viewsModule; export default viewsModule;

View File

@@ -29,6 +29,32 @@
opacity: .78; opacity: .78;
} }
.views-management-layout {
display: grid;
gap: 18px;
}
.views-admin-page.embedded > .admin-page-heading {
position: static;
margin: 0 0 18px;
padding: 0 0 14px;
box-shadow: none;
}
.views-personal-panel {
min-width: 0;
}
.views-owner-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.views-owner-toolbar .form-field {
width: min(360px, 100%);
}
.views-admin-shell { .views-admin-shell {
display: grid; display: grid;
grid-template-columns: 280px minmax(0, 1fr); grid-template-columns: 280px minmax(0, 1fr);
@@ -81,24 +107,7 @@
grid-template-columns: minmax(0, 1fr) auto 18px; grid-template-columns: minmax(0, 1fr) auto 18px;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%;
min-height: 58px; min-height: 58px;
padding: 9px 8px 9px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.views-definition-item:hover {
background: var(--hover-tint-soft);
}
.views-definition-item.active {
background: var(--accent-hover-bg);
color: var(--text-strong);
} }
.views-definition-item-main { .views-definition-item-main {
@@ -159,13 +168,18 @@
resize: vertical; resize: vertical;
} }
.views-surface-section, .views-surface-section {
.views-assignments-section {
margin-top: 22px; margin-top: 22px;
padding-top: 18px; padding-top: 18px;
border-top: var(--border-line); border-top: var(--border-line);
} }
.views-assignments-section {
min-width: 0;
padding-top: 18px;
border-top: var(--border-line);
}
.views-section-heading { .views-section-heading {
align-items: center; align-items: center;
margin-bottom: 10px; margin-bottom: 10px;
@@ -200,58 +214,25 @@
padding: 6px; padding: 6px;
} }
.views-surface-modules details { .views-surface-node.disabled {
border-bottom: var(--border-line);
}
.views-surface-modules details:last-child {
border-bottom: 0;
}
.views-surface-modules summary {
list-style: none;
}
.views-surface-modules summary::-webkit-details-marker {
display: none;
}
.views-surface-children {
display: grid;
gap: 2px;
padding: 0 0 6px 24px;
}
.views-surface-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 18px;
align-items: center;
gap: 9px;
min-height: 42px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.views-surface-row:hover {
background: var(--hover-tint-soft);
}
.views-surface-row.disabled {
cursor: default; cursor: default;
opacity: .7; opacity: .7;
} }
.views-surface-row strong, .views-surface-tree .explorer-tree-node-wrap {
.views-surface-row small { border-radius: var(--radius-sm);
display: block;
letter-spacing: 0;
} }
.views-surface-row small { .views-surface-tree .explorer-tree-node {
margin-top: 2px; min-height: 42px;
overflow-wrap: anywhere; border-radius: var(--radius-sm);
color: var(--muted); }
.views-surface-check {
display: inline-grid;
flex: 0 0 18px;
place-items: center;
color: var(--accent);
} }
.views-assignment-list { .views-assignment-list {