mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-05-13 21:30:21 +02: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,
|
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,
|
||||||
)
|
)
|
||||||
|
@ -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),
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
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
|
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,
|
||||||
|
@ -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
|
||||||
][
|
][
|
||||||
|
@ -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
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 { 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>
|
||||||
);
|
);
|
||||||
|
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,
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
@ -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 {
|
||||||
|
@ -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."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
@ -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(
|
||||||
|
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",
|
email: "admin_user@test.com",
|
||||||
password: "TestPassword123!",
|
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 { 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user