mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-03-17 21:32:36 +01:00
Non-SMTP password reset (#4031)
* update * validate * k * minor cleanup * nit * finalize * k * fix tests * fix tests * fix tests
This commit is contained in:
parent
5a9ec61446
commit
e82a25f49e
@ -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,
|
||||
)
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
61
backend/onyx/server/features/password/api.py
Normal file
61
backend/onyx/server/features/password/api.py
Normal 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,
|
||||
)
|
15
backend/onyx/server/features/password/models.py
Normal file
15
backend/onyx/server/features/password/models.py
Normal 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
|
@ -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,
|
||||
|
@ -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
|
||||
][
|
||||
|
@ -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
15
web/admin2_auth.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "fastapiusersauth",
|
||||
"value": "9HrehHtJj1-5UXudkc96qNBS1Aq5yFDFNCPlLR7PW7k",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1740532793.140733,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
@ -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>
|
||||
|
116
web/src/components/admin/users/ResetPasswordModal.tsx
Normal file
116
web/src/components/admin/users/ResetPasswordModal.tsx
Normal 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;
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
)}
|
||||
|
@ -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 {
|
||||
|
@ -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."
|
||||
);
|
||||
});
|
||||
|
@ -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(
|
||||
|
112
web/tests/e2e/auth/password_management.spec.ts
Normal file
112
web/tests/e2e/auth/password_management.spec.ts
Normal 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();
|
||||
});
|
@ -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!",
|
||||
};
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user