From 8dd85d98b6dc939a209e11c4d28a2a7464602d0b Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa <72977554+Cristhianzl@users.noreply.github.com> Date: Wed, 21 Aug 2024 12:55:47 -0300 Subject: [PATCH] fix: auto_login=off error on login and editing a user + FE tests (#3471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 (users.py): Fix issue where user password was not being updated correctly 📝 (constants.ts, authContext.tsx, index.tsx): Add LANGFLOW_REFRESH_TOKEN constant and update related code to support refresh token functionality 📝 (userManagementModal/index.tsx): Update form reset logic and handle input values correctly 📝 (LoginPage/index.tsx, LoginAdminPage/index.tsx): Update login function to include refresh token parameter 📝 (components/index.ts, auth.ts): Update inputHandlerEventType to support boolean values ✨ (auto-login-off.spec.ts): Add end-to-end test for user login functionality with auto_login set to false, CRUD operations for users, and verification of user flows visibility based on permissions. * ✨ (auto-login-off.spec.ts): improve test description for better clarity and understanding 📝 (auto-login-off.spec.ts): add comments to clarify the purpose of intercepting requests and performing CRUD operations * 🐛 (users.py): fix comparison of password to check for None using 'is not None' instead of '!= None' for better accuracy --- src/backend/base/langflow/api/v1/users.py | 6 +- src/frontend/package-lock.json | 1 - src/frontend/src/constants/constants.ts | 1 + src/frontend/src/contexts/authContext.tsx | 10 +- .../src/modals/userManagementModal/index.tsx | 21 +- .../src/pages/AdminPage/LoginPage/index.tsx | 2 +- src/frontend/src/pages/LoginPage/index.tsx | 2 +- src/frontend/src/types/components/index.ts | 2 +- src/frontend/src/types/contexts/auth.ts | 6 +- .../tests/end-to-end/auto-login-off.spec.ts | 249 ++++++++++++++++++ .../auto-save-off.spec.ts | 17 +- 11 files changed, 296 insertions(+), 21 deletions(-) create mode 100644 src/frontend/tests/end-to-end/auto-login-off.spec.ts diff --git a/src/backend/base/langflow/api/v1/users.py b/src/backend/base/langflow/api/v1/users.py index 41d27e3fd..56f8f443f 100644 --- a/src/backend/base/langflow/api/v1/users.py +++ b/src/backend/base/langflow/api/v1/users.py @@ -90,17 +90,21 @@ def patch_user( Update an existing user's data. """ + update_password = user_update.password is not None and user_update.password != "" + if not user.is_superuser and user_update.is_superuser: raise HTTPException(status_code=403, detail="Permission denied") if not user.is_superuser and user.id != user_id: raise HTTPException(status_code=403, detail="Permission denied") - if user_update.password: + if update_password: if not user.is_superuser: raise HTTPException(status_code=400, detail="You can't change your password here") user_update.password = get_password_hash(user_update.password) if user_db := get_user_by_id(session, user_id): + if not update_password: + user_update.password = user_db.password return update_user(user_db, user_update, session) else: raise HTTPException(status_code=404, detail="User not found") diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 0ee08125c..8cbae7c90 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -1079,7 +1079,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 452830133..5e7d5b18c 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -879,6 +879,7 @@ export const TABS_ORDER = [ export const LANGFLOW_ACCESS_TOKEN = "access_token_lf"; export const LANGFLOW_API_TOKEN = "apikey_tkn_lflw"; export const LANGFLOW_AUTO_LOGIN_OPTION = "auto_login_lf"; +export const LANGFLOW_REFRESH_TOKEN = "refresh_token_lf"; export const LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS = 60 * 60 - 60 * 60 * 0.1; export const LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV = diff --git a/src/frontend/src/contexts/authContext.tsx b/src/frontend/src/contexts/authContext.tsx index 230fbce73..59e4a9d32 100644 --- a/src/frontend/src/contexts/authContext.tsx +++ b/src/frontend/src/contexts/authContext.tsx @@ -2,6 +2,7 @@ import { LANGFLOW_ACCESS_TOKEN, LANGFLOW_API_TOKEN, LANGFLOW_AUTO_LOGIN_OPTION, + LANGFLOW_REFRESH_TOKEN, } from "@/constants/constants"; import { useGetUserData } from "@/controllers/API/queries/auth"; import useAuthStore from "@/stores/authStore"; @@ -76,8 +77,15 @@ export function AuthProvider({ children }): React.ReactElement { ); } - function login(newAccessToken: string, autoLogin: string) { + function login( + newAccessToken: string, + autoLogin: string, + refreshToken?: string, + ) { cookies.set(LANGFLOW_AUTO_LOGIN_OPTION, autoLogin, { path: "/" }); + if (refreshToken) { + cookies.set(LANGFLOW_REFRESH_TOKEN, refreshToken, { path: "/" }); + } setAccessToken(newAccessToken); setIsAuthenticated(true); getUser(); diff --git a/src/frontend/src/modals/userManagementModal/index.tsx b/src/frontend/src/modals/userManagementModal/index.tsx index d246a11b1..594bf30e4 100644 --- a/src/frontend/src/modals/userManagementModal/index.tsx +++ b/src/frontend/src/modals/userManagementModal/index.tsx @@ -42,14 +42,21 @@ export default function UserManagementModal({ }: inputHandlerEventType): void { setInputState((prev) => ({ ...prev, [name]: value })); } + console.log(data); useEffect(() => { - if (!data) { - resetForm(); - } else { - handleInput({ target: { name: "username", value: username } }); - handleInput({ target: { name: "is_active", value: isActive } }); - handleInput({ target: { name: "is_superuser", value: isSuperUser } }); + if (open) { + if (!data) { + resetForm(); + } else { + setUserName(data.username); + setIsActive(data.is_active); + setIsSuperUser(data.is_superuser); + + handleInput({ target: { name: "username", value: username } }); + handleInput({ target: { name: "is_active", value: isActive } }); + handleInput({ target: { name: "is_superuser", value: isSuperUser } }); + } } }, [open]); @@ -57,6 +64,8 @@ export default function UserManagementModal({ setPassword(""); setUserName(""); setConfirmPassword(""); + setIsActive(false); + setIsSuperUser(false); } return ( diff --git a/src/frontend/src/pages/AdminPage/LoginPage/index.tsx b/src/frontend/src/pages/AdminPage/LoginPage/index.tsx index dec8c8e2d..68bd53755 100644 --- a/src/frontend/src/pages/AdminPage/LoginPage/index.tsx +++ b/src/frontend/src/pages/AdminPage/LoginPage/index.tsx @@ -41,7 +41,7 @@ export default function LoginAdminPage() { setSelectedFolder(null); setLoading(true); - login(res.access_token, "login"); + login(res.access_token, "login", res.refresh_token); }, onError: (error) => { setErrorData({ diff --git a/src/frontend/src/pages/LoginPage/index.tsx b/src/frontend/src/pages/LoginPage/index.tsx index 3698d69d5..52f2b5747 100644 --- a/src/frontend/src/pages/LoginPage/index.tsx +++ b/src/frontend/src/pages/LoginPage/index.tsx @@ -43,7 +43,7 @@ export default function LoginPage(): JSX.Element { onSuccess: (data) => { setSelectedFolder(null); - login(data.access_token, "login"); + login(data.access_token, "login", data.refresh_token); }, onError: (error) => { setErrorData({ diff --git a/src/frontend/src/types/components/index.ts b/src/frontend/src/types/components/index.ts index ddd5a2a48..5cedeb185 100644 --- a/src/frontend/src/types/components/index.ts +++ b/src/frontend/src/types/components/index.ts @@ -417,7 +417,7 @@ export type signUpInputStateType = { export type inputHandlerEventType = { target: { - value: string; + value: string | boolean; name: string; }; }; diff --git a/src/frontend/src/types/contexts/auth.ts b/src/frontend/src/types/contexts/auth.ts index 8bbaac0aa..349ae5e07 100644 --- a/src/frontend/src/types/contexts/auth.ts +++ b/src/frontend/src/types/contexts/auth.ts @@ -2,7 +2,11 @@ import { Users } from "../api"; export type AuthContextType = { accessToken: string | null; - login: (accessToken: string, autoLogin: string) => void; + login: ( + accessToken: string, + autoLogin: string, + refreshToken?: string, + ) => void; userData: Users | null; setUserData: (userData: Users | null) => void; authenticationErrorCount: number; diff --git a/src/frontend/tests/end-to-end/auto-login-off.spec.ts b/src/frontend/tests/end-to-end/auto-login-off.spec.ts new file mode 100644 index 000000000..0680b8d78 --- /dev/null +++ b/src/frontend/tests/end-to-end/auto-login-off.spec.ts @@ -0,0 +1,249 @@ +import { expect, test } from "@playwright/test"; +import { before, beforeEach } from "node:test"; + +test("when auto_login is false, admin can CRUD user's and should see just your own flows", async ({ + page, +}) => { + await page.route("**/api/v1/auto_login", (route) => { + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + detail: { auto_login: false }, + }), + }); + }); + + const randomName = Math.random().toString(36).substring(5); + const randomPassword = Math.random().toString(36).substring(5); + const secondRandomName = Math.random().toString(36).substring(5); + const randomFlowName = Math.random().toString(36).substring(5); + const secondRandomFlowName = Math.random().toString(36).substring(5); + + await page.goto("/"); + + await page.waitForSelector("text=sign in to langflow", { timeout: 30000 }); + + await page.getByPlaceholder("Username").fill("langflow"); + await page.getByPlaceholder("Password").fill("langflow"); + + await page.getByRole("button", { name: "Sign In" }).click(); + + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 30000, + }); + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + await page.getByTestId("user-profile-settings").click(); + + await page.getByText("Admin Page", { exact: true }).click(); + + //CRUD an user + await page.getByText("New User", { exact: true }).click(); + + await page.getByPlaceholder("Username").last().fill(randomName); + await page.locator('input[name="password"]').fill(randomPassword); + await page.locator('input[name="confirmpassword"]').fill(randomPassword); + + await page.waitForTimeout(1000); + + await page.locator("#is_active").click(); + + await page.getByText("Save", { exact: true }).click(); + + await page.waitForSelector("text=new user added", { timeout: 30000 }); + + expect(await page.getByText(randomName, { exact: true }).isVisible()).toBe( + true, + ); + + await page.getByTestId("icon-Trash2").last().click(); + await page.getByText("Delete", { exact: true }).last().click(); + + await page.waitForSelector("text=user deleted", { timeout: 30000 }); + + expect(await page.getByText(randomName, { exact: true }).isVisible()).toBe( + false, + ); + + await page.getByText("New User", { exact: true }).click(); + + await page.getByPlaceholder("Username").last().fill(randomName); + await page.locator('input[name="password"]').fill(randomPassword); + await page.locator('input[name="confirmpassword"]').fill(randomPassword); + + await page.waitForTimeout(1000); + + await page.locator("#is_active").click(); + + await page.getByText("Save", { exact: true }).click(); + + await page.waitForSelector("text=new user added", { timeout: 30000 }); + + await page.getByPlaceholder("Username").last().fill(randomName); + + await page.getByTestId("icon-Pencil").last().click(); + + await page.getByPlaceholder("Username").last().fill(secondRandomName); + + await page.getByText("Save", { exact: true }).click(); + + await page.waitForSelector("text=user edited", { timeout: 30000 }); + + await page.waitForTimeout(1000); + + expect( + await page.getByText(secondRandomName, { exact: true }).isVisible(), + ).toBe(true); + + //user must see just your own flows + await page.getByText("My Collection", { exact: true }).last().click(); + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + let modalCount = 0; + try { + const modalTitleElement = await page?.getByTestId("modal-title"); + if (modalTitleElement) { + modalCount = await modalTitleElement.count(); + } + } catch (error) { + modalCount = 0; + } + + while (modalCount === 0) { + await page.getByText("New Project", { exact: true }).click(); + await page.waitForTimeout(3000); + modalCount = await page.getByTestId("modal-title")?.count(); + } + + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[title="fit view"]', { + timeout: 100000, + }); + + await page.getByTitle("fit view").click(); + await page.getByTitle("zoom out").click(); + + await page.getByTestId("flow-configuration-button").click(); + await page.getByText("Settings", { exact: true }).last().click(); + + await page.getByPlaceholder("Flow Name").fill(randomFlowName); + + await page.getByText("Save", { exact: true }).click(); + + await page.waitForSelector('[data-testid="icon-ChevronLeft"]', { + timeout: 100000, + }); + + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + expect( + await page.getByText(randomFlowName, { exact: true }).last().isVisible(), + ).toBe(true); + + await page.getByTestId("user-profile-settings").click(); + + await page.getByText("Log Out", { exact: true }).click(); + + await page.waitForSelector("text=sign in to langflow", { timeout: 30000 }); + + await page.getByPlaceholder("Username").fill(secondRandomName); + await page.getByPlaceholder("Password").fill(randomPassword); + await page.waitForTimeout(1000); + + await page.getByRole("button", { name: "Sign In" }).click(); + + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 30000, + }); + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + expect( + ( + await page.waitForSelector("text=this folder is empty", { + timeout: 30000, + }) + ).isVisible(), + ); + + while (modalCount === 0) { + await page.getByText("New Project", { exact: true }).click(); + await page.waitForTimeout(3000); + modalCount = await page.getByTestId("modal-title")?.count(); + } + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + await page.getByText("New Project", { exact: true }).click(); + + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[title="fit view"]', { + timeout: 100000, + }); + + await page.getByTitle("fit view").click(); + await page.getByTitle("zoom out").click(); + + await page.getByTestId("flow-configuration-button").click(); + await page.getByText("Settings", { exact: true }).last().click(); + + await page.getByPlaceholder("Flow Name").fill(secondRandomFlowName); + + await page.getByText("Save", { exact: true }).click(); + + await page.waitForSelector('[data-testid="icon-ChevronLeft"]', { + timeout: 100000, + }); + + await page.getByTestId("icon-ChevronLeft").first().click(); + + expect( + await page.getByText(secondRandomFlowName, { exact: true }).isVisible(), + ).toBe(true); + expect( + await page.getByText(randomFlowName, { exact: true }).isVisible(), + ).toBe(false); + + await page.getByTestId("user-profile-settings").click(); + + await page.getByText("Log Out", { exact: true }).click(); + + await page.waitForSelector("text=sign in to langflow", { timeout: 30000 }); + + await page.getByPlaceholder("Username").fill("langflow"); + await page.getByPlaceholder("Password").fill("langflow"); + + await page.getByRole("button", { name: "Sign In" }).click(); + + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 30000, + }); + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + expect( + await page.getByText(secondRandomFlowName, { exact: true }).isVisible(), + ).toBe(false); + expect( + await page.getByText(randomFlowName, { exact: true }).isVisible(), + ).toBe(true); +}); diff --git a/src/frontend/tests/scheduled-end-to-end/auto-save-off.spec.ts b/src/frontend/tests/scheduled-end-to-end/auto-save-off.spec.ts index a5b60b350..51030dba4 100644 --- a/src/frontend/tests/scheduled-end-to-end/auto-save-off.spec.ts +++ b/src/frontend/tests/scheduled-end-to-end/auto-save-off.spec.ts @@ -3,23 +3,24 @@ import { expect, test } from "@playwright/test"; test("user should be able to manually save a flow when the auto_save is off", async ({ page, }) => { - // Intercept the request to any base URL ending with /api/v1/config - await page.route("**/api/v1/config", async (route) => { - const response = await route.fetch(); - const responseBody = await response.json(); - responseBody.auto_saving = false; + await page.route("**/api/v1/config", (route) => { route.fulfill({ - response, - body: JSON.stringify(responseBody), + status: 200, + contentType: "application/json", + body: JSON.stringify({ + auto_saving: false, + frontend_timeout: 0, + }), headers: { - ...response.headers(), "content-type": "application/json", + ...route.request().headers(), }, }); }); await page.goto("/"); await page.locator("span").filter({ hasText: "My Collection" }).isVisible(); + await page.waitForSelector('[data-testid="mainpage_title"]', { timeout: 30000, });