diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index bf2c45461e7..96fb267f5e4 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1583,6 +1583,16 @@ def check_user_info(cls, values): return values +class ChangePasswordRequest(LiteLLMPydanticObjectBase): + current_password: str + new_password: str + + +class ChangePasswordResponse(LiteLLMPydanticObjectBase): + message: str + password_expiry: Optional[datetime] = None + + class DeleteUserRequest(LiteLLMPydanticObjectBase): user_ids: List[str] # required @@ -2591,6 +2601,7 @@ class UserInfoV2Response(LiteLLMPydanticObjectBase): metadata: Optional[dict] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None + password_expiry: Optional[datetime] = None sso_user_id: Optional[str] = None teams: List[str] = [] # Just team IDs, not full team objects diff --git a/litellm/proxy/common_utils/user_credentials_rotation_manager.py b/litellm/proxy/common_utils/user_credentials_rotation_manager.py index 6c35736aa8a..8802ed36fa3 100644 --- a/litellm/proxy/common_utils/user_credentials_rotation_manager.py +++ b/litellm/proxy/common_utils/user_credentials_rotation_manager.py @@ -4,7 +4,7 @@ from litellm._logging import verbose_proxy_logger from litellm.constants import LITELLM_USER_CREDENTIALS_ROTATION_LOCK_TTL_SECONDS from litellm.models.user import LiteLLM_UserTable -from litellm.proxy.utils import PrismaClient, send_email +from litellm.proxy.utils import PrismaClient, get_custom_url, send_email from litellm.repositories.user_repository import UserRepository @@ -98,11 +98,13 @@ async def _notify_user(self, user: UserCredentialsReminderRow) -> None: if not isinstance(user_email, str) or not user_email.strip() or password_expiry is None: return + change_password_url = get_custom_url("", "ui/change-password") subject = "LiteLLM password rotation reminder" html = ( "

Your LiteLLM password will expire soon.

" f"

Password expiry: {password_expiry}

" - "

Please rotate your password before the expiry date to keep access.

" + "

Please change your password before the expiry date to keep access.

" + f'

Change password in the Console

