This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 0164c9931e3 Show the teams of the signed-in user in multi-team mode 
(#71503)
0164c9931e3 is described below

commit 0164c9931e3ef0749600875a7cf2d7eef9f5e9aa
Author: Vincent <[email protected]>
AuthorDate: Fri Aug 14 10:43:30 2026 -0400

    Show the teams of the signed-in user in multi-team mode (#71503)
    
    When an Airflow environment runs in multi-team mode, users had no way to 
see which
    teams they belong to.
    
    `GET /ui/auth/me` now returns a `teams` field, and the UI lists those teams 
in the
    user menu below the user name. The field is `null`, and the UI section 
hidden,
    when the environment is not in multi-team mode, so nothing changes for
    single-team deployments and auth managers that don't support teams are never
    queried.
---
 .../api_fastapi/core_api/datamodels/ui/auth.py     |  6 ++
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  9 +++
 .../airflow/api_fastapi/core_api/routes/ui/auth.py |  7 ++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 15 ++++
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  4 ++
 .../airflow/ui/public/i18n/locales/en/common.json  |  6 ++
 .../ui/src/layouts/Nav/UserSettingsButton.test.tsx | 81 +++++++++++++++++++++-
 .../ui/src/layouts/Nav/UserSettingsButton.tsx      | 45 +++++++++++-
 .../api_fastapi/core_api/routes/ui/test_auth.py    | 44 ++++++++++++
 9 files changed, 213 insertions(+), 4 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
index f5563fbd6c7..3d5d2431400 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
@@ -19,6 +19,8 @@ from __future__ import annotations
 
 from enum import Enum
 
+from pydantic import Field
+
 from airflow.api_fastapi.common.types import ExtraMenuItem, MenuItem
 from airflow.api_fastapi.core_api.base import BaseModel
 
@@ -35,6 +37,10 @@ class AuthenticatedMeResponse(BaseModel):
 
     id: str
     username: str
+    teams: list[str] | None = Field(
+        default=None,
+        description="Teams the user has access to. Null when the environment 
does not run in multi-team mode.",
+    )
 
 
 class TokenType(str, Enum):
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 32f0f6f1735..57ff25b664a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -2377,6 +2377,15 @@ components:
         username:
           type: string
           title: Username
+        teams:
+          anyOf:
+          - items:
+              type: string
+            type: array
+          - type: 'null'
+          title: Teams
+          description: Teams the user has access to. Null when the environment 
does
+            not run in multi-team mode.
       type: object
       required:
       - id
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
index c7d889fca52..10f88a27728 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
@@ -22,6 +22,7 @@ import logging
 from fastapi import Depends
 
 from airflow.api_fastapi.app import get_auth_manager
+from airflow.api_fastapi.common.db.common import SessionDep
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.datamodels.ui.auth import (
     AuthenticatedMeResponse,
@@ -55,11 +56,17 @@ def get_auth_menus(
 @auth_router.get("/auth/me")
 def get_current_user_info(
     user: GetUserDep,
+    session: SessionDep,
 ) -> AuthenticatedMeResponse:
     """Convienently get the current authenticated user information."""
+    teams = None
+    if conf.getboolean("core", "multi_team"):
+        teams = sorted(get_auth_manager().get_authorized_teams(user=user, 
session=session))
+
     return AuthenticatedMeResponse(
         id=user.get_id(),
         username=user.get_name(),
+        teams=teams,
     )
 
 
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 1388b6f37f4..624c749fc6c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -8710,6 +8710,21 @@ export const $AuthenticatedMeResponse = {
         username: {
             type: 'string',
             title: 'Username'
+        },
+        teams: {
+            anyOf: [
+                {
+                    items: {
+                        type: 'string'
+                    },
+                    type: 'array'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Teams',
+            description: 'Teams the user has access to. Null when the 
environment does not run in multi-team mode.'
         }
     },
     type: 'object',
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 8136b7c0d56..8f9c0dd3845 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2194,6 +2194,10 @@ export type XComUpdateBody = {
 export type AuthenticatedMeResponse = {
     id: string;
     username: string;
+    /**
+     * Teams the user has access to. Null when the environment does not run in 
multi-team mode.
+     */
+    teams?: Array<(string)> | null;
 };
 
 /**
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 741495ba110..4648ecd4eac 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -420,6 +420,12 @@
   },
   "taskInstance_one": "Task Instance",
   "taskInstance_other": "Task Instances",
+  "teams": {
+    "more_one": "and {{count}} more",
+    "more_other": "and {{count}} more",
+    "none": "No team",
+    "title": "Teams"
+  },
   "timeRange": {
     "last12Hours": "Last 12 Hours",
     "last24Hours": "Last 24 Hours",
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
index c6688151b16..6d6b79c9c68 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
@@ -18,20 +18,97 @@
  */
 import "@testing-library/jest-dom";
 import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 
+import type * as OpenapiQueries from "openapi/queries";
 import { Wrapper } from "src/utils/Wrapper";
 
 import { UserSettingsButton } from "./UserSettingsButton";
 
+const mockCurrentUser: { data: { id: string; teams?: Array<string> | null; 
username: string } } = {
+  data: { id: "test", teams: null, username: "test" },
+};
+
+vi.mock("openapi/queries", async (importOriginal) => ({
+  ...(await importOriginal<typeof OpenapiQueries>()),
+  useAuthLinksServiceGetCurrentUserInfo: () => mockCurrentUser,
+}));
+
+const openUserMenu = () => fireEvent.click(screen.getByRole("button", { name: 
/user/iu }));
+
 describe("UserSettingsButton", () => {
+  afterEach(() => {
+    mockCurrentUser.data = { id: "test", teams: null, username: "test" };
+  });
+
   it("links to the settings page from the user menu", async () => {
     render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
 
-    fireEvent.click(screen.getByRole("button", { name: /user/iu }));
+    openUserMenu();
 
     const settingsLink = await screen.findByRole("menuitem", { name: 
/settings.title/iu });
 
     expect(settingsLink).toHaveAttribute("href", "/settings");
   });
+
+  it("lists each team the user belongs to when multi-team is enabled", async 
() => {
+    mockCurrentUser.data = { id: "test", teams: ["team-a", "team-b"], 
username: "test" };
+    render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+    openUserMenu();
+
+    expect(await screen.findByText("team-a")).toBeInTheDocument();
+    expect(screen.getByText("team-b")).toBeInTheDocument();
+  });
+
+  it("counts the teams it does not list when the user belongs to many", async 
() => {
+    mockCurrentUser.data = {
+      id: "test",
+      teams: Array.from({ length: 8 }, (_, index) => `team-${index}`),
+      username: "test",
+    };
+    render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+    openUserMenu();
+
+    expect(await screen.findByText("team-4")).toBeInTheDocument();
+    expect(screen.queryByText("team-5")).not.toBeInTheDocument();
+    expect(screen.getByText("teams.more")).toBeInTheDocument();
+  });
+
+  it("reveals the teams it does not list when hovering the overflow count", 
async () => {
+    mockCurrentUser.data = {
+      id: "test",
+      teams: Array.from({ length: 8 }, (_, index) => `team-${index}`),
+      username: "test",
+    };
+    render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+    openUserMenu();
+
+    fireEvent.pointerMove(await screen.findByText("teams.more"), { 
pointerType: "mouse" });
+
+    expect(await screen.findByText("team-5")).toBeInTheDocument();
+    expect(screen.getByText("team-6")).toBeInTheDocument();
+    expect(screen.getByText("team-7")).toBeInTheDocument();
+  });
+
+  it("tells the user they belong to no team when multi-team is enabled", async 
() => {
+    mockCurrentUser.data = { id: "test", teams: [], username: "test" };
+    render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+    openUserMenu();
+
+    expect(await screen.findByText("teams.none")).toBeInTheDocument();
+  });
+
+  it("hides teams when the deployment does not run in multi-team mode", async 
() => {
+    render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+    openUserMenu();
+
+    await screen.findByRole("menuitem", { name: /settings.title/iu });
+
+    expect(screen.queryByText("teams.title")).not.toBeInTheDocument();
+  });
 });
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
index df41e3912fd..54439c21c0a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Icon, useDisclosure } from "@chakra-ui/react";
+import { Box, HStack, Icon, useDisclosure, VStack } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import {
   FiKey,
@@ -33,7 +33,7 @@ import {
 } from "react-icons/fi";
 
 import { useAuthLinksServiceGetCurrentUserInfo } from "openapi/queries";
-import { Menu } from "src/components/ui";
+import { Menu, Tooltip } from "src/components/ui";
 import { RouterLink } from "src/components/ui/RouterLink";
 import { useColorMode } from "src/context/colorMode/useColorMode";
 import type { NavItemResponse } from "src/utils/types";
@@ -44,6 +44,9 @@ import { NavButton } from "./NavButton";
 import { PluginMenuItem } from "./PluginMenuItem";
 import TokenGenerationModal from "./TokenGenerationModal";
 
+// Beyond a handful of teams the list would push the menu items below the fold.
+const MAX_VISIBLE_TEAMS = 5;
+
 const COLOR_MODES = {
   DARK: "dark",
   LIGHT: "light",
@@ -97,6 +100,44 @@ export const UserSettingsButton = ({ externalViews }: { 
readonly externalViews:
                 <Box fontSize="md" fontWeight="semibold">
                   {`${currentUser.username} (id: ${currentUser.id})`}
                 </Box>
+                {Array.isArray(currentUser.teams) ? (
+                  <>
+                    <Box color="fg.muted" fontSize="sm" mt={2}>
+                      {translate("teams.title")}
+                    </Box>
+                    {currentUser.teams.length ? (
+                      <HStack fontSize="sm" gap={2} wrap="wrap">
+                        {currentUser.teams.slice(0, 
MAX_VISIBLE_TEAMS).map((team) => (
+                          <Box key={team}>{team}</Box>
+                        ))}
+                        {currentUser.teams.length > MAX_VISIBLE_TEAMS ? (
+                          <Tooltip
+                            closeDelay={0}
+                            content={
+                              <VStack align="start" gap={0}>
+                                
{currentUser.teams.slice(MAX_VISIBLE_TEAMS).map((team) => (
+                                  <Box key={team}>{team}</Box>
+                                ))}
+                              </VStack>
+                            }
+                            openDelay={0}
+                            portalled
+                          >
+                            <Box color="fg.muted" textDecoration="underline 
dotted">
+                              {translate("teams.more", {
+                                count: currentUser.teams.length - 
MAX_VISIBLE_TEAMS,
+                              })}
+                            </Box>
+                          </Tooltip>
+                        ) : undefined}
+                      </HStack>
+                    ) : (
+                      <Box color="fg.muted" fontSize="sm">
+                        {translate("teams.none")}
+                      </Box>
+                    )}
+                  </>
+                ) : undefined}
               </Box>
               <Menu.Separator />
             </>
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
index 287f2f9318b..1601169f81c 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
@@ -22,6 +22,10 @@ import pytest
 
 from airflow.api_fastapi.auth.managers.simple.simple_auth_manager import 
SimpleAuthManager
 from airflow.api_fastapi.common.types import ExtraMenuItem, MenuItem
+from airflow.models.team import Team
+
+from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_teams
 
 pytestmark = pytest.mark.db_test
 
@@ -61,6 +65,12 @@ class TestGetAuthLinks:
 
 
 class TestGetMeResponse:
+    @pytest.fixture(autouse=True)
+    def clean_teams(self):
+        clear_db_teams()
+        yield
+        clear_db_teams()
+
     def test_should_response_200_with_authenticated_user(self, test_client):
         """Test /auth/me endpoint with SimpleAuthManager authenticated user."""
         response = test_client.get("/auth/me")
@@ -69,8 +79,42 @@ class TestGetMeResponse:
         assert response.json() == {
             "username": "test",
             "id": "test",
+            "teams": None,
         }
 
+    @conf_vars({("core", "multi_team"): "true"})
+    def test_teams_of_user_authorized_on_all_teams(self, test_client, session):
+        session.add_all([Team(name="team2"), Team(name="team1")])
+        session.commit()
+
+        response = test_client.get("/auth/me")
+
+        assert response.status_code == 200
+        assert response.json()["teams"] == ["team1", "team2"]
+
+    @conf_vars({("core", "multi_team"): "true"})
+    @mock.patch.object(SimpleAuthManager, "_is_admin", return_value=False)
+    def test_teams_limited_to_the_teams_the_user_belongs_to(self, _, 
test_client, session):
+        session.add_all([Team(name="team1"), Team(name="team2")])
+        session.commit()
+
+        response = test_client.get("/auth/me")
+
+        assert response.status_code == 200
+        # The authenticated test user belongs to ``team1`` only.
+        assert response.json()["teams"] == ["team1"]
+
+    @conf_vars({("core", "multi_team"): "true"})
+    @mock.patch.object(SimpleAuthManager, "_is_admin", return_value=False)
+    def test_teams_empty_when_user_belongs_to_no_team(self, _, test_client, 
session):
+        session.add(Team(name="team2"))
+        session.commit()
+
+        response = test_client.get("/auth/me")
+
+        assert response.status_code == 200
+        assert response.json()["teams"] == []
+
     def test_with_unauthenticated_user(self, unauthenticated_test_client):
         """Test /auth/me endpoint with no authentication."""
         response = unauthenticated_test_client.get("/auth/me")

Reply via email to