diff --git a/backend/onyx/auth/noauth_user.py b/backend/onyx/auth/noauth_user.py index e17694894..a77bdb90f 100644 --- a/backend/onyx/auth/noauth_user.py +++ b/backend/onyx/auth/noauth_user.py @@ -42,4 +42,5 @@ def fetch_no_auth_user( role=UserRole.BASIC if anonymous_user_enabled else UserRole.ADMIN, preferences=load_no_auth_user_preferences(store), is_anonymous_user=anonymous_user_enabled, + password_configured=False, ) diff --git a/backend/onyx/auth/users.py b/backend/onyx/auth/users.py index b6de396e0..4962554ed 100644 --- a/backend/onyx/auth/users.py +++ b/backend/onyx/auth/users.py @@ -1,5 +1,7 @@ import json +import random import secrets +import string import uuid from collections.abc import AsyncGenerator from datetime import datetime @@ -143,6 +145,30 @@ def get_display_email(email: str | None, space_less: bool = False) -> str: return email or "" +def generate_password() -> str: + lowercase_letters = string.ascii_lowercase + uppercase_letters = string.ascii_uppercase + digits = string.digits + special_characters = string.punctuation + + # Ensure at least one of each required character type + password = [ + secrets.choice(uppercase_letters), + secrets.choice(digits), + secrets.choice(special_characters), + ] + + # Fill the rest with a mix of characters + remaining_length = 12 - len(password) + all_characters = lowercase_letters + uppercase_letters + digits + special_characters + password.extend(secrets.choice(all_characters) for _ in range(remaining_length)) + + # Shuffle the password to randomize the position of the required characters + random.shuffle(password) + + return "".join(password) + + def user_needs_to_be_verified() -> bool: if AUTH_TYPE == AuthType.BASIC or AUTH_TYPE == AuthType.CLOUD: return REQUIRE_EMAIL_VERIFICATION @@ -595,6 +621,39 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): return user + async def reset_password_as_admin(self, user_id: uuid.UUID) -> str: + """Admin-only. Generate a random password for a user and return it.""" + user = await self.get(user_id) + new_password = generate_password() + await self._update(user, {"password": new_password}) + return new_password + + async def change_password_if_old_matches( + self, user: User, old_password: str, new_password: str + ) -> None: + """ + For normal users to change password if they know the old one. + Raises 400 if old password doesn't match. + """ + verified, updated_password_hash = self.password_helper.verify_and_update( + old_password, user.hashed_password + ) + if not verified: + # Raise some HTTPException (or your custom exception) if old password is invalid: + from fastapi import HTTPException, status + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid current password", + ) + + # If the hash was upgraded behind the scenes, we can keep it before setting the new password: + if updated_password_hash: + user.hashed_password = updated_password_hash + + # Now apply and validate the new password + await self._update(user, {"password": new_password}) + async def get_user_manager( user_db: SQLAlchemyUserDatabase = Depends(get_user_db), diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index be222440e..d29de56b5 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -205,6 +205,13 @@ class User(SQLAlchemyBaseUserTableUUID, Base): primaryjoin="User.id == foreign(ConnectorCredentialPair.creator_id)", ) + @property + def password_configured(self) -> bool: + """ + Returns True if the user has at least one OAuth (or OIDC) account. + """ + return not bool(self.oauth_accounts) + class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base): pass diff --git a/backend/onyx/main.py b/backend/onyx/main.py index 9f77c4cdf..c852fd0fe 100644 --- a/backend/onyx/main.py +++ b/backend/onyx/main.py @@ -61,6 +61,7 @@ from onyx.server.features.input_prompt.api import ( basic_router as input_prompt_router, ) from onyx.server.features.notifications.api import router as notification_router +from onyx.server.features.password.api import router as password_router from onyx.server.features.persona.api import admin_router as admin_persona_router from onyx.server.features.persona.api import basic_router as persona_router from onyx.server.features.tool.api import admin_router as admin_tool_router @@ -281,6 +282,7 @@ def get_application() -> FastAPI: status.HTTP_500_INTERNAL_SERVER_ERROR, log_http_error ) + include_router_with_global_prefix_prepended(application, password_router) include_router_with_global_prefix_prepended(application, chat_router) include_router_with_global_prefix_prepended(application, query_router) include_router_with_global_prefix_prepended(application, document_router) diff --git a/backend/onyx/server/features/password/api.py b/backend/onyx/server/features/password/api.py new file mode 100644 index 000000000..94f49c54b --- /dev/null +++ b/backend/onyx/server/features/password/api.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from fastapi_users.exceptions import InvalidPasswordException +from sqlalchemy.orm import Session + +from onyx.auth.users import current_admin_user +from onyx.auth.users import current_user +from onyx.auth.users import get_user_manager +from onyx.auth.users import User +from onyx.auth.users import UserManager +from onyx.db.engine import get_session +from onyx.db.users import get_user_by_email +from onyx.server.features.password.models import ChangePasswordRequest +from onyx.server.features.password.models import UserResetRequest +from onyx.server.features.password.models import UserResetResponse + +router = APIRouter(prefix="/password") + + +@router.post("/change-password") +async def change_my_password( + form_data: ChangePasswordRequest, + user_manager: UserManager = Depends(get_user_manager), + current_user: User = Depends(current_user), +) -> None: + """ + Change the password for the current user. + """ + try: + await user_manager.change_password_if_old_matches( + user=current_user, + old_password=form_data.old_password, + new_password=form_data.new_password, + ) + except InvalidPasswordException as e: + raise HTTPException(status_code=400, detail=str(e.reason)) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) + + +@router.post("/reset_password") +async def admin_reset_user_password( + user_reset_request: UserResetRequest, + user_manager: UserManager = Depends(get_user_manager), + db_session: Session = Depends(get_session), + _: User = Depends(current_admin_user), +) -> UserResetResponse: + """ + Reset the password for a user (admin only). + """ + user = get_user_by_email(user_reset_request.user_email, db_session) + if not user: + raise HTTPException(status_code=404, detail="User not found") + new_password = await user_manager.reset_password_as_admin(user.id) + return UserResetResponse( + user_id=str(user.id), + new_password=new_password, + ) diff --git a/backend/onyx/server/features/password/models.py b/backend/onyx/server/features/password/models.py new file mode 100644 index 000000000..b73c9eab3 --- /dev/null +++ b/backend/onyx/server/features/password/models.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class UserResetRequest(BaseModel): + user_email: str + + +class UserResetResponse(BaseModel): + user_id: str + new_password: str + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str diff --git a/backend/onyx/server/manage/models.py b/backend/onyx/server/manage/models.py index 7d0c5a173..786b9074a 100644 --- a/backend/onyx/server/manage/models.py +++ b/backend/onyx/server/manage/models.py @@ -67,6 +67,7 @@ class UserInfo(BaseModel): is_cloud_superuser: bool = False organization_name: str | None = None is_anonymous_user: bool | None = None + password_configured: bool | None = None @classmethod def from_model( @@ -85,6 +86,7 @@ class UserInfo(BaseModel): is_superuser=user.is_superuser, is_verified=user.is_verified, role=user.role, + password_configured=user.password_configured, preferences=( UserPreferences( shortcut_enabled=user.shortcut_enabled, diff --git a/backend/onyx/server/manage/users.py b/backend/onyx/server/manage/users.py index 457e7ec4b..3a454547f 100644 --- a/backend/onyx/server/manage/users.py +++ b/backend/onyx/server/manage/users.py @@ -206,6 +206,7 @@ def list_all_users( email=user.email, role=user.role, is_active=user.is_active, + password_configured=user.password_configured, ) for user in accepted_users ], @@ -215,6 +216,7 @@ def list_all_users( email=user.email, role=user.role, is_active=user.is_active, + password_configured=user.password_configured, ) for user in slack_users ], @@ -232,6 +234,7 @@ def list_all_users( email=user.email, role=user.role, is_active=user.is_active, + password_configured=user.password_configured, ) for user in accepted_users ][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE], @@ -241,6 +244,7 @@ def list_all_users( email=user.email, role=user.role, is_active=user.is_active, + password_configured=user.password_configured, ) for user in slack_users ][ diff --git a/backend/onyx/server/models.py b/backend/onyx/server/models.py index a785e2d07..0309fbf70 100644 --- a/backend/onyx/server/models.py +++ b/backend/onyx/server/models.py @@ -36,6 +36,7 @@ class FullUserSnapshot(BaseModel): email: str role: UserRole is_active: bool + password_configured: bool @classmethod def from_user_model(cls, user: User) -> "FullUserSnapshot": @@ -44,6 +45,7 @@ class FullUserSnapshot(BaseModel): email=user.email, role=user.role, is_active=user.is_active, + password_configured=user.password_configured, ) diff --git a/web/admin2_auth.json b/web/admin2_auth.json new file mode 100644 index 000000000..cfd81313b --- /dev/null +++ b/web/admin2_auth.json @@ -0,0 +1,15 @@ +{ + "cookies": [ + { + "name": "fastapiusersauth", + "value": "9HrehHtJj1-5UXudkc96qNBS1Aq5yFDFNCPlLR7PW7k", + "domain": "localhost", + "path": "/", + "expires": 1740532793.140733, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + } + ], + "origins": [] +} \ No newline at end of file diff --git a/web/src/app/chat/modal/UserSettingsModal.tsx b/web/src/app/chat/modal/UserSettingsModal.tsx index 678b95a26..e69c34a17 100644 --- a/web/src/app/chat/modal/UserSettingsModal.tsx +++ b/web/src/app/chat/modal/UserSettingsModal.tsx @@ -10,12 +10,9 @@ import { PopupSpec } from "@/components/admin/connectors/Popup"; import { useUser } from "@/components/user/UserProvider"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/admin/connectors/Field"; +import { SubLabel } from "@/components/admin/connectors/Field"; import { SettingsContext } from "@/components/settings/SettingsProvider"; -import { useChatContext } from "@/components/context/ChatContext"; -import { InputPromptsSection } from "./InputPromptsSection"; import { LLMSelector } from "@/components/llm/LLMSelector"; -import { ModeToggle } from "./ThemeToggle"; import { Select, SelectContent, @@ -25,6 +22,10 @@ import { } from "@/components/ui/select"; import { Monitor, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +type SettingsSection = "settings" | "password"; export function UserSettingsModal({ setPopup, @@ -39,17 +40,18 @@ export function UserSettingsModal({ onClose: () => void; defaultModel: string | null; }) { - const { - refreshUser, - user, - updateUserAutoScroll, - updateUserShortcuts, - updateUserTemperatureOverrideEnabled, - } = useUser(); + const { refreshUser, user, updateUserAutoScroll, updateUserShortcuts } = + useUser(); const containerRef = useRef(null); const messageRef = useRef(null); const { theme, setTheme } = useTheme(); const [selectedTheme, setSelectedTheme] = useState(theme); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [activeSection, setActiveSection] = + useState("settings"); useEffect(() => { const container = containerRef.current; @@ -163,138 +165,238 @@ export function UserSettingsModal({ ? autoScroll : user?.preferences?.auto_scroll; + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setPopup({ message: "New passwords do not match", type: "error" }); + return; + } + + setIsLoading(true); + + try { + const response = await fetch("/api/password/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + old_password: currentPassword, + new_password: newPassword, + }), + }); + + if (response.ok) { + setPopup({ message: "Password changed successfully", type: "success" }); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } else { + const errorData = await response.json(); + setPopup({ + message: errorData.detail || "Failed to change password", + type: "error", + }); + } + } catch (error) { + setPopup({ + message: "An error occurred while changing the password", + type: "error", + }); + } finally { + setIsLoading(false); + } + }; + const showPasswordSection = user?.password_configured; + return ( - +
-
-