' ) await send_email( receiver_email=user_email, diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index a10d3a01c66..7a07df461d1 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -47,7 +47,11 @@ prepare_metadata_fields, ) from litellm.proxy.management_helpers.utils import management_endpoint_wrapper -from litellm.proxy.utils import handle_exception_on_proxy, hash_password +from litellm.proxy.utils import ( + handle_exception_on_proxy, + hash_password, + verify_password, +) from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.table_repositories import ( InvitationLinkRepository, @@ -1044,6 +1048,7 @@ async def user_info_v2( metadata=_redact_scim_enterprise_metadata(user_data.get("metadata")), created_at=user_data.get("created_at"), updated_at=user_data.get("updated_at"), + password_expiry=user_data.get("password_expiry"), sso_user_id=user_data.get("sso_user_id"), teams=user_data.get("teams") or [], ) @@ -1288,7 +1293,6 @@ def _check_user_update_authz( }, ) elif user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: - # Silent-create guard: only PROXY_ADMIN may create via /user/update. raise HTTPException( status_code=404, detail={ @@ -1297,6 +1301,49 @@ def _check_user_update_authz( ) +def _resolve_password_expiry_for_new_password() -> Optional[datetime]: + rotation_interval = _get_user_credentials_rotation_interval() + if rotation_interval is None: + return None + return _calculate_password_expiry(rotation_interval) + + +async def _get_user_row_by_id_or_throw( + prisma_client: "PrismaClient", + user_id: str, +) -> BaseModel: + user_row = await UserRepository(prisma_client).table.find_first( + where={"user_id": user_id} + ) + if user_row is None: + raise HTTPException( + status_code=404, + detail={"error": f"User not found: {user_id}"}, + ) + return user_row + + +async def _change_password_for_user( + prisma_client: "PrismaClient", + user_id: str, + new_password: str, +) -> ChangePasswordResponse: + password_expiry = _resolve_password_expiry_for_new_password() + response = await prisma_client.update_data( + user_id=user_id, + data={ + "password": hash_password(new_password), + "password_expiry": password_expiry, + }, + table_name="user", + ) + _strip_password_from_response(response) + return ChangePasswordResponse( + message="Password updated successfully", + password_expiry=password_expiry, + ) + + async def _invalidate_user_spend_counter_if_changed( non_default_values: dict[str, Any], ) -> None: @@ -1477,6 +1524,70 @@ def can_user_call_user_update( return False +@router.post( + "/user/change_password", + tags=["Internal User management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ChangePasswordResponse, +) +@management_endpoint_wrapper +async def user_change_password( + data: ChangePasswordRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + try: + from litellm.proxy.proxy_server import prisma_client + + caller_user_id = require_caller_user_id_for_non_admin(user_api_key_dict) + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + user_row = await _get_user_row_by_id_or_throw(prisma_client, caller_user_id) + typed_user = LiteLLM_UserTable(**user_row.model_dump(exclude_none=True)) + stored_password = typed_user.password + if stored_password is None: + raise HTTPException( + status_code=400, + detail={"error": "User does not have a password set."}, + ) + if not verify_password(data.current_password, stored_password): + raise HTTPException( + status_code=401, + detail={"error": "Current password is incorrect."}, + ) + + return await _change_password_for_user( + prisma_client=prisma_client, + user_id=caller_user_id, + new_password=data.new_password, + ) + except Exception as e: + verbose_proxy_logger.exception( + "litellm.proxy.proxy_server.user_change_password(): Exception occured - {}".format( + str(e) + ) + ) + verbose_proxy_logger.debug(traceback.format_exc()) + if isinstance(e, HTTPException): + raise ProxyException( + message=getattr(e, "detail", f"Authentication Error({str(e)})"), + type=ProxyErrorTypes.auth_error, + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), + ) + elif isinstance(e, ProxyException): + raise e + raise ProxyException( + message="Authentication Error, " + str(e), + type=ProxyErrorTypes.auth_error, + param=getattr(e, "param", "None"), + code=status.HTTP_400_BAD_REQUEST, + ) + + @router.post( "/user/update", tags=["Internal User management"], diff --git a/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py b/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py index 02cb4d2eabc..2840644df2a 100644 --- a/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py +++ b/tests/test_litellm/proxy/common_utils/test_user_credentials_rotation_manager.py @@ -47,6 +47,7 @@ async def test_notify_user_sends_email_for_expiring_password(): assert call["receiver_email"] == "user@example.com" assert call["subject"] == "LiteLLM password rotation reminder" assert "2030-01-01" in call["html"] + assert "/ui/change-password" in call["html"] @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index dfca62d5dd1..b938891e0d3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -13,6 +13,7 @@ ) # Adds the parent directory to the system path from litellm.proxy._types import ( + ChangePasswordRequest, LiteLLM_UserTableFiltered, LitellmUserRoles, NewUserRequest, @@ -28,6 +29,8 @@ get_users, new_user, ui_view_users, + user_change_password, + user_info_v2, ) from litellm.proxy.proxy_server import app @@ -1036,9 +1039,7 @@ async def mock_check(*_args, **_kwargs): user_role=LitellmUserRoles.INTERNAL_USER, permissions={"get_spend_routes": True}, ) - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(ProxyException) as exc_info: await new_user(data=data, user_api_key_dict=caller) @@ -1082,9 +1083,7 @@ async def mock_check(*_args, **_kwargs): permissions={}, ) assert "permissions" in data.model_fields_set - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(ProxyException) as exc_info: await new_user(data=data, user_api_key_dict=caller) @@ -1137,9 +1136,7 @@ async def stub_helper(**_kwargs): user_role=LitellmUserRoles.INTERNAL_USER, ) assert "permissions" not in data.model_fields_set - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) result = await new_user(data=data, user_api_key_dict=caller) assert result is not None @@ -1213,14 +1210,10 @@ async def test_update_single_user_non_admin_permissions_rejected(mocker): user_id="alice", permissions={"get_spend_routes": True}, ) - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(HTTPException) as exc_info: - await _update_single_user_helper( - user_request=data, user_api_key_dict=caller - ) + await _update_single_user_helper(user_request=data, user_api_key_dict=caller) assert exc_info.value.status_code == 403 assert "permissions" in str(exc_info.value.detail) @@ -1242,14 +1235,10 @@ async def test_update_single_user_non_admin_permissions_explicit_empty_rejected( data = UpdateUserRequest(user_id="alice", permissions={}) assert "permissions" in data.model_fields_set - caller = UserAPIKeyAuth( - user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN - ) + caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN) with pytest.raises(HTTPException) as exc_info: - await _update_single_user_helper( - user_request=data, user_api_key_dict=caller - ) + await _update_single_user_helper(user_request=data, user_api_key_dict=caller) assert exc_info.value.status_code == 403 assert "permissions" in str(exc_info.value.detail) @@ -1563,7 +1552,9 @@ async def mock_count(*args, **kwargs): user_role="internal_user", key_alias="TestKey Name", ), - user_api_key_dict=UserAPIKeyAuth(user_id="test_admin", user_email="caller@example.com"), + user_api_key_dict=UserAPIKeyAuth( + user_id="test_admin", user_email="caller@example.com" + ), ) call_kwargs = mock_generate_key_helper_fn.call_args.kwargs @@ -1595,12 +1586,18 @@ async def mock_count(*args, **kwargs): mocker.patch("litellm.proxy.proxy_server._license_check", mock_license_check) mocker.patch( "litellm.proxy.management_endpoints.internal_user_endpoints._enforce_email_prefix_on_key_alias", - mocker.AsyncMock(side_effect=HTTPException(status_code=400, detail={"error": "missing email"})), + mocker.AsyncMock( + side_effect=HTTPException( + status_code=400, detail={"error": "missing email"} + ) + ), ) with pytest.raises(ProxyException) as exc_info: await new_user( - data=NewUserRequest(user_email=None, user_role="internal_user", key_alias="Needs Alias"), + data=NewUserRequest( + user_email=None, user_role="internal_user", key_alias="Needs Alias" + ), user_api_key_dict=UserAPIKeyAuth(user_id="test_admin", user_email=None), ) @@ -1654,8 +1651,12 @@ async def mock_count(*args, **kwargs): ) await new_user( - data=NewUserRequest(user_email="owner.user@example.com", user_role="internal_user"), - user_api_key_dict=UserAPIKeyAuth(user_id="test_admin", user_email="caller@example.com"), + data=NewUserRequest( + user_email="owner.user@example.com", user_role="internal_user" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="test_admin", user_email="caller@example.com" + ), ) call_kwargs = mock_generate_key_helper_fn.call_args.kwargs @@ -2994,6 +2995,7 @@ async def test_user_info_v2_response_shape(mocker): "user_email": "shape@example.com", "user_alias": "Shape Test", "user_role": "internal_user", + "password_expiry": datetime(2024, 4, 1, tzinfo=timezone.utc), "spend": 5.0, "max_budget": 50.0, "models": ["gpt-3.5-turbo"], @@ -3036,6 +3038,7 @@ async def mock_find_unique(*args, **kwargs): "user_email", "user_alias", "user_role", + "password_expiry", "spend", "max_budget", "models", @@ -3756,3 +3759,177 @@ async def test_resolve_user_email_metadata_skips_db_when_no_user_ids(mocker): assert result == {} find_many.assert_not_called() + + +@pytest.mark.asyncio +async def test_user_change_password_updates_password_and_expiry(mocker): + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.update_data = mocker.AsyncMock( + return_value={"user_id": "user-1"} + ) + mock_user = mocker.MagicMock() + mock_user.model_dump.return_value = { + "user_id": "user-1", + "password": "scrypt:stored", + "user_email": "test@example.com", + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_row_by_id_or_throw", + new=mocker.AsyncMock(return_value=mock_user), + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.verify_password", + return_value=True, + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.hash_password", + return_value="hashed-new-pass", + ) + mocker.patch( + "litellm.proxy.proxy_server.general_settings", + {"user_credentials_rotation_interval": "30d"}, + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.duration_in_seconds", + return_value=30 * 24 * 60 * 60, + ) + + response = await user_change_password( + data=ChangePasswordRequest( + current_password="old-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + update_call = mock_prisma_client.update_data.await_args + assert update_call.kwargs["user_id"] == "user-1" + assert update_call.kwargs["data"]["password"] == "hashed-new-pass" + assert update_call.kwargs["data"]["password_expiry"] is not None + assert response.message == "Password updated successfully" + assert response.password_expiry is not None + + +@pytest.mark.asyncio +async def test_user_change_password_rejects_wrong_current_password(mocker): + mock_prisma_client = mocker.MagicMock() + mock_user = mocker.MagicMock() + mock_user.model_dump.return_value = { + "user_id": "user-1", + "password": "scrypt:stored", + "user_email": "test@example.com", + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_row_by_id_or_throw", + new=mocker.AsyncMock(return_value=mock_user), + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.verify_password", + return_value=False, + ) + + with pytest.raises(ProxyException) as exc_info: + await user_change_password( + data=ChangePasswordRequest( + current_password="wrong-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert exc_info.value.code == "401" + mock_prisma_client.update_data.assert_not_called() + + +@pytest.mark.asyncio +async def test_user_change_password_requires_user_bound_key(): + with pytest.raises(ProxyException) as exc_info: + await user_change_password( + data=ChangePasswordRequest( + current_password="old-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id=None, + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert exc_info.value.code == "403" + assert "Service-account keys" in str(exc_info.value.message) + + +@pytest.mark.asyncio +async def test_user_change_password_rejects_users_without_stored_password(mocker): + mock_prisma_client = mocker.MagicMock() + mock_user = mocker.MagicMock() + mock_user.model_dump.return_value = { + "user_id": "user-1", + "password": None, + "user_email": "test@example.com", + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_row_by_id_or_throw", + new=mocker.AsyncMock(return_value=mock_user), + ) + + with pytest.raises(ProxyException) as exc_info: + await user_change_password( + data=ChangePasswordRequest( + current_password="old-pass", new_password="new-pass" + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert exc_info.value.code == "400" + mock_prisma_client.update_data.assert_not_called() + + +@pytest.mark.asyncio +async def test_user_info_v2_returns_password_expiry(mocker): + password_expiry = datetime.now(timezone.utc) + user_row = mocker.MagicMock() + user_row.model_dump.return_value = { + "user_id": "user-1", + "user_email": "test@example.com", + "user_alias": "Test User", + "user_role": LitellmUserRoles.INTERNAL_USER, + "spend": 0.0, + "max_budget": None, + "models": [], + "budget_duration": None, + "budget_reset_at": None, + "metadata": None, + "created_at": password_expiry, + "updated_at": password_expiry, + "password_expiry": password_expiry, + "sso_user_id": None, + "teams": [], + } + mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.MagicMock()) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._check_user_info_v2_access", + new=mocker.AsyncMock(return_value=user_row), + ) + + response = await user_info_v2( + request=mocker.MagicMock(), + user_id=None, + user_api_key_dict=UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ), + ) + + assert response.password_expiry == password_expiry + assert response.user_id == "user-1" + assert response.user_email == "test@example.com" diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 8b92683db67..2c985d58c5d 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -8593,7 +8593,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.test.tsx new file mode 100644 index 00000000000..987a8e79710 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.test.tsx @@ -0,0 +1,99 @@ +import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ChangePasswordPage from "./ChangePasswordPage"; + +const mockUseAuthorized = vi.fn(); +const mockUseCurrentUser = vi.fn(); +const mockInvalidateQueries = vi.fn(); +const mockSuccess = vi.fn(); +const mockError = vi.fn(); +const mockChangePasswordCall = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ + useCurrentUser: () => mockUseCurrentUser(), +})); + +vi.mock("@/components/molecules/message_manager", () => ({ + default: { + success: (...args: unknown[]) => mockSuccess(...args), + error: (...args: unknown[]) => mockError(...args), + }, +})); + +vi.mock("@/components/networking", () => ({ + changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => mockInvalidateQueries(...args), + }), + }; +}); + +describe("ChangePasswordPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "access-token" }); + mockUseCurrentUser.mockReturnValue({ + data: { + user_id: "user-1", + password_expiry: "2030-01-01T00:00:00Z", + }, + }); + mockInvalidateQueries.mockResolvedValue(undefined); + mockChangePasswordCall.mockResolvedValue({ + message: "Password updated successfully", + password_expiry: "2030-02-01T00:00:00Z", + }); + }); + + it("renders the page heading and current expiry", () => { + renderWithProviders(); + + expect(screen.getByRole("heading", { name: "Change password" })).toBeInTheDocument(); + expect(screen.getByText(/Current password expires on/i)).toBeInTheDocument(); + }); + + it("validates that the new passwords match", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText("Current password"), "old-pass"); + await user.type(screen.getByLabelText("New password"), "new-pass-1"); + await user.type(screen.getByLabelText("Confirm new password"), "new-pass-2"); + await user.click(screen.getByRole("button", { name: "Change password" })); + + await waitFor(() => { + expect(screen.getByText("New passwords do not match")).toBeInTheDocument(); + }); + + expect(mockChangePasswordCall).not.toHaveBeenCalled(); + }); + + it("submits the change password request and shows success", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText("Current password"), "old-pass"); + await user.type(screen.getByLabelText("New password"), "new-pass"); + await user.type(screen.getByLabelText("Confirm new password"), "new-pass"); + await user.click(screen.getByRole("button", { name: "Change password" })); + + await waitFor(() => { + expect(mockChangePasswordCall).toHaveBeenCalledWith("access-token", "old-pass", "new-pass"); + }); + await waitFor(() => { + expect(mockSuccess).toHaveBeenCalledWith("Password updated successfully"); + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ["users"] }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.tsx new file mode 100644 index 00000000000..3a5029641bb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordPage.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import MessageManager from "@/components/molecules/message_manager"; +import { changePasswordCall } from "@/components/networking"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Alert, Button, Card, Form, Input, Typography } from "antd"; +import { useMemo } from "react"; + +function formatPasswordExpiry(passwordExpiry: string | null | undefined): string | null { + if (!passwordExpiry) { + return null; + } + + const parsed = new Date(passwordExpiry); + if (Number.isNaN(parsed.getTime())) { + return passwordExpiry; + } + + return parsed.toLocaleString(); +} + +export default function ChangePasswordPage() { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + const { data: currentUser } = useCurrentUser(); + const [form] = Form.useForm(); + + const passwordExpiryLabel = useMemo( + () => formatPasswordExpiry(currentUser?.password_expiry), + [currentUser?.password_expiry], + ); + + const mutation = useMutation({ + mutationFn: async (values: { currentPassword: string; newPassword: string }) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + + return changePasswordCall(accessToken, values.currentPassword, values.newPassword); + }, + onSuccess: async (response) => { + MessageManager.success(response.message); + form.resetFields(); + await queryClient.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (error) => { + MessageManager.error(error instanceof Error ? error.message : "Failed to change password"); + }, + }); + + return ( +
+ + Change password + + Update your Console password. You must confirm your current password before setting a new one. + + + {passwordExpiryLabel ? ( + + ) : null} + +
{ + mutation.mutate({ + currentPassword: values.currentPassword, + newPassword: values.newPassword, + }); + }} + > + + + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error("New passwords do not match")); + }, + }), + ]} + > + + + + +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/page.tsx new file mode 100644 index 00000000000..d6950834d62 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ChangePasswordPage from "./ChangePasswordPage"; + +export default function Page() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useCurrentUser.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useCurrentUser.test.ts index 0b37b605460..00cc4a7f172 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useCurrentUser.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useCurrentUser.test.ts @@ -6,12 +6,10 @@ import { useCurrentUser } from "./useCurrentUser"; import { userGetInfoV2 } from "@/components/networking"; import type { UserInfoV2Response } from "@/components/networking"; -// Mock the networking function vi.mock("@/components/networking", () => ({ userGetInfoV2: vi.fn(), })); -// Mock the queryKeysFactory - we'll mock the specific return value vi.mock("../common/queryKeysFactory", () => ({ createQueryKeys: vi.fn((resource: string) => ({ all: [resource], @@ -22,13 +20,11 @@ vi.mock("../common/queryKeysFactory", () => ({ })), })); -// Mock useAuthorized hook - we can override this in individual tests const mockUseAuthorized = vi.fn(); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized(), })); -// Mock data - response from userGetInfoV2 is the user object directly const mockUserInfoV2Response: UserInfoV2Response = { user_id: "test-user-id", user_email: "test@example.com", @@ -42,6 +38,7 @@ const mockUserInfoV2Response: UserInfoV2Response = { metadata: null, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", + password_expiry: "2030-01-01T00:00:00Z", sso_user_id: null, teams: ["team-1"], }; @@ -58,10 +55,8 @@ describe("useCurrentUser", () => { }, }); - // Reset all mocks vi.clearAllMocks(); - // Set default mock for useAuthorized (enabled state) mockUseAuthorized.mockReturnValue({ accessToken: "test-access-token", userId: "test-user-id", @@ -78,16 +73,13 @@ describe("useCurrentUser", () => { React.createElement(QueryClientProvider, { client: queryClient }, children); it("should return user info data when query is successful", async () => { - // Mock successful API call - v2 returns user object directly (userGetInfoV2 as any).mockResolvedValue(mockUserInfoV2Response); const { result } = renderHook(() => useCurrentUser(), { wrapper }); - // Initially loading expect(result.current.isLoading).toBe(true); expect(result.current.data).toBeUndefined(); - // Wait for success await waitFor(() => { expect(result.current.isLoading).toBe(false); expect(result.current.isSuccess).toBe(true); @@ -95,24 +87,18 @@ describe("useCurrentUser", () => { expect(result.current.data).toEqual(mockUserInfoV2Response); expect(result.current.error).toBeNull(); - // v2 call only needs accessToken (no userId for self-lookup) expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token"); expect(userGetInfoV2).toHaveBeenCalledTimes(1); }); it("should handle error when userGetInfoV2 fails", async () => { - const errorMessage = "Failed to fetch user info"; - const testError = new Error(errorMessage); - - // Mock failed API call + const testError = new Error("Failed to fetch user info"); (userGetInfoV2 as any).mockRejectedValue(testError); const { result } = renderHook(() => useCurrentUser(), { wrapper }); - // Initially loading expect(result.current.isLoading).toBe(true); - // Wait for error await waitFor(() => { expect(result.current.isLoading).toBe(false); expect(result.current.isError).toBe(true); @@ -125,7 +111,6 @@ describe("useCurrentUser", () => { }); it("should not execute query when accessToken is missing", async () => { - // Mock missing accessToken mockUseAuthorized.mockReturnValue({ accessToken: null, userId: "test-user-id", @@ -139,17 +124,13 @@ describe("useCurrentUser", () => { const { result } = renderHook(() => useCurrentUser(), { wrapper }); - // Query should not execute expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); expect(result.current.isFetched).toBe(false); - - // API should not be called expect(userGetInfoV2).not.toHaveBeenCalled(); }); it("should not execute query when userId is missing", async () => { - // Mock missing userId mockUseAuthorized.mockReturnValue({ accessToken: "test-access-token", userId: null, @@ -163,69 +144,21 @@ describe("useCurrentUser", () => { const { result } = renderHook(() => useCurrentUser(), { wrapper }); - // Query should not execute expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); expect(result.current.isFetched).toBe(false); - - // API should not be called expect(userGetInfoV2).not.toHaveBeenCalled(); }); - it("should not execute query when all auth values are missing", async () => { - // Mock all auth values missing - mockUseAuthorized.mockReturnValue({ - accessToken: null, - userId: null, - userRole: null, - token: null, - userEmail: "test@example.com", - premiumUser: false, - disabledPersonalKeyCreation: null, - showSSOBanner: false, - }); - - const { result } = renderHook(() => useCurrentUser(), { wrapper }); - - // Query should not execute - expect(result.current.isLoading).toBe(false); - expect(result.current.data).toBeUndefined(); - expect(result.current.isFetched).toBe(false); - - // API should not be called - expect(userGetInfoV2).not.toHaveBeenCalled(); - }); - - it("should execute query when all auth values are present", async () => { - // Mock successful API call + it("should expose password_expiry when present", async () => { (userGetInfoV2 as any).mockResolvedValue(mockUserInfoV2Response); - // Ensure all auth values are present (already set in beforeEach) - const { result } = renderHook(() => useCurrentUser(), { wrapper }); - - // Wait for query to execute - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token"); - expect(userGetInfoV2).toHaveBeenCalledTimes(1); - }); - - it("should handle network timeout error", async () => { - const timeoutError = new Error("Network timeout"); - - // Mock network timeout - (userGetInfoV2 as any).mockRejectedValue(timeoutError); - const { result } = renderHook(() => useCurrentUser(), { wrapper }); - // Wait for error await waitFor(() => { - expect(result.current.isError).toBe(true); + expect(result.current.isSuccess).toBe(true); }); - expect(result.current.error).toEqual(timeoutError); - expect(result.current.data).toBeUndefined(); + expect(result.current.data?.password_expiry).toBe("2030-01-01T00:00:00Z"); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx index 31ddae31798..778a4263ccb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx @@ -128,6 +128,36 @@ describe("UserDropdown", () => { }); }); + it("should show a change password link when dropdown is opened", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(getAccountTrigger()); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /change password/i })).toHaveAttribute("href", "/ui/change-password"); + }); + }); + + it("should use a root-relative change password link in development", async () => { + vi.resetModules(); + vi.stubEnv("NODE_ENV", "development"); + vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); + const { default: DevelopmentUserDropdown } = await import("./UserDropdown"); + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(getAccountTrigger()); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /change password/i })).toHaveAttribute("href", "/change-password"); + }); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + it("should call onLogout when logout is clicked", async () => { const user = userEvent.setup(); renderWithProviders(); @@ -259,26 +289,7 @@ describe("UserDropdown", () => { await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("-")).toBeInTheDocument(); - }); - }); - - it("should display dash when user ID is not available", async () => { - const user = userEvent.setup(); - mockUseAuthorizedImpl = () => ({ - userId: null as any, - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: false, - }); - - renderWithProviders(); - - await user.click(getAccountTrigger()); - - await waitFor(() => { - const dashElements = screen.getAllByText("-"); - expect(dashElements.length).toBeGreaterThan(0); + expect(screen.getAllByText("-").length).toBeGreaterThan(0); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index bd197f3ec8b..6bccc99015c 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -3,16 +3,11 @@ import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; -import { - emitLocalStorageChange, - getLocalStorageItem, - removeLocalStorageItem, - setLocalStorageItem, -} from "@/utils/localStorageUtils"; import { navAccountDisplayName } from "@/components/Navbar/navDisplayName"; import { CrownOutlined, DownOutlined, + LockOutlined, LogoutOutlined, MailOutlined, SafetyOutlined, @@ -20,7 +15,15 @@ import { } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; +import Link from "next/link"; import React, { useEffect, useState } from "react"; +import { + emitLocalStorageChange, + getLocalStorageItem, + removeLocalStorageItem, + setLocalStorageItem, +} from "@/utils/localStorageUtils"; +import { migratedHref } from "@/utils/migratedPages"; const { Text } = Typography; @@ -75,6 +78,15 @@ const UserDropdown: React.FC = ({ onLogout }) => { }, []); const userItems: MenuProps["items"] = [ + { + key: "change-password", + label: ( + + + Change password + + ), + }, { key: "logout", label: ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 403647106d4..45dbd13f96c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -978,10 +978,35 @@ export interface UserInfoV2Response { metadata: Record | null; created_at: string | null; updated_at: string | null; + password_expiry: string | null; sso_user_id: string | null; teams: string[]; } +export interface ChangePasswordResponse { + message: string; + password_expiry: string | null; +} + +export const changePasswordCall = async ( + accessToken: string, + currentPassword: string, + newPassword: string, +): Promise => { + try { + return await apiClient.post(`/user/change_password`, { + accessToken, + body: { + current_password: currentPassword, + new_password: newPassword, + }, + }); + } catch (error) { + console.error("Failed to change password:", error); + throw error; + } +}; + /** * Lightweight user info fetch from /v2/user/info. * Returns only the user object — no keys, no teams objects. diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 933ffbb6516..8458b0f6b77 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -14388,6 +14388,23 @@ export interface paths { patch?: never; trace?: never; }; + "/user/change_password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** User Change Password */ + post: operations["user_change_password_user_change_password_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/user/daily/activity": { parameters: { query?: never; @@ -21685,6 +21702,20 @@ export interface components { */ status: "cancelled"; }; + /** ChangePasswordRequest */ + ChangePasswordRequest: { + /** Current Password */ + current_password: string; + /** New Password */ + new_password: string; + }; + /** ChangePasswordResponse */ + ChangePasswordResponse: { + /** Message */ + message: string; + /** Password Expiry */ + password_expiry?: string | null; + }; /** ChatCompletionAnnotation */ ChatCompletionAnnotation: { /** @@ -32759,6 +32790,8 @@ export interface components { * @default [] */ models: string[]; + /** Password Expiry */ + password_expiry?: string | null; /** * Spend * @default 0 @@ -50995,6 +51028,39 @@ export interface operations { }; }; }; + user_change_password_user_change_password_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangePasswordRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChangePasswordResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_user_daily_activity_user_daily_activity_get: { parameters: { query?: {