feat: add personal and group view ownership
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,20 +102,96 @@ def _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None:
|
|||||||
SYSTEM_DEFINITION_WRITE_SCOPE,
|
SYSTEM_DEFINITION_WRITE_SCOPE,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if scope_type == "tenant":
|
||||||
_require_any_scope(
|
_require_any_scope(
|
||||||
principal,
|
principal,
|
||||||
DEFINITION_READ_SCOPE,
|
DEFINITION_READ_SCOPE,
|
||||||
DEFINITION_WRITE_SCOPE,
|
DEFINITION_WRITE_SCOPE,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
if _has_any_scope(principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE):
|
||||||
def _require_definition_write(principal: ApiPrincipal, scope_type: str) -> None:
|
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(
|
_require_any_scope(
|
||||||
principal,
|
principal,
|
||||||
SYSTEM_DEFINITION_WRITE_SCOPE
|
GROUP_DEFINITION_READ_SCOPE,
|
||||||
if scope_type == "system"
|
GROUP_DEFINITION_WRITE_SCOPE,
|
||||||
else 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,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
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:
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
else:
|
||||||
query = _definition_query_for_actor(
|
query = _definition_query_for_actor(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
include_system=include_inherited,
|
include_system=True,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
target_id = (scope_id or "").strip()
|
||||||
|
if not target_id:
|
||||||
|
raise ViewsValidationError(
|
||||||
|
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",
|
||||||
|
|||||||
64
tests/test_authorization.py
Normal file
64
tests/test_authorization.py
Normal 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()
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
122
webui/src/features/views/PersonalViewsPanel.tsx
Normal file
122
webui/src/features/views/PersonalViewsPanel.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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-management-layout">
|
||||||
<div className="views-admin-shell">
|
<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,6 +647,17 @@ export default function ViewsAdminPanel({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="views-empty-editor">
|
||||||
|
<h3>No View selected</h3>
|
||||||
|
<p>Create a View to define a focused interface projection.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{showAssignments &&
|
||||||
|
(scopeType === "system" || scopeType === "tenant") && (
|
||||||
<AssignmentsSection
|
<AssignmentsSection
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
definitions={definitions}
|
definitions={definitions}
|
||||||
@@ -621,14 +669,7 @@ export default function ViewsAdminPanel({
|
|||||||
onToggle={toggleAssignment}
|
onToggle={toggleAssignment}
|
||||||
onDelete={setDeleteAssignmentTarget}
|
onDelete={setDeleteAssignmentTarget}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="views-empty-editor">
|
|
||||||
<h3>No View selected</h3>
|
|
||||||
<p>Create a View to define a focused interface projection.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
|
||||||
</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,90 +1172,76 @@ 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 ? (
|
||||||
|
<ChevronDown size={16} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} aria-hidden="true" />
|
||||||
|
)
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
disabled={disabled || requiredIds.has(moduleSurface.id)}
|
getNodeButtonClassName={(surface) =>
|
||||||
label={moduleSurface.label}
|
disabled || requiredIds.has(surface.id)
|
||||||
secondary={`${selectedChildren}/${childIds.length}`}
|
? "views-surface-node disabled"
|
||||||
onChange={(checked) => toggle(moduleSurface.id, checked)}
|
: "views-surface-node"
|
||||||
/>
|
}
|
||||||
</summary>
|
renderNodeContent={(surface) => {
|
||||||
<div className="views-surface-children">
|
const childIds = descendants(surface.id);
|
||||||
{moduleSurfaces.slice(1).map((surface) => (
|
const selectedChildren = childIds.filter((id) =>
|
||||||
<SurfaceCheckbox
|
selectedSet.has(id)
|
||||||
key={surface.id}
|
).length;
|
||||||
checked={selectedSet.has(surface.id)}
|
const checked = selectedSet.has(surface.id);
|
||||||
disabled={disabled || requiredIds.has(surface.id)}
|
const indeterminate =
|
||||||
label={surface.label}
|
selectedChildren > 0 && selectedChildren < childIds.length;
|
||||||
secondary={`${surface.kind} · ${surface.description ?? surface.id}`}
|
const secondary =
|
||||||
onChange={(checked) => toggle(surface.id, checked)}
|
surface.kind === "module"
|
||||||
/>
|
? `${selectedChildren}/${childIds.length} surfaces`
|
||||||
))}
|
: `${surface.kind} · ${surface.description ?? surface.id}`;
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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 (
|
return (
|
||||||
<label className={disabled ? "views-surface-row disabled" : "views-surface-row"}>
|
<>
|
||||||
<input
|
<span className="views-surface-check" aria-hidden="true">
|
||||||
ref={ref}
|
{indeterminate ? (
|
||||||
type="checkbox"
|
<MinusSquare size={17} />
|
||||||
checked={checked}
|
) : checked ? (
|
||||||
disabled={disabled}
|
<CheckSquare2 size={17} />
|
||||||
onChange={(event) => onChange(event.target.checked)}
|
) : (
|
||||||
/>
|
<Square size={17} />
|
||||||
<span>
|
)}
|
||||||
<strong>{label}</strong>
|
</span>
|
||||||
|
<span className="explorer-tree-node-content">
|
||||||
|
<strong>{surface.label}</strong>
|
||||||
<small>{secondary}</small>
|
<small>{secondary}</small>
|
||||||
</span>
|
</span>
|
||||||
{checked && <Check size={15} aria-hidden="true" />}
|
</>
|
||||||
</label>
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!visibleModules.length && (
|
||||||
|
<div className="views-empty-list">No matching surfaces.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user