Browse Source

fix(oidc): use preferred_username/name claim for auto-created username (#1173) (#1176)

fix(oidc): use preferred_username/name claim for auto-created username

When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
Sn0rrii 3 weeks ago
parent
commit
d0d0be89ea

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 47 - 9
backend/app/api/routes/mfa.py

@@ -1168,6 +1168,13 @@ async def create_oidc_provider(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
 ) -> OIDCProviderResponse:
     """Create a new OIDC provider (admin only)."""
     """Create a new OIDC provider (admin only)."""
+    if body.default_group_id is not None:
+        grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
+        if not grp_chk.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+                detail="default_group_id references a non-existent group",
+            )
     provider = OIDCProvider(
     provider = OIDCProvider(
         name=body.name,
         name=body.name,
         issuer_url=body.issuer_url.rstrip("/"),
         issuer_url=body.issuer_url.rstrip("/"),
@@ -1180,6 +1187,7 @@ async def create_oidc_provider(
         email_claim=body.email_claim,
         email_claim=body.email_claim,
         require_email_verified=body.require_email_verified,
         require_email_verified=body.require_email_verified,
         icon_url=body.icon_url,
         icon_url=body.icon_url,
+        default_group_id=body.default_group_id,
     )
     )
     # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
     # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
     # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
     # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
@@ -1203,6 +1211,14 @@ async def update_oidc_provider(
     if not provider:
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
 
 
+    if body.default_group_id is not None:
+        grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
+        if not grp_chk.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+                detail="default_group_id references a non-existent group",
+            )
+
     for field, value in body.model_dump(exclude_none=True).items():
     for field, value in body.model_dump(exclude_none=True).items():
         if field == "issuer_url" and value:
         if field == "issuer_url" and value:
             value = value.rstrip("/")
             value = value.rstrip("/")
@@ -1572,7 +1588,21 @@ async def oidc_callback(
                     if provider_email:
                     if provider_email:
                         raw = provider_email.split("@")[0]
                         raw = provider_email.split("@")[0]
                     else:
                     else:
-                        raw = provider_sub[:30]
+                        # Prefer a human-readable IdP claim over the opaque sub.
+                        # isinstance guards are required: claims may carry non-string
+                        # values (e.g. a list) that would break .strip().
+                        # Sanitization is applied per-candidate so that a value that
+                        # strips to empty (e.g. "!!!") correctly falls through to the
+                        # next candidate rather than silently becoming "oidcuser".
+                        _pref = claims.get("preferred_username")
+                        _name = claims.get("name")
+                        raw = ""
+                        if isinstance(_pref, str):
+                            raw = re.sub(r"[^a-zA-Z0-9._-]", "", _pref.strip())[:30]
+                        if not raw and isinstance(_name, str):
+                            raw = re.sub(r"[^a-zA-Z0-9._-]", "", _name.strip())[:30]
+                        if not raw:
+                            raw = provider_sub[:30]
                     candidate = re.sub(r"[^a-zA-Z0-9._-]", "", raw)[:30] or "oidcuser"
                     candidate = re.sub(r"[^a-zA-Z0-9._-]", "", raw)[:30] or "oidcuser"
 
 
                     username = candidate
                     username = candidate
@@ -1584,13 +1614,21 @@ async def oidc_callback(
                         username = f"{candidate}{counter}"
                         username = f"{candidate}{counter}"
                         counter += 1
                         counter += 1
 
 
-                    # I9: Assign new OIDC users to the default "Viewers" group so they
-                    # have read-only access rather than starting with no permissions.
-                    # Fetch the group BEFORE creating the user so we can set the
-                    # relationship before flush — accessing new_user.groups after a
-                    # flush triggers a lazy-load which fails in async context.
-                    viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
-                    viewers_group = viewers_result.scalar_one_or_none()
+                    # I9: Assign new OIDC users to a group before flush — accessing
+                    # new_user.groups after a flush triggers a lazy-load which fails
+                    # in async context.  Resolution order:
+                    #   1. provider.default_group_id (operator-configured)
+                    #   2. "Viewers" (system fallback for read-only access)
+                    #   3. no group (last resort if Viewers was deleted)
+                    # SQLite does not enforce ON DELETE SET NULL, so a dangling
+                    # default_group_id returns None here and falls through to Viewers.
+                    default_group: Group | None = None
+                    if provider.default_group_id is not None:
+                        dg_result = await db.execute(select(Group).where(Group.id == provider.default_group_id))
+                        default_group = dg_result.scalar_one_or_none()
+                    if default_group is None:
+                        viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
+                        default_group = viewers_result.scalar_one_or_none()
 
 
                     new_user = User(
                     new_user = User(
                         username=username,
                         username=username,
@@ -1601,7 +1639,7 @@ async def oidc_callback(
                         password_hash=None,  # OIDC users never use password auth
                         password_hash=None,  # OIDC users never use password auth
                         role="user",
                         role="user",
                         is_active=True,
                         is_active=True,
-                        groups=[viewers_group] if viewers_group else [],
+                        groups=[default_group] if default_group else [],
                     )
                     )
                     db.add(new_user)
                     db.add(new_user)
                     await db.flush()
                     await db.flush()

+ 8 - 0
backend/app/core/database.py

@@ -1745,6 +1745,14 @@ async def run_migrations(conn):
     # is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
     # is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
     await _migrate_update_auto_link_constraint(conn)
     await _migrate_update_auto_link_constraint(conn)
 
 
+    # Migration: Add default_group_id to oidc_providers.
+    # Must run AFTER _migrate_update_auto_link_constraint to avoid being dropped during
+    # the SQLite table recreation that function performs on stale-formula databases.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE oidc_providers ADD COLUMN default_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL",
+    )
+
     # Migration: Add password_changed_at to users (M-R7-B)
     # Migration: Add password_changed_at to users (M-R7-B)
     # Tracks the last time a user's password was changed/reset.  JWTs whose iat
     # Tracks the last time a user's password was changed/reset.  JWTs whose iat
     # predates this timestamp are rejected in all six auth validation paths.
     # predates this timestamp are rejected in all six auth validation paths.

+ 6 - 0
backend/app/models/oidc_provider.py

@@ -73,6 +73,12 @@ class OIDCProvider(Base):
     # Has no effect when email_claim is not "email": the custom-claim path never
     # Has no effect when email_claim is not "email": the custom-claim path never
     # performs an email_verified check regardless of this setting.
     # performs an email_verified check regardless of this setting.
     require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
     require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
+    # Nullable FK — configurable default group for auto-created OIDC users.
+    # Falls back to "Viewers" when None. ON DELETE SET NULL fires on PostgreSQL;
+    # SQLite ignores it (no PRAGMA foreign_keys=ON), so runtime resolution handles dangling refs.
+    default_group_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, default=None
+    )
     # Optional icon URL (SVG/PNG) shown on the login button
     # Optional icon URL (SVG/PNG) shown on the login button
     icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
     icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 3 - 0
backend/app/schemas/auth.py

@@ -371,6 +371,7 @@ class OIDCProviderCreate(BaseModel):
     email_claim: str = Field(default="email", max_length=64)
     email_claim: str = Field(default="email", max_length=64)
     require_email_verified: bool = True
     require_email_verified: bool = True
     icon_url: str | None = None
     icon_url: str | None = None
+    default_group_id: int | None = None
 
 
     @field_validator("issuer_url")
     @field_validator("issuer_url")
     @classmethod
     @classmethod
@@ -426,6 +427,7 @@ class OIDCProviderUpdate(BaseModel):
     email_claim: str | None = Field(default=None, max_length=64)
     email_claim: str | None = Field(default=None, max_length=64)
     require_email_verified: bool | None = None
     require_email_verified: bool | None = None
     icon_url: str | None = None
     icon_url: str | None = None
+    default_group_id: int | None = None
 
 
     @field_validator("scopes")
     @field_validator("scopes")
     @classmethod
     @classmethod
@@ -471,6 +473,7 @@ class OIDCProviderResponse(BaseModel):
     email_claim: str = "email"
     email_claim: str = "email"
     require_email_verified: bool = True
     require_email_verified: bool = True
     icon_url: str | None = None
     icon_url: str | None = None
+    default_group_id: int | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

+ 603 - 0
backend/tests/integration/test_mfa_api.py

@@ -862,6 +862,151 @@ class TestOIDCProviders:
         )
         )
         assert response.status_code == 404
         assert response.status_code == 404
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_with_default_group_id(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Creating a provider with a valid default_group_id stores and returns the value."""
+        from sqlalchemy import select
+
+        from backend.app.models.group import Group
+
+        token = await _setup_and_login(async_client, "oidcdg_create", "OidcDgCreate1!")
+        grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
+        operators = grp_result.scalar_one()
+
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "DgCreateProvider",
+                "issuer_url": "https://dgcreate.example.com",
+                "client_id": "dgcreate-client",
+                "client_secret": "secret",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+                "default_group_id": operators.id,
+            },
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 201, resp.text
+        assert resp.json()["default_group_id"] == operators.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_invalid_default_group_id_returns_422(self, async_client: AsyncClient):
+        """A default_group_id referencing a non-existent group returns 422."""
+        token = await _setup_and_login(async_client, "oidcdg_bad", "OidcDgBad1!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "DgBadProvider",
+                "issuer_url": "https://dgbad.example.com",
+                "client_id": "dgbad-client",
+                "client_secret": "secret",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+                "default_group_id": 999999,
+            },
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_omit_default_group_id_stores_null(self, async_client: AsyncClient):
+        """Omitting default_group_id results in null in the response."""
+        token = await _setup_and_login(async_client, "oidcdg_null", "OidcDgNull1!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "DgNullProvider",
+                "issuer_url": "https://dgnull.example.com",
+                "client_id": "dgnull-client",
+                "client_secret": "secret",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 201, resp.text
+        assert resp.json()["default_group_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_provider_default_group_id(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Updating default_group_id via PUT stores the new value."""
+        from sqlalchemy import select
+
+        from backend.app.models.group import Group
+
+        token = await _setup_and_login(async_client, "oidcdg_update", "OidcDgUpdate1!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "DgUpdateProvider",
+                "issuer_url": "https://dgupdate.example.com",
+                "client_id": "dgupdate-client",
+                "client_secret": "secret",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(token),
+        )
+        provider_id = create_resp.json()["id"]
+        assert create_resp.json()["default_group_id"] is None
+
+        grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
+        operators = grp_result.scalar_one()
+
+        put_resp = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"default_group_id": operators.id},
+            headers=_auth_header(token),
+        )
+        assert put_resp.status_code == 200, put_resp.text
+        assert put_resp.json()["default_group_id"] == operators.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_group_id_in_public_and_admin_list(self, async_client: AsyncClient, db_session: AsyncSession):
+        """default_group_id appears in both the public and admin list responses."""
+        from sqlalchemy import select
+
+        from backend.app.models.group import Group
+
+        token = await _setup_and_login(async_client, "oidcdg_list", "OidcDgList1!")
+        grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
+        operators = grp_result.scalar_one()
+
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "DgListProvider",
+                "issuer_url": "https://dglist.example.com",
+                "client_id": "dglist-client",
+                "client_secret": "secret",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+                "default_group_id": operators.id,
+            },
+            headers=_auth_header(token),
+        )
+        provider_id = create_resp.json()["id"]
+
+        all_resp = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
+        match = next((p for p in all_resp.json() if p["id"] == provider_id), None)
+        assert match is not None
+        assert match["default_group_id"] == operators.id
+
+        pub_resp = await async_client.get("/api/v1/auth/oidc/providers")
+        pub_match = next((p for p in pub_resp.json() if p["id"] == provider_id), None)
+        assert pub_match is not None
+        assert pub_match["default_group_id"] == operators.id
+
 
 
 # ===========================================================================
 # ===========================================================================
 # Security: pre-auth token single-use
 # Security: pre-auth token single-use
