Cleanup user management

This commit is contained in:
Weves 2024-06-18 14:22:57 -07:00 committed by Chris Weaver
parent 54c2547d89
commit b07fdbf1d1
9 changed files with 258 additions and 181 deletions

View File

@ -10,8 +10,6 @@ from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from danswer.auth.invited_users import get_invited_users
from danswer.auth.invited_users import write_invited_users
from danswer.auth.users import current_admin_user
from danswer.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ
from danswer.configs.app_configs import TOKEN_BUDGET_GLOBALLY_ENABLED
@ -28,7 +26,6 @@ from danswer.db.feedback import update_document_boost
from danswer.db.feedback import update_document_hidden
from danswer.db.index_attempt import cancel_indexing_attempts_for_connector
from danswer.db.models import User
from danswer.db.users import get_user_by_email
from danswer.document_index.document_index_utils import get_both_index_names
from danswer.document_index.factory import get_default_document_index
from danswer.dynamic_configs.factory import get_dynamic_config_store
@ -40,7 +37,6 @@ from danswer.server.documents.models import ConnectorCredentialPairIdentifier
from danswer.server.manage.models import BoostDoc
from danswer.server.manage.models import BoostUpdateRequest
from danswer.server.manage.models import HiddenUpdateRequest
from danswer.server.manage.models import UserByEmail
from danswer.utils.logger import setup_logger
router = APIRouter(prefix="/manage")
@ -235,66 +231,3 @@ def update_token_budget_settings(
# Store the settings in the dynamic config store
get_dynamic_config_store().store(TOKEN_BUDGET_SETTINGS, settings_json)
return {"message": "Token budget settings updated successfully."}
@router.put("/admin/users")
def bulk_invite_users(
emails: list[str] = Body(..., embed=True),
_: User | None = Depends(current_admin_user),
) -> int:
all_emails = list(set(emails) | set(get_invited_users()))
return write_invited_users(all_emails)
@router.patch("/admin/remove-invited-user")
def remove_invited_user(
user_email: UserByEmail,
_: User | None = Depends(current_admin_user),
) -> int:
user_emails = get_invited_users()
remaining_users = [user for user in user_emails if user != user_email.user_email]
return write_invited_users(remaining_users)
@router.patch("/admin/deactivate-user")
def deactivate_user(
user_email: UserByEmail,
current_user: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
if current_user.email == user_email.user_email:
raise HTTPException(status_code=400, detail="You cannot deactivate yourself")
user_to_deactivate = get_user_by_email(
email=user_email.user_email, db_session=db_session
)
if not user_to_deactivate:
raise HTTPException(status_code=404, detail="User not found")
if user_to_deactivate.is_active is False:
logger.warning("{} is already deactivated".format(user_to_deactivate.email))
user_to_deactivate.is_active = False
db_session.add(user_to_deactivate)
db_session.commit()
@router.patch("/admin/activate-user")
def activate_user(
user_email: UserByEmail,
_: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
user_to_activate = get_user_by_email(
email=user_email.user_email, db_session=db_session
)
if not user_to_activate:
raise HTTPException(status_code=404, detail="User not found")
if user_to_activate.is_active is True:
logger.warning("{} is already activated".format(user_to_activate.email))
user_to_activate.is_active = True
db_session.add(user_to_activate)
db_session.commit()

View File

@ -1,6 +1,7 @@
import re
from fastapi import APIRouter
from fastapi import Body
from fastapi import Depends
from fastapi import HTTPException
from fastapi import status
@ -9,6 +10,7 @@ from sqlalchemy import update
from sqlalchemy.orm import Session
from danswer.auth.invited_users import get_invited_users
from danswer.auth.invited_users import write_invited_users
from danswer.auth.noauth_user import fetch_no_auth_user
from danswer.auth.noauth_user import set_no_auth_user_preferences
from danswer.auth.schemas import UserRole
@ -17,6 +19,7 @@ from danswer.auth.users import current_admin_user
from danswer.auth.users import current_user
from danswer.auth.users import optional_user
from danswer.configs.app_configs import AUTH_TYPE
from danswer.configs.app_configs import VALID_EMAIL_DOMAINS
from danswer.configs.constants import AuthType
from danswer.db.engine import get_session
from danswer.db.models import User
@ -30,9 +33,14 @@ from danswer.server.manage.models import UserRoleResponse
from danswer.server.models import FullUserSnapshot
from danswer.server.models import InvitedUserSnapshot
from danswer.server.models import MinimalUserSnapshot
from danswer.utils.logger import setup_logger
logger = setup_logger()
router = APIRouter()
USERS_PAGE_SIZE = 10
@ -104,16 +112,94 @@ def list_all_users(
)
for user in users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
invited=[
InvitedUserSnapshot(email=email)
for email in invited_emails
if email not in accepted_emails
][invited_page * USERS_PAGE_SIZE : (invited_page + 1) * USERS_PAGE_SIZE],
invited=[InvitedUserSnapshot(email=email) for email in invited_emails][
invited_page * USERS_PAGE_SIZE : (invited_page + 1) * USERS_PAGE_SIZE
],
accepted_pages=accepted_count // USERS_PAGE_SIZE + 1,
invited_pages=invited_count // USERS_PAGE_SIZE + 1,
)
@router.put("/manage/admin/users")
def bulk_invite_users(
emails: list[str] = Body(..., embed=True),
current_user: User | None = Depends(current_admin_user),
) -> int:
if current_user is None:
raise HTTPException(
status_code=400, detail="Auth is disabled, cannot invite users"
)
all_emails = list(set(emails) | set(get_invited_users()))
return write_invited_users(all_emails)
@router.patch("/manage/admin/remove-invited-user")
def remove_invited_user(
user_email: UserByEmail,
_: User | None = Depends(current_admin_user),
) -> int:
user_emails = get_invited_users()
remaining_users = [user for user in user_emails if user != user_email.user_email]
return write_invited_users(remaining_users)
@router.patch("/manage/admin/deactivate-user")
def deactivate_user(
user_email: UserByEmail,
current_user: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
if current_user is None:
raise HTTPException(
status_code=400, detail="Auth is disabled, cannot deactivate user"
)
if current_user.email == user_email.user_email:
raise HTTPException(status_code=400, detail="You cannot deactivate yourself")
user_to_deactivate = get_user_by_email(
email=user_email.user_email, db_session=db_session
)
if not user_to_deactivate:
raise HTTPException(status_code=404, detail="User not found")
if user_to_deactivate.is_active is False:
logger.warning("{} is already deactivated".format(user_to_deactivate.email))
user_to_deactivate.is_active = False
db_session.add(user_to_deactivate)
db_session.commit()
@router.patch("/manage/admin/activate-user")
def activate_user(
user_email: UserByEmail,
_: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
user_to_activate = get_user_by_email(
email=user_email.user_email, db_session=db_session
)
if not user_to_activate:
raise HTTPException(status_code=404, detail="User not found")
if user_to_activate.is_active is True:
logger.warning("{} is already activated".format(user_to_activate.email))
user_to_activate.is_active = True
db_session.add(user_to_activate)
db_session.commit()
@router.get("/manage/admin/valid-domains")
def get_valid_domains(
_: User | None = Depends(current_admin_user),
) -> list[str]:
return VALID_EMAIL_DOMAINS
"""Endpoints for all"""

View File

@ -4,19 +4,9 @@ import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
import { SearchBar } from "@/components/search/SearchBar";
import { useState } from "react";
import { FiPlusSquare } from "react-icons/fi";
import Link from "next/link";
import { Modal } from "@/components/Modal";
import {
Table,
TableHead,
TableRow,
TableHeaderCell,
TableBody,
TableCell,
Button,
Text,
} from "@tremor/react";
import { Button, Text } from "@tremor/react";
import { LoadingAnimation } from "@/components/Loading";
import { AdminPageTitle } from "@/components/admin/Title";
import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup";
@ -24,11 +14,43 @@ import { UsersIcon } from "@/components/icons/icons";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { type User, UserStatus } from "@/lib/types";
import useSWR, { mutate } from "swr";
import useSWRMutation from "swr/mutation";
import { ErrorCallout } from "@/components/ErrorCallout";
import { HidableSection } from "@/app/admin/assistants/HidableSection";
import BulkAdd from "@/components/admin/users/BulkAdd";
const ValidDomainsDisplay = ({ validDomains }: { validDomains: string[] }) => {
if (!validDomains.length) {
return (
<div className="text-sm">
No invited users. Anyone can sign up with a valid email address. To
restrict access you can:
<div className="flex flex-wrap ml-2 mt-1">
(1) Invite users above. Once a user has been invited, only emails that
have explicitly been invited will be able to sign-up.
</div>
<div className="mt-1 ml-2">
(2) Set the{" "}
<b className="font-mono w-fit h-fit">VALID_EMAIL_DOMAINS</b>{" "}
environment variable to a comma separated list of email domains. This
will restrict access to users with email addresses from these domains.
</div>
</div>
);
}
return (
<div className="text-sm">
No invited users. Anyone with an email address with any of the following
domains can sign up: <i>{validDomains.join(", ")}</i>.
<div className="mt-2">
To further restrict access you can invite users above. Once a user has
been invited, only emails that have explicitly been invited will be able
to sign-up.
</div>
</div>
);
};
interface UsersResponse {
accepted: User[];
invited: User[];
@ -46,11 +68,18 @@ const UsersTables = ({
const [invitedPage, setInvitedPage] = useState(1);
const [acceptedPage, setAcceptedPage] = useState(1);
const { data, isLoading, mutate, error } = useSWR<UsersResponse>(
`/api/manage/users?q=${encodeURI(q)}&accepted_page=${acceptedPage - 1}&invited_page=${invitedPage - 1}`,
`/api/manage/users?q=${encodeURI(q)}&accepted_page=${
acceptedPage - 1
}&invited_page=${invitedPage - 1}`,
errorHandlingFetcher
);
const {
data: validDomains,
isLoading: isLoadingDomains,
error: domainsError,
} = useSWR<string[]>("/api/manage/admin/valid-domains", errorHandlingFetcher);
if (isLoading) {
if (isLoading || isLoadingDomains) {
return <LoadingAnimation text="Loading" />;
}
@ -63,18 +92,45 @@ const UsersTables = ({
);
}
if (domainsError || !validDomains) {
return (
<ErrorCallout
errorTitle="Error loading valid domains"
errorMsg={domainsError?.info?.detail}
/>
);
}
const { accepted, invited, accepted_pages, invited_pages } = data;
// remove users that are already accepted
const finalInvited = invited.filter(
(user) => !accepted.map((u) => u.email).includes(user.email)
);
return (
<>
<InvitedUserTable
users={invited}
setPopup={setPopup}
currentPage={invitedPage}
onPageChange={setInvitedPage}
totalPages={invited_pages}
mutate={mutate}
/>
<HidableSection sectionTitle="Invited Users">
{invited.length > 0 ? (
finalInvited.length > 0 ? (
<InvitedUserTable
users={finalInvited}
setPopup={setPopup}
currentPage={invitedPage}
onPageChange={setInvitedPage}
totalPages={invited_pages}
mutate={mutate}
/>
) : (
<div className="text-sm">
To invite additional teammates, use the <b>Invite Users</b> button
above!
</div>
)
) : (
<ValidDomainsDisplay validDomains={validDomains} />
)}
</HidableSection>
<SignedUpUserTable
users={accepted}
setPopup={setPopup}
@ -129,6 +185,13 @@ const AddUserButton = ({
type: "success",
});
};
const onFailure = async (res: Response) => {
const error = (await res.json()).detail;
setPopup({
message: `Failed to invite users - ${error}`,
type: "error",
});
};
return (
<>
<Button className="w-fit" onClick={() => setModal(true)}>
@ -143,7 +206,7 @@ const AddUserButton = ({
<Text className="font-medium text-base">
Add the email addresses to import, separated by whitespaces.
</Text>
<BulkAdd onSuccess={onSuccess} />
<BulkAdd onSuccess={onSuccess} onFailure={onFailure} />
</div>
</Modal>
)}

View File

@ -1,6 +1,21 @@
"use client";
import { Button } from "@tremor/react";
import Link from "next/link";
import { FiLogIn } from "react-icons/fi";
const Page = () => {
return (
<div>Unable to login, please try again and/or contact an administrator</div>
<div className="flex flex-col items-center justify-center h-screen">
<div className="font-bold">
Unable to login, please try again and/or contact an administrator.
</div>
<Link href="/auth/login" className="w-fit">
<Button className="mt-4" size="xs" icon={FiLogIn}>
Back to login
</Button>
</Link>
</div>
);
};

View File

@ -7,8 +7,8 @@ export interface PopupSpec {
export const Popup: React.FC<PopupSpec> = ({ message, type }) => (
<div
className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-30 ${
type === "success" ? "bg-green-500" : "bg-red-500"
className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-[100] ${
type === "success" ? "bg-green-500" : "bg-error"
}`}
>
{message}

View File

@ -1,13 +1,8 @@
"use client";
import useSWRMutation from "swr/mutation";
import { RobotIcon } from "@/components/icons/icons";
import { withFormik, FormikProps, FormikErrors, Form, Field } from "formik";
import { BackButton } from "@/components/BackButton";
import { Card } from "@tremor/react";
import { AdminPageTitle } from "@/components/admin/Title";
import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { Button, Text } from "@tremor/react";
import { Button } from "@tremor/react";
const WHITESPACE_SPLIT = /\s+/;
const EMAIL_REGEX = /[^@]+@[^.]+\.[^.]/;
@ -24,6 +19,7 @@ const addUsers = async (url: string, { arg }: { arg: Array<string> }) => {
interface FormProps {
onSuccess: () => void;
onFailure: (res: Response) => void;
}
interface FormValues {
@ -77,13 +73,15 @@ const AddUserForm = withFormik<FormProps, FormValues>({
await addUsers("/api/manage/admin/users", { arg: emails }).then((res) => {
if (res.ok) {
formikBag.props.onSuccess();
} else {
formikBag.props.onFailure(res);
}
});
},
})(AddUserFormRenderer);
const BulkAdd = ({ onSuccess }: FormProps) => {
return <AddUserForm onSuccess={onSuccess} />;
const BulkAdd = ({ onSuccess, onFailure }: FormProps) => {
return <AddUserForm onSuccess={onSuccess} onFailure={onFailure} />;
};
export default BulkAdd;

View File

@ -68,43 +68,41 @@ const InvitedUserTable = ({
};
return (
<HidableSection sectionTitle="Invited Users">
<>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
<Table className="overflow-visible">
<TableHead>
<TableRow>
<TableHeaderCell>Email</TableHeaderCell>
<TableHeaderCell>
<div className="flex justify-end">Actions</div>
</TableHeaderCell>
<>
<Table className="overflow-visible">
<TableHead>
<TableRow>
<TableHeaderCell>Email</TableHeaderCell>
<TableHeaderCell>
<div className="flex justify-end">Actions</div>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.email}>
<TableCell>{user.email}</TableCell>
<TableCell>
<div className="flex justify-end">
<RemoveUserButton
user={user}
onSuccess={onRemovalSuccess}
onError={onRemovalError}
/>
</div>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.email}>
<TableCell>{user.email}</TableCell>
<TableCell>
<div className="flex justify-end">
<RemoveUserButton
user={user}
onSuccess={onRemovalSuccess}
onError={onRemovalError}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
</HidableSection>
))}
</TableBody>
</Table>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
</>
);
};

View File

@ -14,7 +14,6 @@ import {
TableCell,
Button,
} from "@tremor/react";
import { PageSelector } from "@/components/PageSelector";
interface Props {
users: Array<User>;
@ -45,6 +44,7 @@ const PromoterButton = ({
className="w-min"
onClick={() => trigger({ user_email: user.email })}
disabled={isMutating}
size="xs"
>
{promote ? "Promote" : "Demote"} to {promote ? "Admin" : "Basic"} User
</Button>
@ -54,28 +54,38 @@ const PromoterButton = ({
const DeactivaterButton = ({
user,
deactivate,
onSuccess,
onError,
setPopup,
mutate,
}: {
user: User;
deactivate: boolean;
onSuccess: () => void;
onError: (message: string) => void;
setPopup: (spec: PopupSpec) => void;
mutate: () => void;
}) => {
const { trigger, isMutating } = useSWRMutation(
deactivate
? "/api/manage/admin/deactivate-user"
: "/api/manage/admin/activate-user",
userMutationFetcher,
{ onSuccess, onError }
{
onSuccess: () => {
mutate();
setPopup({
message: `User ${deactivate ? "deactivated" : "activated"}!`,
type: "success",
});
},
onError: (errorMsg) => setPopup({ message: errorMsg, type: "error" }),
}
);
return (
<Button
className="w-min"
onClick={() => trigger({ user_email: user.email })}
disabled={isMutating}
size="xs"
>
{deactivate ? "Deactivate" : "Activate"} Access
{deactivate ? "Deactivate" : "Activate"}
</Button>
);
};
@ -116,34 +126,8 @@ const SignedUpUserTable = ({
onError(`Unable to demote admin - ${errorMsg}`);
};
const onDeactivateSuccess = () => {
mutate();
setPopup({
message: "User deactivated!",
type: "success",
});
};
const onDeactivateError = (errorMsg: string) => {
setPopup({
message: `Unable to deactivate user - ${errorMsg}`,
type: "error",
});
};
const onActivateSuccess = () => {
mutate();
setPopup({
message: "User activate!",
type: "success",
});
};
const onActivateError = (errorMsg: string) => {
setPopup({
message: `Unable to activate user - ${errorMsg}`,
type: "error",
});
};
return (
<HidableSection sectionTitle="Signed Up Users">
<HidableSection sectionTitle="Current Users">
<>
{totalPages > 1 ? (
<CenteredPageSelector
@ -186,8 +170,8 @@ const SignedUpUserTable = ({
<DeactivaterButton
user={user}
deactivate={user.status === UserStatus.live}
onSuccess={onDeactivateSuccess}
onError={onDeactivateError}
setPopup={setPopup}
mutate={mutate}
/>
</div>
</TableCell>

View File

@ -12,8 +12,8 @@ const userMutationFetcher = async (
}),
}).then(async (res) => {
if (res.ok) return res.json();
const text = await res.text();
throw Error(text);
const errorDetail = (await res.json()).detail;
throw Error(errorDetail);
});
};