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, role=UserRole.BASIC if anonymous_user_enabled else UserRole.ADMIN,
preferences=load_no_auth_user_preferences(store), preferences=load_no_auth_user_preferences(store),
is_anonymous_user=anonymous_user_enabled, is_anonymous_user=anonymous_user_enabled,
password_configured=False,
) )

View File

@ -1,5 +1,7 @@
import json import json
import random
import secrets import secrets
import string
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from datetime import datetime from datetime import datetime
@ -143,6 +145,30 @@ def get_display_email(email: str | None, space_less: bool = False) -> str:
return email or "" 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: def user_needs_to_be_verified() -> bool:
if AUTH_TYPE == AuthType.BASIC or AUTH_TYPE == AuthType.CLOUD: if AUTH_TYPE == AuthType.BASIC or AUTH_TYPE == AuthType.CLOUD:
return REQUIRE_EMAIL_VERIFICATION return REQUIRE_EMAIL_VERIFICATION
@ -595,6 +621,39 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
return user 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( async def get_user_manager(
user_db: SQLAlchemyUserDatabase = Depends(get_user_db), user_db: SQLAlchemyUserDatabase = Depends(get_user_db),

View File

@ -205,6 +205,13 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
primaryjoin="User.id == foreign(ConnectorCredentialPair.creator_id)", 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): class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
pass pass

View File

@ -61,6 +61,7 @@ from onyx.server.features.input_prompt.api import (
basic_router as input_prompt_router, basic_router as input_prompt_router,
) )
from onyx.server.features.notifications.api import router as notification_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 admin_router as admin_persona_router
from onyx.server.features.persona.api import basic_router as 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 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 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, chat_router)
include_router_with_global_prefix_prepended(application, query_router) include_router_with_global_prefix_prepended(application, query_router)
include_router_with_global_prefix_prepended(application, document_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 is_cloud_superuser: bool = False
organization_name: str | None = None organization_name: str | None = None
is_anonymous_user: bool | None = None is_anonymous_user: bool | None = None
password_configured: bool | None = None
@classmethod @classmethod
def from_model( def from_model(
@ -85,6 +86,7 @@ class UserInfo(BaseModel):
is_superuser=user.is_superuser, is_superuser=user.is_superuser,
is_verified=user.is_verified, is_verified=user.is_verified,
role=user.role, role=user.role,
password_configured=user.password_configured,
preferences=( preferences=(
UserPreferences( UserPreferences(
shortcut_enabled=user.shortcut_enabled, shortcut_enabled=user.shortcut_enabled,

View File

@ -206,6 +206,7 @@ def list_all_users(
email=user.email, email=user.email,
role=user.role, role=user.role,
is_active=user.is_active, is_active=user.is_active,
password_configured=user.password_configured,
) )
for user in accepted_users for user in accepted_users
], ],
@ -215,6 +216,7 @@ def list_all_users(
email=user.email, email=user.email,
role=user.role, role=user.role,
is_active=user.is_active, is_active=user.is_active,
password_configured=user.password_configured,
) )
for user in slack_users for user in slack_users
], ],
@ -232,6 +234,7 @@ def list_all_users(
email=user.email, email=user.email,
role=user.role, role=user.role,
is_active=user.is_active, is_active=user.is_active,
password_configured=user.password_configured,
) )
for user in accepted_users for user in accepted_users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE], ][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
@ -241,6 +244,7 @@ def list_all_users(
email=user.email, email=user.email,
role=user.role, role=user.role,
is_active=user.is_active, is_active=user.is_active,
password_configured=user.password_configured,
) )
for user in slack_users for user in slack_users
][ ][

View File

@ -36,6 +36,7 @@ class FullUserSnapshot(BaseModel):
email: str email: str
role: UserRole role: UserRole
is_active: bool is_active: bool
password_configured: bool
@classmethod @classmethod
def from_user_model(cls, user: User) -> "FullUserSnapshot": def from_user_model(cls, user: User) -> "FullUserSnapshot":
@ -44,6 +45,7 @@ class FullUserSnapshot(BaseModel):
email=user.email, email=user.email,
role=user.role, role=user.role,
is_active=user.is_active, 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 { useUser } from "@/components/user/UserProvider";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; 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 { SettingsContext } from "@/components/settings/SettingsProvider";
import { useChatContext } from "@/components/context/ChatContext";
import { InputPromptsSection } from "./InputPromptsSection";
import { LLMSelector } from "@/components/llm/LLMSelector"; import { LLMSelector } from "@/components/llm/LLMSelector";
import { ModeToggle } from "./ThemeToggle";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -25,6 +22,10 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Monitor, Moon, Sun } from "lucide-react"; import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type SettingsSection = "settings" | "password";
export function UserSettingsModal({ export function UserSettingsModal({
setPopup, setPopup,
@ -39,17 +40,18 @@ export function UserSettingsModal({
onClose: () => void; onClose: () => void;
defaultModel: string | null; defaultModel: string | null;
}) { }) {
const { const { refreshUser, user, updateUserAutoScroll, updateUserShortcuts } =
refreshUser, useUser();
user,
updateUserAutoScroll,
updateUserShortcuts,
updateUserTemperatureOverrideEnabled,
} = useUser();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef<HTMLDivElement>(null); const messageRef = useRef<HTMLDivElement>(null);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [selectedTheme, setSelectedTheme] = useState(theme); 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(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@ -163,70 +165,98 @@ export function UserSettingsModal({
? autoScroll ? autoScroll
: user?.preferences?.auto_scroll; : 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 ( 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 className="p-2">
<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>
)}
<div className={`${showPasswordSection ? "w-3/4 pl-4" : "w-full"}`}>
{activeSection === "settings" && (
<div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold">User settings</h2> <h3 className="text-lg font-medium">Theme</h3>
</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>
</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 <Select
value={selectedTheme} value={selectedTheme}
onValueChange={(value) => { onValueChange={(value) => {
@ -234,40 +264,54 @@ export function UserSettingsModal({
setTheme(value); setTheme(value);
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full mt-2">
<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" /> <SelectValue placeholder="Select theme" />
</div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
icon={<Monitor className="h-4 w-4" />}
value="system" value="system"
icon={<Monitor className="h-4 w-4" />}
> >
System System
</SelectItem> </SelectItem>
<SelectItem icon={<Sun className="h-4 w-4" />} value="light"> <SelectItem
value="light"
icon={<Sun className="h-4 w-4" />}
>
Light Light
</SelectItem> </SelectItem>
<SelectItem icon={<Moon className="h-4 w-4" />} value="dark"> <SelectItem icon={<Moon />} value="dark">
Dark Dark
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center justify-between">
<Separator className="my-4" /> <div>
<h3 className="text-lg font-medium">Auto-scroll</h3>
{/* Default Model Section */} <SubLabel>Automatically scroll to new content</SubLabel>
<div className="space-y-3"> </div>
<h4 className="text-base font-medium">Default Model</h4> <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 <LLMSelector
userSettings userSettings
llmProviders={llmProviders} llmProviders={llmProviders}
@ -297,6 +341,64 @@ export function UserSettingsModal({
/> />
</div> </div>
</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> </div>
</Modal> </Modal>
); );

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, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
const ITEMS_PER_PAGE = 10; import { RefreshCcw } from "lucide-react";
const PAGES_PER_BATCH = 2;
import { useUser } from "@/components/user/UserProvider"; import { useUser } from "@/components/user/UserProvider";
import { LeaveOrganizationButton } from "./buttons/LeaveOrganizationButton"; import { LeaveOrganizationButton } from "./buttons/LeaveOrganizationButton";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants"; 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 { interface Props {
invitedUsers: InvitedUserSnapshot[]; invitedUsers: InvitedUserSnapshot[];
@ -43,6 +58,15 @@ interface Props {
invitedUsersMutate: () => void; invitedUsersMutate: () => void;
} }
interface ActionMenuProps {
user: User;
currentUser: User | null;
setPopup: (spec: PopupSpec) => void;
refresh: () => void;
invitedUsersMutate: () => void;
handleResetPassword: (user: User) => void;
}
const SignedUpUserTable = ({ const SignedUpUserTable = ({
invitedUsers, invitedUsers,
setPopup, setPopup,
@ -55,6 +79,7 @@ const SignedUpUserTable = ({
}>({}); }>({});
const [selectedRoles, setSelectedRoles] = useState<UserRole[]>([]); const [selectedRoles, setSelectedRoles] = useState<UserRole[]>([]);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
const { const {
currentPageData: pageOfUsers, currentPageData: pageOfUsers,
@ -113,6 +138,10 @@ const SignedUpUserTable = ({
toggleRole(roleEnum); // Deselect the role in filters toggleRole(roleEnum); // Deselect the role in filters
}; };
const handleResetPassword = (user: User) => {
setResetPasswordUser(user);
};
// -------------- // --------------
// Render Functions // 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) => { const renderActionButtons = (user: User) => {
if (user.role === UserRole.SLACK_USER) { if (user.role === UserRole.SLACK_USER) {
return ( return (
@ -212,24 +312,15 @@ const SignedUpUserTable = ({
/> />
); );
} }
return NEXT_PUBLIC_CLOUD_ENABLED && user.id === currentUser?.id ? ( return (
<LeaveOrganizationButton <ActionMenu
user={user} user={user}
currentUser={currentUser}
setPopup={setPopup} 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]"> <TableCell className="text-center w-[140px]">
<i>{user.is_active ? "Active" : "Inactive"}</i> <i>{user.is_active ? "Active" : "Inactive"}</i>
</TableCell> </TableCell>
<TableCell className="text-right w-[200px]"> <TableCell className="text-right w-[300px] ">
{renderActionButtons(user)} {renderActionButtons(user)}
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -295,6 +386,13 @@ const SignedUpUserTable = ({
onPageChange={goToPage} onPageChange={goToPage}
/> />
)} )}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
setPopup={setPopup}
/>
)}
</> </>
); );
}; };

View File

@ -9,11 +9,15 @@ const DeactivateUserButton = ({
deactivate, deactivate,
setPopup, setPopup,
mutate, mutate,
className,
children,
}: { }: {
user: User; user: User;
deactivate: boolean; deactivate: boolean;
setPopup: (spec: PopupSpec) => void; setPopup: (spec: PopupSpec) => void;
mutate: () => void; mutate: () => void;
className?: string;
children?: React.ReactNode;
}) => { }) => {
const { trigger, isMutating } = useSWRMutation( const { trigger, isMutating } = useSWRMutation(
deactivate deactivate
@ -34,12 +38,12 @@ const DeactivateUserButton = ({
); );
return ( return (
<Button <Button
className="w-min" className={className}
onClick={() => trigger({ user_email: user.email })} onClick={() => trigger({ user_email: user.email })}
disabled={isMutating} disabled={isMutating}
size="sm" variant="ghost"
> >
{deactivate ? "Deactivate" : "Activate"} {children}
</Button> </Button>
); );
}; };

View File

@ -10,10 +10,14 @@ const DeleteUserButton = ({
user, user,
setPopup, setPopup,
mutate, mutate,
className,
children,
}: { }: {
user: User; user: User;
setPopup: (spec: PopupSpec) => void; setPopup: (spec: PopupSpec) => void;
mutate: () => void; mutate: () => void;
className?: string;
children?: React.ReactNode;
}) => { }) => {
const { trigger, isMutating } = useSWRMutation( const { trigger, isMutating } = useSWRMutation(
"/api/manage/admin/delete-user", "/api/manage/admin/delete-user",
@ -28,7 +32,7 @@ const DeleteUserButton = ({
}, },
onError: (errorMsg) => onError: (errorMsg) =>
setPopup({ setPopup({
message: `Unable to delete user - ${errorMsg}`, message: `Unable to delete user - ${errorMsg.message}`,
type: "error", type: "error",
}), }),
} }
@ -48,13 +52,13 @@ const DeleteUserButton = ({
)} )}
<Button <Button
className="w-min" className={className}
onClick={() => setShowDeleteModal(true)} onClick={() => setShowDeleteModal(true)}
disabled={isMutating} disabled={isMutating}
size="sm" size="sm"
variant="destructive" variant="destructive"
> >
Delete {children}
</Button> </Button>
</> </>
); );

View File

@ -11,10 +11,14 @@ export const LeaveOrganizationButton = ({
user, user,
setPopup, setPopup,
mutate, mutate,
className,
children,
}: { }: {
user: User; user: User;
setPopup: (spec: PopupSpec) => void; setPopup: (spec: PopupSpec) => void;
mutate: () => void; mutate: () => void;
className?: string;
children?: React.ReactNode;
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { trigger, isMutating } = useSWRMutation( const { trigger, isMutating } = useSWRMutation(
@ -58,13 +62,12 @@ export const LeaveOrganizationButton = ({
)} )}
<Button <Button
className="w-min" className={className}
onClick={() => setShowLeaveModal(true)} onClick={() => setShowLeaveModal(true)}
disabled={isMutating} disabled={isMutating}
size="sm" variant="ghost"
variant="destructive"
> >
Leave Organization {children}
</Button> </Button>
</> </>
); );

View File

@ -68,13 +68,13 @@ const UserRoleDropdown = ({
onValueChange={handleChange} onValueChange={handleChange}
disabled={isSettingRole} disabled={isSettingRole}
> >
<SelectTrigger> <SelectTrigger data-testid={`user-role-dropdown-trigger-${user.email}`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map( {(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map(
([role, label]) => { ([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; if (role === UserRole.EXT_PERM_USER) return null;
// Only want to show limited users if paid enterprise features are enabled // Only want to show limited users if paid enterprise features are enabled
@ -92,7 +92,11 @@ const UserRoleDropdown = ({
return isNotVisibleRole && !isCurrentRole ? null : ( return isNotVisibleRole && !isCurrentRole ? null : (
<SelectItem <SelectItem
key={role} key={role}
onClick={() => {
console.log("clicked");
}}
value={role} value={role}
data-testid={`user-role-dropdown-${role}`}
title={INVALID_ROLE_HOVER_TEXT[role] ?? ""} title={INVALID_ROLE_HOVER_TEXT[role] ?? ""}
data-tooltip-delay="0" data-tooltip-delay="0"
> >

View File

@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -18,7 +18,7 @@ const Separator = React.forwardRef<
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( 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]", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className className
)} )}

View File

@ -59,6 +59,12 @@ export interface User {
is_cloud_superuser?: boolean; is_cloud_superuser?: boolean;
organization_name: string | null; organization_name: string | null;
is_anonymous_user?: boolean; 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 { 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." "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 page.goto(`http://localhost:3000/admin/${path}`);
try {
await expect(page.locator("h1.text-3xl")).toHaveText(pageTitle, { await expect(page.locator("h1.text-3xl")).toHaveText(pageTitle, {
timeout: 3000, timeout: 3000,
}); });
} catch (error) {
console.error(
`Failed to find h1 with text "${pageTitle}" for path "${path}"`
);
throw error;
}
if (options?.paragraphText) { if (options?.paragraphText) {
await expect(page.locator("p.text-sm").nth(0)).toHaveText( 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", email: "admin_user@test.com",
password: "TestPassword123!", 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 { chromium, FullConfig } from "@playwright/test";
import { loginAs } from "./utils/auth"; import { inviteAdmin2AsAdmin1, loginAs } from "./utils/auth";
async function globalSetup(config: FullConfig) { async function globalSetup(config: FullConfig) {
const browser = await chromium.launch(); const browser = await chromium.launch();
@ -16,6 +16,35 @@ async function globalSetup(config: FullConfig) {
await userContext.storageState({ path: "user_auth.json" }); await userContext.storageState({ path: "user_auth.json" });
await userContext.close(); 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(); await browser.close();
} }

View File

@ -1,11 +1,23 @@
import { Page } from "@playwright/test"; 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 // 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 // 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 } = 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.goto("http://localhost:3000/auth/login", { timeout: 1000 });
await page.fill("#email", email); await page.fill("#email", email);
@ -72,3 +84,51 @@ export async function loginAsRandomUser(page: Page) {
return { email, password }; 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;
}
}