diff --git a/gnexus_creds/api.py b/gnexus_creds/api.py index e3f2df6..79baf15 100644 --- a/gnexus_creds/api.py +++ b/gnexus_creds/api.py @@ -17,6 +17,7 @@ from gnexus_creds.errors import AppError from gnexus_creds.models import ApiToken, AuditEvent, Secret, SecretTag, User from gnexus_creds.schemas import ( + SUPPORTED_LOCALES, ApiTokenCreate, ApiTokenCreated, ApiTokenRead, @@ -58,6 +59,20 @@ router = APIRouter(prefix="/api/v1") +def effective_locale(user: User) -> str: + """UI language in effect: settings override wins, then the locale the + auth account carries (kept in users.profile), then English.""" + + def normalize(tag: object) -> str | None: + text = str(tag or "").lower() + if not text: + return None + prefix = text.split("-", 1)[0].split("_", 1)[0] + return prefix if prefix in SUPPORTED_LOCALES else None + + return normalize(user.locale) or normalize((user.profile or {}).get("locale")) or "en" + + def _export_secret(row: Secret, actor: Actor, db: Session) -> dict: revealed = serialize_secret(row, reveal=True, db=db, user=actor.user) return SecretCreate( @@ -94,6 +109,7 @@ email=actor.user.email, display_name=actor.user.display_name, locale=actor.user.locale, + locale_effective=effective_locale(actor.user), role=actor.user.system_role, status=actor.user.status, avatar_url=profile.get("avatar_url"), @@ -111,9 +127,11 @@ if payload.display_name is not None: actor.user.display_name = payload.display_name if payload.locale is not None: - actor.user.locale = payload.locale + # "" (the "auto" sentinel) clears the per-service override + actor.user.locale = payload.locale or None db.commit() - db.refresh(actor.user) + # no refresh: the fixture-shared identity and the in-memory values are + # already what we want to return settings = get_settings() profile = actor.user.profile or {} return UserRead( @@ -121,6 +139,7 @@ email=actor.user.email, display_name=actor.user.display_name, locale=actor.user.locale, + locale_effective=effective_locale(actor.user), role=actor.user.system_role, status=actor.user.status, avatar_url=profile.get("avatar_url"), @@ -523,6 +542,7 @@ email=row.email, display_name=row.display_name, locale=row.locale, + locale_effective=effective_locale(row), status=row.status, role=row.system_role, ) diff --git a/gnexus_creds/errors.py b/gnexus_creds/errors.py index 2293a00..5eb3bd4 100644 --- a/gnexus_creds/errors.py +++ b/gnexus_creds/errors.py @@ -37,9 +37,14 @@ async def validation_error_handler(_request: Request, exc: RequestValidationError) -> JSONResponse: + # strip the raw pydantic ctx (may hold non-serializable exception objects) + fields = [ + {"loc": err.get("loc"), "msg": err.get("msg"), "type": err.get("type")} + for err in exc.errors() + ] return error_response( "validation_error", "Request validation failed", status.HTTP_422_UNPROCESSABLE_ENTITY, - {"fields": exc.errors()}, + {"fields": fields}, ) diff --git a/gnexus_creds/oauth.py b/gnexus_creds/oauth.py index 37b73c7..09367f7 100644 --- a/gnexus_creds/oauth.py +++ b/gnexus_creds/oauth.py @@ -100,7 +100,6 @@ profile=profile, status="disabled" if auth_user.status in {"disabled", "blocked", "deleted"} else "enabled", system_role="admin" if auth_user.system_role == "admin" else "user", - locale=profile.get("locale"), ) if user.status == "disabled": raise AppError("user_disabled", "User is disabled.", status_code=403) @@ -157,7 +156,8 @@ user.display_name = ( profile.get("display_name") or profile.get("name") or user.display_name ) - user.locale = profile.get("locale") or user.locale + # same rule as upsert_user_from_auth: keep the settings + # override, auth locale stays inside users.profile status = event.metadata.get("status") if status in {"disabled", "blocked", "deleted"}: user.status = "disabled" diff --git a/gnexus_creds/schemas.py b/gnexus_creds/schemas.py index 16bb97f..9509e5a 100644 --- a/gnexus_creds/schemas.py +++ b/gnexus_creds/schemas.py @@ -162,16 +162,36 @@ email: str display_name: str | None locale: str | None + locale_effective: str role: str status: str avatar_url: str | None = None auth_profile_url: str | None = None +# locales the UI ships; language tags normalize to their 2-letter prefix +SUPPORTED_LOCALES = ("en", "uk", "ru") + + class UserUpdate(BaseModel): display_name: str | None = Field(default=None, max_length=120) + # None = leave unchanged; "" = clear the override (auto, from account); + # a language tag is normalized ("en-US" -> "en") and must be supported. locale: str | None = Field(default=None, max_length=10) + @field_validator("locale") + @classmethod + def normalize_locale(cls, locale: str | None) -> str | None: + if locale is None: + return None + text = locale.strip().lower() + if text in {"", "auto"}: + return "" + prefix = text.split("-", 1)[0].split("_", 1)[0] + if prefix not in SUPPORTED_LOCALES: + raise ValueError("invalid_locale") + return prefix + class ExportResponse(BaseModel): format: str diff --git a/gnexus_creds/services.py b/gnexus_creds/services.py index e907d71..51329e5 100644 --- a/gnexus_creds/services.py +++ b/gnexus_creds/services.py @@ -90,8 +90,9 @@ profile: dict, status: str = "enabled", system_role: str = "user", - locale: str | None = None, ) -> User: + # The auth-derived default lives in users.profile.locale (kept below); + # users.locale holds only the per-service override chosen in settings. user = db.scalar(select(User).where(User.auth_subject == auth_subject)) if user is None: user = User( @@ -101,7 +102,6 @@ profile=profile, status=status, system_role=system_role if system_role in {"user", "admin"} else "user", - locale=locale, last_seen_at=utcnow(), ) db.add(user) @@ -113,7 +113,9 @@ user.profile = profile user.status = status user.system_role = system_role if system_role in {"user", "admin"} else "user" - user.locale = locale + # users.locale is the per-service override chosen in settings; the + # auth-derived default lives in users.profile.locale — never clobber + # the override with it here (or in the webhook in oauth.py). user.last_seen_at = utcnow() return user diff --git a/tests/test_api.py b/tests/test_api.py index f7ae04c..1f2384c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,7 +2,7 @@ from httpx import ASGITransport, AsyncClient from gnexus_creds import crypto -from gnexus_creds.models import ApiToken, AuditEvent +from gnexus_creds.models import ApiToken, AuditEvent, User from gnexus_creds.schemas import SecretCreate, SecretFieldIn from gnexus_creds.services import Actor, create_secret @@ -310,3 +310,36 @@ assert data["total_secrets"] == 2 assert data["active_secrets"] == 2 assert data["mcp_enabled_secrets"] == 1 + + +@pytest.mark.anyio +async def test_me_locale_effective_chain(app, db_session, user): + """override -> profile.locale -> en""" + user_id = user.id # the ORM instance detaches once another session commits + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + body = (await client.get("/api/v1/me")).json() + assert body["locale_effective"] == "en" + assert body["locale"] == "en" + + # the auth account switches to ru; the override still wins + user = db_session.get(User, user_id) + user.profile = {"locale": "ru"} + db_session.commit() + body = (await client.get("/api/v1/me")).json() + assert body["locale_effective"] == "en" + + response = await client.patch("/api/v1/me", json={"locale": "auto"}) + assert response.status_code == 200, response.text + db_session.commit() # end the snapshot so we see the request session's write + db_session.expire_all() + user = db_session.get(User, user_id) + assert user.locale is None # override cleared, not just ignored + body = (await client.get("/api/v1/me")).json() + assert body["locale_effective"] == "ru" # now follows the account + + response = await client.patch("/api/v1/me", json={"locale": "en-US"}) + assert response.status_code == 200, response.text + assert response.json()["locale"] == "en" + + response = await client.patch("/api/v1/me", json={"locale": "de"}) + assert response.status_code == 422 diff --git a/tests/test_auth.py b/tests/test_auth.py index 2462347..1ff8503 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -141,4 +141,7 @@ db_session.refresh(user) assert user.status == "disabled" assert user.display_name == "Disabled User" - assert user.locale == "uk" + # the webhook refreshes the auth profile but must not overwrite the + # per-service locale override + assert user.locale == "en" + assert user.profile == {"display_name": "Disabled User", "locale": "uk"}