@@ -4240,3 +4385,461 @@ class TestOIDCFallCAutoLinkE2E:
             link = result.scalar_one_or_none()
             link = result.scalar_one_or_none()
         assert link is not None, "UserOIDCLink must have been created by auto-link"
         assert link is not None, "UserOIDCLink must have been created by auto-link"
         assert link.provider_user_id == "azure-sub-alice"
         assert link.provider_user_id == "azure-sub-alice"
+
+
+class TestOIDCAutoCreateUsername:
+    """Username derivation priority for auto-created OIDC users (#1173).
+
+    Priority order: email local-part > preferred_username > name > provider_sub.
+    Covers: plain claim, spaces-sanitized, name fallback, sub fallback,
+    non-string isinstance guard, sanitizes-to-empty fallback, collision counter.
+    """
+
+    # ── shared helpers ───────────────────────────────────────────────────────
+
+    @staticmethod
+    async def _create_provider(async_client: AsyncClient, admin_token: str, issuer: str, client_id: str) -> int:
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": f"AutoUser-{secrets.token_hex(4)}",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "secret",
+                "scopes": "openid profile",
+                "is_enabled": True,
+                "auto_create_users": True,
+                "email_claim": "email",
+                "require_email_verified": True,
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 201, resp.text
+        return resp.json()["id"]
+
+    @staticmethod
+    async def _exchange_username(async_client: AsyncClient, location: str) -> str:
+        assert "oidc_token=" in location, f"No oidc_token in redirect: {location}"
+        token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
+        resp = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": token})
+        assert resp.status_code == 200, resp.text
+        return resp.json()["user"]["username"]
+
+    # ── tests ────────────────────────────────────────────────────────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preferred_username_used_when_no_email(self, async_client: AsyncClient, db_session: AsyncSession):
+        """preferred_username='johndoe' → username 'johndoe' (no email claim present)."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://au-pref.example"
+        client_id = "au-pref-client"
+        admin_token = await _setup_and_login(async_client, "au_pref_adm", "AuPrefAdm1!")
+        provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "pref-sub-1", "preferred_username": "johndoe"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        username = await self._exchange_username(async_client, location)
+        assert username == "johndoe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preferred_username_spaces_sanitized(self, async_client: AsyncClient, db_session: AsyncSession):
+        """preferred_username='John Doe' → sanitized to 'JohnDoe'."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://au-spaces.example"
+        client_id = "au-spaces-client"
+        admin_token = await _setup_and_login(async_client, "au_spaces_adm", "AuSpacesAdm1!")
+        provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "spaces-sub-1", "preferred_username": "John Doe"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        username = await self._exchange_username(async_client, location)
+        assert username == "JohnDoe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_name_claim_used_when_no_preferred_username(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """name='Jane Smith', no preferred_username → username 'JaneSmith'."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://au-name.example"
+        client_id = "au-name-client"
+        admin_token = await _setup_and_login(async_client, "au_name_adm", "AuNameAdm1!")
+        provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "name-sub-1", "name": "Jane Smith"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        username = await self._exchange_username(async_client, location)
+        assert username == "JaneSmith"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_provider_sub_fallback_when_no_claims(self, async_client: AsyncClient, db_session: AsyncSession):
+        """No preferred_username, no name, no email → username derived from provider_sub."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://au-sub.example"
+        client_id = "au-sub-client"
+        admin_token = await _setup_and_login(async_client, "au_sub_adm", "AuSubAdm1!")
+        provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "abc123xyz"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        username = await self._exchange_username(async_client, location)
+        assert username == "abc123xyz"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_string_preferred_username_falls_through_to_name(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """preferred_username is a list (non-string) → isinstance guard skips it, uses name."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://au-nonstr.example"
+        client_id = "au-nonstr-client"
+        admin_token = await _setup_and_login(async_client, "au_nonstr_adm", "AuNonstrAdm1!")
+        provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "nonstr-sub-2", "preferred_username": ["listval"], "name": "BobJones"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        username = await self._exchange_username(async_client, location)
+        assert username == "BobJones"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preferred_username_sanitizes_to_empty_falls_through_to_name(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """preferred_username='!!!' sanitizes to '' → falls through to name claim."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://au-empty.example"
+        client_id = "au-empty-client"
+        admin_token = await _setup_and_login(async_client, "au_empty_adm", "AuEmptyAdm1!")
+        provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "empty-sub-1", "preferred_username": "!!!", "name": "bob"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        username = await self._exchange_username(async_client, location)
+        assert username == "bob"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_username_collision_appends_counter(self, async_client: AsyncClient, db_session: AsyncSession):
+        """When preferred_username 'collider' is already taken, counter suffix is appended."""
+        from backend.app.core.auth import get_password_hash
+
+        # Pre-create a user occupying the candidate username
+        existing = User(
+            username="collider",
+            email="collider@example.com",
+            password_hash=get_password_hash("irrelevant"),
+            role="user",
+            is_active=True,
+        )
+        db_session.add(existing)
+        await db_session.commit()
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://au-collision.example"
+        client_id = "au-collision-client"
+        admin_token = await _setup_and_login(async_client, "au_col_adm", "AuColAdm1!")
+        provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "col-sub-1", "preferred_username": "collider"},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        username = await self._exchange_username(async_client, location)
+        assert username == "collider1"
+
+
+# ===========================================================================
+# OIDC auto-create: configurable default group (#1173 Thread 2)
+# ===========================================================================
+
+
+class TestOIDCAutoCreateDefaultGroup:
+    """Auto-created OIDC users receive the provider's configured default group.
+
+    Resolution order:
+      1. provider.default_group_id (configured)
+      2. "Viewers" system group (fallback when default_group_id is None)
+      3. no group (last resort when both are unavailable)
+
+    All tests are DB-agnostic: they verify group membership via the OIDC
+    exchange response, which includes the user's group list.
+    """
+
+    @staticmethod
+    async def _create_provider(
+        async_client: AsyncClient,
+        admin_token: str,
+        *,
+        issuer: str,
+        client_id: str,
+        default_group_id: int | None = None,
+    ) -> int:
+        payload: dict = {
+            "name": f"DgAutoProvider-{secrets.token_hex(4)}",
+            "issuer_url": issuer,
+            "client_id": client_id,
+            "client_secret": "secret",
+            "scopes": "openid profile",
+            "is_enabled": True,
+            "auto_create_users": True,
+            "email_claim": "email",
+            "require_email_verified": True,
+        }
+        if default_group_id is not None:
+            payload["default_group_id"] = default_group_id
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json=payload,
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 201, resp.text
+        return resp.json()["id"]
+
+    @staticmethod
+    async def _run_autocreate_and_get_groups(
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        *,
+        provider_id: int,
+        sub: str,
+        issuer: str,
+        client_id: str,
+        private_pem: bytes,
+        jwks_data: dict,
+    ) -> list[str]:
+        """Complete OIDC callback + exchange and return the new user's group names."""
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": sub},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        assert "oidc_token=" in location, f"No oidc_token in redirect: {location}"
+        token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
+        resp = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": token})
+        assert resp.status_code == 200, resp.text
+        return [g["name"] for g in resp.json()["user"]["groups"]]
+
+    # ── tests ────────────────────────────────────────────────────────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configured_group_assigned_to_auto_created_user(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Auto-created user is placed in the provider's configured default_group_id."""
+        from sqlalchemy import select
+
+        from backend.app.models.group import Group
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://dg-configured.example"
+        client_id = "dg-configured-client"
+        admin_token = await _setup_and_login(async_client, "dg_cfg_adm", "DgCfgAdm1!")
+
+        grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
+        operators = grp_result.scalar_one()
+
+        provider_id = await self._create_provider(
+            async_client,
+            admin_token,
+            issuer=issuer,
+            client_id=client_id,
+            default_group_id=operators.id,
+        )
+
+        group_names = await self._run_autocreate_and_get_groups(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            sub="dg-cfg-sub-1",
+            issuer=issuer,
+            client_id=client_id,
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+        )
+        assert "Operators" in group_names, f"Expected Operators, got {group_names}"
+        assert "Viewers" not in group_names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_null_default_group_id_falls_back_to_viewers(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """When default_group_id is None, auto-created user falls back to Viewers."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://dg-null.example"
+        client_id = "dg-null-client"
+        admin_token = await _setup_and_login(async_client, "dg_null_adm", "DgNullAdm1!")
+
+        provider_id = await self._create_provider(
+            async_client,
+            admin_token,
+            issuer=issuer,
+            client_id=client_id,
+        )
+
+        group_names = await self._run_autocreate_and_get_groups(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            sub="dg-null-sub-1",
+            issuer=issuer,
+            client_id=client_id,
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+        )
+        assert "Viewers" in group_names, f"Expected Viewers, got {group_names}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dangling_default_group_id_falls_back_to_viewers(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """When configured group is deleted, auto-created user falls back to Viewers.
+
+        SQLite does not enforce FK ON DELETE SET NULL (no PRAGMA foreign_keys=ON),
+        so provider.default_group_id may point to a deleted group. The runtime
+        resolution chain must handle this and fall back to Viewers.
+        """
+        from sqlalchemy import delete as sa_delete, select
+
+        from backend.app.models.group import Group
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://dg-dangling.example"
+        client_id = "dg-dangling-client"
+        admin_token = await _setup_and_login(async_client, "dg_dangle_adm", "DgDangleAdm1!")
+
+        # Create a temporary group and use it as default_group_id
+        temp_group = Group(name="TempGroup-DgDangle", permissions=[])
+        db_session.add(temp_group)
+        await db_session.commit()
+        await db_session.refresh(temp_group)
+        temp_group_id = temp_group.id
+
+        provider_id = await self._create_provider(
+            async_client,
+            admin_token,
+            issuer=issuer,
+            client_id=client_id,
+            default_group_id=temp_group_id,
+        )
+
+        # Delete the group — simulates dangling FK (especially on SQLite)
+        await db_session.execute(sa_delete(Group).where(Group.id == temp_group_id))
+        await db_session.commit()
+
+        group_names = await self._run_autocreate_and_get_groups(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            sub="dg-dangle-sub-1",
+            issuer=issuer,
+            client_id=client_id,
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+        )
+        assert "Viewers" in group_names, f"Expected Viewers fallback, got {group_names}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_group_can_be_set_as_default(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Operators can configure Administrators as the default group (e.g. single-tenant IdP)."""
+        from sqlalchemy import select
+
+        from backend.app.models.group import Group
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://dg-admin.example"
+        client_id = "dg-admin-client"
+        admin_token = await _setup_and_login(async_client, "dg_admgrp_adm", "DgAdmgrpAdm1!")
+
+        grp_result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
+        administrators = grp_result.scalar_one()
+
+        provider_id = await self._create_provider(
+            async_client,
+            admin_token,
+            issuer=issuer,
+            client_id=client_id,
+            default_group_id=administrators.id,
+        )
+
+        group_names = await self._run_autocreate_and_get_groups(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            sub="dg-admin-sub-1",
+            issuer=issuer,
+            client_id=client_id,
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+        )
+        assert "Administrators" in group_names, f"Expected Administrators, got {group_names}"

+ 77 - 0
frontend/src/__tests__/components/OIDCProviderSettings.test.tsx

@@ -24,6 +24,7 @@ const mockProviders = [
     email_claim: 'email',
     email_claim: 'email',
     require_email_verified: true,
     require_email_verified: true,
     icon_url: null,
     icon_url: null,
+    default_group_id: null,
     created_at: '2026-01-01T00:00:00Z',
     created_at: '2026-01-01T00:00:00Z',
     updated_at: '2026-01-01T00:00:00Z',
     updated_at: '2026-01-01T00:00:00Z',
   },
   },
@@ -148,5 +149,81 @@ describe('OIDCProviderSettings', () => {
       expect(screen.getByText(/Email Claim/i)).toBeInTheDocument();
       expect(screen.getByText(/Email Claim/i)).toBeInTheDocument();
       expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
       expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
     });
     });
