Non-SMTP password reset (#4031)

* update

* validate

* k

* minor cleanup

* nit

* finalize

* k

* fix tests

* fix tests

* fix tests
This commit is contained in:
pablonyx 2025-02-18 18:02:28 -08:00 committed by GitHub
parent 5a9ec61446
commit e82a25f49e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 897 additions and 189 deletions

View File

@ -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,
)

View File

@ -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),

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -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

View File

@ -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,

View File

@ -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
][

View File

@ -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,
)

15
web/admin2_auth.json Normal file
View File

@ -0,0 +1,15 @@
{
"cookies": [
{
"name": "fastapiusersauth",
"value": "9HrehHtJj1-5UXudkc96qNBS1Aq5yFDFNCPlLR7PW7k",
"domain": "localhost",
"path": "/",
"expires": 1740532793.140733,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": []
}

View File

@ -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<HTMLDivElement>(null);
const messageRef = useRef<HTMLDivElement>(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<SettingsSection>("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 (
<Modal onOutsideClick={onClose} width="rounded-lg w-full max-w-xl">
<Modal
onOutsideClick={onClose}
width={`rounded-lg w-full ${
showPasswordSection ? "max-w-3xl" : "max-w-xl"
}`}
>
<div className="p-2">
<div>
<h2 className="text-2xl font-bold">User settings</h2>
</div>
<div className="space-y-6 py-4">
{/* Auto-scroll Section */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="text-base font-medium">Auto-scroll</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Automatically scroll to new content
</p>
<h2 className="text-xl font-bold mb-4">User Settings</h2>
<Separator className="mb-6" />
<div className="flex">
{showPasswordSection && (
<div className="w-1/4 pr-4">
<nav>
<ul className="space-y-2">
<li>
<button
className={`w-full text-base text-left py-2 px-4 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
activeSection === "settings"
? "bg-neutral-100 dark:bg-neutral-700 font-semibold"
: ""
}`}
onClick={() => setActiveSection("settings")}
>
Settings
</button>
</li>
<li>
<button
className={`w-full text-left py-2 px-4 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
activeSection === "password"
? "bg-neutral-100 dark:bg-neutral-700 font-semibold"
: ""
}`}
onClick={() => setActiveSection("password")}
>
Password
</button>
</li>
</ul>
</nav>
</div>
<Switch
size="sm"
checked={checked}
onCheckedChange={(checked) => {
updateUserAutoScroll(checked);
}}
/>
</div>
{/* Prompt Shortcuts Section */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="text-base font-medium">Prompt Shortcuts</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Enable keyboard shortcuts for prompts
</p>
</div>
<Switch
size="sm"
checked={user?.preferences?.shortcut_enabled}
onCheckedChange={(checked) => {
updateUserShortcuts(checked);
}}
/>
</div>
{/* Temperature Override Section */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="text-base font-medium">Temperature Override</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Override default temperature settings
</p>
</div>
<Switch
size="sm"
checked={user?.preferences?.temperature_override_enabled}
onCheckedChange={(checked) => {
updateUserTemperatureOverrideEnabled(checked);
}}
/>
</div>
<Separator className="my-4" />
{/* Theme Section */}
<div className="space-y-3">
<h4 className="text-base font-medium">Theme</h4>
<Select
value={selectedTheme}
onValueChange={(value) => {
setSelectedTheme(value);
setTheme(value);
}}
>
<SelectTrigger className="w-full">
<div className="flex items-center gap-2">
{theme === "system" ? (
<Monitor className="h-4 w-4" />
) : theme === "light" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
<SelectValue placeholder="Select theme" />
)}
<div className={`${showPasswordSection ? "w-3/4 pl-4" : "w-full"}`}>
{activeSection === "settings" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Theme</h3>
<Select
value={selectedTheme}
onValueChange={(value) => {
setSelectedTheme(value);
setTheme(value);
}}
>
<SelectTrigger className="w-full mt-2">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectItem
value="system"
icon={<Monitor className="h-4 w-4" />}
>
System
</SelectItem>
<SelectItem
value="light"
icon={<Sun className="h-4 w-4" />}
>
Light
</SelectItem>
<SelectItem icon={<Moon />} value="dark">
Dark
</SelectItem>
</SelectContent>
</Select>
</div>
</SelectTrigger>
<SelectContent>
<SelectItem
icon={<Monitor className="h-4 w-4" />}
value="system"
>
System
</SelectItem>
<SelectItem icon={<Sun className="h-4 w-4" />} value="light">
Light
</SelectItem>
<SelectItem icon={<Moon className="h-4 w-4" />} value="dark">
Dark
</SelectItem>
</SelectContent>
</Select>
</div>
<Separator className="my-4" />
{/* Default Model Section */}
<div className="space-y-3">
<h4 className="text-base font-medium">Default Model</h4>
<LLMSelector
userSettings
llmProviders={llmProviders}
currentLlm={
defaultModel
? structureValue(
destructureValue(defaultModel).provider,
"",
destructureValue(defaultModel).modelName
)
: null
}
requiresImageGeneration={false}
onSelect={(selected) => {
if (selected === null) {
handleChangedefaultModel(null);
} else {
const { modelName, provider, name } =
destructureValue(selected);
if (modelName && name) {
handleChangedefaultModel(
structureValue(provider, "", modelName)
);
}
}
}}
/>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">Auto-scroll</h3>
<SubLabel>Automatically scroll to new content</SubLabel>
</div>
<Switch
checked={checked}
onCheckedChange={(checked) => {
updateUserAutoScroll(checked);
}}
/>
</div>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">Prompt Shortcuts</h3>
<SubLabel>Enable keyboard shortcuts for prompts</SubLabel>
</div>
<Switch
checked={user?.preferences?.shortcut_enabled}
onCheckedChange={(checked) => {
updateUserShortcuts(checked);
}}
/>
</div>
<div>
<h3 className="text-lg font-medium">Default Model</h3>
<LLMSelector
userSettings
llmProviders={llmProviders}
currentLlm={
defaultModel
? structureValue(
destructureValue(defaultModel).provider,
"",
destructureValue(defaultModel).modelName
)
: null
}
requiresImageGeneration={false}
onSelect={(selected) => {
if (selected === null) {
handleChangedefaultModel(null);
} else {
const { modelName, provider, name } =
destructureValue(selected);
if (modelName && name) {
handleChangedefaultModel(
structureValue(provider, "", modelName)
);
}
}
}}
/>
</div>
</div>
)}
{activeSection === "password" && (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xl font-medium">Change Password</h3>
<SubLabel>
Enter your current password and new password to change your
password.
</SubLabel>
</div>
<form onSubmit={handleChangePassword} className="w-full">
<div className="w-full">
<label htmlFor="currentPassword" className="block mb-1">
Current Password
</label>
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full"
/>
</div>
<div className="w-full">
<label htmlFor="newPassword" className="block mb-1">
New Password
</label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full"
/>
</div>
<div className="w-full">
<label htmlFor="confirmPassword" className="block mb-1">
Confirm New Password
</label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full"
/>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? "Changing..." : "Change Password"}
</Button>
</form>
</div>
)}
</div>
</div>
</div>

View File

@ -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<ResetPasswordModalProps> = ({
user,
onClose,
setPopup,
}) => {
const [newPassword, setNewPassword] = useState<string | null>(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 (
<Modal onOutsideClick={onClose} width="rounded-lg w-full max-w-md">
<div className="p- text-neutral-900 dark:text-neutral-100">
<h2 className="text-2xl font-bold mb-4">Reset Password</h2>
<p className="mb-4">
Are you sure you want to reset the password for {user.email}?
</p>
{newPassword ? (
<div className="mb-4">
<p className="font-semibold">New Password:</p>
<div className="flex items-center bg-neutral-200 dark:bg-neutral-700 p-2 rounded">
<p data-testid="new-password" className="flex-grow">
{newPassword}
</p>
<Button
onClick={handleCopyPassword}
variant="ghost"
size="sm"
className="ml-2"
>
{isCopied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Please securely communicate this password to the user.
</p>
</div>
) : (
<Button
onClick={handleResetPassword}
disabled={isLoading}
className="w-full bg-neutral-700 hover:bg-neutral-600 dark:bg-neutral-200 dark:hover:bg-neutral-300 dark:text-neutral-900"
>
{isLoading ? (
"Resetting..."
) : (
<>
<RefreshCcw className="w-4 h-4 mr-2" />
Reset Password
</>
)}
</Button>
)}
</div>
</Modal>
);
};
export default ResetPasswordModal;

View File

@ -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<UserRole[]>([]);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(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<ActionMenuProps> = ({
user,
currentUser,
setPopup,
refresh,
invitedUsersMutate,
handleResetPassword,
}) => {
const buttonClassName = "w-full justify-start";
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48">
<div className="grid gap-2">
{NEXT_PUBLIC_CLOUD_ENABLED && user.id === currentUser?.id ? (
<LeaveOrganizationButton
user={user}
setPopup={setPopup}
mutate={refresh}
className={buttonClassName}
>
<LogOut className="mr-2 h-4 w-4" />
<span>Leave Organization</span>
</LeaveOrganizationButton>
) : (
<>
{!user.is_active && (
<DeleteUserButton
user={user}
setPopup={setPopup}
mutate={refresh}
className={buttonClassName}
>
<UserMinus className="mr-2 h-4 w-4" />
<span>Delete User</span>
</DeleteUserButton>
)}
<DeactivateUserButton
user={user}
deactivate={user.is_active}
setPopup={setPopup}
mutate={refresh}
className={buttonClassName}
>
<UserX className="mr-2 h-4 w-4" />
<span>{user.is_active ? "Deactivate" : "Activate"} User</span>
</DeactivateUserButton>
</>
)}
{user.password_configured && (
<Button
variant="ghost"
className={buttonClassName}
onClick={() => handleResetPassword(user)}
>
<KeyRound className="mr-2 h-4 w-4" />
<span>Reset Password</span>
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
};
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 ? (
<LeaveOrganizationButton
return (
<ActionMenu
user={user}
currentUser={currentUser}
setPopup={setPopup}
mutate={refresh}
refresh={refresh}
invitedUsersMutate={invitedUsersMutate}
handleResetPassword={handleResetPassword}
/>
) : (
<>
<DeactivateUserButton
user={user}
deactivate={user.is_active}
setPopup={setPopup}
mutate={refresh}
/>
{!user.is_active && (
<DeleteUserButton user={user} setPopup={setPopup} mutate={refresh} />
)}
</>
);
};
@ -279,7 +370,7 @@ const SignedUpUserTable = ({
<TableCell className="text-center w-[140px]">
<i>{user.is_active ? "Active" : "Inactive"}</i>
</TableCell>
<TableCell className="text-right w-[200px]">
<TableCell className="text-right w-[300px] ">
{renderActionButtons(user)}
</TableCell>
</TableRow>
@ -295,6 +386,13 @@ const SignedUpUserTable = ({
onPageChange={goToPage}
/>
)}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
setPopup={setPopup}
/>
)}
</>
);
};

View File

@ -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 (
<Button
className="w-min"
className={className}
onClick={() => trigger({ user_email: user.email })}
disabled={isMutating}
size="sm"
variant="ghost"
>
{deactivate ? "Deactivate" : "Activate"}
{children}
</Button>
);
};

View File

@ -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 = ({
)}
<Button
className="w-min"
className={className}
onClick={() => setShowDeleteModal(true)}
disabled={isMutating}
size="sm"
variant="destructive"
>
Delete
{children}
</Button>
</>
);

View File

@ -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 = ({
)}
<Button
className="w-min"
className={className}
onClick={() => setShowLeaveModal(true)}
disabled={isMutating}
size="sm"
variant="destructive"
variant="ghost"
>
Leave Organization
{children}
</Button>
</>
);

View File

@ -68,13 +68,13 @@ const UserRoleDropdown = ({
onValueChange={handleChange}
disabled={isSettingRole}
>
<SelectTrigger>
<SelectTrigger data-testid={`user-role-dropdown-trigger-${user.email}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(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 : (
<SelectItem
key={role}
onClick={() => {
console.log("clicked");
}}
value={role}
data-testid={`user-role-dropdown-${role}`}
title={INVALID_ROLE_HOVER_TEXT[role] ?? ""}
data-tooltip-delay="0"
>

View File

@ -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}

View File

@ -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
)}

View File

@ -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 {

View File

@ -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."
);
});

View File

@ -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(

View File

@ -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();
});

View File

@ -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!",
};

View File

@ -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();
}

View File

@ -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;
}
}