diff --git a/.gitignore b/.gitignore index 2e3b15af5..986f39ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ backend/app/tests/* !clowder-theme.tgz *clowder2-software-dev.yaml secrets.yaml + +# faker +official.csv +fact.png diff --git a/backend/app/models/users.py b/backend/app/models/users.py index beee19b36..fd168d69f 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -24,6 +24,8 @@ class UserLogin(BaseModel): class UserDoc(Document, UserBase): + admin: bool + class Settings: name = "users" @@ -31,7 +33,6 @@ class Settings: class UserDB(UserDoc): hashed_password: str = Field() keycloak_id: Optional[str] = None - admin: bool def verify_password(self, password): return pwd_context.verify(password, self.hashed_password) diff --git a/backend/app/routers/authentication.py b/backend/app/routers/authentication.py index 7513b488a..f3e82736d 100644 --- a/backend/app/routers/authentication.py +++ b/backend/app/routers/authentication.py @@ -1,5 +1,6 @@ import json +from beanie import PydanticObjectId from fastapi import APIRouter, HTTPException, Depends from keycloak.exceptions import ( KeycloakAuthenticationError, @@ -8,8 +9,6 @@ ) from passlib.hash import bcrypt -from beanie import PydanticObjectId - from app.keycloak_auth import create_user, get_current_user from app.keycloak_auth import keycloak_openid from app.models.datasets import DatasetDB @@ -113,7 +112,7 @@ async def get_admin(dataset_id: str = None, current_username=Depends(get_current async def set_admin( useremail: str, current_username=Depends(get_current_user), admin=Depends(get_admin) ): - if admin: + if admin and current_username.admin: if (user := await UserDB.find_one(UserDB.email == useremail)) is not None: user.admin = True await user.replace() @@ -125,3 +124,29 @@ async def set_admin( status_code=403, detail=f"User {current_username.email} is not an admin. Only admin can make others admin.", ) + + +@router.post("/users/revoke_admin/{useremail}", response_model=UserOut) +async def revoke_admin( + useremail: str, current_username=Depends(get_current_user), admin=Depends(get_admin) +): + if admin: + if current_username.email == useremail: + raise HTTPException( + status_code=403, + detail=f"You are currently an admin. Admin cannot revoke their own admin access.", + ) + else: + if (user := await UserDB.find_one(UserDB.email == useremail)) is not None: + user.admin = False + await user.replace() + return user.dict() + else: + raise HTTPException( + status_code=404, detail=f"User {useremail} not found" + ) + else: + raise HTTPException( + status_code=403, + detail=f"User {current_username.email} is not an admin. Only admin can revoke admin access.", + ) diff --git a/frontend/src/actions/user.js b/frontend/src/actions/user.js index 9e46b03e8..a4eca2fa6 100644 --- a/frontend/src/actions/user.js +++ b/frontend/src/actions/user.js @@ -231,3 +231,39 @@ export function fetchUserProfile() { }); }; } + +export const SET_ADMIN = "SET_ADMIN"; + +export function setAdmin(email) { + return (dispatch) => { + return V2.LoginService.setAdminApiV2UsersSetAdminUseremailPost(email) + .then((json) => { + dispatch({ + type: SET_ADMIN, + profile: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, setAdmin(email))); + }); + }; +} + +export const REVOKE_ADMIN = "REVOKE_ADMIN"; + +export function revokeAdmin(email) { + return (dispatch) => { + return V2.LoginService.revokeAdminApiV2UsersRevokeAdminUseremailPost(email) + .then((json) => { + dispatch({ + type: REVOKE_ADMIN, + profile: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, revokeAdmin(email))); + }); + }; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f7e2887ed..448c6f670 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -18,7 +18,7 @@ import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import { Link, Menu, MenuItem, MenuList, Typography } from "@mui/material"; import { Link as RouterLink, useLocation } from "react-router-dom"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { RootState } from "../types/data"; import { AddBox, Explore } from "@material-ui/icons"; import HistoryIcon from "@mui/icons-material/History"; @@ -30,6 +30,8 @@ import { getCurrEmail } from "../utils/common"; import VpnKeyIcon from "@mui/icons-material/VpnKey"; import LogoutIcon from "@mui/icons-material/Logout"; import { EmbeddedSearch } from "./search/EmbeddedSearch"; +import { fetchUserProfile } from "../actions/user"; +import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; const drawerWidth = 240; @@ -103,6 +105,9 @@ export default function PersistentDrawerLeft(props) { const [embeddedSearchHidden, setEmbeddedSearchHidden] = React.useState(false); const [anchorEl, setAnchorEl] = React.useState(null); const isMenuOpen = Boolean(anchorEl); + const profile = useSelector((state: RootState) => state.user.profile); + const dispatch = useDispatch(); + const fetchProfile = () => dispatch(fetchUserProfile()); const handleDrawerOpen = () => { setOpen(true); @@ -128,6 +133,7 @@ export default function PersistentDrawerLeft(props) { } else { setEmbeddedSearchHidden(false); } + fetchProfile(); }, [location]); const loggedOut = useSelector((state: RootState) => state.error.loggedOut); @@ -278,6 +284,21 @@ export default function PersistentDrawerLeft(props) { + {profile.admin ? ( + <> + + + + + + + + + + + + + ) : null} diff --git a/frontend/src/components/ApiKeys/ApiKey.tsx b/frontend/src/components/apikeys/ApiKey.tsx similarity index 100% rename from frontend/src/components/ApiKeys/ApiKey.tsx rename to frontend/src/components/apikeys/ApiKey.tsx diff --git a/frontend/src/components/ApiKeys/CreateApiKeyModal.tsx b/frontend/src/components/apikeys/CreateApiKeyModal.tsx similarity index 100% rename from frontend/src/components/ApiKeys/CreateApiKeyModal.tsx rename to frontend/src/components/apikeys/CreateApiKeyModal.tsx diff --git a/frontend/src/components/groups/Groups.tsx b/frontend/src/components/groups/Groups.tsx index 5ed93bec5..e8ae5cced 100644 --- a/frontend/src/components/groups/Groups.tsx +++ b/frontend/src/components/groups/Groups.tsx @@ -97,128 +97,129 @@ export function Groups() { {/*Error Message dialogue*/} - - {/*create new group*/} - { - setCreateGroupOpen(false); - }} - fullWidth={true} - maxWidth="md" - aria-labelledby="form-dialog" - > - Create New Group - - - - - - - - - - - - - +
+ {/*create new group*/} + { + setCreateGroupOpen(false); + }} + fullWidth={true} + maxWidth="md" + aria-labelledby="form-dialog" + > + Create New Group + + + + + + + + + - - - - - - - Group Name - - - Description - - - - - - - - {groups.map((group) => { - return ( - + + + + + +
+ + + - - - - - {group.description} - - - {group.users !== undefined ? group.users.length : 0} - - - ); - })} - -
- - - - + + + {group.description} + + + {group.users !== undefined ? group.users.length : 0} + + + ); + })} + + + + - Next - - - -
+ + + + + +
- +
); } diff --git a/frontend/src/components/metadata/MetadataDefinitions.tsx b/frontend/src/components/metadata/MetadataDefinitions.tsx index 0ba28a7a4..5db91cab4 100644 --- a/frontend/src/components/metadata/MetadataDefinitions.tsx +++ b/frontend/src/components/metadata/MetadataDefinitions.tsx @@ -8,7 +8,7 @@ import { DialogTitle, Grid, IconButton, - InputBase, Snackbar, + Snackbar, } from "@mui/material"; import { RootState } from "../../types/data"; import { useDispatch, useSelector } from "react-redux"; @@ -66,7 +66,7 @@ export function MetadataDefinitions() { const [selectedMetadataDefinition, setSelectedMetadataDefinition] = useState(); - // snack bar + // snack bar const [snackBarOpen, setSnackBarOpen] = useState(false); const [snackBarMessage, setSnackBarMessage] = useState(""); @@ -163,124 +163,128 @@ export function MetadataDefinitions() { /> - - - - +
+ + + + + - -
- - - - - - - - - - - Metadata Definition - - - Description - - - - - - {metadataDefinitions.map((mdd) => { - return ( - + + + + + + +
+ + + - - - - - {mdd.description} - - + + Description + + + + + + {metadataDefinitions.map((mdd) => { + return ( + - { - setSelectedMetadataDefinition(mdd.id); - setDeleteMetadataDefinitionConfirmOpen(true); - }} + + + + - - - - - ); - })} - -
- - - - - - -
+ + + + + +
- +
); } diff --git a/frontend/src/components/search/GenericSearchBox.tsx b/frontend/src/components/search/GenericSearchBox.tsx index 51a7fbe79..305df6b60 100644 --- a/frontend/src/components/search/GenericSearchBox.tsx +++ b/frontend/src/components/search/GenericSearchBox.tsx @@ -40,7 +40,6 @@ export function GenericSearchBox(props: GenericSearchBoxProps) { sx={{ border: "1px solid #ced4da", borderRadius: "6px", - mb: 2, p: "2px 4px", display: "flex", alignItems: "left", diff --git a/frontend/src/components/users/ManageUsers.tsx b/frontend/src/components/users/ManageUsers.tsx new file mode 100644 index 000000000..6968f8200 --- /dev/null +++ b/frontend/src/components/users/ManageUsers.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useState } from "react"; +import { RootState } from "../../types/data"; +import { useDispatch, useSelector } from "react-redux"; +import Layout from "../Layout"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Paper from "@mui/material/Paper"; +import { + fetchAllUsers as fetchAllUsersAction, + fetchUserProfile, + prefixSearchAllUsers as prefixSearchAllUsersAction, + revokeAdmin as revokeAdminAction, + setAdmin as setAdminAction, +} from "../../actions/user"; +import { Box, Button, ButtonGroup, Grid, Switch } from "@mui/material"; +import { ArrowBack, ArrowForward } from "@material-ui/icons"; +import { ErrorModal } from "../errors/ErrorModal"; +import { GenericSearchBox } from "../search/GenericSearchBox"; +import Gravatar from "react-gravatar"; +import PersonIcon from "@mui/icons-material/Person"; + +export const ManageUsers = (): JSX.Element => { + const [currPageNum, setCurrPageNum] = useState(0); + const [limit, setLimit] = useState(5); + const [skip, setSkip] = useState(0); + const [prevDisabled, setPrevDisabled] = useState(true); + const [nextDisabled, setNextDisabled] = useState(false); + const [errorOpen, setErrorOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + const dispatch = useDispatch(); + const users = useSelector((state: RootState) => state.group.users); + const currentUser = useSelector((state: RootState) => state.user.profile); + + const fetchAllUsers = (skip: number, limit: number) => + dispatch(fetchAllUsersAction(skip, limit)); + const fetchCurrentUser = () => dispatch(fetchUserProfile()); + const prefixSearchAllUsers = (text: string, skip: number, limit: number) => + dispatch(prefixSearchAllUsersAction(text, skip, limit)); + + const setAdmin = (email: string) => dispatch(setAdminAction(email)); + const revokeAdmin = (email: string) => dispatch(revokeAdminAction(email)); + + // component did mount + useEffect(() => { + fetchAllUsers(skip, limit); + fetchCurrentUser(); + }, []); + + useEffect(() => { + // disable flipping if reaches the last page + if (users.length < limit) setNextDisabled(true); + else setNextDisabled(false); + }, [users]); + + useEffect(() => { + if (skip !== null && skip !== undefined) { + fetchAllUsers(skip, limit); + if (skip === 0) setPrevDisabled(true); + else setPrevDisabled(false); + } + }, [skip]); + + const previous = () => { + if (currPageNum - 1 >= 0) { + setSkip((currPageNum - 1) * limit); + setCurrPageNum(currPageNum - 1); + } + }; + const next = () => { + if (users.length === limit) { + setSkip((currPageNum + 1) * limit); + setCurrPageNum(currPageNum + 1); + } + }; + + const searchUsers = (searchTerm: string) => { + prefixSearchAllUsers(searchTerm, skip, limit); + setSearchTerm(searchTerm); + }; + + // search while typing + useEffect(() => { + if (searchTerm !== "") prefixSearchAllUsers(searchTerm, skip, limit); + else fetchAllUsers(skip, limit); + }, [searchTerm]); + + return ( + + {/*Error Message dialogue*/} + +
+ + + + + + + + + + Name + Email + Admin + + + + {users.map((profile) => { + return ( + + + {profile.email !== undefined ? ( + + ) : ( + + )} + {profile.first_name} {profile.last_name} + + {profile.email} + + { + if (profile.admin) { + revokeAdmin(profile.email); + } else { + setAdmin(profile.email); + } + }} + disabled={profile.email === currentUser.email} + /> + + + ); + })} + +
+ + + + + + +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/users/Profile.tsx b/frontend/src/components/users/Profile.tsx index ebed22427..edcc177ec 100644 --- a/frontend/src/components/users/Profile.tsx +++ b/frontend/src/components/users/Profile.tsx @@ -13,43 +13,39 @@ import { fetchUserProfile } from "../../actions/user"; export const Profile = (): JSX.Element => { const dispatch = useDispatch(); - const user = useSelector((state: RootState) => state.user); - const profile = user["profile"]; + const profile = useSelector((state: RootState) => state.user.profile); const fetchProfile = () => dispatch(fetchUserProfile()); // component did mount useEffect(() => { fetchProfile(); }, []); - if (profile != null) { - return ( - - - - - - Name - Email - Admin - - - - - - - {profile.first_name} {profile.last_name} - - {profile.email} - {"false"} - - -
-
-
- ); - } else { - return

nothing yet

; - } + return ( + + + + + + Name + Email + Admin + + + + + + {profile.first_name} {profile.last_name} + + {profile.email} + + {profile.admin ? "True" : "False"} + + + +
+
+
+ ); }; diff --git a/frontend/src/openapi/v2/models/UserOut.ts b/frontend/src/openapi/v2/models/UserOut.ts index 42c7657d5..f624b5122 100644 --- a/frontend/src/openapi/v2/models/UserOut.ts +++ b/frontend/src/openapi/v2/models/UserOut.ts @@ -20,4 +20,5 @@ export type UserOut = { first_name: string; last_name: string; id?: string; + admin: boolean; } diff --git a/frontend/src/openapi/v2/services/LoginService.ts b/frontend/src/openapi/v2/services/LoginService.ts index 7d982cc2d..58d601260 100644 --- a/frontend/src/openapi/v2/services/LoginService.ts +++ b/frontend/src/openapi/v2/services/LoginService.ts @@ -72,4 +72,27 @@ export class LoginService { }); } + /** + * Revoke Admin + * @param useremail + * @param datasetId + * @returns UserOut Successful Response + * @throws ApiError + */ + public static revokeAdminApiV2UsersRevokeAdminUseremailPost( + useremail: string, + datasetId?: string, + ): CancelablePromise { + return __request({ + method: 'POST', + path: `/api/v2/users/revoke_admin/${useremail}`, + query: { + 'dataset_id': datasetId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + } \ No newline at end of file diff --git a/frontend/src/reducers/group.ts b/frontend/src/reducers/group.ts index 691b55b3f..339d4f30a 100644 --- a/frontend/src/reducers/group.ts +++ b/frontend/src/reducers/group.ts @@ -13,7 +13,12 @@ import { RECEIVE_GROUP_ROLE } from "../actions/authorization"; import { DataAction } from "../types/action"; import { GroupState } from "../types/data"; import { GroupOut, RoleType } from "../openapi/v2"; -import { LIST_USERS, PREFIX_SEARCH_USERS } from "../actions/user"; +import { + LIST_USERS, + PREFIX_SEARCH_USERS, + REVOKE_ADMIN, + SET_ADMIN, +} from "../actions/user"; const defaultState: GroupState = { groups: [], @@ -44,6 +49,18 @@ const group = (state = defaultState, action: DataAction) => { return Object.assign({}, state, { about: action.about }); case LIST_USERS: return Object.assign({}, state, { users: action.users }); + case SET_ADMIN: + return Object.assign({}, state, { + users: state.users.map((user) => + user.email === action.profile.email ? action.profile : user + ), + }); + case REVOKE_ADMIN: + return Object.assign({}, state, { + users: state.users.map((user) => + user.email === action.profile.email ? action.profile : user + ), + }); case PREFIX_SEARCH_USERS: return Object.assign({}, state, { users: action.users }); case ASSIGN_GROUP_MEMBER_ROLE: diff --git a/frontend/src/reducers/user.ts b/frontend/src/reducers/user.ts index 2a83e5984..badc97424 100644 --- a/frontend/src/reducers/user.ts +++ b/frontend/src/reducers/user.ts @@ -11,6 +11,7 @@ import { } from "../actions/user"; import { UserState } from "../types/data"; import { DataAction } from "../types/action"; +import { UserOut } from "../openapi/v2"; const defaultState: UserState = { Authorization: null, @@ -19,7 +20,7 @@ const defaultState: UserState = { errorMsg: "", hashedKey: "", apiKeys: [], - profile: null, + profile: {}, }; const user = (state = defaultState, action: DataAction) => { diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 2cdd433ef..ee1bb9d45 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -29,6 +29,7 @@ import { PageNotFound } from "./components/errors/PageNotFound"; import { Forbidden } from "./components/errors/Forbidden"; import { ApiKeys } from "./components/ApiKeys/ApiKey"; import { Profile } from "./components/users/Profile"; +import { ManageUsers } from "./components/users/ManageUsers"; import config from "./app.config"; import { MetadataDefinitions } from "./components/metadata/MetadataDefinitions"; import { MetadataDefinitionEntry } from "./components/metadata/MetadataDefinitionEntry"; @@ -114,6 +115,14 @@ export const AppRoutes = (): JSX.Element => { } /> + + + + } + />