diff --git a/keep-ui/app/(keep)/settings/auth/users-sidebar.tsx b/keep-ui/app/(keep)/settings/auth/users-sidebar.tsx
index a5d336a7c3..2e5bdb9dde 100644
--- a/keep-ui/app/(keep)/settings/auth/users-sidebar.tsx
+++ b/keep-ui/app/(keep)/settings/auth/users-sidebar.tsx
@@ -266,17 +266,27 @@ const UsersSidebar = ({
)}
{/* Password Field */}
{(authType === AuthType.DB || authType === AuthType.KEYCLOAK) &&
- isNewUser &&
userCreationAllowed && (
-
Password
+
+ {isNewUser ? "Password" : "Reset Password"}
+
(
void;
+}
+
+export const ChangePasswordModal = ({
+ isOpen,
+ onClose,
+}: ChangePasswordModalProps) => {
+ const api = useApi();
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const resetForm = () => {
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setError(null);
+ setIsSubmitting(false);
+ };
+
+ const handleClose = () => {
+ resetForm();
+ onClose();
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ if (!currentPassword) {
+ setError("Current password is required");
+ return;
+ }
+ if (!newPassword) {
+ setError("New password is required");
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ setError("New password and confirmation do not match");
+ return;
+ }
+ if (newPassword === currentPassword) {
+ setError("New password must be different from the current password");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await api.put("/auth/users/me/password", {
+ current_password: currentPassword,
+ new_password: newPassword,
+ });
+ showSuccessToast("Password changed successfully");
+ handleClose();
+ } catch (err) {
+ if (err instanceof KeepApiError) {
+ setError(err.message || "Failed to change password");
+ } else {
+ setError("An unexpected error occurred");
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/keep-ui/components/navbar/UserInfo.tsx b/keep-ui/components/navbar/UserInfo.tsx
index 093ddfd523..10b604fe74 100644
--- a/keep-ui/components/navbar/UserInfo.tsx
+++ b/keep-ui/components/navbar/UserInfo.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState } from "react";
import { Menu } from "@headlessui/react";
import { LinkWithIcon } from "components/LinkWithIcon";
import { Session } from "next-auth";
@@ -14,6 +15,7 @@ import { useSignOut } from "@/shared/lib/hooks/useSignOut";
import { FaSlack } from "react-icons/fa";
import { ThemeControl } from "@/shared/ui";
import { HiOutlineDocumentText } from "react-icons/hi2";
+import { ChangePasswordModal } from "./ChangePasswordModal";
const ONBOARDING_FLOW_ID = "flow_FHDz1hit";
@@ -24,6 +26,7 @@ type UserDropdownProps = {
const UserDropdown = ({ session }: UserDropdownProps) => {
const { data: configData } = useConfig();
const signOut = useSignOut();
+ const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const { refs, floatingStyles } = useFloating({
placement: "right-end",
strategy: "fixed",
@@ -36,6 +39,8 @@ const UserDropdown = ({ session }: UserDropdownProps) => {
const { name, image, email } = user;
const isNoAuth = configData?.AUTH_TYPE === AuthType.NOAUTH;
+ // Self-service password change is only supported for local (DB) accounts.
+ const canChangePassword = configData?.AUTH_TYPE === AuthType.DB;
return (
+ {canChangePassword && (
+ setIsChangePasswordOpen(false)}
+ />
+ )}
);
};
diff --git a/keep-ui/components/navbar/__tests__/ChangePasswordModal.test.tsx b/keep-ui/components/navbar/__tests__/ChangePasswordModal.test.tsx
new file mode 100644
index 0000000000..2998e51058
--- /dev/null
+++ b/keep-ui/components/navbar/__tests__/ChangePasswordModal.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { ChangePasswordModal } from "../ChangePasswordModal";
+import { useApi } from "@/shared/lib/hooks/useApi";
+import { showSuccessToast } from "@/shared/ui";
+import { KeepApiError } from "@/shared/api";
+
+jest.mock("@/shared/lib/hooks/useApi");
+jest.mock("@/shared/ui", () => ({
+ showSuccessToast: jest.fn(),
+}));
+
+describe("ChangePasswordModal", () => {
+ const mockPut = jest.fn();
+ const mockOnClose = jest.fn();
+
+ beforeEach(() => {
+ (useApi as jest.Mock).mockReturnValue({ put: mockPut });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const fillForm = (current: string, next: string, confirm: string) => {
+ fireEvent.change(
+ screen.getByPlaceholderText("Enter your current password"),
+ { target: { value: current } }
+ );
+ fireEvent.change(screen.getByPlaceholderText("Enter a new password"), {
+ target: { value: next },
+ });
+ fireEvent.change(screen.getByPlaceholderText("Re-enter the new password"), {
+ target: { value: confirm },
+ });
+ };
+
+ it("submits the password change and closes on success", async () => {
+ mockPut.mockResolvedValue({ status: "OK" });
+ render();
+
+ fillForm("oldpass", "newpass", "newpass");
+ fireEvent.click(screen.getByRole("button", { name: /change password/i }));
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalledWith("/auth/users/me/password", {
+ current_password: "oldpass",
+ new_password: "newpass",
+ });
+ });
+ expect(showSuccessToast).toHaveBeenCalledWith(
+ "Password changed successfully"
+ );
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it("shows an error when passwords do not match", async () => {
+ render();
+
+ fillForm("oldpass", "newpass", "different");
+ fireEvent.click(screen.getByRole("button", { name: /change password/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("New password and confirmation do not match")
+ ).toBeInTheDocument();
+ });
+ expect(mockPut).not.toHaveBeenCalled();
+ });
+
+ it("shows an error when the new password equals the current one", async () => {
+ render();
+
+ fillForm("samepass", "samepass", "samepass");
+ fireEvent.click(screen.getByRole("button", { name: /change password/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ "New password must be different from the current password"
+ )
+ ).toBeInTheDocument();
+ });
+ expect(mockPut).not.toHaveBeenCalled();
+ });
+
+ it("surfaces the API error message on failure", async () => {
+ mockPut.mockRejectedValue(
+ new KeepApiError(
+ "Current password is incorrect",
+ "/auth/users/me/password",
+ "Current password is incorrect",
+ undefined,
+ 403
+ )
+ );
+ render();
+
+ fillForm("wrongpass", "newpass", "newpass");
+ fireEvent.click(screen.getByRole("button", { name: /change password/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Current password is incorrect")
+ ).toBeInTheDocument();
+ });
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/keep/api/core/db.py b/keep/api/core/db.py
index 140c3a6a3e..878d0d877b 100644
--- a/keep/api/core/db.py
+++ b/keep/api/core/db.py
@@ -2146,6 +2146,25 @@ def update_user_role(tenant_id, username, role):
return user
+def update_user_password(tenant_id, username, password):
+ from keep.api.models.db.user import User
+
+ password_hash = hashlib.sha256(password.encode()).hexdigest()
+ with Session(engine) as session:
+ user = session.exec(
+ select(User)
+ .where(User.tenant_id == tenant_id)
+ .where(User.username == username)
+ ).first()
+ if not user:
+ return None
+ user.password_hash = password_hash
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+ return user
+
+
def save_workflow_results(tenant_id, workflow_execution_id, workflow_results):
with Session(engine) as session:
workflow_execution = session.exec(
diff --git a/keep/api/routes/auth/users.py b/keep/api/routes/auth/users.py
index 15d577d081..c3d56714d9 100644
--- a/keep/api/routes/auth/users.py
+++ b/keep/api/routes/auth/users.py
@@ -4,6 +4,9 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, validator
+from keep.api.core.config import config
+from keep.api.core.db import get_user as get_db_user
+from keep.api.core.db import update_user_password
from keep.api.models.user import User
from keep.identitymanager.authenticatedentity import AuthenticatedEntity
from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory
@@ -41,6 +44,17 @@ def validate_role(cls, v):
return v
+class ChangePasswordRequest(BaseModel):
+ current_password: str
+ new_password: str
+
+ @validator("new_password", allow_reuse=True)
+ def validate_new_password(cls, v):
+ if not v or not v.strip():
+ raise ValueError("New password cannot be empty")
+ return v
+
+
@router.get("", description="Get all users")
def get_users(
authenticated_entity: AuthenticatedEntity = Depends(
@@ -93,6 +107,42 @@ async def create_user(
)
+@router.put(
+ "/me/password",
+ description="Change the password of the currently authenticated user",
+)
+def change_my_password(
+ request_data: ChangePasswordRequest,
+ authenticated_entity: AuthenticatedEntity = Depends(
+ IdentityManagerFactory.get_auth_verifier(["read:settings"])
+ ),
+):
+ # Self-service password change is only supported for local (DB) accounts.
+ auth_type = config("AUTH_TYPE", default="").lower()
+ if auth_type not in ("db", "single_tenant"):
+ raise HTTPException(
+ status_code=501,
+ detail="Changing your password is not supported for this authentication type",
+ )
+
+ username = authenticated_entity.email
+ if not username:
+ raise HTTPException(status_code=400, detail="Unable to resolve current user")
+
+ # Verify the current password before allowing a change.
+ user = get_db_user(username, request_data.current_password, update_sign_in=False)
+ if not user:
+ raise HTTPException(status_code=403, detail="Current password is incorrect")
+
+ updated_user = update_user_password(
+ authenticated_entity.tenant_id, username, request_data.new_password
+ )
+ if not updated_user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ return {"status": "OK"}
+
+
@router.put("/{user_email}", description="Update a user")
async def update_user(
user_email: str,
diff --git a/keep/identitymanager/identity_managers/db/db_identitymanager.py b/keep/identitymanager/identity_managers/db/db_identitymanager.py
index b2673f1d6a..0492c77f50 100644
--- a/keep/identitymanager/identity_managers/db/db_identitymanager.py
+++ b/keep/identitymanager/identity_managers/db/db_identitymanager.py
@@ -8,6 +8,8 @@
from keep.api.core.db import delete_user as delete_user_from_db
from keep.api.core.db import get_user
from keep.api.core.db import get_users as get_users_from_db
+from keep.api.core.db import update_user_password as update_user_password_in_db
+from keep.api.core.db import update_user_role as update_user_role_in_db
from keep.api.core.dependencies import SINGLE_TENANT_UUID
from keep.api.models.user import User
from keep.contextmanager.contextmanager import ContextManager
@@ -110,4 +112,32 @@ def get_auth_verifier(self, scopes) -> DbAuthVerifier:
return DbAuthVerifier(scopes)
def update_user(self, user_email: str, update_data: dict) -> User:
- raise NotImplementedError("DbIdentityManager.update_user")
+ # For DB auth the identifier is the username (stored as `email` in the DTO)
+ password = update_data.get("password")
+ role = update_data.get("role")
+
+ updated_user = None
+ if password:
+ updated_user = update_user_password_in_db(
+ self.tenant_id, user_email, password
+ )
+ if not updated_user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ if role:
+ updated_user = update_user_role_in_db(self.tenant_id, user_email, role)
+ if not updated_user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ if updated_user is None:
+ raise HTTPException(status_code=400, detail="No update data provided")
+
+ return User(
+ email=updated_user.username,
+ name=updated_user.username,
+ role=updated_user.role,
+ last_login=(
+ str(updated_user.last_sign_in) if updated_user.last_sign_in else None
+ ),
+ created_at=str(updated_user.created_at),
+ )
diff --git a/tests/test_change_password.py b/tests/test_change_password.py
new file mode 100644
index 0000000000..88665dce88
--- /dev/null
+++ b/tests/test_change_password.py
@@ -0,0 +1,179 @@
+import hashlib
+
+import pytest
+
+from keep.api.core.dependencies import SINGLE_TENANT_UUID
+from tests.fixtures.client import client, test_app # noqa
+
+
+def _create_db_user(db_session, username, password, role="admin"):
+ from keep.api.models.db.user import User
+
+ db_session.add(
+ User(
+ tenant_id=SINGLE_TENANT_UUID,
+ username=username,
+ password_hash=hashlib.sha256(password.encode()).hexdigest(),
+ role=role,
+ )
+ )
+ db_session.commit()
+
+
+def _signin(client, username, password):
+ response = client.post(
+ "/signin",
+ json={"username": username, "password": password},
+ )
+ return response
+
+
+def _get_password_hash(db_session, username):
+ from keep.api.models.db.user import User
+ from sqlmodel import select
+
+ user = db_session.exec(
+ select(User)
+ .where(User.tenant_id == SINGLE_TENANT_UUID)
+ .where(User.username == username)
+ ).first()
+ return user.password_hash if user else None
+
+
+@pytest.mark.parametrize(
+ "test_app",
+ [{"AUTH_TYPE": "DB", "KEEP_JWT_SECRET": "somesecret"}],
+ indirect=True,
+)
+def test_change_own_password_success(db_session, client, test_app):
+ """A local (DB) user can change their own password."""
+ _create_db_user(db_session, "alice", "oldpassword")
+
+ signin = _signin(client, "alice", "oldpassword")
+ assert signin.status_code == 200
+ token = signin.json()["accessToken"]
+
+ response = client.put(
+ "/auth/users/me/password",
+ json={"current_password": "oldpassword", "new_password": "newpassword"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["status"] == "OK"
+
+ # old password no longer works
+ assert _signin(client, "alice", "oldpassword").status_code == 401
+ # new password works
+ assert _signin(client, "alice", "newpassword").status_code == 200
+
+ # password hash was actually updated in the db
+ expected_hash = hashlib.sha256("newpassword".encode()).hexdigest()
+ assert _get_password_hash(db_session, "alice") == expected_hash
+
+
+@pytest.mark.parametrize(
+ "test_app",
+ [{"AUTH_TYPE": "DB", "KEEP_JWT_SECRET": "somesecret"}],
+ indirect=True,
+)
+def test_change_own_password_wrong_current_password(db_session, client, test_app):
+ """Changing password fails if the current password is incorrect."""
+ _create_db_user(db_session, "bob", "correctpassword")
+
+ signin = _signin(client, "bob", "correctpassword")
+ assert signin.status_code == 200
+ token = signin.json()["accessToken"]
+
+ response = client.put(
+ "/auth/users/me/password",
+ json={"current_password": "wrongpassword", "new_password": "newpassword"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 403
+ assert response.json()["detail"] == "Current password is incorrect"
+
+ # password unchanged
+ assert _signin(client, "bob", "correctpassword").status_code == 200
+
+
+@pytest.mark.parametrize(
+ "test_app",
+ [{"AUTH_TYPE": "DB", "KEEP_JWT_SECRET": "somesecret"}],
+ indirect=True,
+)
+def test_change_own_password_empty_new_password(db_session, client, test_app):
+ """Changing password fails if the new password is empty."""
+ _create_db_user(db_session, "carol", "somepassword")
+
+ signin = _signin(client, "carol", "somepassword")
+ assert signin.status_code == 200
+ token = signin.json()["accessToken"]
+
+ response = client.put(
+ "/auth/users/me/password",
+ json={"current_password": "somepassword", "new_password": " "},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 422
+
+
+@pytest.mark.parametrize(
+ "test_app",
+ [{"AUTH_TYPE": "DB", "KEEP_JWT_SECRET": "somesecret"}],
+ indirect=True,
+)
+def test_change_password_requires_authentication(db_session, client, test_app):
+ """Changing password requires authentication."""
+ response = client.put(
+ "/auth/users/me/password",
+ json={"current_password": "x", "new_password": "y"},
+ )
+ assert response.status_code == 401
+
+
+@pytest.mark.parametrize(
+ "test_app",
+ [{"AUTH_TYPE": "DB", "KEEP_JWT_SECRET": "somesecret"}],
+ indirect=True,
+)
+def test_noc_user_can_change_own_password(db_session, client, test_app):
+ """A non-admin (noc) local user can still change their own password."""
+ _create_db_user(db_session, "dave", "oldpass", role="noc")
+
+ signin = _signin(client, "dave", "oldpass")
+ assert signin.status_code == 200
+ token = signin.json()["accessToken"]
+
+ response = client.put(
+ "/auth/users/me/password",
+ json={"current_password": "oldpass", "new_password": "brandnewpass"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ assert _signin(client, "dave", "brandnewpass").status_code == 200
+
+
+@pytest.mark.parametrize(
+ "test_app",
+ [{"AUTH_TYPE": "DB", "KEEP_JWT_SECRET": "somesecret"}],
+ indirect=True,
+)
+def test_admin_can_reset_user_password_via_update(db_session, client, test_app):
+ """An admin can reset another user's password via the update endpoint."""
+ _create_db_user(db_session, "admin_user", "adminpass", role="admin")
+ _create_db_user(db_session, "managed_user", "initialpass", role="noc")
+
+ signin = _signin(client, "admin_user", "adminpass")
+ assert signin.status_code == 200
+ token = signin.json()["accessToken"]
+
+ response = client.put(
+ "/auth/users/managed_user",
+ json={"password": "resetpass"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+
+ # managed_user can sign in with new password
+ assert _signin(client, "managed_user", "resetpass").status_code == 200
+ assert _signin(client, "managed_user", "initialpass").status_code == 401