11 Commits

31 changed files with 1304 additions and 136 deletions

View File

@@ -18,7 +18,13 @@ module directly. Access-side admin service helpers remain
here for users, groups, roles, system accounts, sessions, API keys, tenant here for users, groups, roles, system accounts, sessions, API keys, tenant
access enforcement, admin/audit lookup capabilities, tenant owner access enforcement, admin/audit lookup capabilities, tenant owner
provisioning, and governance-template materialization into access-owned groups provisioning, and governance-template materialization into access-owned groups
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The and roles. The module also enforces narrowly declared managed
`RoleTemplate.default_authenticated` baselines. Their explicit permissions are
derived from the active manifest set for every authenticated tenant member,
without writing assignments during an authorization read. The optional role
row is a non-assignable administration projection, not the source of the
automatic grant. Domain permissions and resource policy remain separate checks.
Governance-template metadata CRUD lives in `govoplan-admin`. The
transitional administration WebUI route shell and transitional administration WebUI route shell and
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
system administration panels are contributed by `@govoplan/admin-webui` through system administration panels are contributed by `@govoplan/admin-webui` through
@@ -113,3 +119,21 @@ From the core checkout:
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -e ../govoplan-access ./.venv/bin/python -m pip install -e ../govoplan-access
``` ```
## Login Throttling
Interactive password login is throttled by normalized global login identity and by
the directly connected client address. The deployment defaults are 10 identity
failures and 100 client failures in a 15-minute window. Counters use
`REDIS_URL` when Redis is reachable, allowing all API workers to share the same
limits. Local development and Redis outages fall back automatically to a
bounded, process-local counter; authentication remains available, but limits
then apply per API process.
The deployment settings are `AUTH_LOGIN_THROTTLE_ENABLED`,
`AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT`, `AUTH_LOGIN_THROTTLE_CLIENT_LIMIT`,
`AUTH_LOGIN_THROTTLE_WINDOW_SECONDS`, and
`AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS`. Client-supplied forwarding headers
are not trusted for throttling. A reverse proxy should pass the real peer
address only through the platform's separately configured trusted-proxy
boundary.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.8", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.8" version = "0.1.11"
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives." description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.11",
"redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]

View File

@@ -1,3 +1,3 @@
"""GovOPlaN access platform module.""" """GovOPlaN access platform module."""
__version__ = "0.1.6" __version__ = "0.1.11"

View File

@@ -31,7 +31,7 @@ from govoplan_access.backend.permissions.catalog import (
validate_tenant_permissions, validate_tenant_permissions,
role_templates_for_level, role_templates_for_level,
) )
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper from govoplan_core.tenancy.service import tenant_counts # noqa: F401 - re-exported compatibility helper
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#" _TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
@@ -56,7 +56,7 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None)) query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
role = query.one_or_none() role = query.one_or_none()
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None) is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
is_assignable = True is_assignable = not template.default_authenticated
if role is None: if role is None:
role = Role( role = Role(
tenant_id=tenant.id if tenant is not None else None, tenant_id=tenant.id if tenant is not None else None,
@@ -200,7 +200,24 @@ def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None: def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
ids = sorted(set(role_ids)) default_slugs = {
template.slug
for template in role_templates_for_level("tenant")
if template.default_authenticated
}
default_roles = (
session.query(Role)
.filter(
Role.tenant_id == user.tenant_id,
Role.slug.in_(default_slugs),
)
.all()
if default_slugs
else []
)
default_role_ids = {role.id for role in default_roles}
requested_ids = set(role_ids) - default_role_ids
ids = sorted(requested_ids)
roles = ( roles = (
session.query(Role) session.query(Role)
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True)) .filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))

View File

