feat(access): add governed session management
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(...),
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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, /<ConfirmDialog[\s\S]*Retire service account/);
|
||||
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
|
||||
assert.match(moduleSource, /access\.settings\.sessions/);
|
||||
assert.match(moduleSource, /"settings\.sections": accessSettingsSections/);
|
||||
assert.match(sessions, /PageActionBar/);
|
||||
assert.match(sessions, /reloadAction/);
|
||||
assert.match(sessions, /destructiveActions/);
|
||||
assert.match(sessions, /DataGrid/);
|
||||
assert.match(sessions, /ConfirmDialog/);
|
||||
assert.doesNotMatch(sessions, /window\.(alert|confirm|prompt)\s*\(/);
|
||||
assert.match(users, /fetchAdminUserSessions/);
|
||||
assert.match(users, /revokeAdminUserSession/);
|
||||
assert.match(users, /admin-user-sessions-v1/);
|
||||
assert.match(users, /PasswordField/);
|
||||
assert.match(users, /canRevokeSessions/);
|
||||
assert.match(moduleSource, /translations,/);
|
||||
assert.match(moduleSource, /version: "0\.1\.11"/);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type AccountSession = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
current: boolean;
|
||||
status: "active" | "expired" | "revoked";
|
||||
created_at: string;
|
||||
last_seen_at?: string | null;
|
||||
expires_at: string;
|
||||
revoked_at?: string | null;
|
||||
client?: string | null;
|
||||
};
|
||||
|
||||
export type AccountSessionList = {
|
||||
sessions: AccountSession[];
|
||||
};
|
||||
|
||||
export function fetchAccountSessions(
|
||||
settings: ApiSettings
|
||||
): Promise<AccountSessionList> {
|
||||
return apiFetch<AccountSessionList>(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<AccountSessionList> {
|
||||
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 })
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -367,7 +367,7 @@ export default function AdminPage({
|
||||
/>
|
||||
)}
|
||||
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} canRevokeSessions={hasAnyScope(auth, ["admin:users:update", "access:membership:update"])} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
|
||||
|
||||
@@ -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<void>;}) {
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;canRevokeSessions: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
@@ -53,6 +58,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||
const [sessionUser, setSessionUser] = useState<UserAdminItem | null>(null);
|
||||
const [accountSessions, setAccountSessions] = useState<AccountSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
const [sessionError, setSessionError] = useState("");
|
||||
const [revokingSession, setRevokingSession] = useState<AccountSession | null>(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<DataGridColumn<AccountSession>[]>(() => [
|
||||
{ id: "client", header: "i18n:govoplan-access.device_or_client.5e551002", width: "minmax(220px, 1fr)", fill: true, value: (row) => row.client || "", render: (row) => <div><strong>{row.current ? "i18n:govoplan-access.current_session.5e551003" : "i18n:govoplan-access.other_session.5e551004"}</strong><div className="muted small-note">{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}</div></div> },
|
||||
{ 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) => <TableActionGroup actions={[{
|
||||
id: "revoke-session",
|
||||
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||
variant: "danger",
|
||||
applicable: !row.current,
|
||||
disabled: busy || !canRevokeSessions,
|
||||
disabledReason: !canRevokeSessions ? "i18n:govoplan-access.session_revocation_permission_required.5e551016" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined,
|
||||
onClick: () => { setRevokingSession(row); setReauthorizationPassword(""); setSessionError(""); }
|
||||
}]} /> }
|
||||
], [busy, canRevokeSessions]);
|
||||
|
||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||
{ 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) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ 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) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "sessions", label: i18nMessage("i18n:govoplan-access.inspect_sessions_for_value.5e551017", { value0: row.email }), icon: <MonitorSmartphone />, onClick: () => openUserSessions(row) },
|
||||
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, 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: <Trash2 />, 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
|
||||
</DescriptionList>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog variant="administration" size="wide" open={Boolean(sessionUser && !revokingSession)} title="i18n:govoplan-access.user_sessions.5e551018" onClose={() => !busy && setSessionUser(null)} className="" footer={<><Button onClick={() => sessionUser && void loadUserSessions(sessionUser)} disabled={sessionsLoading || busy} disabledReason={sessionsLoading || busy ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><Button onClick={() => setSessionUser(null)} disabled={busy}>i18n:govoplan-access.close.bbfa773e</Button></>}>
|
||||
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
|
||||
{sessionUser && <>
|
||||
<p className="muted small-note">{sessionUser.display_name || sessionUser.email} · {sessionUser.email}</p>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-user-sessions-v1" rows={accountSessions} columns={sessionColumns} initialFit="container" getRowKey={(row) => row.id} loading={sessionsLoading} emptyText="i18n:govoplan-access.no_active_sessions.5e551013" /></div>
|
||||
</>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" onClick={() => void revokeSelectedSession()} disabled={busy || !reauthorizationPassword} disabledReason={!reauthorizationPassword ? "i18n:govoplan-access.current_password_required.5e551019" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.revoke_session.5e551007</Button></>}>
|
||||
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
|
||||
<p>i18n:govoplan-access.admin_session_revocation_confirmation.5e551020</p>
|
||||
<FormField label="i18n:govoplan-access.current_password.5e551021">
|
||||
<PasswordField value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
|
||||
</FormField>
|
||||
</Dialog>
|
||||
|
||||
<Dialog variant="administration" size="wide" open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading} disabledReason={accessExplanationLoading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
||||
{accessExplanation && <>
|
||||
|
||||
@@ -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<AccountSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [revoking, setRevoking] = useState<AccountSession | null>(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<DataGridColumn<AccountSession>[]>(
|
||||
() => [
|
||||
{
|
||||
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) => (
|
||||
<div>
|
||||
<strong>
|
||||
{row.current
|
||||
? "i18n:govoplan-access.current_session.5e551003"
|
||||
: "i18n:govoplan-access.other_session.5e551004"}
|
||||
</strong>
|
||||
<div className="muted small-note">
|
||||
{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "i18n:govoplan-access.status.bae7d5be",
|
||||
width: 120,
|
||||
value: (row) => row.status,
|
||||
render: (row) => <StatusBadge status={row.status} />
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<TableActionGroup
|
||||
actions={[
|
||||
{
|
||||
id: "revoke",
|
||||
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||
variant: "danger",
|
||||
applicable: !row.current,
|
||||
disabled: busy,
|
||||
disabledReason: busy
|
||||
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
|
||||
: undefined,
|
||||
onClick: () => 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 (
|
||||
<ContentGrid columns={1} collapseAt="workspace" className="">
|
||||
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
|
||||
<p>i18n:govoplan-access.browser_session_required.5e551011</p>
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentGrid columns={1} collapseAt="workspace" className="">
|
||||
<PageActionBar
|
||||
variant="detail"
|
||||
actionScope="workspace"
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => void load(),
|
||||
loading,
|
||||
disabledReason: loading
|
||||
? "i18n:govoplan-access.administration_data_is_loading.4af2c001"
|
||||
: undefined
|
||||
}}
|
||||
destructiveActions={
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={busy || sessions.filter((item) => !item.current).length === 0}
|
||||
disabledReason={
|
||||
busy
|
||||
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
|
||||
: sessions.filter((item) => !item.current).length === 0
|
||||
? "i18n:govoplan-access.no_other_active_sessions.5e551010"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setRevokingOthers(true)}
|
||||
>
|
||||
i18n:govoplan-access.revoke_all_other_sessions.5e551012
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id="personal-sessions-v1"
|
||||
rows={sessions}
|
||||
columns={columns}
|
||||
initialFit="container"
|
||||
getRowKey={(row) => row.id}
|
||||
emptyText="i18n:govoplan-access.no_active_sessions.5e551013"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<ConfirmDialog
|
||||
open={Boolean(revoking)}
|
||||
title="i18n:govoplan-access.revoke_session.5e551007"
|
||||
message="i18n:govoplan-access.revoke_session_confirmation.5e551014"
|
||||
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setRevoking(null)}
|
||||
onConfirm={() => void revokeOne()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={revokingOthers}
|
||||
title="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
||||
message="i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015"
|
||||
confirmLabel="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setRevokingOthers(false)}
|
||||
onConfirm={() => void revokeOthers()}
|
||||
/>
|
||||
</ContentGrid>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
+20
-3
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user