diff --git a/.gitignore b/.gitignore index 8cb7cd5..33d6765 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,12 @@ Thumbs.db .vscode/ .idea/ +# Enviroment +.env + +# Build output +dist/ +build/ + # Logs *.log \ No newline at end of file diff --git a/backend/dist/auth/auth.controller.js b/backend/dist/auth/auth.controller.js index e91c7a4..c922ad7 100644 --- a/backend/dist/auth/auth.controller.js +++ b/backend/dist/auth/auth.controller.js @@ -30,6 +30,10 @@ let AuthController = class AuthController { async setNewPassword(newPassword, session, username, email) { return await this.authService.setNewPassword(newPassword, session, username, email); } + async updateProfile(username, displayName) { + await this.authService.updateProfile(username, displayName); + return { message: 'Profile has been updated' }; + } }; __decorate([ (0, common_1.Post)('register'), @@ -58,6 +62,14 @@ __decorate([ __metadata("design:paramtypes", [String, String, String, String]), __metadata("design:returntype", Promise) ], AuthController.prototype, "setNewPassword", null); +__decorate([ + (0, common_1.Post)('update-profile'), + __param(0, (0, common_1.Body)('username')), + __param(1, (0, common_1.Body)('displayName')), + __metadata("design:type", Function), + __metadata("design:paramtypes", [String, String]), + __metadata("design:returntype", Promise) +], AuthController.prototype, "updateProfile", null); AuthController = __decorate([ (0, common_1.Controller)('auth'), __metadata("design:paramtypes", [auth_service_1.AuthService]) diff --git a/backend/dist/auth/auth.service.js b/backend/dist/auth/auth.service.js index d879706..104255c 100644 --- a/backend/dist/auth/auth.service.js +++ b/backend/dist/auth/auth.service.js @@ -238,6 +238,28 @@ let AuthService = AuthService_1 = class AuthService { throw new Error('An unknown error occurred'); } } + async updateProfile(username, displayName) { + try { + const tableName = process.env.DYNAMODB_USER_TABLE_NAME || 'TABLE_FAILURE'; + const params = { + TableName: tableName, + Key: { userId: username }, + UpdateExpression: 'set displayName = :displayName', + ExpressionAttributeValues: { + ':displayName': displayName + }, + }; + await this.dynamoDb.update(params).promise(); + this.logger.log(`User ${username} updated user profile.`); + } + catch (error) { + if (error instanceof Error) { + this.logger.error('Updating the profile failed', error.stack); + throw new Error(error.message || 'Updating the profile failed'); + } + throw new Error('An unknown error occurred'); + } + } }; AuthService = AuthService_1 = __decorate([ (0, common_1.Injectable)() diff --git a/backend/dist/main.js b/backend/dist/main.js index f100103..3e1273b 100644 --- a/backend/dist/main.js +++ b/backend/dist/main.js @@ -30,6 +30,7 @@ const core_1 = require("@nestjs/core"); const app_module_1 = require("./app.module"); const dotenv = __importStar(require("dotenv")); const aws_sdk_1 = __importDefault(require("aws-sdk")); +const common_1 = require("@nestjs/common"); /* ! */ async function bootstrap() { aws_sdk_1.default.config.update({ @@ -37,6 +38,7 @@ async function bootstrap() { }); const app = await core_1.NestFactory.create(app_module_1.AppModule); app.enableCors(); + app.useGlobalPipes(new common_1.ValidationPipe()); await app.listen(3001); } dotenv.config(); diff --git a/backend/package-lock.json b/backend/package-lock.json index fa5b5ba..f4d0f0c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,8 @@ "@nestjs/passport": "^8.0.0", "@nestjs/platform-express": "^8.4.7", "aws-sdk": "^2.1030.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "crypto": "^1.0.1", "dotenv": "^16.4.5", "jwt-decode": "^4.0.0", @@ -1345,6 +1347,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.12.2", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", + "integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "16.0.9", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz", @@ -1978,6 +1986,23 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -4221,6 +4246,12 @@ "node": ">=6" } }, + "node_modules/libphonenumber-js": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.19.tgz", + "integrity": "sha512-bW/Yp/9dod6fmyR+XqSUL1N5JE7QRxQ3KrBIbYS1FTv32e5i3SEtQVX+71CYNv8maWNSOgnlCoNp9X78f/cKiA==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5887,6 +5918,15 @@ "node": ">= 8" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 1a1876e..17416ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,8 @@ "@nestjs/passport": "^8.0.0", "@nestjs/platform-express": "^8.4.7", "aws-sdk": "^2.1030.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "crypto": "^1.0.1", "dotenv": "^16.4.5", "jwt-decode": "^4.0.0", diff --git a/backend/src/grant/customValidators.ts b/backend/src/grant/customValidators.ts new file mode 100644 index 0000000..b996483 --- /dev/null +++ b/backend/src/grant/customValidators.ts @@ -0,0 +1,56 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, + registerDecorator, + ValidationOptions, + validate, + } from 'class-validator'; + + // Custom validator logic + @ValidatorConstraint({ name: 'isEmailOrPhoneFormat', async: false }) + export class IsEmailOrPhoneFormatConstraint implements ValidatorConstraintInterface { + private async isEmail(value: string): Promise { + // Use the built-in IsEmail validator as a function + const errors = await validate({ email: value.replace('Email: ', '') }, { skipMissingProperties: true }); + return errors.length === 0; // No errors means the email is valid + } + + private isPhoneNumber(value: string): boolean { + // Simple check for "Phone Number: " prefix and a valid phone number format + if (!value.startsWith('Phone Number: ')) return false; + const phoneNumber = value.replace('Phone Number: ', ''); + // Example: Validate phone number format (adjust as needed) + return /^\+\d{1,3} \d{3,14}$/.test(phoneNumber); // Example: +1 1234567890 + } + + async validate(value: string, args: ValidationArguments) { + if(!value || value == undefined){ + return false + } + + if (value.startsWith('Email: ')) { + return this.isEmail(value); + } else if (value.startsWith('Phone Number: ')) { + return this.isPhoneNumber(value); + } + return false; // Invalid format + } + + defaultMessage(args: ValidationArguments) { + return `The point of contact value must be in the format "Email: " or "Phone Number: "`; + } + } + + // Decorator for easier use + export function IsPointOfContact(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsEmailOrPhoneFormatConstraint, + }); + }; + } \ No newline at end of file diff --git a/backend/src/grant/dto/grant.dto.ts b/backend/src/grant/dto/grant.dto.ts new file mode 100644 index 0000000..d0364c3 --- /dev/null +++ b/backend/src/grant/dto/grant.dto.ts @@ -0,0 +1,74 @@ +import { + IsNumber, + IsString, + IsBoolean, + IsArray, + IsISO8601, + IsNotEmpty, + IsDefined, +} from "class-validator"; +import { IsPointOfContact } from "../customValidators"; + +// Tried just doing this without the ! definite statement but i got errors +// From my research definite statement just says this starts as undefined but the program will treat it as the type you said +export class CreateGrantDto { + @IsString() + @IsNotEmpty() + @IsDefined() + organization_name!: string; + + @IsString() + @IsNotEmpty() + @IsDefined() + description!: string; + + @IsBoolean() + @IsNotEmpty() + @IsDefined() + is_bcan_qualifying!: boolean; + + @IsString() + @IsNotEmpty() + @IsDefined() + status!: boolean; + + @IsNumber() + @IsNotEmpty() + @IsDefined() + amount!: number; + + @IsISO8601() + @IsNotEmpty() + @IsDefined() + deadline!: string; + + @IsBoolean() + @IsNotEmpty() + @IsDefined() + notifications_on_for_user!: boolean; + + @IsString() + @IsDefined() + reporting_requirements!: string; + + @IsString() + @IsDefined() + restrictions!: string; + + @IsArray() + @IsDefined() + @IsDefined({each: true}) + @IsPointOfContact({ each: true }) + point_of_contacts!: string[]; + + @IsArray() + @IsString({ each: true }) + @IsDefined() + @IsNotEmpty({each: true}) + attached_resources!: string[]; + + @IsString() + @IsNotEmpty() + @IsDefined() + comments!: string; +} diff --git a/backend/src/grant/grant.controller.ts b/backend/src/grant/grant.controller.ts index 7e02c14..2981a20 100644 --- a/backend/src/grant/grant.controller.ts +++ b/backend/src/grant/grant.controller.ts @@ -1,18 +1,51 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { GrantService } from './grant.service'; +import { + Controller, + Get, + Param, + Put, + Body, + Post, + ValidationPipe, +} from "@nestjs/common"; +import { GrantService } from "./grant.service"; +import { CreateGrantDto } from "./dto/grant.dto"; -@Controller('grant') +@Controller("grant") export class GrantController { - constructor(private readonly grantService: GrantService) { } + constructor(private readonly grantService: GrantService) {} - @Get() - async getAllGrants() { - return await this.grantService.getAllGrants(); - } + @Get() + async getAllGrants() { + return await this.grantService.getAllGrants(); + } - @Get(':id') - async getGrantById(@Param('id') GrantId: string) { - return await this.grantService.getGrantById(parseInt(GrantId, 10)); - } + @Get(":id") + async getGrantById(@Param("id") GrantId: string) { + console.log("getting grant by id") + return await this.grantService.getGrantById(parseInt(GrantId, 10)); + } -} \ No newline at end of file + @Put("archive") + async archive(@Body("grantIds") grantIds: number[]): Promise { + return await this.grantService.unarchiveGrants(grantIds); + } + + @Put("unarchive") + async unarchive(@Body("grantIds") grantIds: number[]): Promise { + return await this.grantService.unarchiveGrants(grantIds); + } + + @Post("new-grant") + async addGrant( + @Body( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + }) + ) + grant: CreateGrantDto + ) { + console.log(grant); + return await this.grantService.addGrant(grant); + } +} diff --git a/backend/src/grant/grant.model.ts b/backend/src/grant/grant.model.ts index e89ebf2..bc26138 100644 --- a/backend/src/grant/grant.model.ts +++ b/backend/src/grant/grant.model.ts @@ -1,16 +1,17 @@ // model for Grant objects -export interface Grant { +export default interface Grant { grantId: number; organization_name: string; description: string; is_bcan_qualifying: boolean; status: string; amount: number; - deadline: string; // dynamo does not have a built-in Date type + deadline: string; // dynamo does not have a built-in Date type and will be in iso8601 notifications_on_for_user: boolean; reporting_requirements: string; restrictions: string; point_of_contacts: string[]; attached_resources: string[]; comments: string[]; -} \ No newline at end of file +} + diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 8760dc4..94070a6 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable,Logger } from '@nestjs/common'; import AWS from 'aws-sdk'; -import { Grant } from './grant.model' +import Grant from './grant.model' +import { CreateGrantDto } from './dto/grant.dto'; // TODO: set up the region elsewhere - code does not work without the line below AWS.config.update({ region: 'us-east-2' }); @@ -9,6 +10,7 @@ const dynamodb = new AWS.DynamoDB.DocumentClient(); @Injectable() export class GrantService { + private readonly logger = new Logger(GrantService.name); // function to retrieve all grants in our database async getAllGrants(): Promise { @@ -49,4 +51,75 @@ export class GrantService { throw new Error('Failed to retrieve grant.'); } } + + // Method to archive grants takes in array + async unarchiveGrants(grantIds :number[]) : Promise { + let successfulUpdates: number[] = []; + for (const grantId of grantIds) { + const params = { + TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || 'TABLE_FAILURE', + Key: { + grantId: grantId, + }, + UpdateExpression: "set isArchived = :archived", + ExpressionAttributeValues: { ":archived": false }, + ReturnValues: "UPDATED_NEW", + }; + + try{ + const res = await dynamodb.update(params).promise(); + console.log(res) + + if (res.Attributes && res.Attributes.isArchived === false) { + console.log(`Grant ${grantId} successfully archived.`); + successfulUpdates.push(grantId); + } else { + console.log(`Grant ${grantId} update failed or no change in status.`); + } + } + catch(err){ + console.log(err); + throw new Error(`Failed to update Grant ${grantId} status.`); + } + }; + return successfulUpdates; + } + + + async addGrant(grant : CreateGrantDto) : Promise { + // When it comes to processing the resources I need a custom decorator + // Did it hoe + // TODO Could possibly do more validation like theres a grant with that name already and such + console.log(grant) + // TODO unique grantId + const params = { + TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || 'TABLE_FAILURE', + Item: { + grantId: 1000, + organization_name: grant.organization_name, + description: grant.description, + is_bcan_qualifying : grant.is_bcan_qualifying, + status : grant.status, + amount: grant.amount, + deadline : grant.deadline, + notifications_on_for_user: grant.notifications_on_for_user, + reporting_requirements: grant.reporting_requirements, + restrictions: grant.restrictions, + point_of_contacts: grant.point_of_contacts, + attached_resources: grant.attached_resources, + comments:grant.comments + } + }; + + try { + const res = await dynamodb.put(params).promise(); + console.log(`Uploaded grant from ${grant.organization_name}`) + // Check if the operation was succesful + } catch (error) { + console.log(error) + throw new Error(`Failed to upload new grant from ${grant.organization_name}`) + } + + return 0 + } } \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index 6b6438c..e595a61 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,7 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as dotenv from 'dotenv' import AWS from 'aws-sdk'; - +import { ValidationPipe } from '@nestjs/common'; /* ! */ async function bootstrap() { AWS.config.update({ @@ -10,6 +10,7 @@ async function bootstrap() { }); const app = await NestFactory.create(AppModule); app.enableCors(); + app.useGlobalPipes(new ValidationPipe()); await app.listen(3001); } dotenv.config(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c79df9..f94a450 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,11 +5,14 @@ import { observer } from 'mobx-react-lite'; import Login from './Login'; import Register from './Register'; import Dashboard from './Dashboard'; +import NewGrant from './NewGrant' import './App.css'; + // Register store and mutators import './external/bcanSatchel/mutators'; import { getStore } from './external/bcanSatchel/store'; +import UploadSucess from './UploadSucess'; const App = observer(() => { const store = getStore(); @@ -30,6 +33,14 @@ const App = observer(() => { path="/dashboard" element={store.isAuthenticated ? : } /> + } + /> + } + /> } diff --git a/frontend/src/NewGrant.css b/frontend/src/NewGrant.css new file mode 100644 index 0000000..54abe03 --- /dev/null +++ b/frontend/src/NewGrant.css @@ -0,0 +1,22 @@ +.main_div { + display:flex; + flex-direction: column; + height:100% + +} + +.center-screen { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + min-height: 100vh; + } + +.row-container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + \ No newline at end of file diff --git a/frontend/src/NewGrant.tsx b/frontend/src/NewGrant.tsx new file mode 100644 index 0000000..c04b24c --- /dev/null +++ b/frontend/src/NewGrant.tsx @@ -0,0 +1,208 @@ +import React, { useState } from 'react'; +import './NewGrant.css' +import POCEntry from './POCEntry'; +import { useNavigate } from 'react-router-dom'; + +type POCEntryRef = { + getPOC: () => string; +}; + +// TODO need to change the string[] fields to have multiple inputs to make the array +const NewGrant = (): JSX.Element => { + const [orgName, changeOrgName] = useState("") + const [desc, changeDesc] = useState("") + // On submission make sure that you check if its yes or no + const [qual, changeIsQualifying] = useState(false) + const [ status, changeStatus] = useState("Unarchived") + const [amt, changeGrantAmount] = useState(0) + const [deadline, changeDeadline] = useState("") + const [notifications, changeNotificationStatus] = useState(false) + const [restrictions, changeRestrictions] = useState("") + // // poc = point of contact + const [pocComponents, setPOCComponentList] = useState([]) + const [pocRefs, setPOCRefs] = useState[]>([]) + const [resources, changeResources] = useState("") + const [comments, changeComments] = useState("") + const [errMessage, changeErrorMessage] = useState("") + const [showErr, changeShowError] = useState(false) + const [repReq, changeReportingReqs] = useState("") + const navigate = useNavigate(); + + const addPOC = () => { + console.log("added component") + const curRef = React.createRef(); + const pocComp = + + setPOCComponentList([...pocComponents, pocComp]) + setPOCRefs([...pocRefs, curRef]) + } + + + function validInputs(): boolean { + if (!orgName) { + changeErrorMessage("Please enter an organization name") + changeShowError(true) + return false; + } + else if (!desc) { + changeErrorMessage("Please describe the grant in the description") + changeShowError(true) + return false + } else if (!restrictions) { + changeErrorMessage("Please enter restrictions if none enter N/A") + changeShowError(true) + return false + } else if (!resources) { + changeErrorMessage("Please enter resources if none enter N/A") + changeShowError(true) + return false + } else if (!comments) { + changeErrorMessage("Please enter comments if none enter N/A") + changeShowError(true) + return false + } else if (amt <= 0) { + changeErrorMessage("Please a non zero amount for the grant") + changeShowError(true) + return false + } else if (!deadline) { + changeErrorMessage("Please enter a date") + changeShowError(true) + return false + } + + return true + } + + const handleDeadlineChange = (event: { target: { value: React.SetStateAction; }; }) => { + changeDeadline(event.target.value) + console.log(deadline) + } + + // won't make a + const submitGrant = async () => { + if (!validInputs()) { + return + } + const pocList: string[] = [] + pocRefs.forEach(ref => { + if (ref.current != null) { + pocList.push(ref.current.getPOC()) + } + }) + const resourcesArr = Array.of(resources.trim()) + const grantJson = { + organization_name: orgName.trim(), + description: desc.trim(), + attached_resources: resourcesArr, + status: status, + // change to input to be checkboxes + is_bcan_qualifying: qual, + amount: amt, + notifications_on_for_user: notifications, + restrictions: restrictions.trim(), + comments: comments.trim(), + point_of_contacts: pocList, + deadline: deadline, + reporting_requirements: repReq + } + console.log(grantJson) + + try { + const response = await fetch('http://localhost:3001/grant/new-grant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(grantJson) + }) + + console.log(response) + if (!response.ok) { + const errorData = await response.json(); + console.log("error message " + errorData.errMessage) + alert(errorData.errMessage || 'Grant upload failed due to server error.'); + return; + } + navigate('/upload-success'); + + + } catch (error) { + changeShowError(true) + changeErrorMessage("Server Error") + console.log(error) + return + } + + } + + + + // TODO : CSS problem where when i move the text area the orgname goes off the screen + return ( +
+
+