@@ -714,7 +714,7 @@ class ConfigurationSafetyFieldItem(BaseModel):
storage: str storage: str
ui_managed: bool ui_managed: bool
risk: Literal["low", "medium", "high", "destructive"] risk: Literal["low", "medium", "high", "destructive"]
secret_handling: Literal["none", "reference_only", "env_only"] = "none" secret_handling: Literal["none", "reference_only", "env_only"] = "none" # noqa: S105 - policy vocabulary.
required_scopes: list[str] = Field(default_factory=list) required_scopes: list[str] = Field(default_factory=list)
dry_run_required: bool = False dry_run_required: bool = False
validation_required: bool = True validation_required: bool = True

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -50,7 +51,12 @@ from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.passwords import verify_password from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
from govoplan_access.backend.security.login_throttle import (
LoginThrottle,
LoginThrottleDecision,
build_login_throttle,
)
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
authenticate_session_token, authenticate_session_token,
collect_user_authorization_context, collect_user_authorization_context,
@@ -253,7 +259,9 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True)) .filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
.one_or_none() .one_or_none()
) )
if account is None or not verify_password(payload.password, account.password_hash): password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
password_matches = verify_password(payload.password, password_hash)
if account is None or not account.password_hash or not password_matches:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
query = ( query = (
@@ -269,10 +277,73 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
query = query.filter(Tenant.slug == payload.tenant_slug) query = query.filter(Tenant.slug == payload.tenant_slug)
row = query.order_by(Tenant.name.asc()).first() row = query.order_by(Tenant.name.asc()).first()
if row is None: if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership") # Keep every authentication failure generic so callers cannot infer
# whether an account exists but lacks an active tenant membership.
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
return account, row[0], row[1] return account, row[0], row[1]
@lru_cache(maxsize=1)
def _configured_login_throttle() -> LoginThrottle | None:
if not settings.auth_login_throttle_enabled:
return None
return build_login_throttle(
redis_url=settings.redis_url,
identity_limit=settings.auth_login_throttle_identity_limit,
client_limit=settings.auth_login_throttle_client_limit,
window_seconds=settings.auth_login_throttle_window_seconds,
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
)
def _raise_login_throttled(decision: LoginThrottleDecision) -> None:
retry_after = max(1, decision.retry_after_seconds)
# The detail deliberately matches every credential failure. Status and
# Retry-After communicate endpoint throttling without disclosing whether
# the supplied identity exists or has an active membership.
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Invalid login",
headers={"Retry-After": str(retry_after)},
)
def _resolve_throttled_login_user(
session: Session,
payload: LoginRequest,
request: Request,
) -> tuple[Account, User, Tenant]:
throttle = _configured_login_throttle()
if throttle is None:
return _resolve_login_user(session, payload)
normalized_email = normalize_email(payload.email)
client_address = request.client.host if request.client else None
context = {
"normalized_email": normalized_email,
"tenant_slug": payload.tenant_slug,
"client_address": client_address,
}
decision = throttle.check(**context)
if not decision.allowed:
_raise_login_throttled(decision)
try:
resolved = _resolve_login_user(session, payload)
except HTTPException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
decision = throttle.record_failure(**context)
if not decision.allowed:
_raise_login_throttled(decision)
raise
throttle.record_success(
normalized_email=normalized_email,
tenant_slug=payload.tenant_slug,
)
return resolved
def _extract_auth_token(request: Request) -> tuple[str | None, str]: def _extract_auth_token(request: Request) -> tuple[str | None, str]:
x_api_key = request.headers.get("x-api-key") x_api_key = request.headers.get("x-api-key")
if x_api_key: if x_api_key:
@@ -616,7 +687,7 @@ def _me_response(
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload) account, user, tenant = _resolve_throttled_login_user(session, payload, request)
identity_directory = _identity_directory_from_request(request) identity_directory = _identity_directory_from_request(request)
authorization_context = collect_user_authorization_context( authorization_context = collect_user_authorization_context(
session, session,

View File

@@ -200,7 +200,6 @@ from govoplan_access.backend.semantic import collect_external_function_roles, co
from govoplan_access.backend.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes from govoplan_access.backend.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_access.backend.permissions.catalog import ( from govoplan_access.backend.permissions.catalog import (
normalize_email,
permission_catalog as access_permission_catalog, permission_catalog as access_permission_catalog,
scopes_grant, scopes_grant,
) )

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.access import ( from govoplan_core.core.access import (
@@ -33,12 +34,13 @@ from govoplan_core.core.modules import (
FrontendRoute, FrontendRoute,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider,
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
@@ -597,11 +599,20 @@ def _route_factory(context: ModuleContext):
return router return router
def _people_search(context: ModuleContext) -> object:
from govoplan_access.backend.people_search import people_search_capability
return people_search_capability(context)
manifest = ModuleManifest( manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="0.1.8", version="0.1.11",
optional_dependencies=("identity", "organizations", "tenancy", "idm"), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
),
permissions=ACCESS_PERMISSIONS, permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES, role_templates=ACCESS_ROLE_TEMPLATES,
route_factory=_route_factory, route_factory=_route_factory,
@@ -653,6 +664,7 @@ manifest = ModuleManifest(
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner, CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration, CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
}, },
documentation=ACCESS_DOCUMENTATION, documentation=ACCESS_DOCUMENTATION,

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, User
from govoplan_core.core.people import (
PeopleSearchError,
PeopleSearchGroup,
PersonSearchCandidate,
person_selection_key,
)
def _principal_tenant_id(principal: object) -> str:
try:
tenant_id = getattr(principal, "tenant_id")
except (AttributeError, RuntimeError) as exc:
raise PeopleSearchError("People search requires an active tenant context.") from exc
normalized = str(tenant_id or "").strip()
if not normalized:
raise PeopleSearchError("People search requires an active tenant context.")
return normalized
def _like_pattern(query: str) -> str:
escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
class AccessPeopleSearchProvider:
"""Search active accounts through their active tenant membership.
The active principal's tenant is the only accepted visibility boundary;
global accounts and memberships of other tenants are never candidates.
Feature routers authorize the surrounding task before invoking this
capability.
"""
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> tuple[PeopleSearchGroup, ...]:
if not isinstance(session, Session):
raise PeopleSearchError("People search requires a database session.")
tenant_id = _principal_tenant_id(principal)
normalized_limit = max(1, min(int(limit), 100))
account_query = (
session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(
User.tenant_id == tenant_id,
User.is_active.is_(True),
Account.is_active.is_(True),
)
)
normalized_query = str(query or "").strip()
if normalized_query:
pattern = _like_pattern(normalized_query)
account_query = account_query.filter(
or_(
func.lower(User.display_name).like(pattern, escape="\\"),
func.lower(User.email).like(pattern, escape="\\"),
func.lower(Account.display_name).like(pattern, escape="\\"),
func.lower(Account.email).like(pattern, escape="\\"),
)
)
rows = (
account_query
.order_by(
func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(),
Account.id.asc(),
)
.limit(normalized_limit)
.all()
)
candidates = tuple(
PersonSearchCandidate(
selection_key=person_selection_key("account", account.id),
kind="account",
reference_id=account.id,
display_name=user.display_name or account.display_name or user.email or account.email,
email=user.email or account.email,
source_module="access",
source_label="Accounts",
source_ref=f"access:account:{account.id}",
)
for user, account in rows
)
return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),)
def people_search_capability(_context: object) -> AccessPeopleSearchProvider:
return AccessPeopleSearchProvider()
__all__ = ["AccessPeopleSearchProvider", "people_search_capability"]

View File

@@ -0,0 +1,358 @@
from __future__ import annotations
import hashlib
import logging
import threading
import time
from dataclasses import dataclass
from typing import Protocol
from redis import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
_REDIS_INCREMENT_SCRIPT = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"""
@dataclass(frozen=True, slots=True)
class AttemptBucket:
count: int = 0
retry_after_seconds: int = 0
@dataclass(frozen=True, slots=True)
class LoginThrottleDecision:
allowed: bool
retry_after_seconds: int = 0
class LoginAttemptStore(Protocol):
def read(self, key: str) -> AttemptBucket: ...
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket: ...
def delete(self, key: str) -> None: ...
class InMemoryLoginAttemptStore:
"""Bounded process-local fallback for development and Redis outages."""
def __init__(self, *, max_entries: int = 10_000) -> None:
self._entries: dict[str, tuple[int, float]] = {}
self._lock = threading.Lock()
self._max_entries = max(2, max_entries)
def read(self, key: str) -> AttemptBucket:
now = time.monotonic()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
return AttemptBucket()
count, expires_at = entry
return AttemptBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
now = time.monotonic()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
self._make_room(now=now, incoming_key=key)
count = 1
expires_at = now + window_seconds
else:
count = entry[0] + 1
expires_at = entry[1]
self._entries[key] = (count, expires_at)
return AttemptBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def delete(self, key: str) -> None:
with self._lock:
self._entries.pop(key, None)
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
entry = self._entries.get(key)
if entry is None:
return None
if entry[1] <= now:
self._entries.pop(key, None)
return None
return entry
def _make_room(self, *, now: float, incoming_key: str) -> None:
if incoming_key in self._entries or len(self._entries) < self._max_entries:
return
expired = [key for key, (_, expires_at) in self._entries.items() if expires_at <= now]
for key in expired:
self._entries.pop(key, None)
while len(self._entries) >= self._max_entries:
self._entries.pop(next(iter(self._entries)))
class RedisLoginAttemptStore:
"""Redis-backed fixed-window counters shared by all API workers."""
def __init__(self, redis_url: str) -> None:
self._client = Redis.from_url(
redis_url,
decode_responses=True,
socket_connect_timeout=0.25,
socket_timeout=0.25,
health_check_interval=30,
)
def read(self, key: str) -> AttemptBucket:
pipeline = self._client.pipeline(transaction=False)
pipeline.get(key)
pipeline.ttl(key)
raw_count, raw_ttl = pipeline.execute()
count = int(raw_count or 0)
ttl = int(raw_ttl or 0)
return AttemptBucket(count=count, retry_after_seconds=max(0, ttl))
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
result = self._client.eval(_REDIS_INCREMENT_SCRIPT, 1, key, window_seconds)
if not isinstance(result, (list, tuple)) or len(result) != 2:
raise RedisError("Unexpected login throttle response from Redis")
return AttemptBucket(
count=int(result[0]),
retry_after_seconds=max(1, int(result[1])),
)
def delete(self, key: str) -> None:
self._client.delete(key)
class ResilientLoginAttemptStore:
"""Prefer the distributed store and fail safely to a local bounded store."""
def __init__(
self,
primary: LoginAttemptStore | None,
fallback: LoginAttemptStore,
*,
retry_seconds: int = 30,
) -> None:
self._primary = primary
self._fallback = fallback
self._retry_seconds = max(1, retry_seconds)
self._primary_unavailable_until = 0.0
self._state_lock = threading.Lock()
def read(self, key: str) -> AttemptBucket:
fallback_result = self._fallback.read(key)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
return _stricter_bucket(primary.read(key), fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
primary = self._available_primary()
if primary is not None:
fallback_result = self._fallback.increment(key, window_seconds=window_seconds)
try:
# Mirror the active process's failures so a later Redis outage
# or recovery cannot restart its protection window from zero.
primary_result = primary.increment(key, window_seconds=window_seconds)
return _stricter_bucket(primary_result, fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
return self._fallback.increment(key, window_seconds=window_seconds)
def delete(self, key: str) -> None:
self._fallback.delete(key)
primary = self._available_primary()
if primary is None:
return
try:
primary.delete(key)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
def _available_primary(self) -> LoginAttemptStore | None:
if self._primary is None:
return None
with self._state_lock:
if time.monotonic() < self._primary_unavailable_until:
return None
return self._primary
def _mark_primary_unavailable(self, exc: Exception) -> None:
should_log = False
with self._state_lock:
now = time.monotonic()
if now >= self._primary_unavailable_until:
should_log = True
self._primary_unavailable_until = now + self._retry_seconds
if should_log:
logger.warning(
"Redis login throttling is unavailable; using the process-local fallback (%s)",
type(exc).__name__,
)
def _stricter_bucket(first: AttemptBucket, second: AttemptBucket) -> AttemptBucket:
return AttemptBucket(
count=max(first.count, second.count),
retry_after_seconds=max(first.retry_after_seconds, second.retry_after_seconds),
)
class LoginThrottle:
def __init__(
self,
store: LoginAttemptStore,
*,
identity_limit: int,
client_limit: int,
window_seconds: int,
key_prefix: str = "govoplan:access:login:v1",
) -> None:
self._store = store
self._identity_limit = max(1, identity_limit)
self._client_limit = max(1, client_limit)
self._window_seconds = max(1, window_seconds)
self._key_prefix = key_prefix.rstrip(":")
def check(
self,
*,
normalized_email: str,
tenant_slug: str | None,
client_address: str | None,
) -> LoginThrottleDecision:
return self._decision(
self._buckets(
normalized_email=normalized_email,
tenant_slug=tenant_slug,
client_address=client_address,
),
increment=False,
)
def record_failure(
self,
*,
normalized_email: str,
tenant_slug: str | None,
client_address: str | None,
) -> LoginThrottleDecision:
return self._decision(
self._buckets(
normalized_email=normalized_email,
tenant_slug=tenant_slug,
client_address=client_address,
),
increment=True,
)
def record_success(
self,
*,
normalized_email: str,
tenant_slug: str | None,
) -> None:
del tenant_slug
identity_key, _ = self._keys(
normalized_email=normalized_email,
client_address=None,
)
self._store.delete(identity_key)
def _decision(
self,
buckets: tuple[tuple[str, int], ...],
*,
increment: bool,
) -> LoginThrottleDecision:
blocked_retry_after = 0
for key, limit in buckets:
state = (
self._store.increment(key, window_seconds=self._window_seconds)
if increment
else self._store.read(key)
)
if state.count >= limit:
blocked_retry_after = max(
blocked_retry_after,
max(1, state.retry_after_seconds),
)
return LoginThrottleDecision(
allowed=blocked_retry_after == 0,
retry_after_seconds=blocked_retry_after,
)
def _buckets(
self,
*,
normalized_email: str,
tenant_slug: str | None,
client_address: str | None,
) -> tuple[tuple[str, int], ...]:
# Accounts and their passwords are global login identities. Do not put
# the caller-supplied tenant slug into the identity key: rotating fake
# slugs must not bypass the account-level limit.
del tenant_slug
identity_key, client_key = self._keys(
normalized_email=normalized_email,
client_address=client_address,
)
buckets = [(identity_key, self._identity_limit)]
if client_key is not None:
buckets.append((client_key, self._client_limit))
return tuple(buckets)
def _keys(
self,
*,
normalized_email: str,
client_address: str | None,
) -> tuple[str, str | None]:
identity = normalized_email.strip().casefold()
identity_digest = hashlib.sha256(identity.encode()).hexdigest()
identity_key = f"{self._key_prefix}:identity:{identity_digest}"
if not client_address:
return identity_key, None
client_digest = hashlib.sha256(client_address.strip().casefold().encode()).hexdigest()
return identity_key, f"{self._key_prefix}:client:{client_digest}"
def build_login_throttle(
*,
redis_url: str | None,
identity_limit: int,
client_limit: int,
window_seconds: int,
redis_retry_seconds: int,
) -> LoginThrottle:
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
resilient_store = ResilientLoginAttemptStore(
redis_store,
InMemoryLoginAttemptStore(),
retry_seconds=redis_retry_seconds,
)
return LoginThrottle(
resilient_store,
identity_limit=identity_limit,
client_limit=client_limit,
window_seconds=window_seconds,
)

View File

@@ -9,6 +9,14 @@ _ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000 _DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16 _SALT_BYTES = 16
# A valid, fixed-cost hash used when a login identity has no local password hash.
# Its plaintext value is intentionally irrelevant; the hash only keeps failed
# login attempts on the same verification path as existing local accounts.
DUMMY_PASSWORD_HASH = (
"pbkdf2_sha256$260000$Z292b3BsYW4tZHVtbXktdjE=" # noqa: S105 # nosec B105 - non-account timing equalizer.
"$uWgE7ht8wO6cotOqNKK2yNomPt57gstVss5ben5gTbw="
)
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str: def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES) salt = os.urandom(_SALT_BYTES)
@@ -37,4 +45,3 @@ def verify_password(password: str, encoded: str | None) -> bool:
return False return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)

View File

@@ -20,7 +20,7 @@ from govoplan_access.backend.db.models import (
UserRoleAssignment, UserRoleAssignment,
) )
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
from govoplan_access.backend.permissions.catalog import expand_scopes from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
from govoplan_core.security.time import ensure_aware_utc, utc_now from govoplan_core.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32 SESSION_RANDOM_BYTES = 32
@@ -183,6 +183,8 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in collect_function_roles(session, user): for role in collect_function_roles(session, user):
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in _materialized_default_authenticated_roles(session, user):
roles_by_id[role.id] = role
return list(roles_by_id.values()) return list(roles_by_id.values())
@@ -242,14 +244,24 @@ def collect_user_authorization_context(
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in extra_roles: for role in extra_roles:
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in _materialized_default_authenticated_roles(session, user):
roles_by_id[role.id] = role
tenant_roles = list(roles_by_id.values()) tenant_roles = list(roles_by_id.values())
system_roles = collect_system_roles(session, account) if include_system and account is not None else [] system_roles = collect_system_roles(session, account) if include_system and account is not None else []
default_slugs = _default_authenticated_slugs()
scopes = { scopes = {
scope scope
for role in [*tenant_roles, *system_roles] for role in tenant_roles
if role.slug not in default_slugs
for scope in (role.permissions or []) for scope in (role.permissions or [])
} }
scopes.update(
scope
for role in system_roles
for scope in (role.permissions or [])
)
scopes.update(_default_authenticated_scopes())
return UserAuthorizationContext( return UserAuthorizationContext(
tenant_roles=tenant_roles, tenant_roles=tenant_roles,
system_roles=system_roles, system_roles=system_roles,
@@ -261,8 +273,10 @@ def collect_user_authorization_context(
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]: def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
scopes: set[str] = set() scopes = _default_authenticated_scopes()
default_slugs = _default_authenticated_slugs()
for role in collect_user_roles(session, user): for role in collect_user_roles(session, user):
if role.slug not in default_slugs:
scopes.update(role.permissions or []) scopes.update(role.permissions or [])
if include_system and user.account: if include_system and user.account:
for role in collect_system_roles(session, user.account): for role in collect_system_roles(session, user.account):
@@ -270,6 +284,43 @@ def collect_user_scopes(session: Session, user: User, *, include_system: bool =
return expand_scopes(scopes) return expand_scopes(scopes)
def _default_authenticated_slugs() -> set[str]:
return {
template.slug
for template in role_templates_for_level("tenant")
if template.default_authenticated
}
def _default_authenticated_scopes() -> set[str]:
return {
scope
for template in role_templates_for_level("tenant")
if template.default_authenticated
for scope in template.permissions
}
def _materialized_default_authenticated_roles(
session: Session,
user: User,
) -> list[Role]:
"""Return the optional database projection without mutating auth reads."""
default_slugs = _default_authenticated_slugs()
if not default_slugs:
return []
return (
session.query(Role)
.filter(
Role.tenant_id == user.tenant_id,
Role.slug.in_(default_slugs),
)
.order_by(Role.name.asc(), Role.id.asc())
.all()
)
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]: def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
return ( return (
session.query(User, Tenant) session.query(User, Tenant)

View File

@@ -22,12 +22,15 @@ class AdminBatchHelperTests(unittest.TestCase):
self.engine = create_engine("sqlite:///:memory:") self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
def tearDown(self) -> None: def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine) Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_access_admin_batch_maps_related_rows(self) -> None: def test_access_admin_batch_maps_related_rows(self) -> None:
session = self.Session() session = self.session
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test") account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email) user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks") group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.admin.service import ensure_default_roles, set_user_roles
from govoplan_access.backend.db.models import Account, Role, User, UserRoleAssignment
from govoplan_access.backend.security.sessions import collect_user_authorization_context
from govoplan_core.core.modules import RoleTemplate
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
DOCS_READER = RoleTemplate(
slug="docs_reader",
name="Documentation reader",
description="Authenticated documentation baseline.",
permissions=("docs:documentation:read",),
default_authenticated=True,
)
class DefaultAuthenticatedRoleTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
self.session.add(self.tenant)
self.session.flush()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _add_user(self, suffix: str) -> User:
account = Account(
id=f"account-{suffix}",
email=f"{suffix}@example.test",
normalized_email=f"{suffix}@example.test",
)
user = User(
id=f"user-{suffix}",
tenant_id=self.tenant.id,
account_id=account.id,
email=account.email,
)
self.session.add_all([account, user])
self.session.flush()
return user
def test_materialized_default_role_is_implicit_and_cannot_be_assigned_or_removed(self) -> None:
first = self._add_user("first")
self._add_user("second")
with (
patch(
"govoplan_access.backend.admin.service.role_templates_for_level",
return_value=(DOCS_READER,),
),
patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
),
):
roles = ensure_default_roles(self.session, self.tenant)
default_role = roles["docs_reader"]
self.assertFalse(default_role.is_assignable)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
# The database row is only an administration projection. Even if
# stale or tampered, it must never broaden the manifest baseline.
default_role.permissions = ["mail:profile:write"]
self.session.flush()
set_user_roles(self.session, user=first, role_ids=[])
context = collect_user_authorization_context(
self.session,
first,
account=first.account,
include_system=False,
)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
self.assertIn("docs:documentation:read", context.scopes)
self.assertNotIn("mail:profile:write", context.scopes)
self.assertEqual([role.slug for role in context.tenant_roles], ["docs_reader"])
def test_first_authorized_request_grants_default_without_database_mutation(self) -> None:
user = self._add_user("new")
with patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
):
context = collect_user_authorization_context(
self.session,
user,
account=user.account,
include_system=False,
)
self.assertIn("docs:documentation:read", context.scopes)
self.assertEqual(context.tenant_roles, [])
self.assertEqual(self.session.query(Role).count(), 0)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
self.assertFalse(self.session.new)
self.assertFalse(self.session.dirty)
user_id = user.id
self.session.commit()
self.session.close()
self.session = self.Session()
persisted_user = self.session.get(User, user_id)
with patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
):
reopened_context = collect_user_authorization_context(
self.session,
persisted_user,
account=persisted_user.account,
include_system=False,
)
self.assertIn("docs:documentation:read", reopened_context.scopes)
self.assertEqual(self.session.query(Role).count(), 0)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from govoplan_access.backend.api.v1 import auth
from govoplan_access.backend.security.passwords import (
DUMMY_PASSWORD_HASH,
verify_password,
)
from govoplan_core.api.v1.schemas import LoginRequest
class LoginSecurityTests(unittest.TestCase):
@staticmethod
def _session_with_account(account: object | None) -> MagicMock:
session = MagicMock()
session.query.return_value.filter.return_value.one_or_none.return_value = (
account
)
return session
def test_absent_identity_verifies_a_valid_dummy_hash_before_generic_failure(
self,
) -> None:
self.assertTrue(verify_password("not-a-user-password", DUMMY_PASSWORD_HASH))
payload = LoginRequest(
email="missing@example.test", password="attempted-password"
)
with patch.object(auth, "verify_password", return_value=True) as verifier:
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(self._session_with_account(None), payload)
verifier.assert_called_once_with(payload.password, DUMMY_PASSWORD_HASH)
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
def test_present_identity_verifies_account_hash_before_same_generic_failure(
self,
) -> None:
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
account = SimpleNamespace(password_hash=account_hash)
payload = LoginRequest(email="known@example.test", password="wrong-password")
with patch.object(auth, "verify_password", return_value=False) as verifier:
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
verifier.assert_called_once_with(payload.password, account_hash)
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
account = SimpleNamespace(password_hash=None)
payload = LoginRequest(
email="passwordless@example.test", password="not-a-user-password"
)
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
account = SimpleNamespace(
id="account-1",
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
)
session = self._session_with_account(account)
session.query.return_value.join.return_value.filter.return_value.order_by.return_value.first.return_value = None
payload = LoginRequest(email="known@example.test", password="correct-password")
with patch.object(auth, "verify_password", return_value=True):
with self.assertRaises(HTTPException) as raised:
auth._resolve_login_user(session, payload) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Invalid login")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,219 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from redis.exceptions import RedisError
from govoplan_access.backend.api.v1 import auth
from govoplan_access.backend.security.login_throttle import (
AttemptBucket,
InMemoryLoginAttemptStore,
LoginThrottle,
LoginThrottleDecision,
ResilientLoginAttemptStore,
)
from govoplan_core.api.v1.schemas import LoginRequest
class LoginThrottleTests(unittest.TestCase):
def test_identity_and_client_buckets_enforce_independent_limits(self) -> None:
throttle = LoginThrottle(
InMemoryLoginAttemptStore(),
identity_limit=2,
client_limit=3,
window_seconds=60,
)
context = {
"normalized_email": "person@example.test",
"tenant_slug": "tenant-a",
"client_address": "192.0.2.4",
}
self.assertTrue(throttle.record_failure(**context).allowed)
self.assertFalse(throttle.record_failure(**context).allowed)
other_identity = {**context, "normalized_email": "other@example.test"}
self.assertFalse(throttle.record_failure(**other_identity).allowed)
def test_success_clears_identity_bucket_without_erasing_client_failures(self) -> None:
throttle = LoginThrottle(
InMemoryLoginAttemptStore(),
identity_limit=2,
client_limit=3,
window_seconds=60,
)
context = {
"normalized_email": "person@example.test",
"tenant_slug": "tenant-a",
"client_address": "192.0.2.8",
}
self.assertTrue(throttle.record_failure(**context).allowed)
throttle.record_success(
normalized_email=context["normalized_email"],
tenant_slug=context["tenant_slug"],
)
self.assertTrue(throttle.check(**context).allowed)
other_identity = {**context, "normalized_email": "other@example.test"}
self.assertTrue(throttle.record_failure(**other_identity).allowed)
self.assertFalse(throttle.record_failure(**other_identity).allowed)
def test_rotating_untrusted_tenant_slug_does_not_bypass_identity_limit(self) -> None:
throttle = LoginThrottle(
InMemoryLoginAttemptStore(),
identity_limit=2,
client_limit=100,
window_seconds=60,
)
self.assertTrue(
throttle.record_failure(
normalized_email="person@example.test",
tenant_slug="tenant-a",
client_address="192.0.2.1",
).allowed
)
self.assertFalse(
throttle.record_failure(
normalized_email="person@example.test",
tenant_slug="made-up-tenant",
client_address="198.51.100.9",
).allowed
)
def test_bucket_keys_do_not_contain_identity_or_client_data(self) -> None:
store = MagicMock()
store.increment.return_value = AttemptBucket(count=1, retry_after_seconds=60)
throttle = LoginThrottle(
store,
identity_limit=10,
client_limit=100,
window_seconds=60,
)
throttle.record_failure(
normalized_email="private.person@example.test",
tenant_slug="private-tenant",
client_address="192.0.2.9",
)
keys = [call.args[0] for call in store.increment.call_args_list]
self.assertEqual(len(keys), 2)
for key in keys:
self.assertNotIn("private", key)
self.assertNotIn("example", key)
self.assertNotIn("192.0.2.9", key)
def test_redis_failure_uses_local_store_during_retry_window(self) -> None:
primary = MagicMock()
primary.read.side_effect = RedisError("not available")
fallback = InMemoryLoginAttemptStore()
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
with self.assertLogs(
"govoplan_access.backend.security.login_throttle",
level="WARNING",
) as captured:
self.assertEqual(store.read("bucket"), AttemptBucket())
result = store.increment("bucket", window_seconds=60)
self.assertEqual(result.count, 1)
self.assertEqual(primary.read.call_count, 1)
primary.increment.assert_not_called()
self.assertIn("process-local fallback", captured.output[0])
def test_redis_recovery_does_not_erase_failures_counted_by_the_fallback(self) -> None:
primary = MagicMock()
primary.read.side_effect = [RedisError("not available"), AttemptBucket(count=1, retry_after_seconds=30)]
primary.increment.return_value = AttemptBucket(count=2, retry_after_seconds=30)
fallback = InMemoryLoginAttemptStore()
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
with self.assertLogs("govoplan_access.backend.security.login_throttle", level="WARNING"):
store.read("bucket")
store.increment("bucket", window_seconds=60)
store.increment("bucket", window_seconds=60)
store._primary_unavailable_until = 0 # Simulate the next Redis retry window.
recovered = store.read("bucket")
incremented = store.increment("bucket", window_seconds=60)
self.assertEqual(recovered.count, 2)
self.assertEqual(incremented.count, 3)
def test_in_memory_store_is_bounded(self) -> None:
store = InMemoryLoginAttemptStore(max_entries=2)
for key in ("one", "two", "three"):
store.increment(key, window_seconds=60)
active = sum(store.read(key).count > 0 for key in ("one", "two", "three"))
self.assertEqual(active, 2)
class LoginThrottleRouteTests(unittest.TestCase):
def setUp(self) -> None:
self.payload = LoginRequest(
email="Person@Example.Test",
password="attempt",
tenant_slug="tenant-a",
)
self.request = SimpleNamespace(client=SimpleNamespace(host="192.0.2.10"))
def test_throttled_identity_gets_same_generic_failure_detail(self) -> None:
throttle = MagicMock()
throttle.check.return_value = LoginThrottleDecision(False, 42)
with patch.object(auth, "_configured_login_throttle", return_value=throttle):
with self.assertRaises(HTTPException) as raised:
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 429)
self.assertEqual(raised.exception.detail, "Invalid login")
self.assertEqual(raised.exception.headers, {"Retry-After": "42"})
def test_failed_login_is_counted_and_threshold_response_stays_generic(self) -> None:
throttle = MagicMock()
throttle.check.return_value = LoginThrottleDecision(True)
throttle.record_failure.return_value = LoginThrottleDecision(False, 60)
generic_failure = HTTPException(status_code=401, detail="Invalid login")
with (
patch.object(auth, "_configured_login_throttle", return_value=throttle),
patch.object(auth, "_resolve_login_user", side_effect=generic_failure),
):
with self.assertRaises(HTTPException) as raised:
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 429)
self.assertEqual(raised.exception.detail, "Invalid login")
throttle.record_failure.assert_called_once_with(
normalized_email="person@example.test",
tenant_slug="tenant-a",
client_address="192.0.2.10",
)
def test_success_clears_only_the_identity_bucket(self) -> None:
throttle = MagicMock()
throttle.check.return_value = LoginThrottleDecision(True)
resolved = (object(), object(), object())
with (
patch.object(auth, "_configured_login_throttle", return_value=throttle),
patch.object(auth, "_resolve_login_user", return_value=resolved),
):
result = auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
self.assertEqual(result, resolved)
throttle.record_failure.assert_not_called()
throttle.record_success.assert_called_once_with(
normalized_email="person@example.test",
tenant_slug="tenant-a",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -15,7 +15,7 @@ class OptionalTenancyContractTests(unittest.TestCase):
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.8", dependencies) self.assertIn("govoplan-core>=0.1.11", dependencies)
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies) self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, User
from govoplan_access.backend.manifest import manifest
from govoplan_access.backend.people_search import AccessPeopleSearchProvider
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider
from govoplan_core.db.base import Base
class AccessPeopleSearchTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.provider = AccessPeopleSearchProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _add_user(
self,
suffix: str,
*,
tenant_id: str = "tenant-1",
display_name: str | None = None,
user_active: bool = True,
account_active: bool = True,
) -> None:
email = f"{suffix}@example.test"
account = Account(
id=f"account-{suffix}",
email=email,
normalized_email=email,
display_name=display_name,
is_active=account_active,
)
self.session.add_all([
account,
User(
id=f"user-{suffix}",
tenant_id=tenant_id,
account_id=account.id,
email=email,
display_name=display_name,
is_active=user_active,
),
])
self.session.flush()
def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None:
self._add_user("ada", display_name="Ada Lovelace")
self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant")
self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False)
self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False)
groups = self.provider.search_people(
self.session,
SimpleNamespace(tenant_id="tenant-1"),
query="ada",
)
self.assertEqual([group.key for group in groups], ["accounts"])
self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"])
self.assertEqual(groups[0].candidates[0].email, "ada@example.test")
self.assertNotIn("tenant_id", groups[0].candidates[0].metadata)
def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None:
self._add_user("ada", display_name="Ada Lovelace")
groups = self.provider.search_people(
self.session,
SimpleNamespace(tenant_id="tenant-1"),
query="%",
)
self.assertEqual(groups[0].candidates, ())
with self.assertRaises(PeopleSearchError):
self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada")
def test_manifest_exposes_the_shared_interface(self) -> None:
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories)
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
self.assertIsInstance(self.provider, PeopleSearchProvider)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.8", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,7 +13,7 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -1,5 +1,13 @@
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui"; import type {
import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui"; AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
ApiSettings,
DeltaDeletedItem,
PrivacyRetentionPolicy,
ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
TenantAdminItem
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
export type { export type {
AdminOverview, AdminOverview,
@@ -101,12 +109,7 @@ export type AccessScopeExplanationItem = {
sources: AccessRoleSourceItem[]; sources: AccessRoleSourceItem[];
}; };
export type AccessDecisionProvenanceItem = { export type AccessDecisionProvenanceItem = Omit<CoreAccessDecisionProvenanceItem, "details"> & {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details: Record<string, unknown>; details: Record<string, unknown>;
}; };
@@ -136,13 +139,7 @@ export type UserAccessExplanationResponse = {
function_facts: FunctionFactExplanationItem[]; function_facts: FunctionFactExplanationItem[];
}; };
export type ResourceAccessExplanationResponse = { export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationResponse<UserAdminItem, AccessDecisionProvenanceItem>;
user: UserAdminItem;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export type SystemAccountItem = { export type SystemAccountItem = {
account_id: string; account_id: string;
@@ -356,15 +353,9 @@ export function fetchUserAccessExplanation(settings: ApiSettings, userId: string
export function fetchResourceAccessExplanation( export function fetchResourceAccessExplanation(
settings: ApiSettings, settings: ApiSettings,
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null } options: ResourceAccessExplanationOptions
): Promise<ResourceAccessExplanationResponse> { ): Promise<ResourceAccessExplanationResponse> {
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", { return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action,
tenant_id: options.tenantId
}));
} }
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> { export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin"; import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
@@ -8,8 +8,9 @@ import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { DateTimeField } from "@govoplan/core-webui"; import { DateTimeField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
@@ -99,11 +100,10 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> }, { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) }, { id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" }, { id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} disabled /> { id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, onClick: () => setRevoking(row) }
<AdminIconButton label={i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} /> ]} /> }],
</div> }],
[canRevoke]); [canRevoke]);
function openCreate() { function openCreate() {
@@ -151,7 +151,7 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
return ( return (
<> <>
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> i18n:govoplan-access.show_revoked.b4265807</label><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}> <AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div> <div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
</AdminPageLayout> </AdminPageLayout>

View File

@@ -15,7 +15,7 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
@@ -257,12 +257,10 @@ export default function ExternalFunctionRoleMappingsPanel({
sticky: "end", sticky: "end",
resizable: false, resizable: false,
align: "right", align: "right",
render: (row) => ( render: (row) => <TableActionGroup actions={[
<div className="admin-icon-actions"> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} /> { id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, onClick: () => setDeleting(row) }
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} /> ]} />
</div>
)
} }
], ],
[auth, canWrite, functionPicker, roleById, settings] [auth, canWrite, functionPicker, roleById, settings]

View File

@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] }; const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
@@ -127,15 +127,15 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
} }
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [ const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> }, { id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count }, { id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, { id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> }, { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} /> { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), onClick: () => setDeactivating(row) }
</div> }], ]} /> }],
[canAssignRoles, canDefine, canManageMembers]); [canAssignRoles, canDefine, canManageMembers]);
return ( return (

View File

@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
@@ -78,10 +78,12 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
setDraft(emptyDraft); setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft)); setSavedDraftKey(draftKey(emptyDraft));
} }
function togglePermission(scope: string, checked: boolean) { function setPermissionGroup(scopes: string[], selected: string[]) {
const next = new Set(draft.permissions); const groupScopes = new Set(scopes);
if (checked) next.add(scope);else next.delete(scope); setDraft({
setDraft({ ...draft, permissions: Array.from(next) }); ...draft,
permissions: [...draft.permissions.filter((scope) => !groupScopes.has(scope)), ...selected]
});
} }
async function save(): Promise<boolean> { async function save(): Promise<boolean> {
@@ -118,15 +120,15 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
} }
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [ const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> }, { id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) }, { id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` }, { id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> }, { id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} /> { id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, onClick: () => setDeleting(row) }
</div> }], ]} /> }],
[canDefine, permissions]); [canDefine, permissions]);
return ( return (
@@ -142,7 +144,10 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>} {editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
</div> </div>
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div> <div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
const scopes = items.map((permission) => permission.scope);
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
})}</div>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>

View File

@@ -16,7 +16,7 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { const emptyDraft = {
@@ -223,11 +223,11 @@ export default function SystemRolesPanel({
align: "right", align: "right",
render: (row) => { render: (row) => {
const protectedOwner = row.slug === "system_owner"; const protectedOwner = row.slug === "system_owner";
return <div className="admin-icon-actions"> return <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} /> { id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, onClick: () => setDeleting(row) }
</div>; ]} />;
} }
}], }],
[canWrite]); [canWrite]);

View File

@@ -20,7 +20,7 @@ import {
type SystemMembershipDraft, type SystemMembershipDraft,
type TenantAdminItem type TenantAdminItem
} from "../../api/admin"; } from "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const emptyDraft = { const emptyDraft = {
email: "", email: "",
@@ -149,16 +149,13 @@ export default function SystemUsersPanel({
setSavedDraftKey(draftKey(emptyDraft)); setSavedDraftKey(draftKey(emptyDraft));
} }
function membership(tenantId: string) { function setMembershipSelection(tenantIds: string[]) {
return draft.memberships.find((item) => item.tenant_id === tenantId);
}
function setMembership(tenantId: string, enabled: boolean) {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
memberships: enabled ? memberships: tenantIds.map((tenantId) =>
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] : current.memberships.find((item) => item.tenant_id === tenantId) ??
current.memberships.filter((item) => item.tenant_id !== tenantId) { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }
)
})); }));
} }
@@ -215,11 +212,11 @@ export default function SystemUsersPanel({
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, { id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> }, { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ 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: "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: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} /> { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), onClick: () => setDeactivating(row) }
</div> }], ]} /> }],
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]); [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
return ( return (
@@ -253,7 +250,7 @@ export default function SystemUsersPanel({
</div> </div>
<div className="admin-assignment-grid"> <div className="admin-assignment-grid">
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div> <div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div> <div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
</div> </div>
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>} {editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p> <p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>

View File

@@ -4,7 +4,7 @@ import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin"; import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const DELTA_KEY = "access:tenant-settings"; const DELTA_KEY = "access:tenant-settings";
@@ -94,10 +94,8 @@ export default function TenantSettingsPanel({
} }
} }
function toggleLanguage(code: string, checked: boolean) { function setEnabledLanguages(selected: string[]) {
const enabled = new Set(draft.enabled_language_codes); const enabled = new Set(selected);
if (checked) enabled.add(code);
else enabled.delete(code);
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item)); const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale); const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale }); setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
@@ -122,21 +120,14 @@ export default function TenantSettingsPanel({
})} })}
</select> </select>
</FormField> </FormField>
<div className="settings-list"> <AdminSelectionList
{draft.system_enabled_language_codes.map((code) => { options={draft.system_enabled_language_codes.map((code) => {
const language = draft.available_languages.find((item) => item.code === code); const language = draft.available_languages.find((item) => item.code === code);
return ( return { id: code, label: code.toUpperCase(), description: languageOptionLabel(language ?? { code, label: code.toUpperCase() }), disabled: !canWrite || busy || code === draft.default_locale };
<label className="admin-inline-check" key={code}>
<input
type="checkbox"
checked={draft.enabled_language_codes.includes(code)}
disabled={!canWrite || busy || code === draft.default_locale}
onChange={(event) => toggleLanguage(code, event.target.checked)} />
<span><strong>{code.toUpperCase()}</strong> {languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</span>
</label>
);
})} })}
</div> selected={draft.enabled_language_codes}
onChange={setEnabledLanguages}
/>
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p> <p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
<dl className="detail-list"> <dl className="detail-list">
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div> <div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>

View File

@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
type OverrideValue = "inherit" | "allow" | "deny"; type OverrideValue = "inherit" | "allow" | "deny";
@@ -225,11 +225,11 @@ export default function TenantsPanel({
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 }, { id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale }, { id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> }, { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate, onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} /> { id: "suspend", label: i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) }
</div> }], ]} /> }],
[activeTenantId, canSuspend, canUpdate]); [activeTenantId, canSuspend, canUpdate]);
return ( return (

View File

@@ -8,8 +8,9 @@ import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { PasswordField } from "@govoplan/core-webui"; import { PasswordField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
@@ -186,12 +187,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) }, { id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> }, { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
{ 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: "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) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email })} icon={<KeyRound />} onClick={() => void openAccessExplanation(row)} /> { id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} /> { 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, onClick: () => setDeactivating(row) }
</div> }], ]} /> }],
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]); [canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
return ( return (
@@ -216,7 +217,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
} }
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField> <FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
</div> </div>
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3</label>} {editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>} {editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
<div className="admin-assignment-grid"> <div className="admin-assignment-grid">
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div> <div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>

View File

@@ -2,4 +2,6 @@ export { default } from "./module";
export * from "./module"; export * from "./module";
export * from "./api/admin"; export * from "./api/admin";
export { default as AdminPage } from "./features/admin/AdminPage"; export { default as AdminPage } from "./features/admin/AdminPage";
export { ResourceAccessExplanation } from "@govoplan/core-webui";
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";