Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions keep-ui/app/(keep)/settings/auth/users-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,17 +266,27 @@ const UsersSidebar = ({
)}
{/* Password Field */}
{(authType === AuthType.DB || authType === AuthType.KEYCLOAK) &&
isNewUser &&
userCreationAllowed && (
<div className="mt-4">
<Subtitle>Password</Subtitle>
<Subtitle>
{isNewUser ? "Password" : "Reset Password"}
</Subtitle>
<Controller
name="password"
control={control}
rules={{ required: "Password is required" }}
rules={{
required: isNewUser
? "Password is required"
: false,
}}
render={({ field }) => (
<TextInput
type="password"
placeholder={
isNewUser
? undefined
: "Leave empty to keep current password"
}
{...field}
error={!!errors.password}
errorMessage={
Expand Down
144 changes: 144 additions & 0 deletions keep-ui/components/navbar/ChangePasswordModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use client";

import { useState } from "react";
import { Button, TextInput, Subtitle, Callout } from "@tremor/react";
import Modal from "@/components/ui/Modal";
import { useApi } from "@/shared/lib/hooks/useApi";
import { KeepApiError } from "@/shared/api";
import { showSuccessToast } from "@/shared/ui";

interface ChangePasswordModalProps {
isOpen: boolean;
onClose: () => 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<string | null>(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 (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Change Password"
className="w-[400px]"
>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<Subtitle>Current Password</Subtitle>
<TextInput
type="password"
placeholder="Enter your current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div>
<Subtitle>New Password</Subtitle>
<TextInput
type="password"
placeholder="Enter a new password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div>
<Subtitle>Confirm New Password</Subtitle>
<TextInput
type="password"
placeholder="Re-enter the new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
{error && (
<Callout title="Error" color="rose">
{error}
</Callout>
)}
<div className="flex justify-end gap-2 mt-2">
<Button
type="button"
variant="secondary"
color="orange"
className="border border-orange-500 text-orange-500"
onClick={handleClose}
>
Cancel
</Button>
<Button
type="submit"
color="orange"
disabled={isSubmitting}
loading={isSubmitting}
>
{isSubmitting ? "Saving..." : "Change Password"}
</Button>
</div>
</form>
</Modal>
);
};
22 changes: 22 additions & 0 deletions keep-ui/components/navbar/UserInfo.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -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",
Expand All @@ -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 (
<Menu as="li" ref={refs.setReference} className="min-w-0 flex-1">
<Menu.Button className="flex items-center justify-between w-full text-sm pl-2.5 pr-2 py-1 text-gray-700 hover:bg-stone-200/50 font-medium rounded-lg hover:text-orange-400 focus:ring focus:ring-orange-300 group capitalize">
Expand Down Expand Up @@ -65,6 +70,17 @@ const UserDropdown = ({ session }: UserDropdownProps) => {
</Menu.Item>
</li>
)}
{canChangePassword && (
<li>
<Menu.Item
as="button"
className="ui-active:bg-orange-400 ui-active:text-white ui-not-active:text-gray-900 group flex w-full items-center rounded-md px-2 py-2 text-sm"
onClick={() => setIsChangePasswordOpen(true)}
>
Change Password
</Menu.Item>
</li>
)}
{!isNoAuth && (
<li>
<Menu.Item
Expand All @@ -78,6 +94,12 @@ const UserDropdown = ({ session }: UserDropdownProps) => {
)}
</div>
</Menu.Items>
{canChangePassword && (
<ChangePasswordModal
isOpen={isChangePasswordOpen}
onClose={() => setIsChangePasswordOpen(false)}
/>
)}
</Menu>
);
};
Expand Down
108 changes: 108 additions & 0 deletions keep-ui/components/navbar/__tests__/ChangePasswordModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ChangePasswordModal isOpen={true} onClose={mockOnClose} />);

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(<ChangePasswordModal isOpen={true} onClose={mockOnClose} />);

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(<ChangePasswordModal isOpen={true} onClose={mockOnClose} />);

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(<ChangePasswordModal isOpen={true} onClose={mockOnClose} />);

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();
});
});
19 changes: 19 additions & 0 deletions keep/api/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading