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 fastapi import HTTPException
from sqlalchemy.orm import Session 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.auth.users import current_admin_user
from danswer.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ from danswer.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ
from danswer.configs.app_configs import TOKEN_BUDGET_GLOBALLY_ENABLED 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.feedback import update_document_hidden
from danswer.db.index_attempt import cancel_indexing_attempts_for_connector from danswer.db.index_attempt import cancel_indexing_attempts_for_connector
from danswer.db.models import User 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.document_index_utils import get_both_index_names
from danswer.document_index.factory import get_default_document_index from danswer.document_index.factory import get_default_document_index
from danswer.dynamic_configs.factory import get_dynamic_config_store 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 BoostDoc
from danswer.server.manage.models import BoostUpdateRequest from danswer.server.manage.models import BoostUpdateRequest
from danswer.server.manage.models import HiddenUpdateRequest from danswer.server.manage.models import HiddenUpdateRequest
from danswer.server.manage.models import UserByEmail
from danswer.utils.logger import setup_logger from danswer.utils.logger import setup_logger
router = APIRouter(prefix="/manage") router = APIRouter(prefix="/manage")
@@ -235,66 +231,3 @@ def update_token_budget_settings(
# Store the settings in the dynamic config store # Store the settings in the dynamic config store
get_dynamic_config_store().store(TOKEN_BUDGET_SETTINGS, settings_json) get_dynamic_config_store().store(TOKEN_BUDGET_SETTINGS, settings_json)
return {"message": "Token budget settings updated successfully."} 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 import re
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Body
from fastapi import Depends from fastapi import Depends
from fastapi import HTTPException from fastapi import HTTPException
from fastapi import status from fastapi import status
@@ -9,6 +10,7 @@ from sqlalchemy import update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from danswer.auth.invited_users import get_invited_users 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 fetch_no_auth_user
from danswer.auth.noauth_user import set_no_auth_user_preferences from danswer.auth.noauth_user import set_no_auth_user_preferences
from danswer.auth.schemas import UserRole 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 current_user
from danswer.auth.users import optional_user from danswer.auth.users import optional_user
from danswer.configs.app_configs import AUTH_TYPE 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.configs.constants import AuthType
from danswer.db.engine import get_session from danswer.db.engine import get_session
from danswer.db.models import User 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 FullUserSnapshot
from danswer.server.models import InvitedUserSnapshot from danswer.server.models import InvitedUserSnapshot
from danswer.server.models import MinimalUserSnapshot from danswer.server.models import MinimalUserSnapshot
from danswer.utils.logger import setup_logger
logger = setup_logger()
router = APIRouter() router = APIRouter()
USERS_PAGE_SIZE = 10 USERS_PAGE_SIZE = 10
@@ -104,16 +112,94 @@ def list_all_users(
) )
for user in users for user in users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE], ][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
invited=[ invited=[InvitedUserSnapshot(email=email) for email in invited_emails][
InvitedUserSnapshot(email=email) invited_page * USERS_PAGE_SIZE : (invited_page + 1) * USERS_PAGE_SIZE
for email in invited_emails ],
if email not in accepted_emails
][invited_page * USERS_PAGE_SIZE : (invited_page + 1) * USERS_PAGE_SIZE],
accepted_pages=accepted_count // USERS_PAGE_SIZE + 1, accepted_pages=accepted_count // USERS_PAGE_SIZE + 1,
invited_pages=invited_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""" """Endpoints for all"""

View File

@@ -4,19 +4,9 @@ import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
import { SearchBar } from "@/components/search/SearchBar"; import { SearchBar } from "@/components/search/SearchBar";
import { useState } from "react"; import { useState } from "react";
import { FiPlusSquare } from "react-icons/fi"; import { FiPlusSquare } from "react-icons/fi";
import Link from "next/link";
import { Modal } from "@/components/Modal"; import { Modal } from "@/components/Modal";
import { import { Button, Text } from "@tremor/react";
Table,
TableHead,
TableRow,
TableHeaderCell,
TableBody,
TableCell,
Button,
Text,
} from "@tremor/react";
import { LoadingAnimation } from "@/components/Loading"; import { LoadingAnimation } from "@/components/Loading";
import { AdminPageTitle } from "@/components/admin/Title"; import { AdminPageTitle } from "@/components/admin/Title";
import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup"; import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup";
@@ -24,11 +14,43 @@ import { UsersIcon } from "@/components/icons/icons";
import { errorHandlingFetcher } from "@/lib/fetcher"; import { errorHandlingFetcher } from "@/lib/fetcher";
import { type User, UserStatus } from "@/lib/types"; import { type User, UserStatus } from "@/lib/types";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import useSWRMutation from "swr/mutation";
import { ErrorCallout } from "@/components/ErrorCallout"; import { ErrorCallout } from "@/components/ErrorCallout";
import { HidableSection } from "@/app/admin/assistants/HidableSection"; import { HidableSection } from "@/app/admin/assistants/HidableSection";
import BulkAdd from "@/components/admin/users/BulkAdd"; 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 { interface UsersResponse {
accepted: User[]; accepted: User[];
invited: User[]; invited: User[];
@@ -46,11 +68,18 @@ const UsersTables = ({
const [invitedPage, setInvitedPage] = useState(1); const [invitedPage, setInvitedPage] = useState(1);
const [acceptedPage, setAcceptedPage] = useState(1); const [acceptedPage, setAcceptedPage] = useState(1);
const { data, isLoading, mutate, error } = useSWR<UsersResponse>( 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 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" />; 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; 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 ( return (
<> <>
<HidableSection sectionTitle="Invited Users">
{invited.length > 0 ? (
finalInvited.length > 0 ? (
<InvitedUserTable <InvitedUserTable
users={invited} users={finalInvited}
setPopup={setPopup} setPopup={setPopup}
currentPage={invitedPage} currentPage={invitedPage}
onPageChange={setInvitedPage} onPageChange={setInvitedPage}
totalPages={invited_pages} totalPages={invited_pages}
mutate={mutate} mutate={mutate}
/> />
) : (
<div className="text-sm">
To invite additional teammates, use the <b>Invite Users</b> button
above!
</div>
)
) : (
<ValidDomainsDisplay validDomains={validDomains} />
)}
</HidableSection>
<SignedUpUserTable <SignedUpUserTable
users={accepted} users={accepted}
setPopup={setPopup} setPopup={setPopup}
@@ -129,6 +185,13 @@ const AddUserButton = ({
type: "success", type: "success",
}); });
}; };
const onFailure = async (res: Response) => {
const error = (await res.json()).detail;
setPopup({
message: `Failed to invite users - ${error}`,
type: "error",
});
};
return ( return (
<> <>
<Button className="w-fit" onClick={() => setModal(true)}> <Button className="w-fit" onClick={() => setModal(true)}>
@@ -143,7 +206,7 @@ const AddUserButton = ({
<Text className="font-medium text-base"> <Text className="font-medium text-base">
Add the email addresses to import, separated by whitespaces. Add the email addresses to import, separated by whitespaces.
</Text> </Text>
<BulkAdd onSuccess={onSuccess} /> <BulkAdd onSuccess={onSuccess} onFailure={onFailure} />
</div> </div>
</Modal> </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 = () => { const Page = () => {
return ( 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 }) => ( export const Popup: React.FC<PopupSpec> = ({ message, type }) => (
<div <div
className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-30 ${ className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-[100] ${
type === "success" ? "bg-green-500" : "bg-red-500" type === "success" ? "bg-green-500" : "bg-error"
}`} }`}
> >
{message} {message}

View File

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

View File

@@ -68,15 +68,7 @@ const InvitedUserTable = ({
}; };
return ( return (
<HidableSection sectionTitle="Invited Users">
<> <>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
<Table className="overflow-visible"> <Table className="overflow-visible">
<TableHead> <TableHead>
<TableRow> <TableRow>
@@ -103,8 +95,14 @@ const InvitedUserTable = ({
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
</> </>
</HidableSection>
); );
}; };

View File

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

View File

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