diff --git a/README.md b/README.md index dd90c9a..80b7011 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ Administrators can: revisions - 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 selector, the Access administration route, and the administration section able to remove the assignment. If an installed-module change leaves an active View diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py index e28b8dc..fa859f2 100644 --- a/src/govoplan_views/backend/manifest.py +++ b/src/govoplan_views/backend/manifest.py @@ -31,6 +31,10 @@ MODULE_VERSION = "0.1.0" DEFINITION_READ_SCOPE = "views:definition:read" 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_WRITE_SCOPE = "views:assignment:write" SELECTION_READ_SCOPE = "views:selection:read" @@ -72,6 +76,26 @@ PERMISSIONS = ( "Manage tenant Views", "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( ASSIGNMENT_READ_SCOPE, "View tenant View assignments", @@ -126,17 +150,39 @@ ROLE_TEMPLATES = ( permissions=( DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, + GROUP_DEFINITION_READ_SCOPE, + GROUP_DEFINITION_WRITE_SCOPE, + PERSONAL_DEFINITION_READ_SCOPE, + PERSONAL_DEFINITION_WRITE_SCOPE, ASSIGNMENT_READ_SCOPE, ASSIGNMENT_WRITE_SCOPE, SELECTION_READ_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( slug="view_user", name="View user", - description="Use and select Views made available to the account.", - permissions=(SELECTION_READ_SCOPE, SELECTION_WRITE_SCOPE), + description="Use available Views and design Views for the current account.", + permissions=( + PERSONAL_DEFINITION_READ_SCOPE, + PERSONAL_DEFINITION_WRITE_SCOPE, + SELECTION_READ_SCOPE, + SELECTION_WRITE_SCOPE, + ), default_authenticated=True, ), ) @@ -203,6 +249,14 @@ manifest = ModuleManifest( description="Edit tenant, group, and user Views and assignments.", 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, @@ -269,6 +323,10 @@ __all__ = [ "ASSIGNMENT_WRITE_SCOPE", "DEFINITION_READ_SCOPE", "DEFINITION_WRITE_SCOPE", + "GROUP_DEFINITION_READ_SCOPE", + "GROUP_DEFINITION_WRITE_SCOPE", + "PERSONAL_DEFINITION_READ_SCOPE", + "PERSONAL_DEFINITION_WRITE_SCOPE", "MODULE_ID", "MODULE_VERSION", "SELECTION_READ_SCOPE", diff --git a/src/govoplan_views/backend/router.py b/src/govoplan_views/backend/router.py index 0f5ccab..ad329c3 100644 --- a/src/govoplan_views/backend/router.py +++ b/src/govoplan_views/backend/router.py @@ -12,6 +12,10 @@ from govoplan_views.backend.manifest import ( ASSIGNMENT_WRITE_SCOPE, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, + GROUP_DEFINITION_READ_SCOPE, + GROUP_DEFINITION_WRITE_SCOPE, + PERSONAL_DEFINITION_READ_SCOPE, + PERSONAL_DEFINITION_WRITE_SCOPE, SELECTION_READ_SCOPE, SELECTION_WRITE_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": _require_any_scope( principal, @@ -90,22 +102,98 @@ def _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None: SYSTEM_DEFINITION_WRITE_SCOPE, ) return - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, + if scope_type == "tenant": + _require_any_scope( + principal, + 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: - _require_any_scope( - principal, - SYSTEM_DEFINITION_WRITE_SCOPE - if scope_type == "system" - else DEFINITION_WRITE_SCOPE, +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: if scope_type == "system": _require_any_scope( @@ -279,6 +367,10 @@ def api_view_surfaces( principal, DEFINITION_READ_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_WRITE_SCOPE, ) @@ -313,16 +405,22 @@ def api_view_surfaces( @router.get("/definitions", response_model=ViewDefinitionListResponse) 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, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> 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( session, tenant_id=principal.tenant_id, scope_type=scope_type, + scope_id=target_id, include_inherited=include_inherited, ) return ViewDefinitionListResponse( @@ -330,7 +428,16 @@ def api_list_definitions( _definition_response( session, 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 ] @@ -347,12 +454,18 @@ def api_create_definition( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> 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: definition = create_definition( session, tenant_id=principal.tenant_id, scope_type=payload.scope_type, + scope_id=target_id, definition_key=payload.definition_key, name=payload.name, description=payload.description, @@ -391,7 +504,11 @@ def api_update_definition( tenant_id=principal.tenant_id, definition_id=definition_id, ) - _require_definition_write(principal, definition.scope_type) + _require_definition_write( + principal, + definition.scope_type, + definition.scope_id, + ) update_definition( session, definition, @@ -430,7 +547,11 @@ def api_list_revisions( tenant_id=principal.tenant_id, definition_id=definition_id, ) - _require_definition_read(principal, definition.scope_type) + _require_definition_read( + principal, + definition.scope_type, + definition.scope_id, + ) return [ ViewRevisionResponse.model_validate(revision) for revision in definition_revisions( @@ -458,7 +579,11 @@ def api_create_revision( tenant_id=principal.tenant_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( session, definition, @@ -500,7 +625,11 @@ def api_publish_revision( tenant_id=principal.tenant_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( session, definition_id=definition.id, @@ -543,7 +672,11 @@ def api_archive_definition( tenant_id=principal.tenant_id, definition_id=definition_id, ) - _require_definition_write(principal, definition.scope_type) + _require_definition_write( + principal, + definition.scope_type, + definition.scope_id, + ) archive_definition( session, definition, diff --git a/src/govoplan_views/backend/schemas.py b/src/govoplan_views/backend/schemas.py index d3ee562..7854320 100644 --- a/src/govoplan_views/backend/schemas.py +++ b/src/govoplan_views/backend/schemas.py @@ -6,7 +6,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator -ViewScopeType = Literal["system", "tenant"] +ViewScopeType = Literal["system", "tenant", "group", "user"] AssignmentScopeType = Literal["system", "tenant", "group", "user"] AssignmentMode = Literal["available", "default", "required"] @@ -68,6 +68,7 @@ class ViewDefinitionListResponse(BaseModel): class ViewDefinitionCreateRequest(BaseModel): scope_type: ViewScopeType = "tenant" + scope_id: str | None = Field(default=None, max_length=255) definition_key: str | None = Field(default=None, max_length=120) name: str = Field(min_length=1, max_length=200) description: str | None = Field(default=None, max_length=4000) diff --git a/src/govoplan_views/backend/service.py b/src/govoplan_views/backend/service.py index 1c4645d..17dc7f0 100644 --- a/src/govoplan_views/backend/service.py +++ b/src/govoplan_views/backend/service.py @@ -27,7 +27,7 @@ from govoplan_views.backend.db.models import ( ASSIGNMENT_MODES = frozenset({"available", "default", "required"}) ASSIGNMENT_SCOPES = frozenset({"system", "tenant", "group", "user"}) -DEFINITION_SCOPES = frozenset({"system", "tenant"}) +DEFINITION_SCOPES = frozenset({"system", "tenant", "group", "user"}) LOCKOUT_BASE_SURFACE_IDS = ( "access.module", "access.nav.admin", @@ -91,11 +91,27 @@ def _scope_values( scope_type: str, *, tenant_id: str, + scope_id: str | None = None, ) -> tuple[str | None, str | None, str]: if scope_type == "system": + if scope_id: + raise ViewsValidationError( + "System View definitions cannot declare a target id" + ) return None, None, "system" 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}" + 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}") @@ -158,6 +174,7 @@ def list_definitions( *, tenant_id: str, scope_type: str, + scope_id: str | None = None, include_inherited: bool = True, ) -> list[ViewDefinition]: if scope_type not in DEFINITION_SCOPES: @@ -168,12 +185,45 @@ def list_definitions( ViewDefinition.scope_type == "system", 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: - query = _definition_query_for_actor( - session, - tenant_id=tenant_id, - include_system=include_inherited, + 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( ViewDefinition.scope_type.asc(), ViewDefinition.name.asc(), @@ -291,6 +341,7 @@ def create_definition( *, tenant_id: str, scope_type: str, + scope_id: str | None = None, definition_key: str | None, name: str, description: str | None, @@ -303,6 +354,7 @@ def create_definition( row_tenant_id, scope_id, scope_key = _scope_values( scope_type, tenant_id=tenant_id, + scope_id=scope_id, ) clean_name = name.strip() if not clean_name: @@ -489,9 +541,65 @@ def publish_revision( definition.updated_by = actor_id definition.updated_at = _now() session.flush() + ensure_owner_available_assignment( + session, + definition, + catalogue=catalogue, + actor_id=actor_id, + ) 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( session: Session, definition: ViewDefinition, @@ -1181,6 +1289,7 @@ __all__ = [ "get_assignment", "get_definition", "get_revision", + "ensure_owner_available_assignment", "list_assignments", "list_definitions", "lockout_required_surface_ids", diff --git a/tests/test_authorization.py b/tests/test_authorization.py new file mode 100644 index 0000000..391826f --- /dev/null +++ b/tests/test_authorization.py @@ -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() diff --git a/tests/test_views.py b/tests/test_views.py index 7660d12..8b5e605 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -15,6 +15,7 @@ from govoplan_views.backend.service import ( create_definition, create_revision, get_revision, + list_definitions, normalize_visible_surface_ids, publish_revision, resolve_effective_view, @@ -163,6 +164,56 @@ class ViewsServiceTests(unittest.TestCase): self.assertIn("views.selector", 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: with self.assertRaisesRegex(ViewsValidationError, "navigation"): normalize_visible_surface_ids( @@ -333,9 +384,7 @@ class ViewsServiceTests(unittest.TestCase): actor_id="account-admin", ) catalogue_without_admin_route = tuple( - surface - for surface in self.catalogue - if surface.id != "access.route.admin" + surface for surface in self.catalogue if surface.id != "access.route.admin" ) state = resolve_effective_view( diff --git a/webui/src/api/views.ts b/webui/src/api/views.ts index a7e93d7..96308e3 100644 --- a/webui/src/api/views.ts +++ b/webui/src/api/views.ts @@ -5,7 +5,7 @@ import { type EffectiveViewProjection } 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 ViewAssignmentMode = "available" | "default" | "required"; @@ -148,12 +148,14 @@ export async function selectEffectiveView( export async function fetchViewDefinitions( settings: ApiSettings, - scopeType: ViewScopeType + scopeType: ViewScopeType, + scopeId?: string | null ): Promise { const response = await apiFetch<{ definitions: ViewDefinition[] }>( settings, apiPath("/api/v1/views/definitions", { scope_type: scopeType, + scope_id: scopeId || undefined, include_inherited: scopeType === "tenant" }) ); @@ -164,6 +166,7 @@ export function createViewDefinition( settings: ApiSettings, payload: { scope_type: ViewScopeType; + scope_id?: string | null; name: string; description?: string | null; visible_surface_ids: string[]; diff --git a/webui/src/features/views/PersonalViewsPanel.tsx b/webui/src/features/views/PersonalViewsPanel.tsx new file mode 100644 index 0000000..cd4120f --- /dev/null +++ b/webui/src/features/views/PersonalViewsPanel.tsx @@ -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; + 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 ( +

+ You do not have access to personal or group View definitions. +

+ ); + } + + return ( +
+ {options.length > 1 && ( +
+ + + +
+ )} + +
+ ); +} + + +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; +} diff --git a/webui/src/features/views/ViewsAdminPanel.tsx b/webui/src/features/views/ViewsAdminPanel.tsx index 55412d0..e953818 100644 --- a/webui/src/features/views/ViewsAdminPanel.tsx +++ b/webui/src/features/views/ViewsAdminPanel.tsx @@ -1,13 +1,16 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Archive, - Check, + CheckSquare2, + ChevronDown, ChevronRight, + MinusSquare, Pencil, Plus, RefreshCw, Save, Send, + Square, Trash2 } from "lucide-react"; import { @@ -16,8 +19,11 @@ import { ConfirmDialog, Dialog, DismissibleAlert, + ExplorerTree, FormField, IconButton, + SelectionList, + SelectionListItem, StatusBadge, ToggleSwitch, adminErrorMessage, @@ -77,13 +83,23 @@ const LOCKOUT_SURFACES = new Set([ export default function ViewsAdminPanel({ settings, scopeType, + scopeId, canWriteDefinitions, - canWriteAssignments + canWriteAssignments, + showAssignments = scopeType === "system" || scopeType === "tenant", + embedded = false, + title: titleOverride, + description: descriptionOverride }: { settings: ApiSettings; scopeType: ViewScopeType; + scopeId?: string | null; canWriteDefinitions: boolean; canWriteAssignments: boolean; + showAssignments?: boolean; + embedded?: boolean; + title?: string; + description?: string; }) { const surfaces = useViewSurfaces(); const { requestDiscard } = useUnsavedChanges(); @@ -133,8 +149,10 @@ export default function ViewsAdminPanel({ setError(""); try { const [nextDefinitions, nextAssignments] = await Promise.all([ - fetchViewDefinitions(settings, scopeType), - fetchViewAssignments(settings, scopeType) + fetchViewDefinitions(settings, scopeType, scopeId), + showAssignments && (scopeType === "system" || scopeType === "tenant") + ? fetchViewAssignments(settings, scopeType) + : Promise.resolve([]) ]); setDefinitions(nextDefinitions); setAssignments(nextAssignments); @@ -161,6 +179,8 @@ export default function ViewsAdminPanel({ void load(); }, [ scopeType, + scopeId, + showAssignments, settings.accessToken, settings.apiBaseUrl, settings.apiKey @@ -268,6 +288,7 @@ export default function ViewsAdminPanel({ .map((surface) => surface.id); const created = await createViewDefinition(settings, { scope_type: scopeType, + scope_id: scopeId || null, name: createDraft.name.trim(), description: createDraft.description.trim() || null, 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)[scopeType]; const description = - scopeType === "system" - ? "Publish reusable interface projections and assign instance-wide defaults or requirements." - : "Tailor the visible interface for this tenant, its groups, and individual users."; + descriptionOverride ?? + ({ + 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)[scopeType]; const canCreate = canWriteDefinitions && surfaces.some((surface) => surface.kind === "navigation") && @@ -426,6 +462,7 @@ export default function ViewsAdminPanel({ } > -
+
+
@@ -610,17 +647,6 @@ export default function ViewsAdminPanel({ /> - ) : (
@@ -629,6 +655,21 @@ export default function ViewsAdminPanel({
)}
+
+ {showAssignments && + (scopeType === "system" || scopeType === "tenant") && ( + + )}
@@ -1026,6 +1067,7 @@ function SurfaceSelector({ onChange: (surfaceIds: string[]) => void; }) { const [filter, setFilter] = useState(""); + const [expandedIds, setExpandedIds] = useState>(new Set()); const byId = useMemo( () => new Map(surfaces.map((surface) => [surface.id, surface])), [surfaces] @@ -1098,6 +1140,26 @@ function SurfaceSelector({ } 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 (
@@ -1110,94 +1172,80 @@ function SurfaceSelector({ />
- {modules.map((moduleSurface) => { - const moduleSurfaces = [ - moduleSurface, - ...descendants(moduleSurface.id) - .map((id) => byId.get(id)) - .filter((item): item is PlatformViewSurface => Boolean(item)) - ]; - const matches = !normalizedFilter || moduleSurfaces.some((surface) => - `${surface.label} ${surface.id} ${surface.description ?? ""}` - .toLowerCase() - .includes(normalizedFilter) - ); - if (!matches) return null; - const childIds = moduleSurfaces.slice(1).map((surface) => surface.id); - const selectedChildren = childIds.filter((id) => selectedSet.has(id)).length; - return ( -
- - 0 && selectedChildren < childIds.length - } - disabled={disabled || requiredIds.has(moduleSurface.id)} - label={moduleSurface.label} - secondary={`${selectedChildren}/${childIds.length}`} - onChange={(checked) => toggle(moduleSurface.id, checked)} - /> - -
- {moduleSurfaces.slice(1).map((surface) => ( - toggle(surface.id, checked)} - /> - ))} -
-
- ); - })} + surface.id} + getNodeLabel={(surface) => surface.label} + getNodeChildren={(surface) => childrenByParent.get(surface.id) ?? []} + expandedIds={effectiveExpandedIds} + depth={0} + className="views-surface-tree" + onToggle={(surface) => { + setExpandedIds((current) => { + const next = new Set(current); + if (next.has(surface.id)) next.delete(surface.id); + else next.add(surface.id); + return next; + }); + }} + onOpen={(surface) => { + if (disabled || requiredIds.has(surface.id)) return; + toggle(surface.id, !selectedSet.has(surface.id)); + }} + renderToggleIcon={(_surface, context) => + context.hasChildren ? ( + context.expanded ? ( +
); } -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(null); - useEffect(() => { - if (ref.current) ref.current.indeterminate = indeterminate; - }, [indeterminate]); - return ( - - ); -} - - function emptyAssignmentDraft(scopeType: ViewScopeType): AssignmentDraft { return { 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; + return definition.scope_id && definition.scope_type !== "tenant" + ? `${label[definition.scope_type]} · ${definition.scope_id}` + : label[definition.scope_type]; +} + + function definitionDraftKey(draft: DefinitionDraft): string { return JSON.stringify({ name: draft.name.trim(), diff --git a/webui/src/module.ts b/webui/src/module.ts index 3c97ae8..e96bf64 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -3,6 +3,7 @@ import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule, + type SettingsSectionsUiCapability, type ViewsRuntimeUiCapability } from "@govoplan/core-webui"; import ViewSelector from "./components/ViewSelector"; @@ -13,6 +14,9 @@ import "./styles/views.css"; const ViewsAdminPanel = lazy( () => import("./features/views/ViewsAdminPanel") ); +const PersonalViewsPanel = lazy( + () => import("./features/views/PersonalViewsPanel") +); const viewsAdminSections: AdminSectionsUiCapability = { sections: [ @@ -63,6 +67,28 @@ const viewsRuntime: ViewsRuntimeUiCapability = { 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 = { id: "views", label: "Views", @@ -90,13 +116,20 @@ export const viewsModule: PlatformWebModule = { kind: "section", label: "Tenant Views administration", order: 30 + }, + { + id: "views.settings.personal", + moduleId: "views", + kind: "section", + label: "Personal and group Views", + order: 40 } ], uiCapabilities: { "admin.sections": viewsAdminSections, + "settings.sections": viewsSettingsSections, "views.runtime": viewsRuntime } }; export default viewsModule; - diff --git a/webui/src/styles/views.css b/webui/src/styles/views.css index d5564bd..a09d0b5 100644 --- a/webui/src/styles/views.css +++ b/webui/src/styles/views.css @@ -29,6 +29,32 @@ 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 { display: grid; grid-template-columns: 280px minmax(0, 1fr); @@ -81,24 +107,7 @@ grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 8px; - width: 100%; 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 { @@ -159,13 +168,18 @@ resize: vertical; } -.views-surface-section, -.views-assignments-section { +.views-surface-section { margin-top: 22px; padding-top: 18px; border-top: var(--border-line); } +.views-assignments-section { + min-width: 0; + padding-top: 18px; + border-top: var(--border-line); +} + .views-section-heading { align-items: center; margin-bottom: 10px; @@ -200,58 +214,25 @@ padding: 6px; } -.views-surface-modules details { - 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 { +.views-surface-node.disabled { cursor: default; opacity: .7; } -.views-surface-row strong, -.views-surface-row small { - display: block; - letter-spacing: 0; +.views-surface-tree .explorer-tree-node-wrap { + border-radius: var(--radius-sm); } -.views-surface-row small { - margin-top: 2px; - overflow-wrap: anywhere; - color: var(--muted); +.views-surface-tree .explorer-tree-node { + min-height: 42px; + border-radius: var(--radius-sm); +} + +.views-surface-check { + display: inline-grid; + flex: 0 0 18px; + place-items: center; + color: var(--accent); } .views-assignment-list {