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


Reply via email to