+
+    it('renders Default Group label in provider details', async () => {
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('TestIdP')).toBeInTheDocument();
+      });
+
+      expect(screen.getByText(/Default Group/i)).toBeInTheDocument();
+    });
+
+    it('shows Viewers fallback label when default_group_id is null', async () => {
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('TestIdP')).toBeInTheDocument();
+      });
+
+      // null default_group_id should display the Viewers fallback text
+      expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument();
+    });
+
+    it('shows group name when default_group_id matches a known group', async () => {
+      server.use(
+        http.get('/api/v1/auth/oidc/providers/all', () =>
+          HttpResponse.json([{ ...mockProviders[0], default_group_id: 2 }])
+        )
+      );
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('TestIdP')).toBeInTheDocument();
+      });
+
+      // default_group_id=2 matches Operators in the global MSW mock
+      expect(screen.getByText('Operators')).toBeInTheDocument();
+    });
+  });
+
+  describe('ProviderForm — default group dropdown', () => {
+    it('renders a Default Group select in the create form', async () => {
+      server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
+      });
+      await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Default Group/i)).toBeInTheDocument();
+      });
+
+      // Dropdown should render with Viewers fallback option
+      const select = screen.getByRole('combobox');
+      expect(select).toBeInTheDocument();
+      expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument();
+    });
+
+    it('populates Default Group dropdown with groups from API', async () => {
+      server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
+      });
+      await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
+
+      await waitFor(() => {
+        // Global MSW mock returns Administrators, Operators, Viewers
+        const options = screen.getAllByRole('option');
+        const optionTexts = options.map((o) => o.textContent);
+        expect(optionTexts).toContain('Operators');
+        expect(optionTexts).toContain('Administrators');
+      });
+    });
   });
   });
 });
 });