Organization Name

+ +

Description

+ +

Reporting Requirements

+ +

Resources

+ + + + + +

Grant Amount

+ changeGrantAmount(Number(e.target.value))}> + +

Deadline

+ + + +

Restrictions

+ +

Comments

+ +

Points of Contact

+ {pocComponents.map(entry => ( + entry + ))} +
+ + +
+ {/* Error message format should be different and stuff */} + {showErr &&

{errMessage}

} + +
+
) +} + + +export default NewGrant; \ No newline at end of file diff --git a/frontend/src/POCEntry.tsx b/frontend/src/POCEntry.tsx new file mode 100644 index 0000000..2d11d12 --- /dev/null +++ b/frontend/src/POCEntry.tsx @@ -0,0 +1,47 @@ +import React, { forwardRef, useImperativeHandle, useState } from 'react'; +import "./NewGrant.css" + +// TODO: Might want to add in a package to have a phone number so no valadiation is needed +const POCEntry = forwardRef((props, ref) => { + const [poc, changePOC] = useState("") + const [pocType, changePOCType] = useState("") + const [inputType, changeInputType] = useState("text") + // This is from chat so idk what this will always do + // TODO: make it so has to be type and then colon then value + useImperativeHandle(ref, () => ({ + getPOC: () => { + return `${pocType}: ${poc}` + } + })); + + const handlePOCType = (event) => { + const val = event.target.value + changePOCType(val) + + if (val === "Email") { + changeInputType("email") + changePOC("") + } else if (val === "Phone Number") { + changeInputType("number") + changePOC("0") + } + else { + changeInputType("text") + } + } + + // make inputs for email, phone number, for now and possibly more + + return (
+ + { changePOC(e.target.value) }}> + + +
) +}) + + +export default POCEntry \ No newline at end of file diff --git a/frontend/src/UploadSucess.tsx b/frontend/src/UploadSucess.tsx new file mode 100644 index 0000000..3d7c529 --- /dev/null +++ b/frontend/src/UploadSucess.tsx @@ -0,0 +1,17 @@ +import { useNavigate } from "react-router-dom" + + +const UploadSucess = (): JSX.Element => { + const navigate = useNavigate() + + return( +
+

Grant has been uploaded

+ +
+ ) +} + + + +export default UploadSucess \ No newline at end of file