Add user management page

This commit is contained in:
Weves
2023-08-25 12:15:38 -07:00
committed by Chris Weaver
parent 8cda11c701
commit b27107c184
7 changed files with 203 additions and 35 deletions

View File

@@ -0,0 +1,12 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from danswer.db.models import User
def list_users(db_session: Session) -> Sequence[User]:
"""List all users. No pagination as of now, as the # of users
is assumed to be relatively small (<< 1 million)"""
return db_session.scalars(select(User)).unique().all()

View File

@@ -34,6 +34,7 @@ from danswer.server.event_loading import router as event_processing_router
from danswer.server.health import router as health_router
from danswer.server.manage import router as admin_router
from danswer.server.search_backend import router as backend_router
from danswer.server.users import router as user_router
from danswer.utils.logger import setup_logger
@@ -66,6 +67,7 @@ def get_application() -> FastAPI:
application.include_router(backend_router)
application.include_router(event_processing_router)
application.include_router(admin_router)
application.include_router(user_router)
application.include_router(health_router)
application.include_router(

View File

@@ -8,11 +8,8 @@ from fastapi import HTTPException
from fastapi import Request
from fastapi import Response
from fastapi import UploadFile
from fastapi_users.db import SQLAlchemyUserDatabase
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from danswer.auth.schemas import UserRole
from danswer.auth.users import current_admin_user
from danswer.auth.users import current_user
from danswer.configs.app_configs import DISABLE_GENERATIVE_AI
@@ -20,9 +17,6 @@ from danswer.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ
from danswer.configs.constants import GEN_AI_API_KEY_STORAGE_KEY
from danswer.connectors.file.utils import write_temp_files
from danswer.connectors.google_drive.connector_auth import build_service_account_creds
from danswer.connectors.google_drive.connector_auth import (
DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY,
)
from danswer.connectors.google_drive.connector_auth import DB_CREDENTIALS_DICT_TOKEN_KEY
from danswer.connectors.google_drive.connector_auth import delete_google_app_cred
from danswer.connectors.google_drive.connector_auth import delete_service_account_key
@@ -58,7 +52,6 @@ from danswer.db.deletion_attempt import check_deletion_attempt_is_allowed
from danswer.db.deletion_attempt import create_deletion_attempt
from danswer.db.deletion_attempt import get_deletion_attempts
from danswer.db.engine import get_session
from danswer.db.engine import get_sqlalchemy_async_engine
from danswer.db.index_attempt import create_index_attempt
from danswer.db.index_attempt import get_latest_index_attempts
from danswer.db.models import DeletionAttempt
@@ -87,7 +80,6 @@ from danswer.server.models import IndexAttemptSnapshot
from danswer.server.models import ObjectCreationIdResponse
from danswer.server.models import RunConnectorRequest
from danswer.server.models import StatusResponse
from danswer.server.models import UserByEmail
from danswer.server.models import UserRoleResponse
from danswer.utils.logger import setup_logger
@@ -101,23 +93,6 @@ _GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME = "google_drive_credential_id"
"""Admin only API endpoints"""
@router.patch("/promote-user-to-admin", response_model=None)
async def promote_admin(
user_email: UserByEmail, user: User = Depends(current_admin_user)
) -> None:
if user.role != UserRole.ADMIN:
raise HTTPException(status_code=401, detail="Unauthorized")
async with AsyncSession(get_sqlalchemy_async_engine()) as asession:
user_db = SQLAlchemyUserDatabase(asession, User) # type: ignore
user_to_promote = await user_db.get_by_email(user_email.user_email)
if not user_to_promote:
raise HTTPException(status_code=404, detail="User not found")
user_to_promote.role = UserRole.ADMIN
asession.add(user_to_promote)
await asession.commit()
return
@router.get("/admin/connector/google-drive/app-credential")
def check_google_app_credentials_exist(
_: User = Depends(current_admin_user),

View File

@@ -0,0 +1,45 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi_users.db import SQLAlchemyUserDatabase
from fastapi_users_db_sqlalchemy import UUID_ID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from danswer.auth.schemas import UserRead
from danswer.auth.schemas import UserRole
from danswer.auth.users import current_admin_user
from danswer.db.engine import get_session
from danswer.db.engine import get_sqlalchemy_async_engine
from danswer.db.models import User
from danswer.db.users import list_users
from danswer.server.models import UserByEmail
router = APIRouter(prefix="/manage")
@router.patch("/promote-user-to-admin")
async def promote_admin(
user_email: UserByEmail, user: User = Depends(current_admin_user)
) -> None:
if user.role != UserRole.ADMIN:
raise HTTPException(status_code=401, detail="Unauthorized")
async with AsyncSession(get_sqlalchemy_async_engine()) as asession:
user_db = SQLAlchemyUserDatabase[User, UUID_ID](asession, User)
user_to_promote = await user_db.get_by_email(user_email.user_email)
if not user_to_promote:
raise HTTPException(status_code=404, detail="User not found")
user_to_promote.role = UserRole.ADMIN
asession.add(user_to_promote)
await asession.commit()
return
@router.get("/users")
def list_all_users(
_: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[UserRead]:
users = list_users(db_session)
return [UserRead.from_orm(user) for user in users]

View File

@@ -17,6 +17,7 @@ import {
ZulipIcon,
ProductboardIcon,
LinearIcon,
UsersIcon,
} from "@/components/icons/icons";
import { DISABLE_AUTH } from "@/lib/constants";
import { getCurrentUserSS } from "@/lib/userSS";
@@ -161,6 +162,15 @@ export default async function AdminLayout({
),
link: "/admin/connectors/bookstack",
},
{
name: (
<div className="flex">
<ZulipIcon size={16} />
<div className="ml-1">Zulip</div>
</div>
),
link: "/admin/connectors/zulip",
},
{
name: (
<div className="flex">
@@ -179,15 +189,6 @@ export default async function AdminLayout({
),
link: "/admin/connectors/file",
},
{
name: (
<div className="flex">
<ZulipIcon size={16} />
<div className="ml-1">Zulip</div>
</div>
),
link: "/admin/connectors/zulip",
},
],
},
{
@@ -204,6 +205,20 @@ export default async function AdminLayout({
},
],
},
{
name: "User Management",
items: [
{
name: (
<div className="flex">
<UsersIcon size={18} />
<div className="ml-1">Users</div>
</div>
),
link: "/admin/users",
},
],
},
]}
/>
<div className="px-12 min-h-screen bg-gray-900 text-gray-100 w-full">

View File

@@ -0,0 +1,111 @@
"use client";
import { Button } from "@/components/Button";
import { LoadingAnimation } from "@/components/Loading";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { Popup, usePopup } from "@/components/admin/connectors/Popup";
import { KeyIcon, TrashIcon, UsersIcon } from "@/components/icons/icons";
import { ApiKeyForm } from "@/components/openai/ApiKeyForm";
import { GEN_AI_API_KEY_URL } from "@/components/openai/constants";
import { fetcher } from "@/lib/fetcher";
import { User } from "@/lib/types";
import { useState } from "react";
import useSWR, { mutate } from "swr";
const columns = [
{
header: "Email",
key: "email",
},
{
header: "Role",
key: "role",
},
{
header: "Promote",
key: "promote",
},
];
const UsersTable = () => {
const { popup, setPopup } = usePopup();
const { data, isLoading, error } = useSWR<User[]>(
"/api/manage/users",
fetcher
);
if (isLoading) {
return <LoadingAnimation text="Loading" />;
}
if (error || !data) {
return <div className="text-red-600">Error loading users</div>;
}
return (
<div>
{popup}
<BasicTable
columns={columns}
data={data.map((user) => {
return {
email: user.email,
role: <i>{user.role === "admin" ? "Admin" : "User"}</i>,
promote:
user.role !== "admin" ? (
<Button
onClick={async () => {
const res = await fetch(
"/api/manage/promote-user-to-admin",
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_email: user.email,
}),
}
);
if (!res.ok) {
const errorMsg = await res.text();
setPopup({
message: `Unable to promote user - ${errorMsg}`,
type: "error",
});
} else {
mutate("/api/manage/users");
setPopup({
message: "User promoted to admin!",
type: "success",
});
}
}}
>
Promote to Admin!
</Button>
) : (
""
),
};
})}
/>
</div>
);
};
const Page = () => {
return (
<div>
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<UsersIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Manage Users</h1>
</div>
<UsersTable />
</div>
);
};
export default Page;

View File

@@ -13,6 +13,7 @@ import {
PencilSimple,
X,
Question,
Users,
} from "@phosphor-icons/react";
import { SiBookstack } from "react-icons/si";
import { FaFile, FaGlobe } from "react-icons/fa";
@@ -51,6 +52,13 @@ export const KeyIcon = ({
return <Key size={size} className={className} />;
};
export const UsersIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <Users size={size} className={className} />;
};
export const TrashIcon = ({
size = 16,
className = defaultTailwindCSS,
@@ -217,7 +225,7 @@ export const ZulipIcon = ({
return (
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] -m-0.5 ` + className}
className={`w-[${size}px] h-[${size}px] ` + className}
>
<Image src={zulipIcon} alt="Logo" width="96" height="96" />
</div>