+ 2 - 0
frontend/src/api/client.ts

@@ -2682,6 +2682,7 @@ export interface OIDCProvider {
   email_claim: string;
   email_claim: string;
   require_email_verified: boolean;
   require_email_verified: boolean;
   icon_url?: string | null;
   icon_url?: string | null;
+  default_group_id?: number | null;
 }
 }
 
 
 export interface OIDCProviderCreate {
 export interface OIDCProviderCreate {
@@ -2696,6 +2697,7 @@ export interface OIDCProviderCreate {
   email_claim?: string;
   email_claim?: string;
   require_email_verified?: boolean;
   require_email_verified?: boolean;
   icon_url?: string | null;
   icon_url?: string | null;
+  default_group_id?: number | null;
 }
 }
 
 
 export interface OIDCLink {
 export interface OIDCLink {

+ 35 - 1
frontend/src/components/OIDCProviderSettings.tsx

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
 import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { OIDCProvider, OIDCProviderCreate } from '../api/client';
+import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
@@ -22,18 +22,21 @@ const EMPTY_FORM: OIDCProviderCreate = {
   email_claim: 'email',
   email_claim: 'email',
   require_email_verified: true,
   require_email_verified: true,
   icon_url: undefined,
   icon_url: undefined,
+  default_group_id: null,
 };
 };
 
 
 // ─── Provider form (create / edit) ───────────────────────────────────────────
 // ─── Provider form (create / edit) ───────────────────────────────────────────
 function ProviderForm({
 function ProviderForm({
   initial,
   initial,
   isEdit = false,
   isEdit = false,
+  groups = [],
   onSave,
   onSave,
   onCancel,
   onCancel,
   isPending,
   isPending,
 }: {
 }: {
   initial: OIDCProviderCreate;
   initial: OIDCProviderCreate;
   isEdit?: boolean;
   isEdit?: boolean;
+  groups?: Group[];
   onSave: (data: OIDCProviderCreate) => void;
   onSave: (data: OIDCProviderCreate) => void;
   onCancel: () => void;
   onCancel: () => void;
   isPending: boolean;
   isPending: boolean;
@@ -157,6 +160,21 @@ function ProviderForm({
         )}
         )}
       </div>
       </div>
 
 
+      <div>
+        <label className={labelCls}>{t('settings.oidc.form.defaultGroup')}</label>
+        <select
+          className={inputCls}
+          value={form.default_group_id ?? ''}
+          onChange={(e) => set('default_group_id', e.target.value ? Number(e.target.value) : null)}
+        >
+          <option value="">{t('settings.oidc.form.defaultGroupViewersFallback')}</option>
+          {groups.map((g) => (
+            <option key={g.id} value={g.id}>{g.name}</option>
+          ))}
+        </select>
+        <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.defaultGroupDesc')}</p>
+      </div>
+
       <div className="flex gap-3 pt-2">
       <div className="flex gap-3 pt-2">
         <Button variant="secondary" onClick={onCancel} className="flex-1">
         <Button variant="secondary" onClick={onCancel} className="flex-1">
           {t('common.cancel')}
           {t('common.cancel')}
@@ -189,6 +207,11 @@ export function OIDCProviderSettings() {
     queryFn: () => api.getOIDCProvidersAll(),
     queryFn: () => api.getOIDCProvidersAll(),
   });
   });
 
 
+  const { data: groups = [] } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+  });
+
   const createMutation = useMutation({
   const createMutation = useMutation({
     mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
     mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
     onSuccess: () => {
     onSuccess: () => {
@@ -256,6 +279,7 @@ export function OIDCProviderSettings() {
               <h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
               <h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
               <ProviderForm
               <ProviderForm
                 initial={EMPTY_FORM}
                 initial={EMPTY_FORM}
+                groups={groups}
                 onSave={(data) => createMutation.mutate(data)}
                 onSave={(data) => createMutation.mutate(data)}
                 onCancel={() => setShowCreate(false)}
                 onCancel={() => setShowCreate(false)}
                 isPending={createMutation.isPending}
                 isPending={createMutation.isPending}
@@ -335,6 +359,7 @@ export function OIDCProviderSettings() {
               <div className="border-t border-bambu-dark-tertiary pt-4">
               <div className="border-t border-bambu-dark-tertiary pt-4">
                 <ProviderForm
                 <ProviderForm
                   isEdit={true}
                   isEdit={true}
+                  groups={groups}
                   initial={{
                   initial={{
                     name: provider.name,
                     name: provider.name,
                     issuer_url: provider.issuer_url,
                     issuer_url: provider.issuer_url,
@@ -347,6 +372,7 @@ export function OIDCProviderSettings() {
                     email_claim: provider.email_claim,
                     email_claim: provider.email_claim,
                     require_email_verified: provider.require_email_verified,
                     require_email_verified: provider.require_email_verified,
                     icon_url: provider.icon_url ?? undefined,
                     icon_url: provider.icon_url ?? undefined,
+                    default_group_id: provider.default_group_id ?? null,
                   }}
                   }}
                   onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
                   onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
                   onCancel={() => setEditingId(null)}
                   onCancel={() => setEditingId(null)}
@@ -389,6 +415,14 @@ export function OIDCProviderSettings() {
                     {provider.require_email_verified ? t('common.yes') : t('common.no')}
                     {provider.require_email_verified ? t('common.yes') : t('common.no')}
                   </dd>
                   </dd>
                 </div>
                 </div>
+                <div>
+                  <dt className="text-bambu-gray">{t('settings.oidc.form.defaultGroup')}</dt>
+                  <dd className="text-white">
+                    {provider.default_group_id
+                      ? (groups.find((g) => g.id === provider.default_group_id)?.name ?? t('settings.oidc.form.defaultGroupViewersFallback'))
+                      : t('settings.oidc.form.defaultGroupViewersFallback')}
+                  </dd>
+                </div>
               </dl>
               </dl>
             </CardContent>
             </CardContent>
           )}
           )}

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -2234,6 +2234,9 @@ export default {
         requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
         requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
         requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.',
         requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.',
         requireEmailVerifiedAutoLink: 'Auto-Verknüpfung zuerst deaktivieren, um diese Einstellung zu ändern.',
         requireEmailVerifiedAutoLink: 'Auto-Verknüpfung zuerst deaktivieren, um diese Einstellung zu ändern.',
+        defaultGroup: 'Standardgruppe',
+        defaultGroupDesc: 'Gruppe, der automatisch erstellte Benutzer zugewiesen werden. Fallback auf Viewers, wenn nicht gesetzt.',
+        defaultGroupViewersFallback: 'Viewers (Standard)',
       },
       },
     },
     },
 
 

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -2237,6 +2237,9 @@ export default {
         requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.',
         requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.',
         requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.',
         requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.',
         requireEmailVerifiedAutoLink: 'Disable auto-link first to change this setting.',
         requireEmailVerifiedAutoLink: 'Disable auto-link first to change this setting.',
+        defaultGroup: 'Default Group',
+        defaultGroupDesc: 'Group assigned to auto-created users. Falls back to Viewers if not set.',
+        defaultGroupViewersFallback: 'Viewers (default)',
       },
       },
     },
     },
 
 

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -2178,6 +2178,9 @@ export default {
         requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.",
         requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.",
         requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.",
         requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.",
         requireEmailVerifiedAutoLink: 'Désactiver le lien automatique d\'abord pour modifier ce paramètre.',
         requireEmailVerifiedAutoLink: 'Désactiver le lien automatique d\'abord pour modifier ce paramètre.',
+        defaultGroup: 'Groupe par défaut',
+        defaultGroupDesc: 'Groupe attribué aux utilisateurs créés automatiquement. Repli sur Viewers si non défini.',
+        defaultGroupViewersFallback: 'Viewers (par défaut)',
       },
       },
     },
     },
 
 

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -2177,6 +2177,9 @@ export default {
         requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
         requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
         requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',
         requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',
         requireEmailVerifiedAutoLink: 'Disabilitare prima il collegamento automatico per modificare questa impostazione.',
         requireEmailVerifiedAutoLink: 'Disabilitare prima il collegamento automatico per modificare questa impostazione.',
+        defaultGroup: 'Gruppo predefinito',
+        defaultGroupDesc: 'Gruppo assegnato agli utenti creati automaticamente. Ritorno a Viewers se non impostato.',
+        defaultGroupViewersFallback: 'Viewers (predefinito)',
       },
       },
     },
     },
 
 

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -2233,6 +2233,9 @@ export default {
         requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
         requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
         requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
         requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
         requireEmailVerifiedAutoLink: 'この設定を変更するには、まず自動リンクを無効にしてください。',
         requireEmailVerifiedAutoLink: 'この設定を変更するには、まず自動リンクを無効にしてください。',
+        defaultGroup: 'デフォルトグループ',
+        defaultGroupDesc: '自動作成ユーザーに割り当てられるグループ。未設定の場合はViewersにフォールバックします。',
+        defaultGroupViewersFallback: 'Viewers(デフォルト)',
       },
       },
     },
     },
 
 

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2177,6 +2177,9 @@ export default {
         requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.',
         requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.',
         requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.',
         requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.',
         requireEmailVerifiedAutoLink: 'Desabilite o vínculo automático primeiro para alterar esta configuração.',
         requireEmailVerifiedAutoLink: 'Desabilite o vínculo automático primeiro para alterar esta configuração.',
+        defaultGroup: 'Grupo padrão',
+        defaultGroupDesc: 'Grupo atribuído aos usuários criados automaticamente. Retorna a Viewers se não definido.',
+        defaultGroupViewersFallback: 'Viewers (padrão)',
       },
       },
     },
     },
 
 

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2221,6 +2221,9 @@ export default {
         requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
         requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
         requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
         requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
         requireEmailVerifiedAutoLink: '请先禁用自动关联以更改此设置。',
         requireEmailVerifiedAutoLink: '请先禁用自动关联以更改此设置。',
+        defaultGroup: '默认组',
+        defaultGroupDesc: '自动创建用户时分配的组。未设置时回退到 Viewers。',
+        defaultGroupViewersFallback: 'Viewers(默认)',
       },
       },
     },
     },
 
 

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2221,6 +2221,9 @@ export default {
         requireEmailVerifiedAutoLink: '請先停用自動連結以變更此設定。',
         requireEmailVerifiedAutoLink: '請先停用自動連結以變更此設定。',
         secretHint: '留空以保留目前',
         secretHint: '留空以保留目前',
         secretPlaceholder: '新金鑰',
         secretPlaceholder: '新金鑰',
+        defaultGroup: '預設群組',
+        defaultGroupDesc: '自動建立使用者時分配的群組。未設定時回退到 Viewers。',
+        defaultGroupViewersFallback: 'Viewers(預設)',
       },
       },
     },
     },
 
 

Some files were not shown because too many files changed in this diff