User settings

-
- -
- {/* Auto-scroll Section */} -
-
-

Auto-scroll

-

- Automatically scroll to new content -

+

User Settings

+ +
+ {showPasswordSection && ( +
+
- { - updateUserAutoScroll(checked); - }} - /> -
- - {/* Prompt Shortcuts Section */} -
-
-

Prompt Shortcuts

-

- Enable keyboard shortcuts for prompts -

-
- { - updateUserShortcuts(checked); - }} - /> -
- - {/* Temperature Override Section */} -
-
-

Temperature Override

-

- Override default temperature settings -

-
- { - updateUserTemperatureOverrideEnabled(checked); - }} - /> -
- - - - {/* Theme Section */} -
-

Theme

- { + setSelectedTheme(value); + setTheme(value); + }} + > + + + + + } + > + System + + } + > + Light + + } value="dark"> + Dark + + +
- - - } - value="system" - > - System - - } value="light"> - Light - - } value="dark"> - Dark - - - -
- - - - {/* Default Model Section */} -
-

Default Model

- { - if (selected === null) { - handleChangedefaultModel(null); - } else { - const { modelName, provider, name } = - destructureValue(selected); - if (modelName && name) { - handleChangedefaultModel( - structureValue(provider, "", modelName) - ); - } - } - }} - /> +
+
+

