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 4df295780..b1ae6bde9 100644
--- a/airavata-research-portal/src/App.tsx
+++ b/airavata-research-portal/src/App.tsx
@@ -1,46 +1,32 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-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();
@@ -74,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,
};
@@ -92,42 +78,50 @@ function App() {
}
return (
- <>
- {
- navigate(location.search, {replace: true});
- }}
- >
-
-
-
- {/* Public Route */}
- }>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
-
+ <>
+ {
+ navigate(location.search, { replace: true });
+ }}
+ >
+
+
+
+ {/* Public Route */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Protected Routes with Layout */}
+ }
+ >
+ }
+ />
+ } />
+ } />
+ } />
+ } />
- {/* Protected Routes with Layout */}
}
- >
- }/>
- }/>
- }/>
- }/>
- }/>
-
-
-
- >
+ path="/admin/pending-resources"
+ element={}
+ />
+
+
+
+ >
);
}
diff --git a/airavata-research-portal/src/components/add/ConfirmRepoDetails.tsx b/airavata-research-portal/src/components/add/ConfirmRepoDetails.tsx
index 360eda2f9..8ad90c96e 100644
--- a/airavata-research-portal/src/components/add/ConfirmRepoDetails.tsx
+++ b/airavata-research-portal/src/components/add/ConfirmRepoDetails.tsx
@@ -1,10 +1,11 @@
-import { PrivacyEnum } from "@/interfaces/PrivacyEnum";
-import { CreateResourceRequest } from "@/interfaces/Requests/CreateResourceRequest";
+import {PrivacyEnum} from "@/interfaces/PrivacyEnum";
+import {CreateResourceRequest} from "@/interfaces/Requests/CreateResourceRequest";
import api from "@/lib/api";
-import { CONTROLLER } from "@/lib/controller";
+import {CONTROLLER} from "@/lib/controller";
import {
Button,
Code,
+ createListCollection,
Field,
HStack,
Input,
@@ -13,11 +14,10 @@ import {
Text,
Textarea,
VStack,
- createListCollection,
} from "@chakra-ui/react";
-import { toaster } from "../ui/toaster";
-import { useNavigate } from "react-router";
-import { useState } from "react";
+import {toaster} from "../ui/toaster";
+import {useNavigate} from "react-router";
+import {useState} from "react";
const privacyOptions = createListCollection({
items: Object.keys(PrivacyEnum).map((key) => ({
@@ -27,10 +27,10 @@ const privacyOptions = createListCollection({
});
export const ConfirmRepoDetails = ({
- createResourceRequest,
- setCreateResourceRequest,
- githubUrl,
-}: {
+ createResourceRequest,
+ setCreateResourceRequest,
+ githubUrl,
+ }: {
createResourceRequest: CreateResourceRequest;
setCreateResourceRequest: (data: CreateResourceRequest) => void;
githubUrl: string;
@@ -42,8 +42,8 @@ export const ConfirmRepoDetails = ({
try {
setLoading(true);
await api.post(
- `${CONTROLLER.resources}/repository?githubUrl=${githubUrl}`,
- createResourceRequest
+ `${CONTROLLER.resources}/repository?githubUrl=${githubUrl}`,
+ createResourceRequest
);
toaster.create({
@@ -52,7 +52,7 @@ export const ConfirmRepoDetails = ({
type: "success",
});
navigate(
- "/resources?resourceTypes=REPOSITORY%2CNOTEBOOK%2CDATASET%2CMODEL"
+ "/resources?resourceTypes=REPOSITORY%2CNOTEBOOK%2CDATASET%2CMODEL"
);
} catch (error) {
console.error("Error adding repository:", error);
@@ -66,87 +66,89 @@ export const ConfirmRepoDetails = ({
}
};
+ console.log(createResourceRequest);
+
return (
- <>
-
-
- To make any changes, please modify the cybershuttle.yml{" "}
- file in your GitHub repository.
-
-
- Repository Name
-
-
-
- Repository URL
-
-
-
- Description
-
-
-
- Tags
-
- {createResourceRequest.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
-
- Authors
-
- {createResourceRequest.authors.map((author) => (
-
- {author}
-
- ))}
-
-
-
- {
- console.log(value);
- setCreateResourceRequest({
- ...createResourceRequest,
- privacy: value.value[0] as PrivacyEnum, // ✅ value is a string[]
- });
- }}
- collection={privacyOptions}
- width="full"
- disabled={true}
- >
-
- Privacy
-
-
-
-
-
-
-
-
-
-
-
- {privacyOptions.items.map((item) => (
-
- {item.label}
-
- ))}
-
-
-
-
-
+ <>
+
+
+ To make any changes, please modify the cybershuttle.yml{" "}
+ file in your GitHub repository.
+
+
+ Repository Name
+
+
+
+ Repository URL
+
+
+
+ Description
+
+
+ Tags
+
+ {createResourceRequest.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ Authors
+
+ {createResourceRequest.authors.map((author) => (
+
+ {author.authorId} ({author.role})
+
+ ))}
+
+
+
+ {
+ console.log(value);
+ setCreateResourceRequest({
+ ...createResourceRequest,
+ privacy: value.value[0] as PrivacyEnum,
+ });
+ }}
+ collection={privacyOptions}
+ width="full"
+ disabled={true}
+ >
+
+ Privacy
+
+
+
+
+
+
+
+
+
+
+
+ {privacyOptions.items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+
+
-
-
- >
+
+
+ >
);
};
diff --git a/airavata-research-portal/src/components/home/ResourceCard.tsx b/airavata-research-portal/src/components/home/ResourceCard.tsx
index c612e959c..f1f5208ca 100644
--- a/airavata-research-portal/src/components/home/ResourceCard.tsx
+++ b/airavata-research-portal/src/components/home/ResourceCard.tsx
@@ -17,32 +17,44 @@
* 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,} 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;
removeOnUnStar?: boolean;
}) => {
const [hideCard, setHideCard] = useState(false);
- const author = resource.authors[0];
const isValidImage = isValidImaage(resource.headerImage);
@@ -50,106 +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 = (
-
- {isValidImage && (
-
-
-
- {/* Full-width Image */}
-
-
- )}
-
-
-
- {resource.name}
-
-
- {resource.privacy === PrivacyEnum.PRIVATE &&
-
+
+ {isValidImage && (
+
+
+
+ {/* Full-width Image */}
+
+
+ )}
+
+
+
+
+ {resource.name}
+
+
+
+ {resource.privacy === PrivacyEnum.PRIVATE && (
+
+ )}
+
-
+ />
-
+
+
-
-
-
+
+
+
+
{!isValidImage && (
-
-
-
+
)}
- {/* Card Content */}
-
- {resource.tags.map((tag: Tag) => (
-
- {tag.value}
-
- ))}
-
-
- {resource.description}
-
-
-
-
- {author && (
-
-
-
-
-
-
-
- {author}
-
-
+ {resource.status === StatusEnum.VERIFIED && (
+
+
+ Verified
+
)}
+
+
+ {/* Card Content */}
+
+ {resource.tags.map((tag: Tag) => (
+
+ {tag.value}
+
+ ))}
+
+
+ {resource.description}
+
+
+
+
+
+ {resource.authors.map((author: ResourceAuthor) => (
+
+
+
+
+
- {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.MODEL && (
-
- )}
-
-
-
-
+
+
+ {author.authorId}
+
+
+
+ ))}
+
+
+ {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.MODEL && (
+
+ )}
+
+
+
+
);
// if (clickable) {
diff --git a/airavata-research-portal/src/components/resources/RequestResourceVerification.tsx b/airavata-research-portal/src/components/resources/RequestResourceVerification.tsx
new file mode 100644
index 000000000..b75c77151
--- /dev/null
+++ b/airavata-research-portal/src/components/resources/RequestResourceVerification.tsx
@@ -0,0 +1,103 @@
+import { Resource } from "@/interfaces/ResourceType";
+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,
+ onRequestSubmitted,
+}: {
+ 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?.();
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Resource Verification
+
+
+ {resource.status === StatusEnum.NONE && (
+
+ When you submit {resource.name} 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.REJECTED && (
+
+ Unfortunately, we found issues with {resource.name}{" "}
+ 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.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {resource.status === StatusEnum.NONE && (
+
+ )}
+
+ {resource.status === StatusEnum.REJECTED && (
+
+ )}
+ >
+ );
+};
diff --git a/airavata-research-portal/src/components/resources/ResourceDetails.tsx b/airavata-research-portal/src/components/resources/ResourceDetails.tsx
index 7bd0b1766..64a685dc6 100644
--- a/airavata-research-portal/src/components/resources/ResourceDetails.tsx
+++ b/airavata-research-portal/src/components/resources/ResourceDetails.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import {useLocation, useNavigate, useParams} from "react-router";
+import { useLocation, useNavigate, useParams } from "react-router";
import {
Avatar,
Badge,
@@ -33,8 +33,8 @@ import {
Spinner,
Text,
} from "@chakra-ui/react";
-import {useEffect, useState} from "react";
-import {BiArrowBack} from "react-icons/bi";
+import { useEffect, useState } from "react";
+import { BiArrowBack } from "react-icons/bi";
import api from "@/lib/api";
import {
DatasetResource,
@@ -43,20 +43,24 @@ import {
RepositoryResource,
Resource,
} from "@/interfaces/ResourceType";
-import {Tag} from "@/interfaces/TagType";
-import {isValidImaage, resourceTypeToColor} from "@/lib/util";
-import {ResourceTypeBadge} from "./ResourceTypeBadge";
-import {ResourceTypeEnum} from "@/interfaces/ResourceTypeEnum";
-import {ModelSpecificBox} from "../models/ModelSpecificBox";
-import {NotebookSpecificDetails} from "../notebooks/NotebookSpecificDetails";
-import {RepositorySpecificDetails} from "../repositories/RepositorySpecificDetails";
-import {CONTROLLER} from "@/lib/controller";
-import {DatasetSpecificDetails} from "../datasets/DatasetSpecificDetails";
-import {ResourceOptions} from "@/components/resources/ResourceOptions.tsx";
-import {toaster} from "@/components/ui/toaster.tsx";
-import {PrivacyEnum} from "@/interfaces/PrivacyEnum.ts";
-import {PrivateResourceTooltip} from "@/components/resources/PrivateResourceTooltip.tsx";
-import {useAuth} from "react-oidc-context";
+import { Tag } from "@/interfaces/TagType";
+import { isAdmin, isValidImaage, resourceTypeToColor } from "@/lib/util";
+import { ResourceTypeBadge } from "./ResourceTypeBadge";
+import { ResourceTypeEnum } from "@/interfaces/ResourceTypeEnum";
+import { ModelSpecificBox } from "../models/ModelSpecificBox";
+import { NotebookSpecificDetails } from "../notebooks/NotebookSpecificDetails";
+import { RepositorySpecificDetails } from "../repositories/RepositorySpecificDetails";
+import { CONTROLLER } from "@/lib/controller";
+import { DatasetSpecificDetails } from "../datasets/DatasetSpecificDetails";
+import { ResourceOptions } from "@/components/resources/ResourceOptions.tsx";
+import { toaster } from "@/components/ui/toaster.tsx";
+import { PrivacyEnum } from "@/interfaces/PrivacyEnum.ts";
+import { PrivateResourceTooltip } from "@/components/resources/PrivateResourceTooltip.tsx";
+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}`);
@@ -64,12 +68,13 @@ async function getResource(id: string) {
}
const ResourceDetails = () => {
- const {id} = useParams();
+ const { id } = useParams();
const [resource, setResource] = useState(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
- const {state} = useLocation();
+ const { state } = useLocation();
const auth = useAuth();
+
useEffect(() => {
if (!id || auth.isLoading) return;
@@ -82,8 +87,8 @@ const ResourceDetails = () => {
toaster.create({
title: "Resource not found",
description: `id: ${id}`,
- type: "error"
- })
+ type: "error",
+ });
} finally {
setLoading(false);
}
@@ -94,9 +99,9 @@ const ResourceDetails = () => {
if (loading) {
return (
-
-
-
+
+
+
);
} else if (!resource) {
return null;
@@ -106,143 +111,161 @@ const ResourceDetails = () => {
const goToResources = () => {
navigate(
- "/resources?resourceTypes=REPOSITORY%2CNOTEBOOK%2CDATASET%2CMODEL"
- )
- }
+ "/resources?resourceTypes=REPOSITORY%2CNOTEBOOK%2CDATASET%2CMODEL"
+ );
+ };
- return (
- <>
-
-
-
-
+ const isAdminUser = isAdmin(auth.user?.profile.email || "");
-
+
+
+
+
+
+
+
+
+
+
+ {isAdminUser && (
+
+ )}
+
+
+
+ {resource.name}
+
-
- {resource.tags.map((tag: Tag) => (
-
- {tag.value}
-
- ))}
+
+ {resource.privacy === PrivacyEnum.PRIVATE && (
+
+ )}
+ {}}
+ />
+
-
- {resource.authors.map((author: string) => {
- return (
-
-
-
-
-
-
-
- {author}
-
-
- );
- })}
-
-
-
-
- {validImage && (
-
- )}
-
-
+
+ {resource.tags.map((tag: Tag) => (
+
+ {tag.value}
+
+ ))}
+
-
-
-
- About
-
+
+ {resource.authors.map((author: ResourceAuthor) => {
+ return (
+
+
+
+
+
- {resource.description}
+
+
+ {author.authorId}
+
+
+
+ );
+ })}
+
-
-
- {(resource.type as ResourceTypeEnum) ===
- ResourceTypeEnum.REPOSITORY && (
-
- )}
-
- {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.DATASET && (
-
+ {validImage && (
+
)}
+
+
- {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.MODEL && (
-
- )}
+
- {(resource.type as ResourceTypeEnum) ===
- ResourceTypeEnum.NOTEBOOK && (
-
- )}
-
-
- >
+
+
+
+ About
+
+
+ {resource.description}
+
+
+
+
+
+ {(resource.type as ResourceTypeEnum) ===
+ ResourceTypeEnum.REPOSITORY && (
+
+ )}
+
+ {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.DATASET && (
+
+ )}
+
+ {(resource.type as ResourceTypeEnum) === ResourceTypeEnum.MODEL && (
+
+ )}
+
+ {(resource.type as ResourceTypeEnum) ===
+ ResourceTypeEnum.NOTEBOOK && (
+
+ )}
+
+
+ >
);
};
diff --git a/airavata-research-portal/src/components/resources/ResourceTypeBadge.tsx b/airavata-research-portal/src/components/resources/ResourceTypeBadge.tsx
index 9ca15e131..a334446e0 100644
--- a/airavata-research-portal/src/components/resources/ResourceTypeBadge.tsx
+++ b/airavata-research-portal/src/components/resources/ResourceTypeBadge.tsx
@@ -1,5 +1,5 @@
-import {resourceTypeToColor} from "@/lib/util";
-import {Badge} from "@chakra-ui/react";
+import { resourceTypeToColor } from "@/lib/util";
+import { Badge } from "@chakra-ui/react";
interface ResourceTypeBadgeProps {
type: string;
@@ -8,20 +8,19 @@ interface ResourceTypeBadgeProps {
}
export const ResourceTypeBadge = ({
- type,
- ...props
- }: ResourceTypeBadgeProps) => {
+ type,
+ ...props
+}: ResourceTypeBadgeProps) => {
return (
-
- {type}
-
+
+ {type}
+
);
};
diff --git a/airavata-research-portal/src/components/resources/ResourceVerification.tsx b/airavata-research-portal/src/components/resources/ResourceVerification.tsx
new file mode 100644
index 000000000..c8f486b63
--- /dev/null
+++ b/airavata-research-portal/src/components/resources/ResourceVerification.tsx
@@ -0,0 +1,95 @@
+import { Resource } from "@/interfaces/ResourceType";
+import { StatusEnum } from "@/interfaces/StatusEnum";
+import { isResourceOwner } from "@/lib/util";
+import { Badge } from "@chakra-ui/react";
+import { MdOutlineVerifiedUser } from "react-icons/md";
+import { useAuth } from "react-oidc-context";
+import { Tooltip } from "../ui/tooltip";
+import { RequestResourceVerification } from "./RequestResourceVerification";
+import { toaster } from "../ui/toaster";
+
+export const ResourceVerification = ({
+ resource,
+ setResource,
+}: {
+ resource: Resource;
+ setResource: (resource: Resource) => void;
+}) => {
+ const auth = useAuth();
+ const isAuthor = isResourceOwner(
+ auth.user?.profile?.email || "INVALID",
+ resource
+ );
+
+ return (
+ <>
+ {resource.status === StatusEnum.VERIFIED && (
+
+
+ Verified
+
+ )}
+
+ {isAuthor && resource.status === StatusEnum.PENDING && (
+
+
+
+ Pending Verification
+
+
+ )}
+
+ {isAuthor && resource.status === StatusEnum.REJECTED && (
+
+ {
+ setResource({
+ ...resource,
+ status: StatusEnum.PENDING,
+ } as Resource);
+ toaster.create({
+ title: "Verification Requested",
+ description:
+ "Your request for resource verification has been submitted.",
+ type: "info",
+ });
+ }}
+ />
+
+ )}
+
+ {isAuthor && resource.status === StatusEnum.NONE && (
+ <>
+ {
+ setResource({
+ ...resource,
+ status: StatusEnum.PENDING,
+ } as Resource);
+ toaster.create({
+ title: "Verification Requested",
+ description:
+ "Your request for resource verification has been submitted.",
+ type: "info",
+ });
+ }}
+ />
+ >
+ )}
+ >
+ );
+};
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(
+ []
+ );
+ 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 (
+
+
+
+ Verification Activities
+
+
+
+
+ This section is only visible to this resource's authors and
+ administrators.
+
+
+ {loading && }
+
+
+
+ Total {activities.length} verification activities
+
+
+
+ Initiating User
+ Created At
+ Verification Status
+ Comments
+
+
+
+
+ {activities.map((activity: ResourceVerificationActivity) => (
+
+ {activity.userId}
+
+ {new Date(activity.createdAt).toLocaleString()}
+
+
+
+ {activity.status}
+
+
+ {activity.message}
+
+ ))}
+
+
+
+ );
+};
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([]);
+
+ 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 (
+
+ {loading && }
+
+ {pendingResources.map((resource) => (
+
+ ))}
+
+
+ );
+};
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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ Reject Resource
+
+
+
+ Please provide a reason for rejecting the resource{" "}
+ {resource.name}.
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/airavata-research-portal/src/interfaces/Requests/CreateResourceRequest.tsx b/airavata-research-portal/src/interfaces/Requests/CreateResourceRequest.tsx
index 21b027f4d..d8b95b3be 100644
--- a/airavata-research-portal/src/interfaces/Requests/CreateResourceRequest.tsx
+++ b/airavata-research-portal/src/interfaces/Requests/CreateResourceRequest.tsx
@@ -1,10 +1,11 @@
-import { PrivacyEnum } from "../PrivacyEnum";
+import {PrivacyEnum} from "../PrivacyEnum";
+import {ResourceAuthor} from "@/interfaces/ResourceAuthor.ts";
export interface CreateResourceRequest {
name: string;
description: string;
tags: string[];
headerImage: string;
- authors: string[];
+ authors: ResourceAuthor[];
privacy: PrivacyEnum;
}
diff --git a/airavata-research-portal/src/interfaces/ResourceAuthor.ts b/airavata-research-portal/src/interfaces/ResourceAuthor.ts
new file mode 100644
index 000000000..05de6f1d7
--- /dev/null
+++ b/airavata-research-portal/src/interfaces/ResourceAuthor.ts
@@ -0,0 +1,12 @@
+export interface ResourceAuthor {
+ authorId: string;
+ role: AuthorRoleEnum;
+}
+
+enum AuthorRoleEnum {
+ PRIMARY,
+ SECONDARY,
+ TERTIARY,
+ QUATERNARY,
+ QUINARY
+}
diff --git a/airavata-research-portal/src/interfaces/ResourceType.ts b/airavata-research-portal/src/interfaces/ResourceType.ts
index 32d4f697a..f1b03272e 100644
--- a/airavata-research-portal/src/interfaces/ResourceType.ts
+++ b/airavata-research-portal/src/interfaces/ResourceType.ts
@@ -1,7 +1,9 @@
-import { PrivacyEnum } from "./PrivacyEnum";
-import { ResourceTypeEnum } from "./ResourceTypeEnum";
-import { StatusEnum } from "./StatusEnum";
-import { Tag } from "./TagType";
+import {PrivacyEnum} from "./PrivacyEnum";
+import {ResourceTypeEnum} from "./ResourceTypeEnum";
+import {StatusEnum} from "./StatusEnum";
+import {Tag} from "./TagType";
+import {ResourceAuthor} from "@/interfaces/ResourceAuthor.ts";
+
// import { User } from "./UserType";
export interface Resource {
@@ -9,7 +11,7 @@ export interface Resource {
name: string;
description: string;
headerImage: string;
- authors: string[];
+ authors: ResourceAuthor[];
tags: Tag[];
status: StatusEnum;
privacy: PrivacyEnum;
diff --git a/airavata-research-portal/src/interfaces/ResourceVerificationActivity.ts b/airavata-research-portal/src/interfaces/ResourceVerificationActivity.ts
new file mode 100644
index 000000000..b5d366498
--- /dev/null
+++ b/airavata-research-portal/src/interfaces/ResourceVerificationActivity.ts
@@ -0,0 +1,48 @@
+import { Resource } from "./ResourceType";
+import { StatusEnum } from "./StatusEnum";
+
+export interface ResourceVerificationActivity {
+ /**
+ * The unique identifier for the activity, generated as a UUID.
+ */
+ id: string;
+
+ /**
+ * The resource associated with this verification activity.
+ * Note: The @JsonBackReference in the backend means this property
+ * might be excluded from the JSON to prevent circular dependencies.
+ * If so, you might want to make this optional (e.g., `resource?: Resource`).
+ */
+ resource?: Resource;
+
+ /**
+ * The ID of the user who performed the activity (e.g., an admin or author).
+ */
+ userId: string;
+
+ /**
+ * The status of the verification activity.
+ */
+ status: StatusEnum;
+
+ /**
+ * An optional message associated with the activity, like a reason for rejection.
+ */
+ message?: string;
+
+ /**
+ * The timestamp when the record was created (ISO 8601 format).
+ */
+ createdAt: string;
+
+ /**
+ * The timestamp when the record was last updated (ISO 8601 format).
+ */
+ updatedAt: string;
+}
+
+
+
+
+
+
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) => (
-
+ const NavLink = ({ title, url, isAdminOnly, ...props }: NavLinkProps) => (
+
);
return (
-
-
- {/* Hamburger Menu (Mobile Only) */}
-
- {open ? : }
-
+
+
+ {/* Hamburger Menu (Mobile Only) */}
+
+ {open ? : }
+
- {/* Logo */}
-
-
-
+ {/* Logo */}
+
+
+
- {/* Desktop Nav Links */}
-
- {filteredNavContent.map((item) => (
-
- ))}
-
+ {/* Desktop Nav Links */}
+
+ {filteredNavContent.map((item) => (
+
+ ))}
+
-
+
- {/* User Profile */}
-
-
+ {/* User Profile */}
+
+
- {/* Mobile Nav Links (Collapse) */}
-
-
-
- {filteredNavContent.map((item) => (
-
-
-
- ))}
-
-
-
-
+ {/* Mobile Nav Links (Collapse) */}
+
+
+
+ {filteredNavContent.map((item) => (
+
+
+
+ ))}
+
+
+
+
);
};
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