This is an automated email from the ASF dual-hosted git repository. ganning pushed a commit to branch resource-approval in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
commit 980041df71c3fa4bb9b2908063720536c662d644 Author: ganning127 <[email protected]> AuthorDate: Sun Aug 10 13:07:00 2025 -0400 frontend support for resource approval --- .vscode/settings.json | 9 + airavata-research-portal/.env.example | 1 + airavata-research-portal/src/App.tsx | 119 +++++----- .../src/components/home/ResourceCard.tsx | 249 ++++++++++++--------- .../resources/RequestResourceVerification.tsx | 70 ++++-- .../src/components/resources/ResourceDetails.tsx | 31 ++- .../components/resources/ResourceVerification.tsx | 20 ++ .../resources/VerificationActivities.tsx | 108 +++++++++ .../resources/admin/PendingResourcesSection.tsx | 44 ++++ .../resources/admin/VerificationControls.tsx | 136 +++++++++++ airavata-research-portal/src/layouts/NavBar.tsx | 171 ++++++++------ airavata-research-portal/src/lib/controller.ts | 3 +- airavata-research-portal/src/lib/util.ts | 31 ++- 13 files changed, 729 insertions(+), 263 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..74f751a93 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/airavata-research-portal/.env.example b/airavata-research-portal/.env.example index 317e2248b..ea13fd78b 100644 --- a/airavata-research-portal/.env.example +++ b/airavata-research-portal/.env.example @@ -3,3 +3,4 @@ VITE_APP_URL=http://localhost:5173 VITE_API_VERSION=v1 VITE_CLIENT_ID= VITE_OPENID_CONFIG_URL= +VITE_ADMIN_EMAILS= diff --git a/airavata-research-portal/src/App.tsx b/airavata-research-portal/src/App.tsx index 7e9a503a6..b1ae6bde9 100644 --- a/airavata-research-portal/src/App.tsx +++ b/airavata-research-portal/src/App.tsx @@ -1,27 +1,32 @@ -import {useColorMode} from "./components/ui/color-mode"; -import {Route, Routes, useLocation, useNavigate} from "react-router"; +import { useColorMode } from "./components/ui/color-mode"; +import { Route, Routes, useLocation, useNavigate } from "react-router"; import Home from "./components/home"; -import {Models} from "./components/models"; -import {Datasets} from "./components/datasets"; +import { Models } from "./components/models"; +import { Datasets } from "./components/datasets"; import ResourceDetails from "./components/resources/ResourceDetails"; import Notebooks from "./components/notebooks"; import Repositories from "./components/repositories"; -import {Login} from "./components/auth/UserLoginPage"; +import { Login } from "./components/auth/UserLoginPage"; import ProtectedComponent from "./components/auth/ProtectedComponent"; -import {AuthProvider, AuthProviderProps} from "react-oidc-context"; -import {useEffect, useState} from "react"; +import { AuthProvider, AuthProviderProps } from "react-oidc-context"; +import { useEffect, useState } from "react"; import NavBarFooterLayout from "./layouts/NavBarFooterLayout"; -import {CybershuttleLanding} from "./components/home/CybershuttleLanding"; -import {APP_REDIRECT_URI, CLIENT_ID, OPENID_CONFIG_URL,} from "./lib/constants"; -import {WebStorageStateStore} from "oidc-client-ts"; -import {Resources} from "./components/resources"; -import {UserSet} from "./components/auth/UserSet"; -import {Toaster} from "./components/ui/toaster"; -import {Events} from "./components/events"; -import {AddRepoMaster} from "./components/add/AddRepoMaster"; -import {Add} from "./components/add"; -import {AddProjectMaster} from "./components/add/AddProjectMaster"; -import {StarredResourcesPage} from "@/components/resources/StarredResourcesPage.tsx"; +import { CybershuttleLanding } from "./components/home/CybershuttleLanding"; +import { + APP_REDIRECT_URI, + CLIENT_ID, + OPENID_CONFIG_URL, +} from "./lib/constants"; +import { WebStorageStateStore } from "oidc-client-ts"; +import { Resources } from "./components/resources"; +import { UserSet } from "./components/auth/UserSet"; +import { Toaster } from "./components/ui/toaster"; +import { Events } from "./components/events"; +import { AddRepoMaster } from "./components/add/AddRepoMaster"; +import { Add } from "./components/add"; +import { AddProjectMaster } from "./components/add/AddProjectMaster"; +import { StarredResourcesPage } from "@/components/resources/StarredResourcesPage.tsx"; +import { PendingResourcesSection } from "./components/resources/admin/PendingResourcesSection"; function App() { const colorMode = useColorMode(); @@ -55,7 +60,7 @@ function App() { userinfo_endpoint: data.userinfo_endpoint, jwks_uri: data.jwks_uri, }, - userStore: new WebStorageStateStore({store: window.localStorage}), + userStore: new WebStorageStateStore({ store: window.localStorage }), automaticSilentRenew: true, }; @@ -73,42 +78,50 @@ function App() { } return ( - <> - <AuthProvider - {...oidcConfig} - onSigninCallback={() => { - navigate(location.search, {replace: true}); - }} - > - <Toaster/> - <UserSet/> - <Routes> - {/* Public Route */} - <Route element={<NavBarFooterLayout/>}> - <Route path="/" element={<CybershuttleLanding/>}/> - <Route path="/login" element={<Login/>}/> - <Route path="/resources" element={<Resources/>}/> - <Route path="/events" element={<Events/>}/> - <Route path="/resources/datasets" element={<Datasets/>}/> - <Route path="/resources/notebooks" element={<Notebooks/>}/> - <Route path="/resources/repositories" element={<Repositories/>}/> - <Route path="/resources/models" element={<Models/>}/> - <Route path="/resources/:type/:id" element={<ResourceDetails/>}/> - </Route> + <> + <AuthProvider + {...oidcConfig} + onSigninCallback={() => { + navigate(location.search, { replace: true }); + }} + > + <Toaster /> + <UserSet /> + <Routes> + {/* Public Route */} + <Route element={<NavBarFooterLayout />}> + <Route path="/" element={<CybershuttleLanding />} /> + <Route path="/login" element={<Login />} /> + <Route path="/resources" element={<Resources />} /> + <Route path="/events" element={<Events />} /> + <Route path="/resources/datasets" element={<Datasets />} /> + <Route path="/resources/notebooks" element={<Notebooks />} /> + <Route path="/resources/repositories" element={<Repositories />} /> + <Route path="/resources/models" element={<Models />} /> + <Route path="/resources/:type/:id" element={<ResourceDetails />} /> + </Route> - {/* Protected Routes with Layout */} + {/* Protected Routes with Layout */} + <Route + element={<ProtectedComponent Component={NavBarFooterLayout} />} + > <Route - element={<ProtectedComponent Component={NavBarFooterLayout}/>} - > - <Route path="/resources/starred" element={<StarredResourcesPage/>}/> - <Route path="/sessions" element={<Home/>}/> - <Route path="/add" element={<Add/>}/> - <Route path="/add/repo" element={<AddRepoMaster/>}/> - <Route path="/add/project" element={<AddProjectMaster/>}/> - </Route> - </Routes> - </AuthProvider> - </> + path="/resources/starred" + element={<StarredResourcesPage />} + /> + <Route path="/sessions" element={<Home />} /> + <Route path="/add" element={<Add />} /> + <Route path="/add/repo" element={<AddRepoMaster />} /> + <Route path="/add/project" element={<AddProjectMaster />} /> + + <Route + path="/admin/pending-resources" + element={<PendingResourcesSection />} + /> + </Route> + </Routes> + </AuthProvider> + </> ); } diff --git a/airavata-research-portal/src/components/home/ResourceCard.tsx b/airavata-research-portal/src/components/home/ResourceCard.tsx index 208c79961..f1f5208ca 100644 --- a/airavata-research-portal/src/components/home/ResourceCard.tsx +++ b/airavata-research-portal/src/components/home/ResourceCard.tsx @@ -17,25 +17,38 @@ * under the License. */ -import {ModelResource, Resource} from "@/interfaces/ResourceType"; -import {Tag} from "@/interfaces/TagType"; -import {isValidImaage, resourceTypeToColor} from "@/lib/util"; -import {Avatar, Badge, Box, Card, HStack, Image, Text, VStack,} from "@chakra-ui/react"; -import {ResourceTypeBadge} from "../resources/ResourceTypeBadge"; -import {ResourceTypeEnum} from "@/interfaces/ResourceTypeEnum"; -import {ModelCardButton} from "../models/ModelCardButton"; -import {useState} from "react"; -import {Link} from 'react-router'; -import {ResourceOptions} from "@/components/resources/ResourceOptions.tsx"; -import {PrivacyEnum} from "@/interfaces/PrivacyEnum.ts"; -import {PrivateResourceTooltip} from "@/components/resources/PrivateResourceTooltip.tsx"; +import { ModelResource, Resource } from "@/interfaces/ResourceType"; +import { Tag } from "@/interfaces/TagType"; +import { isValidImaage, resourceTypeToColor } from "@/lib/util"; +import { + Avatar, + Badge, + Box, + Card, + Flex, + HStack, + Image, + Text, + VStack, +} from "@chakra-ui/react"; +import { ResourceTypeBadge } from "../resources/ResourceTypeBadge"; +import { ResourceTypeEnum } from "@/interfaces/ResourceTypeEnum"; +import { ModelCardButton } from "../models/ModelCardButton"; +import { useState } from "react"; +import { Link } from "react-router"; +import { ResourceOptions } from "@/components/resources/ResourceOptions.tsx"; +import { PrivacyEnum } from "@/interfaces/PrivacyEnum.ts"; +import { PrivateResourceTooltip } from "@/components/resources/PrivateResourceTooltip.tsx"; +import { StatusEnum } from "@/interfaces/StatusEnum"; +import { MdOutlineVerifiedUser } from "react-icons/md"; +import { ResourceAuthor } from "@/interfaces/ResourceAuthor"; export const ResourceCard = ({ - resource, - size = "sm", - deletable = true, - removeOnUnStar = false, - }: { + resource, + size = "sm", + deletable = true, + removeOnUnStar = false, +}: { resource: Resource; size?: "sm" | "md" | "lg"; deletable?: boolean; @@ -49,110 +62,130 @@ export const ResourceCard = ({ const linkToWithType = `${resource.type}/${resource.id}`; - const link = '/resources/' + linkToWithType; + const link = "/resources/" + linkToWithType; const hideCardCallback = () => { setHideCard(true); - } + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const dummyOnUnStarSuccess = (_: string) => { - } + const dummyOnUnStarSuccess = (_: string) => {}; const content = ( - <Card.Root - overflow="hidden" - size={size} - > - {isValidImage && ( - <Box position="relative" width="full"> - <ResourceTypeBadge - type={resource.type} - position="absolute" - top="2" - left="2" - zIndex="1" - boxShadow="md" - /> - - {/* Full-width Image */} - <Image - src={resource.headerImage} - alt={resource.name} - width="100%" // Ensure full width - height="200px" - objectFit="cover" - /> - </Box> - )} - - <Card.Header> - <HStack justifyContent={'space-between'} alignItems={'center'} flexWrap={'wrap'}> - <Card.Title>{resource.name}</Card.Title> - - <HStack alignItems={'center'}> - {resource.privacy === PrivacyEnum.PRIVATE && - <PrivateResourceTooltip/> + <Card.Root overflow="hidden" size={size}> + {isValidImage && ( + <Box position="relative" width="full"> + <ResourceTypeBadge + type={resource.type} + position="absolute" + top="2" + left="2" + zIndex="1" + boxShadow="md" + /> + + {/* Full-width Image */} + <Image + src={resource.headerImage} + alt={resource.name} + width="100%" // Ensure full width + height="200px" + objectFit="cover" + /> + </Box> + )} + + <Card.Header> + <HStack + justifyContent={"space-between"} + alignItems={"center"} + flexWrap={"wrap"} + > + <Card.Title> + <Text>{resource.name}</Text> + </Card.Title> + + <HStack alignItems={"center"}> + {resource.privacy === PrivacyEnum.PRIVATE && ( + <PrivateResourceTooltip /> + )} + <ResourceOptions + deleteable={deletable} + resource={resource} + onDeleteSuccess={hideCardCallback} + onUnStarSuccess={ + removeOnUnStar ? hideCardCallback : dummyOnUnStarSuccess } - <ResourceOptions deleteable={deletable} resource={resource} onDeleteSuccess={hideCardCallback} - onUnStarSuccess={removeOnUnStar ? hideCardCallback : dummyOnUnStarSuccess}/> - </HStack> + /> </HStack> - </Card.Header> + </HStack> + </Card.Header> - <Link to={link} target={"_blank"}> - <Box - _hover={{bg: resourceTypeToColor(resource.type) + ".100"}} - > - <Card.Body gap="2"> + <Link to={link} target={"_blank"}> + <Box _hover={{ bg: resourceTypeToColor(resource.type) + ".100" }}> + <Card.Body gap="2"> + <Flex gap={1} alignItems="center"> {!isValidImage && ( - <Box> - <ResourceTypeBadge type={resource.type}/> - </Box> + <ResourceTypeBadge type={resource.type} size="xs" /> )} - {/* Card Content */} - <HStack flexWrap="wrap"> - {resource.tags.map((tag: Tag) => ( - <Badge - key={tag.id} - size="md" - rounded="md" - colorPalette={resourceTypeToColor(resource.type)} - > - {tag.value} - </Badge> - ))} - </HStack> - <Text color="fg.muted" lineClamp={2}> - {resource.description} - </Text> - </Card.Body> - - <Card.Footer justifyContent="space-between" pt={4}> - <VStack alignItems={'flex-start'}> - {resource.authors.map(author => ( - <HStack> - <Avatar.Root shape="full" size="xs"> - <Avatar.Fallback name={author.authorId}/> - <Avatar.Image src={author.authorId}/> - </Avatar.Root> - - <Box> - <Text fontWeight="bold" fontSize={'sm'}>{author.authorId}</Text> - </Box> - </HStack> - ) - )} - </VStack> - - - {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.MODEL && ( - <ModelCardButton model={resource as ModelResource}/> + {resource.status === StatusEnum.VERIFIED && ( + <Badge + colorPalette="green" + fontWeight="bold" + px="2" + py="1" + borderRadius="md" + size="xs" + > + <MdOutlineVerifiedUser /> + Verified + </Badge> )} - </Card.Footer> - </Box> - </Link> - </Card.Root> + </Flex> + + {/* Card Content */} + <HStack flexWrap="wrap"> + {resource.tags.map((tag: Tag) => ( + <Badge + key={tag.id} + size="md" + rounded="md" + colorPalette={resourceTypeToColor(resource.type)} + > + {tag.value} + </Badge> + ))} + </HStack> + <Text color="fg.muted" lineClamp={2}> + {resource.description} + </Text> + </Card.Body> + + <Card.Footer justifyContent="space-between" pt={4}> + <VStack alignItems={"flex-start"}> + {resource.authors.map((author: ResourceAuthor) => ( + <HStack key={author.authorId}> + <Avatar.Root shape="full" size="xs"> + <Avatar.Fallback name={author.authorId} /> + <Avatar.Image src={author.authorId} /> + </Avatar.Root> + + <Box> + <Text fontWeight="bold" fontSize={"sm"}> + {author.authorId} + </Text> + </Box> + </HStack> + ))} + </VStack> + + {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.MODEL && ( + <ModelCardButton model={resource as ModelResource} /> + )} + </Card.Footer> + </Box> + </Link> + </Card.Root> ); // if (clickable) { diff --git a/airavata-research-portal/src/components/resources/RequestResourceVerification.tsx b/airavata-research-portal/src/components/resources/RequestResourceVerification.tsx index 1ae0348aa..b75c77151 100644 --- a/airavata-research-portal/src/components/resources/RequestResourceVerification.tsx +++ b/airavata-research-portal/src/components/resources/RequestResourceVerification.tsx @@ -1,6 +1,11 @@ import { Resource } from "@/interfaces/ResourceType"; -import { Button, Dialog, useDialog, CloseButton } from "@chakra-ui/react"; +import api from "@/lib/api"; +import { CONTROLLER } from "@/lib/controller"; +import { Button, Dialog, useDialog, CloseButton, Text } from "@chakra-ui/react"; +import { useState } from "react"; import { MdOutlineVerifiedUser } from "react-icons/md"; +import { StatusEnum } from "@/interfaces/StatusEnum"; +import { IoMdClose } from "react-icons/io"; export const RequestResourceVerification = ({ resource, @@ -9,10 +14,15 @@ export const RequestResourceVerification = ({ resource: Resource; onRequestSubmitted?: () => void; }) => { + const [verificationRequestLoading, setVerificationRequestLoading] = + useState(false); const dialog = useDialog(); const onSubmitForVerification = async () => { console.log("Submitting resource for verification:", resource.id); + setVerificationRequestLoading(true); + await api.post(`${CONTROLLER.resources}/${resource.id}/verify`); + setVerificationRequestLoading(false); dialog.setOpen(false); onRequestSubmitted?.(); }; @@ -27,10 +37,24 @@ export const RequestResourceVerification = ({ <Dialog.Title>Resource Verification</Dialog.Title> </Dialog.Header> <Dialog.Body> - When you submit <b>{resource.name}</b> for verification, the - Airavata team will review it to ensure it meets the necessary - safety standards. This process may take some time, and you will be - notified once the verification is complete. + {resource.status === StatusEnum.NONE && ( + <Text> + When you submit <b>{resource.name}</b> for verification, the + Airavata team will review it to ensure it meets the necessary + safety standards. This process may take some time, and you + will be notified once the verification is complete. + </Text> + )} + + {resource.status === StatusEnum.REJECTED && ( + <Text> + Unfortunately, we found issues with <b>{resource.name}</b>{" "} + that prevents it from being verified by our team. Please find + our comments on the resource details page. After you have made + those changes, you may re-submit this resource for + verification. + </Text> + )} </Dialog.Body> <Dialog.Footer> <Button @@ -48,16 +72,32 @@ export const RequestResourceVerification = ({ </Dialog.Positioner> </Dialog.RootProvider> - <Button - size="2xs" - colorPalette={"yellow"} - onClick={() => { - dialog.setOpen(true); - }} - > - <MdOutlineVerifiedUser /> - Request Verification - </Button> + {resource.status === StatusEnum.NONE && ( + <Button + size="2xs" + colorPalette={"yellow"} + onClick={() => { + dialog.setOpen(true); + }} + loading={verificationRequestLoading} + > + <MdOutlineVerifiedUser /> + Request Verification + </Button> + )} + + {resource.status === StatusEnum.REJECTED && ( + <Button + size="2xs" + colorPalette={"red"} + onClick={() => { + dialog.setOpen(true); + }} + > + <IoMdClose /> + Verification Rejected + </Button> + )} </> ); }; diff --git a/airavata-research-portal/src/components/resources/ResourceDetails.tsx b/airavata-research-portal/src/components/resources/ResourceDetails.tsx index 1ddd72e18..64a685dc6 100644 --- a/airavata-research-portal/src/components/resources/ResourceDetails.tsx +++ b/airavata-research-portal/src/components/resources/ResourceDetails.tsx @@ -44,7 +44,7 @@ import { Resource, } from "@/interfaces/ResourceType"; import { Tag } from "@/interfaces/TagType"; -import { isValidImaage, resourceTypeToColor } from "@/lib/util"; +import { isAdmin, isValidImaage, resourceTypeToColor } from "@/lib/util"; import { ResourceTypeBadge } from "./ResourceTypeBadge"; import { ResourceTypeEnum } from "@/interfaces/ResourceTypeEnum"; import { ModelSpecificBox } from "../models/ModelSpecificBox"; @@ -59,6 +59,8 @@ import { PrivateResourceTooltip } from "@/components/resources/PrivateResourceTo import { useAuth } from "react-oidc-context"; import { ResourceVerification } from "./ResourceVerification"; import { ResourceAuthor } from "@/interfaces/ResourceAuthor.ts"; +import { VerificationControls } from "./admin/VerificationControls"; +import { VerificationActivities } from "./VerificationActivities"; async function getResource(id: string) { const response = await api.get(`${CONTROLLER.resources}/public/${id}`); @@ -72,6 +74,7 @@ const ResourceDetails = () => { const navigate = useNavigate(); const { state } = useLocation(); const auth = useAuth(); + useEffect(() => { if (!id || auth.isLoading) return; @@ -112,6 +115,8 @@ const ResourceDetails = () => { ); }; + const isAdminUser = isAdmin(auth.user?.profile.email || ""); + return ( <> <Container maxW="breakpoint-lg" mx="auto" p={4} mt={16}> @@ -142,12 +147,20 @@ const ResourceDetails = () => { justifyContent="space-between" > <Box w={"full"}> - <HStack gap={2} flexWrap="wrap"> - <ResourceTypeBadge type={resource.type} /> - <ResourceVerification - resource={resource} - setResource={setResource} - /> + <HStack justifyContent="space-between" w="full"> + <HStack gap={2} flexWrap="wrap"> + <ResourceTypeBadge type={resource.type} /> + <ResourceVerification + resource={resource} + setResource={setResource} + /> + </HStack> + {isAdminUser && ( + <VerificationControls + resource={resource} + setResource={setResource} + /> + )} </HStack> <HStack mt={1} @@ -188,7 +201,7 @@ const ResourceDetails = () => { <HStack mt={8} wrap={"wrap"}> {resource.authors.map((author: ResourceAuthor) => { return ( - <HStack> + <HStack key={author.authorId}> <Avatar.Root shape="full" size="xs"> <Avatar.Fallback name={author.authorId} /> <Avatar.Image src={author.authorId} /> @@ -217,6 +230,8 @@ const ResourceDetails = () => { </Box> </HStack> + <VerificationActivities resource={resource} /> + <Separator my={6} /> <Box> <Heading fontWeight="bold" size="2xl"> diff --git a/airavata-research-portal/src/components/resources/ResourceVerification.tsx b/airavata-research-portal/src/components/resources/ResourceVerification.tsx index 2c6536b40..c8f486b63 100644 --- a/airavata-research-portal/src/components/resources/ResourceVerification.tsx +++ b/airavata-research-portal/src/components/resources/ResourceVerification.tsx @@ -51,6 +51,26 @@ export const ResourceVerification = ({ </Tooltip> )} + {isAuthor && resource.status === StatusEnum.REJECTED && ( + <Tooltip content="The rejected status is only visible to this resource's authors. Please see below for details."> + <RequestResourceVerification + resource={resource} + onRequestSubmitted={() => { + setResource({ + ...resource, + status: StatusEnum.PENDING, + } as Resource); + toaster.create({ + title: "Verification Requested", + description: + "Your request for resource verification has been submitted.", + type: "info", + }); + }} + /> + </Tooltip> + )} + {isAuthor && resource.status === StatusEnum.NONE && ( <> <RequestResourceVerification diff --git a/airavata-research-portal/src/components/resources/VerificationActivities.tsx b/airavata-research-portal/src/components/resources/VerificationActivities.tsx new file mode 100644 index 000000000..c6b10ddbc --- /dev/null +++ b/airavata-research-portal/src/components/resources/VerificationActivities.tsx @@ -0,0 +1,108 @@ +import { Resource } from "@/interfaces/ResourceType"; +import { ResourceVerificationActivity } from "@/interfaces/ResourceVerificationActivity"; +import api from "@/lib/api"; +import { CONTROLLER } from "@/lib/controller"; +import { getStatusColor, isAdmin, isResourceOwner } from "@/lib/util"; +import { + Box, + Flex, + Heading, + Table, + Spinner, + Text, + Badge, +} from "@chakra-ui/react"; +import { useEffect, useState } from "react"; +import { IoEyeOffOutline } from "react-icons/io5"; +import { useAuth } from "react-oidc-context"; +import { toaster } from "../ui/toaster"; +import { StatusEnum } from "@/interfaces/StatusEnum"; + +export const VerificationActivities = ({ + resource, +}: { + resource: Resource; +}) => { + const [activities, setActivities] = useState<ResourceVerificationActivity[]>( + [] + ); + const [loading, setLoading] = useState(false); + const auth = useAuth(); + const userEmail = auth.user?.profile?.email || ""; + + if (!isResourceOwner(userEmail, resource) && !isAdmin(userEmail)) { + return null; + } + + useEffect(() => { + async function getData() { + try { + setLoading(true); + const response = await api.get( + `${CONTROLLER.resources}/${resource.id}/verification-activities` + ); + setActivities(response.data); + } catch (error) { + toaster.create({ + title: "Failed to load verification activities", + description: "Please try again later.", + type: "error", + }); + } finally { + setLoading(false); + } + } + + getData(); + }, [resource]); + + return ( + <Box bg="gray.100" p={4} rounded="md"> + <Flex alignItems="center" gap={2}> + <Heading fontWeight="bold" size="xl"> + Verification Activities + </Heading> + <IoEyeOffOutline size={24} /> + </Flex> + <Text fontSize="sm" color="gray.700"> + This section is only visible to this resource's authors and + administrators. + </Text> + + {loading && <Spinner />} + + <Table.Root> + <Table.Caption> + Total {activities.length} verification activities + </Table.Caption> + <Table.Header> + <Table.Row> + <Table.ColumnHeader>Initiating User</Table.ColumnHeader> + <Table.ColumnHeader>Created At</Table.ColumnHeader> + <Table.ColumnHeader>Verification Status</Table.ColumnHeader> + <Table.ColumnHeader>Comments</Table.ColumnHeader> + </Table.Row> + </Table.Header> + + <Table.Body> + {activities.map((activity: ResourceVerificationActivity) => ( + <Table.Row key={activity.id}> + <Table.Cell>{activity.userId}</Table.Cell> + <Table.Cell> + {new Date(activity.createdAt).toLocaleString()} + </Table.Cell> + <Table.Cell> + <Badge + colorPalette={getStatusColor(activity.status as StatusEnum)} + > + {activity.status} + </Badge> + </Table.Cell> + <Table.Cell>{activity.message}</Table.Cell> + </Table.Row> + ))} + </Table.Body> + </Table.Root> + </Box> + ); +}; diff --git a/airavata-research-portal/src/components/resources/admin/PendingResourcesSection.tsx b/airavata-research-portal/src/components/resources/admin/PendingResourcesSection.tsx new file mode 100644 index 000000000..03bd6feac --- /dev/null +++ b/airavata-research-portal/src/components/resources/admin/PendingResourcesSection.tsx @@ -0,0 +1,44 @@ +import { ResourceCard } from "@/components/home/ResourceCard"; +import { toaster } from "@/components/ui/toaster"; +import { Resource } from "@/interfaces/ResourceType"; +import api from "@/lib/api"; +import { CONTROLLER } from "@/lib/controller"; +import { Container, SimpleGrid, Spinner } from "@chakra-ui/react"; +import { useEffect, useState } from "react"; + +export const PendingResourcesSection = () => { + const [loading, setLoading] = useState(false); + const [pendingResources, setPendingResources] = useState<Resource[]>([]); + + useEffect(() => { + // Fetch pending resources from the API + async function fetchPendingResources() { + try { + setLoading(true); + const response = await api.get(`${CONTROLLER.admin}/resources/pending`); + setPendingResources(response.data); + } catch (error) { + toaster.create({ + title: "Failed to load pending resources", + description: "Please try again later.", + type: "error", + }); + } finally { + setLoading(false); + } + } + + fetchPendingResources(); + }, []); + + return ( + <Container maxW="container.lg" mt={8}> + {loading && <Spinner />} + <SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={4} mt={4}> + {pendingResources.map((resource) => ( + <ResourceCard key={resource.id} resource={resource} size="md" /> + ))} + </SimpleGrid> + </Container> + ); +}; diff --git a/airavata-research-portal/src/components/resources/admin/VerificationControls.tsx b/airavata-research-portal/src/components/resources/admin/VerificationControls.tsx new file mode 100644 index 000000000..7bac15040 --- /dev/null +++ b/airavata-research-portal/src/components/resources/admin/VerificationControls.tsx @@ -0,0 +1,136 @@ +import { toaster } from "@/components/ui/toaster"; +import { Resource } from "@/interfaces/ResourceType"; +import { StatusEnum } from "@/interfaces/StatusEnum"; +import api from "@/lib/api"; +import { CONTROLLER } from "@/lib/controller"; +import { + Button, + HStack, + Dialog, + useDialog, + Text, + Textarea, + CloseButton, +} from "@chakra-ui/react"; +import { useState } from "react"; + +export const VerificationControls = ({ + resource, + setResource, +}: { + resource: Resource; + setResource: (resource: Resource) => void; +}) => { + const [verifyLoading, setVerifyLoading] = useState(false); + const [rejectLoading, setRejectLoading] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const dialog = useDialog(); + + if (!resource || resource.status !== StatusEnum.PENDING) { + return null; // Only show controls for resources pending verification + } + + const onSubmitVerify = async () => { + try { + setVerifyLoading(true); + const response = await api.post( + `${CONTROLLER.admin}/resources/${resource.id}/verify` + ); + setResource(response.data); + } catch { + toaster.create({ + title: "Error verifying resource", + type: "error", + }); + } finally { + setVerifyLoading(false); + } + }; + + const onSubmitForVerification = async () => { + if (!rejectReason.trim()) { + toaster.create({ + title: "Rejection reason is required", + type: "error", + }); + return; + } + + try { + setRejectLoading(true); + const response = await api.post( + `${CONTROLLER.admin}/resources/${resource.id}/reject`, + rejectReason + ); + setResource(response.data); + dialog.setOpen(false); + } catch { + toaster.create({ + title: "Error rejecting resource", + type: "error", + }); + } finally { + setRejectLoading(false); + } + }; + + return ( + <> + <HStack gap={2} mt={4}> + <Button + size="xs" + colorPalette="red" + onClick={() => dialog.setOpen(true)} + > + Reject + </Button> + + <Button + size="xs" + colorPalette="green" + loading={verifyLoading} + onClick={onSubmitVerify} + > + Verify + </Button> + </HStack> + + <Dialog.RootProvider size="sm" value={dialog}> + <Dialog.Backdrop /> + <Dialog.Positioner> + <Dialog.Content> + <Dialog.Header> + <Dialog.Title>Reject Resource</Dialog.Title> + </Dialog.Header> + <Dialog.Body> + <Text> + Please provide a reason for rejecting the resource{" "} + <b>{resource.name}</b>. + </Text> + <Textarea + mt={2} + placeholder="Enter rejection reason" + value={rejectReason} + onChange={(e) => setRejectReason(e.target.value)} + rows={4} + /> + </Dialog.Body> + <Dialog.Footer> + <Button + width="100%" + colorPalette="red" + onClick={onSubmitForVerification} + loading={rejectLoading} + > + Reject + </Button> + </Dialog.Footer> + <Dialog.CloseTrigger asChild> + <CloseButton size="sm" /> + </Dialog.CloseTrigger> + </Dialog.Content> + </Dialog.Positioner> + </Dialog.RootProvider> + </> + ); +}; diff --git a/airavata-research-portal/src/layouts/NavBar.tsx b/airavata-research-portal/src/layouts/NavBar.tsx index ea53c1996..833594731 100644 --- a/airavata-research-portal/src/layouts/NavBar.tsx +++ b/airavata-research-portal/src/layouts/NavBar.tsx @@ -32,37 +32,49 @@ import { useDisclosure, } from "@chakra-ui/react"; import ApacheAiravataLogo from "../assets/airavata-logo.png"; -import {Link, useNavigate} from "react-router"; -import {RxHamburgerMenu} from "react-icons/rx"; -import {IoClose} from "react-icons/io5"; -import {UserMenu} from "@/components/auth/UserMenu"; -import {useAuth} from "react-oidc-context"; +import { Link, useNavigate } from "react-router"; +import { RxHamburgerMenu } from "react-icons/rx"; +import { IoClose, IoEyeOffOutline } from "react-icons/io5"; +import { UserMenu } from "@/components/auth/UserMenu"; +import { useAuth } from "react-oidc-context"; +import { isAdmin } from "@/lib/util"; const NAV_CONTENT = [ { title: "Catalog", url: "/resources?resourceTypes=REPOSITORY%2CNOTEBOOK%2CDATASET%2CMODEL", needsAuth: false, + isAdminOnly: false, }, { title: "Sessions", url: "/sessions", needsAuth: true, + isAdminOnly: false, }, { title: "Add", url: "/add", needsAuth: true, + isAdminOnly: false, }, { title: "Starred", url: "/resources/starred", needsAuth: true, + isAdminOnly: false, }, { title: "Events", url: "/events", needsAuth: false, + isAdminOnly: false, + }, + { + title: "Pending", + url: "/admin/pending-resources", + needsAuth: true, + isAdminOnly: true, }, // { // title: "Datasets", @@ -85,94 +97,105 @@ const NAV_CONTENT = [ interface NavLinkProps extends ButtonProps { title: string; url: string; + isAdminOnly: boolean; } const NavBar = () => { - const {open, onToggle} = useDisclosure(); + const { open, onToggle } = useDisclosure(); const navigate = useNavigate(); const auth = useAuth(); const filteredNavContent = NAV_CONTENT.filter((item) => { - if (item.needsAuth) { + if (item.isAdminOnly && !isAdmin(auth.user?.profile?.email || "")) { + return false; + } else if (item.needsAuth) { return auth.isAuthenticated; } - return true; // Show all items that do not require authentication + return true; }); - const NavLink = ({title, url, ...props}: NavLinkProps) => ( - <Button - variant="plain" - px={2} - _hover={{bg: "gray.200"}} - onClick={() => { - navigate(url); - onToggle(); - }} - {...props} - > - <Text color="gray.700" fontSize="md" textAlign="left"> - {title} - </Text> - </Button> + const NavLink = ({ title, url, isAdminOnly, ...props }: NavLinkProps) => ( + <Button + variant="plain" + px={2} + _hover={{ bg: "gray.200" }} + color={isAdminOnly ? "blue.400" : "gray.700"} + onClick={() => { + navigate(url); + onToggle(); + }} + {...props} + > + <Text fontSize="md" textAlign="left"> + {title} + </Text> + {isAdminOnly && <IoEyeOffOutline size={16} title="Admin Only" />} + </Button> ); return ( - <Box position="sticky" top="0" zIndex="1000" bg="white" boxShadow="sm"> - <Flex align="center" p={4}> - {/* Hamburger Menu (Mobile Only) */} - <IconButton - aria-label="Toggle Navigation" - display={{base: "inline-flex", md: "none"}} - onClick={onToggle} - variant="ghost" - mr={2} - > - {open ? <IoClose size={24}/> : <RxHamburgerMenu size={24}/>} - </IconButton> + <Box position="sticky" top="0" zIndex="1000" bg="white" boxShadow="sm"> + <Flex align="center" p={4}> + {/* Hamburger Menu (Mobile Only) */} + <IconButton + aria-label="Toggle Navigation" + display={{ base: "inline-flex", md: "none" }} + onClick={onToggle} + variant="ghost" + mr={2} + > + {open ? <IoClose size={24} /> : <RxHamburgerMenu size={24} />} + </IconButton> - {/* Logo */} - <Link to="/"> - <Image src={ApacheAiravataLogo} alt="Logo" boxSize="30px"/> - </Link> + {/* Logo */} + <Link to="/"> + <Image src={ApacheAiravataLogo} alt="Logo" boxSize="30px" /> + </Link> - {/* Desktop Nav Links */} - <HStack ml={4} display={{base: "none", md: "flex"}}> - {filteredNavContent.map((item) => ( - <NavLink key={item.title} title={item.title} url={item.url}/> - ))} - </HStack> + {/* Desktop Nav Links */} + <HStack ml={4} display={{ base: "none", md: "flex" }}> + {filteredNavContent.map((item) => ( + <NavLink + key={item.title} + title={item.title} + url={item.url} + isAdminOnly={item.isAdminOnly} + /> + ))} + </HStack> - <Spacer/> + <Spacer /> - {/* User Profile */} - <UserMenu/> - </Flex> + {/* User Profile */} + <UserMenu /> + </Flex> - {/* Mobile Nav Links (Collapse) */} - <Collapsible.Root open={open}> - <Collapsible.Content> - <Stack - direction="column" - bg="white" - px={4} - pb={4} - spaceY={2} - display={{md: "none"}} - > - {filteredNavContent.map((item) => ( - <Box key={item.title} w="100%"> - <NavLink - key={item.title} - title={item.title} - url={item.url} - width="100%" - /> - </Box> - ))} - </Stack> - </Collapsible.Content> - </Collapsible.Root> - </Box> + {/* Mobile Nav Links (Collapse) */} + <Collapsible.Root open={open}> + <Collapsible.Content> + <Stack + direction="column" + bg="white" + px={4} + pb={4} + spaceY={2} + display={{ md: "none" }} + > + {filteredNavContent.map((item) => ( + <Box key={item.title} w="100%"> + <NavLink + key={item.title} + title={item.title} + url={item.url} + isAdminOnly={item.isAdminOnly} + width="100%" + /> + </Box> + ))} + </Stack> + </Collapsible.Content> + </Collapsible.Root> + </Box> ); }; diff --git a/airavata-research-portal/src/lib/controller.ts b/airavata-research-portal/src/lib/controller.ts index d4657b187..45a4b67b8 100644 --- a/airavata-research-portal/src/lib/controller.ts +++ b/airavata-research-portal/src/lib/controller.ts @@ -22,4 +22,5 @@ export const CONTROLLER = { hub: "/hub", resources: "/resources", sessions: "/sessions", -} \ No newline at end of file + admin: "/admin" +} diff --git a/airavata-research-portal/src/lib/util.ts b/airavata-research-portal/src/lib/util.ts index 9cfb333ad..e8b226f8a 100644 --- a/airavata-research-portal/src/lib/util.ts +++ b/airavata-research-portal/src/lib/util.ts @@ -1,5 +1,6 @@ -import {Resource} from "@/interfaces/ResourceType.ts"; -import {ProjectType} from "@/interfaces/ProjectType.tsx"; +import { Resource } from "@/interfaces/ResourceType.ts"; +import { ProjectType } from "@/interfaces/ProjectType.tsx"; +import { StatusEnum } from "@/interfaces/StatusEnum"; export const resourceTypeToColor = (type: string) => { if (type === "NOTEBOOK") { @@ -29,15 +30,37 @@ export const getGithubOwnerAndRepo = (url: string) => { if (match) { const owner = match[1]; const repo = match[2].replace(/\.git$/, ""); - return {owner, repo}; + return { owner, repo }; } return null; } export const isResourceOwner = (userEmail: string, resource: Resource) => { - return resource.authors.includes(userEmail); + return resource.authors + .map((author) => author.authorId.toLowerCase()) + .includes(userEmail); } export const isProjectOwner = (userEmail: string, project: ProjectType) => { return project.ownerId.toLowerCase() === userEmail.toLowerCase(); +} + +export const isAdmin = (userEmail: string) => { + const adminEmails = import.meta.env.VITE_ADMIN_EMAILS?.split(",") || []; + return adminEmails.map((email: string) => email.toLowerCase()).includes(userEmail.toLowerCase()); +} + +export const getStatusColor = (status: StatusEnum) => { + switch (status) { + case StatusEnum.VERIFIED: + return "green"; + case StatusEnum.REJECTED: + return "red"; + case StatusEnum.PENDING: + return "yellow"; + case StatusEnum.NONE: + return "gray"; + default: + return "gray"; + } } \ No newline at end of file