Auto-scroll

+ Automatically scroll to new content +
+ { + updateUserAutoScroll(checked); + }} + /> +
+
+
+

Prompt Shortcuts

+ Enable keyboard shortcuts for prompts +
+ { + updateUserShortcuts(checked); + }} + /> +
+
+

Default Model

+ { + if (selected === null) { + handleChangedefaultModel(null); + } else { + const { modelName, provider, name } = + destructureValue(selected); + if (modelName && name) { + handleChangedefaultModel( + structureValue(provider, "", modelName) + ); + } + } + }} + /> +
+
+ )} + {activeSection === "password" && ( +
+
+

Change Password

+ + Enter your current password and new password to change your + password. + +
+
+
+ + setCurrentPassword(e.target.value)} + required + className="w-full" + /> +
+
+ + setNewPassword(e.target.value)} + required + className="w-full" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + className="w-full" + /> +
+ +
+
+ )}
diff --git a/web/src/components/admin/users/ResetPasswordModal.tsx b/web/src/components/admin/users/ResetPasswordModal.tsx new file mode 100644 index 000000000..20aafe7ab --- /dev/null +++ b/web/src/components/admin/users/ResetPasswordModal.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { Modal } from "@/components/Modal"; +import { Button } from "@/components/ui/button"; +import { User } from "@/lib/types"; +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import { RefreshCcw, Copy, Check } from "lucide-react"; + +interface ResetPasswordModalProps { + user: User; + onClose: () => void; + setPopup: (spec: PopupSpec) => void; +} + +const ResetPasswordModal: React.FC = ({ + user, + onClose, + setPopup, +}) => { + const [newPassword, setNewPassword] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isCopied, setIsCopied] = useState(false); + + const handleResetPassword = async () => { + setIsLoading(true); + try { + const response = await fetch("/api/password/reset_password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ user_email: user.email }), + }); + + if (response.ok) { + const data = await response.json(); + setNewPassword(data.new_password); + setPopup({ message: "Password reset successfully", type: "success" }); + } else { + const errorData = await response.json(); + setPopup({ + message: errorData.detail || "Failed to reset password", + type: "error", + }); + } + } catch (error) { + setPopup({ + message: "An error occurred while resetting the password", + type: "error", + }); + } finally { + setIsLoading(false); + } + }; + + const handleCopyPassword = () => { + if (newPassword) { + navigator.clipboard.writeText(newPassword); + setPopup({ message: "Password copied to clipboard", type: "success" }); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); // Reset after 2 seconds + } + }; + + return ( + +
+

Reset Password

+

+ Are you sure you want to reset the password for {user.email}? +

+ {newPassword ? ( +
+

New Password:

+
+

+ {newPassword} +

+ +
+

+ Please securely communicate this password to the user. +

+
+ ) : ( + + )} +
+
+ ); +}; + +export default ResetPasswordModal; diff --git a/web/src/components/admin/users/SignedUpUserTable.tsx b/web/src/components/admin/users/SignedUpUserTable.tsx index 08d2735f2..b3a0d56b3 100644 --- a/web/src/components/admin/users/SignedUpUserTable.tsx +++ b/web/src/components/admin/users/SignedUpUserTable.tsx @@ -29,12 +29,27 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; - -const ITEMS_PER_PAGE = 10; -const PAGES_PER_BATCH = 2; +import { Button } from "@/components/ui/button"; +import { RefreshCcw } from "lucide-react"; import { useUser } from "@/components/user/UserProvider"; import { LeaveOrganizationButton } from "./buttons/LeaveOrganizationButton"; import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants"; +import ResetPasswordModal from "./ResetPasswordModal"; +import { + MoreHorizontal, + LogOut, + UserMinus, + UserX, + KeyRound, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +const ITEMS_PER_PAGE = 10; +const PAGES_PER_BATCH = 2; interface Props { invitedUsers: InvitedUserSnapshot[]; @@ -43,6 +58,15 @@ interface Props { invitedUsersMutate: () => void; } +interface ActionMenuProps { + user: User; + currentUser: User | null; + setPopup: (spec: PopupSpec) => void; + refresh: () => void; + invitedUsersMutate: () => void; + handleResetPassword: (user: User) => void; +} + const SignedUpUserTable = ({ invitedUsers, setPopup, @@ -55,6 +79,7 @@ const SignedUpUserTable = ({ }>({}); const [selectedRoles, setSelectedRoles] = useState([]); + const [resetPasswordUser, setResetPasswordUser] = useState(null); const { currentPageData: pageOfUsers, @@ -113,6 +138,10 @@ const SignedUpUserTable = ({ toggleRole(roleEnum); // Deselect the role in filters }; + const handleResetPassword = (user: User) => { + setResetPasswordUser(user); + }; + // -------------- // Render Functions // -------------- @@ -201,6 +230,77 @@ const SignedUpUserTable = ({ ); }; + const ActionMenu: React.FC = ({ + user, + currentUser, + setPopup, + refresh, + invitedUsersMutate, + handleResetPassword, + }) => { + const buttonClassName = "w-full justify-start"; + + return ( + + + + + +
+ {NEXT_PUBLIC_CLOUD_ENABLED && user.id === currentUser?.id ? ( + + + Leave Organization + + ) : ( + <> + {!user.is_active && ( + + + Delete User + + )} + + + {user.is_active ? "Deactivate" : "Activate"} User + + + )} + {user.password_configured && ( + + )} +
+
+
+ ); + }; + const renderActionButtons = (user: User) => { if (user.role === UserRole.SLACK_USER) { return ( @@ -212,24 +312,15 @@ const SignedUpUserTable = ({ /> ); } - return NEXT_PUBLIC_CLOUD_ENABLED && user.id === currentUser?.id ? ( - - ) : ( - <> - - {!user.is_active && ( - - )} - ); }; @@ -279,7 +370,7 @@ const SignedUpUserTable = ({ {user.is_active ? "Active" : "Inactive"} - + {renderActionButtons(user)} @@ -295,6 +386,13 @@ const SignedUpUserTable = ({ onPageChange={goToPage} /> )} + {resetPasswordUser && ( + setResetPasswordUser(null)} + setPopup={setPopup} + /> + )} ); }; diff --git a/web/src/components/admin/users/buttons/DeactivateUserButton.tsx b/web/src/components/admin/users/buttons/DeactivateUserButton.tsx index fa7ae6737..6927e6ace 100644 --- a/web/src/components/admin/users/buttons/DeactivateUserButton.tsx +++ b/web/src/components/admin/users/buttons/DeactivateUserButton.tsx @@ -9,11 +9,15 @@ const DeactivateUserButton = ({ deactivate, setPopup, mutate, + className, + children, }: { user: User; deactivate: boolean; setPopup: (spec: PopupSpec) => void; mutate: () => void; + className?: string; + children?: React.ReactNode; }) => { const { trigger, isMutating } = useSWRMutation( deactivate @@ -34,12 +38,12 @@ const DeactivateUserButton = ({ ); return ( ); }; diff --git a/web/src/components/admin/users/buttons/DeleteUserButton.tsx b/web/src/components/admin/users/buttons/DeleteUserButton.tsx index cdbe4dc6c..97db1edd1 100644 --- a/web/src/components/admin/users/buttons/DeleteUserButton.tsx +++ b/web/src/components/admin/users/buttons/DeleteUserButton.tsx @@ -10,10 +10,14 @@ const DeleteUserButton = ({ user, setPopup, mutate, + className, + children, }: { user: User; setPopup: (spec: PopupSpec) => void; mutate: () => void; + className?: string; + children?: React.ReactNode; }) => { const { trigger, isMutating } = useSWRMutation( "/api/manage/admin/delete-user", @@ -28,7 +32,7 @@ const DeleteUserButton = ({ }, onError: (errorMsg) => setPopup({ - message: `Unable to delete user - ${errorMsg}`, + message: `Unable to delete user - ${errorMsg.message}`, type: "error", }), } @@ -48,13 +52,13 @@ const DeleteUserButton = ({ )} ); diff --git a/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx b/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx index 9108612b5..1af61de97 100644 --- a/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx +++ b/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx @@ -11,10 +11,14 @@ export const LeaveOrganizationButton = ({ user, setPopup, mutate, + className, + children, }: { user: User; setPopup: (spec: PopupSpec) => void; mutate: () => void; + className?: string; + children?: React.ReactNode; }) => { const router = useRouter(); const { trigger, isMutating } = useSWRMutation( @@ -58,13 +62,12 @@ export const LeaveOrganizationButton = ({ )} ); diff --git a/web/src/components/admin/users/buttons/UserRoleDropdown.tsx b/web/src/components/admin/users/buttons/UserRoleDropdown.tsx index 2eb5be11f..2d49d1695 100644 --- a/web/src/components/admin/users/buttons/UserRoleDropdown.tsx +++ b/web/src/components/admin/users/buttons/UserRoleDropdown.tsx @@ -68,13 +68,13 @@ const UserRoleDropdown = ({ onValueChange={handleChange} disabled={isSettingRole} > - + {(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map( ([role, label]) => { - // Dont want to ever show external permissioned users because it's scary + // Don't want to ever show external permissioned users because it's scary if (role === UserRole.EXT_PERM_USER) return null; // Only want to show limited users if paid enterprise features are enabled @@ -92,7 +92,11 @@ const UserRoleDropdown = ({ return isNotVisibleRole && !isCurrentRole ? null : ( { + console.log("clicked"); + }} value={role} + data-testid={`user-role-dropdown-${role}`} title={INVALID_ROLE_HOVER_TEXT[role] ?? ""} data-tooltip-delay="0" > diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx index b523fcc8b..8dabbb3f9 100644 --- a/web/src/components/ui/popover.tsx +++ b/web/src/components/ui/popover.tsx @@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - " z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50", + " z-50 w-64 rounded-md border border-neutral-200 bg-white p-2 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50", className )} {...props} diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx index 51765db84..e5a883d70 100644 --- a/web/src/components/ui/separator.tsx +++ b/web/src/components/ui/separator.tsx @@ -18,7 +18,7 @@ const Separator = React.forwardRef< decorative={decorative} orientation={orientation} className={cn( - "shrink-0 my-4 bg-neutral-200 dark:bg-neutral-800", + "shrink-0 my-4 bg-neutral-200 dark:bg-neutral-600", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className )} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ad0ed00e0..052fb7e32 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -59,6 +59,12 @@ export interface User { is_cloud_superuser?: boolean; organization_name: string | null; is_anonymous_user?: boolean; + // If user does not have a configured password + // (i.e.) they are using an oauth flow + // or are in a no-auth situation + // we don't want to show them things like the reset password + // functionality + password_configured?: boolean; } export interface AllUsersResponse { diff --git a/web/tests/e2e/admin_oauth_redirect_uri.spec.ts b/web/tests/e2e/admin_oauth_redirect_uri.spec.ts index f543574e3..ee59354ae 100644 --- a/web/tests/e2e/admin_oauth_redirect_uri.spec.ts +++ b/web/tests/e2e/admin_oauth_redirect_uri.spec.ts @@ -31,13 +31,3 @@ test("Admin - OAuth Redirect - Invalid Connector", async ({ page }) => { "invalid_connector is not a valid source type." ); }); - -test("Admin - OAuth Redirect - No Session", async ({ page }) => { - await page.goto( - "http://localhost:3000/admin/connectors/slack/oauth/callback?code=123&state=xyz" - ); - - await expect(page.locator("p.text-text-500")).toHaveText( - "An error occurred during the OAuth process. Please try again." - ); -}); diff --git a/web/tests/e2e/admin_pages.spec.ts b/web/tests/e2e/admin_pages.spec.ts index 7d64d7b0d..7be3559ba 100644 --- a/web/tests/e2e/admin_pages.spec.ts +++ b/web/tests/e2e/admin_pages.spec.ts @@ -16,9 +16,16 @@ async function verifyAdminPageNavigation( ) { await page.goto(`http://localhost:3000/admin/${path}`); - await expect(page.locator("h1.text-3xl")).toHaveText(pageTitle, { - timeout: 3000, - }); + try { + await expect(page.locator("h1.text-3xl")).toHaveText(pageTitle, { + timeout: 3000, + }); + } catch (error) { + console.error( + `Failed to find h1 with text "${pageTitle}" for path "${path}"` + ); + throw error; + } if (options?.paragraphText) { await expect(page.locator("p.text-sm").nth(0)).toHaveText( diff --git a/web/tests/e2e/auth/password_management.spec.ts b/web/tests/e2e/auth/password_management.spec.ts new file mode 100644 index 000000000..7ca88d2cb --- /dev/null +++ b/web/tests/e2e/auth/password_management.spec.ts @@ -0,0 +1,112 @@ +import { test, expect } from "@chromatic-com/playwright"; +import { loginAsRandomUser, loginAs } from "../utils/auth"; +import { TEST_ADMIN2_CREDENTIALS, TEST_ADMIN_CREDENTIALS } from "../constants"; + +test("User changes password and logs in with new password", async ({ + page, +}) => { + // Clear browser context before starting the test + await page.context().clearCookies(); + await page.context().clearPermissions(); + + const { email: uniqueEmail, password: initialPassword } = + await loginAsRandomUser(page); + const newPassword = "newPassword456!"; + + // Navigate to user settings + await page.click("#onyx-user-dropdown"); + await page.getByText("User Settings").click(); + await page.getByRole("button", { name: "Password" }).click(); + + // Change password + await page.getByLabel("Current Password").fill(initialPassword); + await page.getByLabel("New Password", { exact: true }).fill(newPassword); + await page.getByLabel("Confirm New Password").fill(newPassword); + await page.getByRole("button", { name: "Change Password" }).click(); + + // Verify password change success message + await expect(page.getByText("Password changed successfully")).toBeVisible(); + + // Log out + await page.getByRole("button", { name: "Close modal", exact: true }).click(); + await page.click("#onyx-user-dropdown"); + await page.getByText("Log out").click(); + + // Log in with new password + await page.goto("http://localhost:3000/auth/login"); + await page.getByTestId("email").fill(uniqueEmail); + await page.getByTestId("password").fill(newPassword); + await page.getByRole("button", { name: "Log In" }).click(); + + // Verify successful login + await expect(page).toHaveURL("http://localhost:3000/chat"); + await expect(page.getByText("Explore Assistants")).toBeVisible(); +}); + +test.use({ storageState: "admin2_auth.json" }); + +test("Admin resets own password and logs in with new password", async ({ + page, +}) => { + const { email: adminEmail, password: adminPassword } = + TEST_ADMIN2_CREDENTIALS; + // Navigate to admin panel + await page.goto("http://localhost:3000/admin/indexing/status"); + + // Check if redirected to login page + if (page.url().includes("/auth/login")) { + await loginAs(page, "admin2"); + } + + // Navigate to Users page in admin panel + await page.goto("http://localhost:3000/admin/users"); + + await page.waitForTimeout(500); + // Find the admin user and click on it + // Log current URL + console.log("Current URL:", page.url()); + // Log current rows + const rows = await page.$$eval("tr", (rows) => + rows.map((row) => row.textContent) + ); + console.log("Current rows:", rows); + + // Log admin email we're looking for + console.log("Admin email:", adminEmail); + + // Attempt to find and click the row + await page + .getByRole("row", { name: adminEmail + " Active" }) + .getByRole("button") + .click(); + + await page.waitForTimeout(500); + // Reset password + await page.getByRole("button", { name: "Reset Password" }).click(); + await page.getByRole("button", { name: "Reset Password" }).click(); + + // Copy the new password + const newPasswordElement = page.getByTestId("new-password"); + const newPassword = await newPasswordElement.textContent(); + if (!newPassword) { + throw new Error("New password not found"); + } + + // Close the modal + await page.getByLabel("Close modal").click(); + + // Log out + await page.click("#onyx-user-dropdown"); + await page.getByText("Log out").click(); + + // Log in with new password + await page.goto("http://localhost:3000/auth/login"); + await page.getByTestId("email").fill(adminEmail); + await page.getByTestId("password").fill(newPassword); + + await page.getByRole("button", { name: "Log In" }).click(); + + // Verify successful login + await expect(page).toHaveURL("http://localhost:3000/chat"); + await expect(page.getByText("Explore Assistants")).toBeVisible(); +}); diff --git a/web/tests/e2e/constants.js b/web/tests/e2e/constants.js index e22b6356b..1d9575e0b 100644 --- a/web/tests/e2e/constants.js +++ b/web/tests/e2e/constants.js @@ -7,3 +7,8 @@ export const TEST_ADMIN_CREDENTIALS = { email: "admin_user@test.com", password: "TestPassword123!", }; + +export const TEST_ADMIN2_CREDENTIALS = { + email: "admin2_user@test.com", + password: "TestPassword123!", +}; diff --git a/web/tests/e2e/global-setup.ts b/web/tests/e2e/global-setup.ts index ab8eabb50..f2d660027 100644 --- a/web/tests/e2e/global-setup.ts +++ b/web/tests/e2e/global-setup.ts @@ -1,5 +1,5 @@ import { chromium, FullConfig } from "@playwright/test"; -import { loginAs } from "./utils/auth"; +import { inviteAdmin2AsAdmin1, loginAs } from "./utils/auth"; async function globalSetup(config: FullConfig) { const browser = await chromium.launch(); @@ -16,6 +16,35 @@ async function globalSetup(config: FullConfig) { await userContext.storageState({ path: "user_auth.json" }); await userContext.close(); + const admin2Context = await browser.newContext(); + const admin2Page = await admin2Context.newPage(); + await loginAs(admin2Page, "admin2"); + await admin2Context.storageState({ path: "admin2_auth.json" }); + await admin2Context.close(); + + const adminContext2 = await browser.newContext({ + storageState: "admin_auth.json", + }); + const adminPage2 = await adminContext2.newPage(); + await inviteAdmin2AsAdmin1(adminPage2); + await adminContext2.close(); + + // Test admin2 access after invitation + const admin2TestContext = await browser.newContext({ + storageState: "admin2_auth.json", + }); + const admin2TestPage = await admin2TestContext.newPage(); + await admin2TestPage.goto("http://localhost:3000/admin/indexing/status"); + + // Ensure we stay on the admin page + if (admin2TestPage.url() !== "http://localhost:3000/admin/indexing/status") { + throw new Error( + `Admin2 was not able to access the admin page after invitation. Actual URL: ${admin2TestPage.url()}` + ); + } + + await admin2TestContext.close(); + await browser.close(); } diff --git a/web/tests/e2e/utils/auth.ts b/web/tests/e2e/utils/auth.ts index 944de4766..d746425fa 100644 --- a/web/tests/e2e/utils/auth.ts +++ b/web/tests/e2e/utils/auth.ts @@ -1,11 +1,23 @@ import { Page } from "@playwright/test"; -import { TEST_ADMIN_CREDENTIALS, TEST_USER_CREDENTIALS } from "../constants"; +import { + TEST_ADMIN2_CREDENTIALS, + TEST_ADMIN_CREDENTIALS, + TEST_USER_CREDENTIALS, +} from "../constants"; // Basic function which logs in a user (either admin or regular user) to the application // It handles both successful login attempts and potential timeouts, with a retry mechanism -export async function loginAs(page: Page, userType: "admin" | "user") { +export async function loginAs( + page: Page, + userType: "admin" | "user" | "admin2" +) { const { email, password } = - userType === "admin" ? TEST_ADMIN_CREDENTIALS : TEST_USER_CREDENTIALS; + userType === "admin" + ? TEST_ADMIN_CREDENTIALS + : userType === "admin2" + ? TEST_ADMIN2_CREDENTIALS + : TEST_USER_CREDENTIALS; + await page.goto("http://localhost:3000/auth/login", { timeout: 1000 }); await page.fill("#email", email); @@ -72,3 +84,51 @@ export async function loginAsRandomUser(page: Page) { return { email, password }; } + +export async function inviteAdmin2AsAdmin1(page: Page) { + await page.goto("http://localhost:3000/admin/users"); + // Wait for 400ms to ensure the page has loaded completely + await page.waitForTimeout(400); + + // Log all currently visible test ids + const testIds = await page.evaluate(() => { + return Array.from(document.querySelectorAll("[data-testid]")).map((el) => + el.getAttribute("data-testid") + ); + }); + console.log("Currently visible test ids:", testIds); + + try { + // Wait for the dropdown trigger to be visible and click it + await page + .getByTestId("user-role-dropdown-trigger-admin2_user@test.com") + .waitFor({ state: "visible", timeout: 5000 }); + await page + .getByTestId("user-role-dropdown-trigger-admin2_user@test.com") + .click(); + + // Wait for the admin option to be visible + await page + .getByTestId("user-role-dropdown-admin") + .waitFor({ state: "visible", timeout: 5000 }); + + // Click the admin option + await page.getByTestId("user-role-dropdown-admin").click(); + + // Wait for any potential loading or update to complete + await page.waitForTimeout(1000); + + // Verify that the change was successful (you may need to adjust this based on your UI) + const newRole = await page + .getByTestId("user-role-dropdown-trigger-admin2_user@test.com") + .textContent(); + if (newRole?.toLowerCase().includes("admin")) { + console.log("Successfully invited admin2 as admin"); + } else { + throw new Error("Failed to update user role to admin"); + } + } catch (error) { + console.error("Error inviting admin2 as admin:", error); + throw error; + } +}