diff --git a/docs/SESSION_MANAGEMENT.md b/docs/SESSION_MANAGEMENT.md new file mode 100644 index 0000000..295d9bf --- /dev/null +++ b/docs/SESSION_MANAGEMENT.md @@ -0,0 +1,23 @@ +# Session And Device Management + +Authenticated users can inspect their active browser sessions under **Settings +> Sessions and devices**. Each row exposes only a stable session identifier, +current-session marker, bounded user-agent label, creation time, last activity, +expiry, and lifecycle state. Session tokens, token and CSRF hashes, cookies, IP +addresses, and unrelated request metadata are never returned. + +Users may revoke one other session or all other active sessions. The current +session is deliberately protected by these operations; use normal logout to end +it. Repeating a revocation is safe. Revoked sessions fail authentication on the +next request, including when a principal summary was previously cached. + +Tenant administrators may list sessions only for a membership in their governed +tenant and may revoke only a session belonging to that membership and tenant. +The mutation requires both the central membership-update permission and an +interactive-session password re-authorization. API-key administration and +cross-tenant session disclosure fail closed. + +Audit events retain the actor, target session or account, action, and revoked +count where applicable. They do not copy client labels, network addresses, or +credentials. Expired and revoked sessions are retained according to Access data +retention and are omitted from the active-session list. diff --git a/src/govoplan_access/backend/api/v1/admin_schemas.py b/src/govoplan_access/backend/api/v1/admin_schemas.py index 5561bc0..7c43f45 100644 --- a/src/govoplan_access/backend/api/v1/admin_schemas.py +++ b/src/govoplan_access/backend/api/v1/admin_schemas.py @@ -37,6 +37,33 @@ class AdminOverviewResponse(BaseModel): capabilities: list[str] = Field(default_factory=list) +class AdminSessionItem(BaseModel): + id: str + tenant_id: str + current: bool + status: Literal["active", "expired", "revoked"] + created_at: datetime + last_seen_at: datetime | None = None + expires_at: datetime + revoked_at: datetime | None = None + client: str | None = None + + +class AdminSessionListResponse(BaseModel): + sessions: list[AdminSessionItem] = Field(default_factory=list) + + +class AdminSessionRevocationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_password: str = Field(min_length=1, max_length=1024) + + +class AdminSessionRevocationResponse(BaseModel): + session: AdminSessionItem + revoked: bool + + class TenantAdminItem(BaseModel): id: str slug: str = Field(min_length=1, max_length=100) diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py index 7c4bd3a..6cc1ead 100644 --- a/src/govoplan_access/backend/api/v1/auth.py +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -1,8 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from datetime import datetime from functools import lru_cache +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel, Field @@ -38,7 +39,7 @@ from govoplan_core.core.idm import ( IdmDirectory, OrganizationFunctionAssignmentRef, ) -from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal +from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, require_scope from govoplan_core.admin.settings import get_system_settings from govoplan_core.audit.logging import audit_event from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode @@ -78,6 +79,13 @@ from govoplan_access.backend.security.sessions import ( create_auth_session, verify_auth_session_csrf, ) +from govoplan_access.backend.session_management import ( + SessionSummary, + list_account_sessions, + revoke_account_session, + revoke_other_account_sessions, + session_summary, +) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -96,6 +104,44 @@ class ActingContextListResponse(BaseModel): active_assignment_id: str | None = None +class AccountSessionInfo(BaseModel): + id: str + tenant_id: str + current: bool + status: Literal["active", "expired", "revoked"] + created_at: datetime + last_seen_at: datetime | None = None + expires_at: datetime + revoked_at: datetime | None = None + client: str | None = None + + +class AccountSessionListResponse(BaseModel): + sessions: list[AccountSessionInfo] = Field(default_factory=list) + + +class AccountSessionRevocationResponse(BaseModel): + session: AccountSessionInfo + revoked: bool + + +class OtherSessionRevocationResponse(BaseModel): + revoked_count: int + + +def _account_session_info(item: SessionSummary) -> AccountSessionInfo: + return AccountSessionInfo(**asdict(item)) + + +def _interactive_session(principal: ApiPrincipal) -> AuthSession: + if principal.auth_session is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Session management requires an interactive browser session.", + ) + return principal.auth_session + + def _acting_assignments( request: Request, *, @@ -1002,6 +1048,98 @@ def switch_tenant( ) +@router.get("/sessions", response_model=AccountSessionListResponse) +def list_own_sessions( + principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")), + session: Session = Depends(get_session), +) -> AccountSessionListResponse: + current = _interactive_session(principal) + items = list_account_sessions( + session, + account_id=principal.account_id, + current_session_id=current.id, + ) + return AccountSessionListResponse( + sessions=[_account_session_info(item) for item in items] + ) + + +@router.post( + "/sessions/revoke-others", + response_model=OtherSessionRevocationResponse, +) +def revoke_own_other_sessions( + principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")), + session: Session = Depends(get_session), +) -> OtherSessionRevocationResponse: + current = _interactive_session(principal) + revoked_ids = revoke_other_account_sessions( + session, + account_id=principal.account_id, + current_session_id=current.id, + ) + if revoked_ids: + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + action="access.sessions.other_sessions_revoked", + object_type="access_account", + object_id=principal.account_id, + details={"revoked_count": len(revoked_ids)}, + ) + session.commit() + principal_summary_cache.clear() + return OtherSessionRevocationResponse(revoked_count=len(revoked_ids)) + + +@router.post( + "/sessions/{session_id}/revoke", + response_model=AccountSessionRevocationResponse, +) +def revoke_own_session( + session_id: str, + principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")), + session: Session = Depends(get_session), +) -> AccountSessionRevocationResponse: + current = _interactive_session(principal) + try: + item, changed = revoke_account_session( + session, + account_id=principal.account_id, + session_id=session_id, + protected_session_id=current.id, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found.", + ) + if changed: + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + action="access.session.revoked", + object_type="access_auth_session", + object_id=item.id, + details={"actor_kind": "self"}, + ) + session.commit() + principal_summary_cache.clear() + return AccountSessionRevocationResponse( + session=_account_session_info( + session_summary(item, current_session_id=current.id) + ), + revoked=changed, + ) + + @router.get("/acting-contexts", response_model=ActingContextListResponse) def list_acting_contexts( request: Request, diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index 3dccc63..76fd2b8 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import asdict from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -62,6 +63,10 @@ from govoplan_access.backend.api.v1.admin_common import ( _user_item, ) from govoplan_access.backend.api.v1.admin_schemas import ( + AdminSessionItem, + AdminSessionListResponse, + AdminSessionRevocationRequest, + AdminSessionRevocationResponse, AdminApiKeyCreateRequest, AdminApiKeyCreateResponse, ApiKeyAdminItem, @@ -137,6 +142,14 @@ from govoplan_access.backend.api.v1.admin_schemas import ( UserUpdateRequest, ) from govoplan_access.backend.security.api_keys import create_api_key +from govoplan_access.backend.security.passwords import verify_password +from govoplan_access.backend.session_management import ( + SessionSummary, + list_account_sessions, + revoke_account_session, + session_summary, +) +from govoplan_access.backend.auth.principal_cache import principal_summary_cache from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope from govoplan_core.audit.logging import audit_event, audit_from_principal from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider @@ -2380,6 +2393,124 @@ def get_user_access_explanation( ) +def _admin_session_item(item: SessionSummary) -> AdminSessionItem: + return AdminSessionItem(**asdict(item)) + + +def _require_session_admin_reauthorization( + principal: ApiPrincipal, + current_password: str, +) -> None: + if principal.auth_session is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Administrative session revocation requires an interactive session.", + ) + if not verify_password(current_password, principal.account.password_hash): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Current password re-authorization failed.", + ) + + +@router.get( + "/users/{user_id}/sessions", + response_model=AdminSessionListResponse, +) +def list_user_sessions( + user_id: str, + tenant_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_any_scope("admin:users:read", "access:membership:read") + ), +) -> AdminSessionListResponse: + tenant = _resolve_tenant(session, principal, tenant_id) + user = ( + session.query(User) + .filter(User.id == user_id, User.tenant_id == tenant.id) + .one_or_none() + ) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + items = list_account_sessions( + session, + account_id=user.account_id, + tenant_id=tenant.id, + current_session_id=principal.session_id, + ) + return AdminSessionListResponse( + sessions=[_admin_session_item(item) for item in items] + ) + + +@router.post( + "/users/{user_id}/sessions/{session_id}/revoke", + response_model=AdminSessionRevocationResponse, +) +def revoke_user_session( + user_id: str, + session_id: str, + payload: AdminSessionRevocationRequest, + tenant_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_any_scope("admin:users:update", "access:membership:update") + ), +) -> AdminSessionRevocationResponse: + _require_session_admin_reauthorization(principal, payload.current_password) + tenant = _resolve_tenant(session, principal, tenant_id) + user = ( + session.query(User) + .filter(User.id == user_id, User.tenant_id == tenant.id) + .one_or_none() + ) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + try: + item, changed = revoke_account_session( + session, + account_id=user.account_id, + session_id=session_id, + tenant_id=tenant.id, + protected_session_id=principal.session_id, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found", + ) + if changed: + audit_from_principal( + session, + principal, + action="access.session.revoked_by_administrator", + scope="tenant", + object_type="access_auth_session", + object_id=item.id, + details={"target_membership_id": user.id}, + ) + session.commit() + principal_summary_cache.clear() + return AdminSessionRevocationResponse( + session=_admin_session_item( + session_summary(item, current_session_id=principal.session_id) + ), + revoked=changed, + ) + + @router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse) def get_resource_access_explanation( user_id: str = Query(...), diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 2ae6a15..84a0493 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -84,6 +84,7 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( _permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"), _permission("access:membership:create", "Create memberships", "Create tenant-local account memberships.", "Tenant access", "tenant"), _permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"), + _permission("access:session:manage_own", "Manage own sessions", "Inspect and revoke the current account's browser sessions without exposing credentials.", "Tenant access", "tenant"), _permission("access:group:read", "View groups", "List tenant groups and members.", "Tenant access", "tenant"), _permission("access:group:write", "Manage groups", "Create and update tenant groups.", "Tenant access", "tenant"), _permission("access:group:manage_members", "Manage group members", "Add and remove memberships from groups.", "Tenant access", "tenant"), @@ -176,6 +177,16 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( managed=False, protected=False, ), + RoleTemplate( + slug="account_security", + name="Account security", + description="Authenticated baseline for inspecting and revoking the current account's browser sessions.", + permissions=("access:session:manage_own",), + level="tenant", + managed=True, + protected=True, + default_authenticated=True, + ), RoleTemplate( slug="owner", name="Tenant owner", @@ -628,6 +639,67 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( "verification": "The administration table shows the expected active credential count, last-use timestamp, revision, and audit events without exposing secret material.", }, ), + DocumentationTopic( + id="access.workflow.manage-sessions", + title="Review and revoke account sessions", + summary="Inspect active browser sessions and revoke one or every other session without exposing credentials or network identifiers.", + body=( + "Settings > Sessions and devices marks the current browser session and shows only bounded client metadata plus creation, last-seen, and expiry times. " + "Users can revoke another session or all other active sessions; the command session is protected and normal logout remains the way to end it. Revocation is idempotent and takes effect on the next authenticated request. " + "Tenant administrators can inspect only sessions belonging to a membership in their governed tenant. Administrative revocation requires central membership-update permission and current-password re-authorization from an interactive session. " + "Audit evidence records stable actors, targets, and counts without tokens, hashes, cookies, IP addresses, or client strings." + ), + layer="always", + documentation_types=("admin", "user"), + audience=("user", "tenant_admin", "access_admin", "operator"), + conditions=( + DocumentationCondition( + required_modules=("access",), + any_scopes=("access:session:manage_own",), + ), + ), + links=( + DocumentationLink(label="Sessions and devices", href="/settings?section=sessions", kind="runtime"), + DocumentationLink(label="Own sessions API", href="/api/v1/auth/sessions", kind="api"), + DocumentationLink(label="Session management reference", href="docs/SESSION_MANAGEMENT.md", kind="repository"), + ), + translations={ + "de": { + "title": "Kontositzungen prüfen und widerrufen", + "summary": "Aktive Browsersitzungen prüfen und einzelne oder alle anderen Sitzungen widerrufen, ohne Zugangsdaten oder Netzwerkkennungen offenzulegen.", + "body": ( + "Einstellungen > Sitzungen und Geräte kennzeichnet die aktuelle Browsersitzung und zeigt nur begrenzte Clientmetadaten sowie Erstellungs-, Aktivitäts- und Ablaufzeitpunkte. " + "Benutzende können eine andere oder alle anderen aktiven Sitzungen widerrufen; die ausführende Sitzung bleibt geschützt und wird regulär abgemeldet. Der Widerruf ist idempotent und gilt beim nächsten authentifizierten Aufruf. " + "Mandantenadministrierende sehen nur Sitzungen einer Mitgliedschaft im verwalteten Mandanten. Der administrative Widerruf erfordert die zentrale Berechtigung zur Mitgliedschaftsänderung und eine erneute Passwortbestätigung in einer interaktiven Sitzung. " + "Auditnachweise speichern stabile Akteure, Ziele und Anzahlen, aber keine Token, Hashes, Cookies, IP-Adressen oder Clienttexte." + ), + } + }, + metadata={ + "kind": "workflow", + "help_contexts": [ + "access.settings.sessions", + "access.sessions.action.revoke", + "access.sessions.action.revoke-others", + "access.admin.user-sessions", + ], + "api_paths": [ + "/api/v1/auth/sessions", + "/api/v1/auth/sessions/{session_id}/revoke", + "/api/v1/auth/sessions/revoke-others", + "/api/v1/admin/users/{user_id}/sessions", + "/api/v1/admin/users/{user_id}/sessions/{session_id}/revoke", + ], + "sensitive_fields_never_returned": [ + "token", + "token_hash", + "csrf_token_hash", + "cookie", + "ip_address", + ], + }, + order=33, + ), DocumentationTopic( id="access.reference.external-function-role-mappings", title="Organization function facts and access roles", @@ -992,6 +1064,7 @@ manifest = ModuleManifest( ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30), ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30), ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30), + ViewSurface(id="access.settings.sessions", module_id="access", kind="section", label="Sessions and devices", order=20), ), ), capability_factories={ diff --git a/src/govoplan_access/backend/session_management.py b/src/govoplan_access/backend/session_management.py new file mode 100644 index 0000000..9eb207f --- /dev/null +++ b/src/govoplan_access/backend/session_management.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import AuthSession +from govoplan_core.security.time import ensure_aware_utc, utc_now + + +MAX_SESSION_LIST_ITEMS = 100 +MAX_CLIENT_LABEL_LENGTH = 160 + + +@dataclass(frozen=True, slots=True) +class SessionSummary: + id: str + tenant_id: str + current: bool + status: str + created_at: datetime + last_seen_at: datetime | None + expires_at: datetime + revoked_at: datetime | None + client: str | None + + +def session_summary( + item: AuthSession, + *, + current_session_id: str | None, + now: datetime | None = None, +) -> SessionSummary: + effective_at = now or utc_now() + expires_at = ensure_aware_utc(item.expires_at) + revoked_at = ensure_aware_utc(item.revoked_at) + if revoked_at is not None: + status = "revoked" + elif expires_at is None or expires_at <= effective_at: + status = "expired" + else: + status = "active" + return SessionSummary( + id=item.id, + tenant_id=item.tenant_id, + current=item.id == current_session_id, + status=status, + created_at=item.created_at, + last_seen_at=item.last_seen_at, + expires_at=item.expires_at, + revoked_at=item.revoked_at, + client=_bounded_client(item.user_agent), + ) + + +def list_account_sessions( + session: Session, + *, + account_id: str, + current_session_id: str | None, + tenant_id: str | None = None, + include_inactive: bool = False, + limit: int = MAX_SESSION_LIST_ITEMS, + now: datetime | None = None, +) -> tuple[SessionSummary, ...]: + effective_at = now or utc_now() + query = session.query(AuthSession).filter(AuthSession.account_id == account_id) + if tenant_id is not None: + query = query.filter(AuthSession.tenant_id == tenant_id) + rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all() + summaries = tuple( + session_summary( + item, + current_session_id=current_session_id, + now=effective_at, + ) + for item in rows + ) + if not include_inactive: + summaries = tuple(item for item in summaries if item.status == "active") + return summaries[: max(1, min(limit, MAX_SESSION_LIST_ITEMS))] + + +def revoke_account_session( + session: Session, + *, + account_id: str, + session_id: str, + tenant_id: str | None = None, + protected_session_id: str | None = None, + now: datetime | None = None, +) -> tuple[AuthSession | None, bool]: + query = session.query(AuthSession).filter( + AuthSession.id == session_id, + AuthSession.account_id == account_id, + ) + if tenant_id is not None: + query = query.filter(AuthSession.tenant_id == tenant_id) + item = query.one_or_none() + if item is None: + return None, False + if protected_session_id is not None and item.id == protected_session_id: + raise ValueError("The current session cannot be revoked through session management.") + if item.revoked_at is not None: + return item, False + item.revoked_at = now or utc_now() + session.add(item) + return item, True + + +def revoke_other_account_sessions( + session: Session, + *, + account_id: str, + current_session_id: str, + now: datetime | None = None, +) -> tuple[str, ...]: + effective_at = now or utc_now() + rows = ( + session.query(AuthSession) + .filter( + AuthSession.account_id == account_id, + AuthSession.id != current_session_id, + AuthSession.revoked_at.is_(None), + ) + .all() + ) + revoked: list[str] = [] + for item in rows: + expires_at = ensure_aware_utc(item.expires_at) + if expires_at is None or expires_at <= effective_at: + continue + item.revoked_at = effective_at + session.add(item) + revoked.append(item.id) + return tuple(sorted(revoked)) + + +def _bounded_client(value: str | None) -> str | None: + normalized = " ".join(str(value or "").split()) + if not normalized: + return None + return normalized[:MAX_CLIENT_LABEL_LENGTH] + + +__all__ = [ + "MAX_CLIENT_LABEL_LENGTH", + "MAX_SESSION_LIST_ITEMS", + "SessionSummary", + "list_account_sessions", + "revoke_account_session", + "revoke_other_account_sessions", + "session_summary", +] diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 8c54d87..08c8d29 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -45,6 +45,12 @@ class InterfaceDocumentationContractTests(unittest.TestCase): "access.workflow.manage-service-account-credentials": { "access.admin.service-accounts", }, + "access.workflow.manage-sessions": { + "access.settings.sessions", + "access.sessions.action.revoke", + "access.sessions.action.revoke-others", + "access.admin.user-sessions", + }, } for topic_id, expected in expected_contexts.items(): @@ -84,6 +90,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase): "access.admin.group-credentials", "access.admin.user-credentials", "access.settings.credentials", + "access.settings.sessions", }.issubset(surface_ids) ) diff --git a/tests/test_session_management.py b/tests/test_session_management.py new file mode 100644 index 0000000..cbce4d6 --- /dev/null +++ b/tests/test_session_management.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.db.base import AccessBase +from govoplan_access.backend.db.models import Account, AuthSession, User +from govoplan_access.backend.security.sessions import authenticate_session_token, hash_session_token +from govoplan_access.backend.security.passwords import hash_password +from govoplan_access.backend.api.v1.admin_schemas import AdminSessionItem +from govoplan_access.backend.api.v1.auth import AccountSessionInfo +from govoplan_access.backend.api.v1.routes import _require_session_admin_reauthorization +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from fastapi import HTTPException +from govoplan_access.backend.session_management import ( + MAX_CLIENT_LABEL_LENGTH, + list_account_sessions, + revoke_account_session, + revoke_other_account_sessions, +) +from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry + + +class SessionManagementTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + create_scope_tables(self.engine) + AccessBase.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine) + self.session = self.Session() + self.now = datetime(2026, 8, 19, 20, 0, tzinfo=timezone.utc) + tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1") + other_tenant = Tenant(id="tenant-2", slug="tenant-2", name="Tenant 2") + self.account = Account( + id="account-1", + email="person@example.test", + normalized_email="person@example.test", + ) + other_account = Account( + id="account-2", + email="other@example.test", + normalized_email="other@example.test", + ) + user = User( + id="user-1", + tenant_id=tenant.id, + account_id=self.account.id, + email=self.account.email, + ) + other_user = User( + id="user-2", + tenant_id=tenant.id, + account_id=other_account.id, + email=other_account.email, + ) + self.session.add_all((tenant, other_tenant, self.account, other_account, user, other_user)) + self.session.flush() + self.tokens = { + "current": "ms_current", + "other": "ms_other", + "expired": "ms_expired", + "revoked": "ms_revoked", + "other-account": "ms_other_account", + } + self.session.add_all( + ( + self._auth_session("current", "tenant-1", "user-1", "account-1"), + self._auth_session("other", "tenant-2", "user-1", "account-1"), + self._auth_session("expired", "tenant-1", "user-1", "account-1", expires=-1), + self._auth_session("revoked", "tenant-1", "user-1", "account-1", revoked=True), + self._auth_session("other-account", "tenant-1", "user-2", "account-2"), + ) + ) + self.session.commit() + + def tearDown(self) -> None: + self.session.close() + AccessBase.metadata.drop_all(bind=self.engine) + scope_registry.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def _auth_session( + self, + name: str, + tenant_id: str, + user_id: str, + account_id: str, + *, + expires: int = 2, + revoked: bool = False, + ) -> AuthSession: + return AuthSession( + id=f"session-{name}", + tenant_id=tenant_id, + user_id=user_id, + account_id=account_id, + token_hash=hash_session_token(self.tokens[name]), + expires_at=self.now + timedelta(hours=expires), + last_seen_at=self.now - timedelta(minutes=5), + revoked_at=self.now - timedelta(minutes=1) if revoked else None, + user_agent="Browser " + ("x" * 500), + ip_address="192.0.2.55", + ) + + def test_listing_is_account_scoped_bounded_and_redacted(self) -> None: + sensitive = { + "token", + "token_hash", + "csrf_token_hash", + "cookie", + "ip_address", + } + self.assertTrue(sensitive.isdisjoint(AccountSessionInfo.model_fields)) + self.assertTrue(sensitive.isdisjoint(AdminSessionItem.model_fields)) + active = list_account_sessions( + self.session, + account_id=self.account.id, + current_session_id="session-current", + now=self.now, + ) + self.assertEqual({"session-current", "session-other"}, {item.id for item in active}) + self.assertTrue(next(item for item in active if item.id == "session-current").current) + self.assertTrue(all(len(item.client or "") <= MAX_CLIENT_LABEL_LENGTH for item in active)) + self.assertNotIn("192.0.2.55", repr(active)) + self.assertNotIn("token_hash", repr(active)) + + all_states = list_account_sessions( + self.session, + account_id=self.account.id, + current_session_id="session-current", + include_inactive=True, + now=self.now, + ) + self.assertEqual( + {"active", "expired", "revoked"}, + {item.status for item in all_states}, + ) + tenant_only = list_account_sessions( + self.session, + account_id=self.account.id, + current_session_id="session-current", + tenant_id="tenant-1", + include_inactive=True, + now=self.now, + ) + self.assertNotIn("session-other", {item.id for item in tenant_only}) + + def test_single_revocation_is_idempotent_and_effective_on_next_request(self) -> None: + item, changed = revoke_account_session( + self.session, + account_id=self.account.id, + session_id="session-other", + protected_session_id="session-current", + now=self.now, + ) + self.assertTrue(changed) + self.session.commit() + self.assertIsNotNone(item) + self.assertIsNone( + authenticate_session_token(self.session, self.tokens["other"]) + ) + + repeated, changed = revoke_account_session( + self.session, + account_id=self.account.id, + session_id="session-other", + protected_session_id="session-current", + now=self.now, + ) + self.assertIs(item, repeated) + self.assertFalse(changed) + hidden, changed = revoke_account_session( + self.session, + account_id=self.account.id, + session_id="session-other-account", + now=self.now, + ) + self.assertIsNone(hidden) + self.assertFalse(changed) + + def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None: + with self.assertRaisesRegex(ValueError, "current session"): + revoke_account_session( + self.session, + account_id=self.account.id, + session_id="session-current", + protected_session_id="session-current", + now=self.now, + ) + revoked = revoke_other_account_sessions( + self.session, + account_id=self.account.id, + current_session_id="session-current", + now=self.now, + ) + self.assertEqual(("session-other",), revoked) + self.assertIsNone(self.session.get(AuthSession, "session-current").revoked_at) + self.assertIsNone(self.session.get(AuthSession, "session-expired").revoked_at) + self.assertEqual( + (), + revoke_other_account_sessions( + self.session, + account_id=self.account.id, + current_session_id="session-current", + now=self.now, + ), + ) + + def test_administrative_revocation_requires_session_and_current_password(self) -> None: + self.account.password_hash = hash_password("correct horse") + membership = self.session.get(User, "user-1") + current = self.session.get(AuthSession, "session-current") + principal_ref = PrincipalRef( + account_id=self.account.id, + membership_id=membership.id, + tenant_id=membership.tenant_id, + scopes=frozenset({"access:membership:update"}), + auth_method="session", + session_id=current.id, + ) + without_session = ApiPrincipal( + principal=principal_ref, + account=self.account, + user=membership, + ) + with self.assertRaises(HTTPException) as missing: + _require_session_admin_reauthorization(without_session, "correct horse") + self.assertEqual(403, missing.exception.status_code) + + principal = ApiPrincipal( + principal=principal_ref, + account=self.account, + user=membership, + auth_session=current, + ) + with self.assertRaises(HTTPException) as incorrect: + _require_session_admin_reauthorization(principal, "incorrect") + self.assertEqual(403, incorrect.exception.status_code) + _require_session_admin_reauthorization(principal, "correct horse") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index 5a75a77..7f13ce6 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -18,6 +18,7 @@ const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx"); const files = read("src/features/admin/FileConnectorsPanel.tsx"); const mail = read("src/features/admin/MailProfilesPanel.tsx"); const moduleSource = read("src/module.ts"); +const sessions = read("src/features/sessions/SessionSettingsPanel.tsx"); const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings]; const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n"); @@ -49,6 +50,19 @@ assert.match(serviceAccounts, /revokeServiceAccountCredential/); assert.match(serviceAccounts, /Secrets are shown once/); assert.match(serviceAccounts, / { + return apiFetch(settings, "/api/v1/auth/sessions", { + cache: "no-store" + }); +} + +export function revokeAccountSession( + settings: ApiSettings, + sessionId: string +): Promise<{ session: AccountSession; revoked: boolean }> { + return apiFetch(settings, `/api/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, { + method: "POST" + }); +} + +export function revokeOtherAccountSessions( + settings: ApiSettings +): Promise<{ revoked_count: number }> { + return apiFetch(settings, "/api/v1/auth/sessions/revoke-others", { + method: "POST" + }); +} + +export function fetchAdminUserSessions( + settings: ApiSettings, + userId: string +): Promise { + return apiFetch( + settings, + `/api/v1/admin/users/${encodeURIComponent(userId)}/sessions`, + { cache: "no-store" } + ); +} + +export function revokeAdminUserSession( + settings: ApiSettings, + userId: string, + sessionId: string, + currentPassword: string +): Promise<{ session: AccountSession; revoked: boolean }> { + return apiFetch( + settings, + `/api/v1/admin/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`, + { + method: "POST", + body: JSON.stringify({ current_password: currentPassword }) + } + ); +} diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx index 01fedd1..3db0d68 100644 --- a/webui/src/features/admin/AdminPage.tsx +++ b/webui/src/features/admin/AdminPage.tsx @@ -367,7 +367,7 @@ export default function AdminPage({ /> )} {!contributedSection && active === "system-roles" && } - {!contributedSection && active === "tenant-users" && } + {!contributedSection && active === "tenant-users" && } {!contributedSection && active === "tenant-groups" && } {!contributedSection && active === "tenant-roles" && } {!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && } diff --git a/webui/src/features/admin/UsersPanel.tsx b/webui/src/features/admin/UsersPanel.tsx index cb2eb77..8bb8e12 100644 --- a/webui/src/features/admin/UsersPanel.tsx +++ b/webui/src/features/admin/UsersPanel.tsx @@ -1,6 +1,6 @@ import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui"; import { useEffect, useMemo, useRef, useState } from "react"; -import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; +import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react"; import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui"; import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin"; import { Button } from "@govoplan/core-webui"; @@ -14,6 +14,11 @@ import { ConfirmDialog } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { loadDeltaRows } from "./utils/deltaRows"; +import { + fetchAdminUserSessions, + revokeAdminUserSession, + type AccountSession +} from "../../api/sessions"; import { ACCESS_INTERFACE_I18N, ACCESS_WORKFLOW_DOCUMENTATION, @@ -30,7 +35,7 @@ const emptyDraft = { roleIds: [] as string[] }; -export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh +export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, canRevokeSessions, onAuthRefresh @@ -39,7 +44,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu -}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise;}) { +}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;canRevokeSessions: boolean;onAuthRefresh: () => Promise;}) { const [users, setUsers] = useState([]); const [groups, setGroups] = useState([]); const [roles, setRoles] = useState([]); @@ -53,6 +58,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu const [accessExplanation, setAccessExplanation] = useState(null); const [accessExplanationLoading, setAccessExplanationLoading] = useState(false); const [deactivating, setDeactivating] = useState(null); + const [sessionUser, setSessionUser] = useState(null); + const [accountSessions, setAccountSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionError, setSessionError] = useState(""); + const [revokingSession, setRevokingSession] = useState(null); + const [reauthorizationPassword, setReauthorizationPassword] = useState(""); const [draft, setDraft] = useState(emptyDraft); const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null); @@ -186,6 +197,65 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu } } + async function loadUserSessions(user: UserAdminItem) { + setSessionsLoading(true); + setSessionError(""); + try { + const response = await fetchAdminUserSessions(settings, user.id); + setAccountSessions(response.sessions); + } catch (err) { + setSessionError(adminErrorMessage(err)); + } finally { + setSessionsLoading(false); + } + } + + function openUserSessions(user: UserAdminItem) { + setSessionUser(user); + setAccountSessions([]); + setRevokingSession(null); + setReauthorizationPassword(""); + void loadUserSessions(user); + } + + async function revokeSelectedSession() { + if (!sessionUser || !revokingSession || !reauthorizationPassword) return; + setBusy(true); + setSessionError(""); + try { + await revokeAdminUserSession( + settings, + sessionUser.id, + revokingSession.id, + reauthorizationPassword + ); + setSuccess("i18n:govoplan-access.session_revoked.5e551008"); + setRevokingSession(null); + setReauthorizationPassword(""); + await loadUserSessions(sessionUser); + } catch (err) { + setSessionError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + const sessionColumns = useMemo[]>(() => [ + { id: "client", header: "i18n:govoplan-access.device_or_client.5e551002", width: "minmax(220px, 1fr)", fill: true, value: (row) => row.client || "", render: (row) =>
{row.current ? "i18n:govoplan-access.current_session.5e551003" : "i18n:govoplan-access.other_session.5e551004"}
{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}
}, + { id: "last_seen", header: "i18n:govoplan-access.last_seen.5e551006", width: 180, value: (row) => row.last_seen_at || "", render: (row) => formatDateTime(row.last_seen_at) }, + { id: "created", header: "i18n:govoplan-access.created.accf40c8", width: 180, value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) }, + { id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, value: (row) => row.expires_at, render: (row) => formatDateTime(row.expires_at) }, + { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 96, sticky: "end", align: "right", render: (row) => { setRevokingSession(row); setReauthorizationPassword(""); setSessionError(""); } + }]} /> } + ], [busy, canRevokeSessions]); + const columns = useMemo[]>(() => [ { id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) =>
{row.display_name || row.email}
{row.email}
}, { id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) }, @@ -195,11 +265,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu { id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) }, { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => , onClick: () => setViewing(row) }, + { id: "sessions", label: i18nMessage("i18n:govoplan-access.inspect_sessions_for_value.5e551017", { value0: row.email }), icon: , onClick: () => openUserSessions(row) }, { id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: , onClick: () => void openAccessExplanation(row) }, { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: , disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), disabledReason: !(canUpdate || canSuspend || canManageGroups || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) }, { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: , variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.is_last_active_owner ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) } ]} /> }], - [canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]); + [canAssignRoles, canManageGroups, canRevokeSessions, canSuspend, canUpdate, settings]); return ( <> @@ -241,6 +312,22 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu } + !busy && setSessionUser(null)} className="" footer={<>}> + {sessionError &&

{sessionError}

} + {sessionUser && <> +

{sessionUser.display_name || sessionUser.email} · {sessionUser.email}

+
row.id} loading={sessionsLoading} emptyText="i18n:govoplan-access.no_active_sessions.5e551013" />
+ } +
+ + !busy && setRevokingSession(null)} className="" footer={<>}> + {sessionError &&

{sessionError}

} +

i18n:govoplan-access.admin_session_revocation_confirmation.5e551020

+ + + +
+ { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="" footer={}> {accessExplanationLoading &&

i18n:govoplan-access.loading_access_explanation.04a7c934

} {accessExplanation && <> diff --git a/webui/src/features/sessions/SessionSettingsPanel.tsx b/webui/src/features/sessions/SessionSettingsPanel.tsx new file mode 100644 index 0000000..728aaf4 --- /dev/null +++ b/webui/src/features/sessions/SessionSettingsPanel.tsx @@ -0,0 +1,261 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Card, + ConfirmDialog, + ContentGrid, + DataGrid, + DismissibleAlert, + PageActionBar, + StatusBadge, + TableActionGroup, + formatAdminDateTime, + type ApiSettings, + type AuthInfo, + type DataGridColumn +} from "@govoplan/core-webui"; +import { + fetchAccountSessions, + revokeAccountSession, + revokeOtherAccountSessions, + type AccountSession +} from "../../api/sessions"; + +export default function SessionSettingsPanel({ + settings, + auth +}: { + settings: ApiSettings; + auth: AuthInfo; +}) { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [revoking, setRevoking] = useState(null); + const [revokingOthers, setRevokingOthers] = useState(false); + const interactive = auth.principal?.auth_method === "session"; + + async function load() { + if (!interactive) { + setSessions([]); + setLoading(false); + return; + } + setLoading(true); + setError(""); + try { + const response = await fetchAccountSessions(settings); + setSessions(response.sessions); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, [ + auth.principal?.session_id, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey + ]); + + const columns = useMemo[]>( + () => [ + { + id: "client", + header: "i18n:govoplan-access.device_or_client.5e551002", + width: "minmax(220px, 1fr)", + minWidth: 180, + fill: true, + sortable: true, + filterable: true, + value: (row) => row.client || "", + render: (row) => ( +
+ + {row.current + ? "i18n:govoplan-access.current_session.5e551003" + : "i18n:govoplan-access.other_session.5e551004"} + +
+ {row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"} +
+
+ ) + }, + { + id: "status", + header: "i18n:govoplan-access.status.bae7d5be", + width: 120, + value: (row) => row.status, + render: (row) => + }, + { + id: "last_seen", + header: "i18n:govoplan-access.last_seen.5e551006", + width: 180, + sortable: true, + value: (row) => row.last_seen_at || "", + render: (row) => formatAdminDateTime(row.last_seen_at) + }, + { + id: "created", + header: "i18n:govoplan-access.created.accf40c8", + width: 180, + sortable: true, + value: (row) => row.created_at, + render: (row) => formatAdminDateTime(row.created_at) + }, + { + id: "expires", + header: "i18n:govoplan-access.expires.a99be3da", + width: 180, + sortable: true, + value: (row) => row.expires_at, + render: (row) => formatAdminDateTime(row.expires_at) + }, + { + id: "actions", + header: "i18n:govoplan-access.actions.c3cd636a", + width: 96, + sticky: "end", + align: "right", + render: (row) => ( + setRevoking(row) + } + ]} + /> + ) + } + ], + [busy] + ); + + async function revokeOne() { + if (!revoking) return; + setBusy(true); + setError(""); + try { + await revokeAccountSession(settings, revoking.id); + setRevoking(null); + setSuccess("i18n:govoplan-access.session_revoked.5e551008"); + await load(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + } + + async function revokeOthers() { + setBusy(true); + setError(""); + try { + const response = await revokeOtherAccountSessions(settings); + setRevokingOthers(false); + setSuccess( + response.revoked_count + ? "i18n:govoplan-access.other_sessions_revoked.5e551009" + : "i18n:govoplan-access.no_other_active_sessions.5e551010" + ); + await load(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + } + + if (!interactive) { + return ( + + +

i18n:govoplan-access.browser_session_required.5e551011

+
+
+ ); + } + + return ( + + void load(), + loading, + disabledReason: loading + ? "i18n:govoplan-access.administration_data_is_loading.4af2c001" + : undefined + }} + destructiveActions={ + + } + /> + {error && {error}} + {success && {success}} + +
+ row.id} + emptyText="i18n:govoplan-access.no_active_sessions.5e551013" + /> +
+
+ setRevoking(null)} + onConfirm={() => void revokeOne()} + /> + setRevokingOthers(false)} + onConfirm={() => void revokeOthers()} + /> +
+ ); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 81ae966..a3325ca 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,27 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; export const generatedTranslations: PlatformTranslations = { "en": { + "i18n:govoplan-access.sessions_and_devices.5e551001": "Sessions and devices", + "i18n:govoplan-access.device_or_client.5e551002": "Device or client", + "i18n:govoplan-access.current_session.5e551003": "Current session", + "i18n:govoplan-access.other_session.5e551004": "Other session", + "i18n:govoplan-access.client_details_unavailable.5e551005": "Client details unavailable", + "i18n:govoplan-access.last_seen.5e551006": "Last seen", + "i18n:govoplan-access.revoke_session.5e551007": "Revoke session", + "i18n:govoplan-access.session_revoked.5e551008": "Session revoked.", + "i18n:govoplan-access.other_sessions_revoked.5e551009": "All other active sessions were revoked.", + "i18n:govoplan-access.no_other_active_sessions.5e551010": "There are no other active sessions.", + "i18n:govoplan-access.browser_session_required.5e551011": "Session management is available only from an interactive browser session.", + "i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Revoke all other sessions", + "i18n:govoplan-access.no_active_sessions.5e551013": "No active sessions were found.", + "i18n:govoplan-access.revoke_session_confirmation.5e551014": "This device or client will lose access on its next authenticated request. The current session remains active.", + "i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Revoke every other active session for this account? This current session remains active.", + "i18n:govoplan-access.session_revocation_permission_required.5e551016": "Membership update permission is required to revoke sessions.", + "i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Inspect sessions for {value0}", + "i18n:govoplan-access.user_sessions.5e551018": "User sessions", + "i18n:govoplan-access.current_password_required.5e551019": "Enter your current password to continue.", + "i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Re-authorize this administrative action with your current password. The selected session will lose access on its next authenticated request.", + "i18n:govoplan-access.current_password.5e551021": "Current password", "i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administration data is loading.", "i18n:govoplan-access.create_permission_is_required.4af2c002": "Create permission is required for this action.", "i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Update or assignment permission is required for this action.", @@ -382,6 +403,27 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access." }, "de": { + "i18n:govoplan-access.sessions_and_devices.5e551001": "Sitzungen und Geräte", + "i18n:govoplan-access.device_or_client.5e551002": "Gerät oder Client", + "i18n:govoplan-access.current_session.5e551003": "Aktuelle Sitzung", + "i18n:govoplan-access.other_session.5e551004": "Andere Sitzung", + "i18n:govoplan-access.client_details_unavailable.5e551005": "Keine Clientdetails verfügbar", + "i18n:govoplan-access.last_seen.5e551006": "Zuletzt aktiv", + "i18n:govoplan-access.revoke_session.5e551007": "Sitzung widerrufen", + "i18n:govoplan-access.session_revoked.5e551008": "Sitzung wurde widerrufen.", + "i18n:govoplan-access.other_sessions_revoked.5e551009": "Alle anderen aktiven Sitzungen wurden widerrufen.", + "i18n:govoplan-access.no_other_active_sessions.5e551010": "Es gibt keine anderen aktiven Sitzungen.", + "i18n:govoplan-access.browser_session_required.5e551011": "Die Sitzungsverwaltung ist nur in einer interaktiven Browsersitzung verfügbar.", + "i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Alle anderen Sitzungen widerrufen", + "i18n:govoplan-access.no_active_sessions.5e551013": "Es wurden keine aktiven Sitzungen gefunden.", + "i18n:govoplan-access.revoke_session_confirmation.5e551014": "Dieses Gerät oder dieser Client verliert beim nächsten authentifizierten Aufruf den Zugriff. Die aktuelle Sitzung bleibt aktiv.", + "i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Alle anderen aktiven Sitzungen dieses Kontos widerrufen? Diese aktuelle Sitzung bleibt aktiv.", + "i18n:govoplan-access.session_revocation_permission_required.5e551016": "Zum Widerrufen von Sitzungen ist die Berechtigung zum Ändern von Mitgliedschaften erforderlich.", + "i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Sitzungen von {value0} prüfen", + "i18n:govoplan-access.user_sessions.5e551018": "Benutzersitzungen", + "i18n:govoplan-access.current_password_required.5e551019": "Geben Sie Ihr aktuelles Passwort ein, um fortzufahren.", + "i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Autorisieren Sie diese administrative Aktion erneut mit Ihrem aktuellen Passwort. Die ausgewählte Sitzung verliert beim nächsten authentifizierten Aufruf den Zugriff.", + "i18n:govoplan-access.current_password.5e551021": "Aktuelles Passwort", "i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administrationsdaten werden geladen.", "i18n:govoplan-access.create_permission_is_required.4af2c002": "Für diese Aktion ist die Berechtigung zum Erstellen erforderlich.", "i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Für diese Aktion ist eine Berechtigung zum Ändern oder Zuweisen erforderlich.", diff --git a/webui/src/index.ts b/webui/src/index.ts index 035e32d..d4c610e 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -1,6 +1,7 @@ export { default } from "./module"; export * from "./module"; export * from "./api/admin"; +export * from "./api/sessions"; export { default as AdminPage } from "./features/admin/AdminPage"; export { ResourceAccessExplanation } from "@govoplan/core-webui"; export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui"; diff --git a/webui/src/module.ts b/webui/src/module.ts index 1c30d32..890c88e 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,10 +1,11 @@ import { createElement, lazy } from "react"; -import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui"; +import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui"; import { adminReadScopes } from "@govoplan/core-webui"; import ActingContextSelector from "./features/acting-context/ActingContextSelector"; import { generatedTranslations } from "./i18n/generatedTranslations"; const AdminPage = lazy(() => import("./features/admin/AdminPage")); +const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel")); const translations = { en: generatedTranslations.en, @@ -24,9 +25,24 @@ const accessAdminSurfaces = [ { id: "access.admin.tenant-service-accounts", moduleId: "access", kind: "section" as const, label: "Service accounts", order: 90 }, { id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 }, { id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 }, - { id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 } + { id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 }, + { id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 } ]; +const accessSettingsSections: SettingsSectionsUiCapability = { + sections: [ + { + id: "sessions", + surfaceId: "access.settings.sessions", + label: "i18n:govoplan-access.sessions_and_devices.5e551001", + group: "account", + order: 20, + allOf: ["access:session:manage_own"], + render: ({ settings, auth }) => createElement(SessionSettingsPanel, { settings, auth }) + } + ] +}; + function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) { if (!onAuthChange) { throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f"); @@ -46,7 +62,8 @@ export const accessModule: PlatformWebModule = { routes: [ { path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }], uiCapabilities: { - "access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability + "access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability, + "settings.sections": accessSettingsSections } };