From 70aa27716a63f88b6c1285a31ee8c9b6da8d84f8 Mon Sep 17 00:00:00 2001 From: Chris Miller Date: Fri, 5 Jan 2024 16:41:25 -0500 Subject: [PATCH] move to openapi generation --- api/nest-cli.json | 3 +- api/package.json | 1 + api/src/groups/group.controller.ts | 37 +++- api/src/groups/group.entity.ts | 4 + api/src/main.ts | 11 +- api/src/users/user.controller.ts | 61 +++++- api/src/users/user.entity.ts | 4 + api/src/users/users.module.ts | 9 +- api/src/users/users.service.ts | 11 +- api/src/utils/types.ts | 3 + frontend/package.json | 5 +- .../src/components/TextInputWithLabel.tsx | 37 ++++ frontend/src/contexts/AuthContext.tsx | 19 +- frontend/src/main.tsx | 11 +- frontend/src/pages/App.tsx | 8 +- frontend/src/pages/Error.tsx | 16 ++ frontend/src/pages/GroupNew.tsx | 15 +- frontend/src/pages/GroupShow.tsx | 16 +- frontend/src/pages/Profile.tsx | 21 +- frontend/src/pages/UserUpdate.tsx | 154 ++++++++++++++ frontend/src/util/api.ts | 68 +++++-- frontend/src/util/events.ts | 3 + frontend/src/util/group.ts | 42 ++-- frontend/src/util/oauth.ts | 8 +- frontend/src/util/user.ts | 27 ++- frontend/src/util/v1.d.ts | 191 ++++++++++++++++++ yarn.lock | 80 +++++++- 27 files changed, 747 insertions(+), 118 deletions(-) create mode 100644 api/src/utils/types.ts create mode 100644 frontend/src/components/TextInputWithLabel.tsx create mode 100644 frontend/src/pages/Error.tsx create mode 100644 frontend/src/pages/UserUpdate.tsx create mode 100644 frontend/src/util/events.ts create mode 100644 frontend/src/util/v1.d.ts diff --git a/api/nest-cli.json b/api/nest-cli.json index f9aa683b..e8552c29 100644 --- a/api/nest-cli.json +++ b/api/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "plugins": ["@nestjs/swagger"] } } diff --git a/api/package.json b/api/package.json index 0d11e3aa..20c444ea 100644 --- a/api/package.json +++ b/api/package.json @@ -25,6 +25,7 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.1.17", "@nestjs/typeorm": "^10.0.1", "@types/passport-jwt": "^3.0.13", "bcrypt": "^5.1.1", diff --git a/api/src/groups/group.controller.ts b/api/src/groups/group.controller.ts index 324c97b4..4b17ab3a 100644 --- a/api/src/groups/group.controller.ts +++ b/api/src/groups/group.controller.ts @@ -9,11 +9,25 @@ import { } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { GroupsService } from './group.service'; -import { Group } from './group.entity'; +import { Group, NewGroup } from './group.entity'; import { AdminRoute } from 'src/admin/admin.decorator'; import { User } from 'src/users/user.entity'; import { UsersService } from 'src/users/users.service'; import { AdminGuard } from 'src/admin/admin.guard'; +import { GetByIdParameter } from 'src/utils/types'; +import { + ApiBearerAuth, + ApiBody, + ApiParam, + ApiParamOptions, +} from '@nestjs/swagger'; + +const groupIdParam: ApiParamOptions = { + name: 'id', + required: true, + type: 'string', + description: 'Group Id', +}; @Controller() export class GroupController { @@ -24,7 +38,9 @@ export class GroupController { @UseGuards(AuthGuard('jwt')) @Get('groups/:id') - getById(@Param() params: any): Promise { + @ApiParam(groupIdParam) + @ApiBearerAuth() + getById(@Param() params: GetByIdParameter): Promise { return this.groupService.findOneBy({ id: params.id }); } @@ -36,13 +52,21 @@ export class GroupController { @Post('groups') @AdminRoute() @UseGuards(AuthGuard('jwt'), AdminGuard) + @ApiBearerAuth() + @ApiBody({ + required: true, + type: NewGroup, + description: 'New Group, can omit id', + }) createGroup(@Body() newGroup: Omit): Promise { return this.groupService.create(newGroup); } @UseGuards(AuthGuard('jwt')) @Get('groups/:id/members') - groupMembers(@Param() params: any): Promise { + @ApiBearerAuth() + @ApiParam(groupIdParam) + groupMembers(@Param() params: GetByIdParameter): Promise { return this.groupService .findOneBy({ id: params.id }) .then((group) => group.users); @@ -50,7 +74,12 @@ export class GroupController { @UseGuards(AuthGuard('jwt')) @Post('groups/:id/join') - async join(@Request() req, @Param() params: any): Promise { + @ApiBearerAuth() + @ApiParam(groupIdParam) + async join( + @Request() req, + @Param() params: GetByIdParameter, + ): Promise { const id = req.user.sub; if (!id) { diff --git a/api/src/groups/group.entity.ts b/api/src/groups/group.entity.ts index cfc00d3c..a560b633 100644 --- a/api/src/groups/group.entity.ts +++ b/api/src/groups/group.entity.ts @@ -2,6 +2,10 @@ import { IsNotEmpty } from 'class-validator'; import { User } from 'src/users/user.entity'; import { Entity, Column, PrimaryGeneratedColumn, ManyToMany } from 'typeorm'; +export class NewGroup { + name: string; +} + @Entity() export class Group { @PrimaryGeneratedColumn('uuid') diff --git a/api/src/main.ts b/api/src/main.ts index 4ce3efff..b3138c38 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { RequestMethod, ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -11,9 +12,17 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe()); app.enableCors({ origin: '*', - preflightContinue: false + preflightContinue: false, }); + const config = new DocumentBuilder() + .setTitle('Realliance Community API') + .setDescription('An API to interact with Realliance groups and members') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + await app.listen(3000); } diff --git a/api/src/users/user.controller.ts b/api/src/users/user.controller.ts index 45c63cc0..ab321a91 100644 --- a/api/src/users/user.controller.ts +++ b/api/src/users/user.controller.ts @@ -1,7 +1,32 @@ -import { Controller, Request, Get, UseGuards, Param } from '@nestjs/common'; +import { + Controller, + Request, + Get, + UseGuards, + Param, + Patch, + Body, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { UsersService } from './users.service'; -import { User } from './user.entity'; +import { UpdateUser, User } from './user.entity'; +import { + ApiBearerAuth, + ApiBody, + ApiParam, + ApiParamOptions, +} from '@nestjs/swagger'; + +const usernameParam: ApiParamOptions = { + name: 'username', + required: true, + type: 'string', + description: 'Username', +}; + +interface GetByUsernameParam { + username: string; +} @Controller() export class UserController { @@ -9,12 +34,42 @@ export class UserController { @Get('profile') @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() getProfile(@Request() req): Promise { return this.userService.findOneByJwt(req.user); } @Get('user/:username') - getOneUser(@Param() params: any): Promise { + @ApiParam(usernameParam) + getOneUser(@Param() params: GetByUsernameParam): Promise { return this.userService.findOneBy({ username: params.username }); } + + @Patch('user/:username') + @UseGuards(AuthGuard('jwt')) + @ApiBody({ + required: true, + type: UpdateUser, + description: 'Updated user parameters', + }) + @ApiParam(usernameParam) + @ApiBearerAuth() + updateUser( + @Request() req, + @Param() params: GetByUsernameParam, + @Body() updatedUser: UpdateUser, + ): Promise { + const fromJwt = User.fromJwt(req.user); + const isSameUser = fromJwt.username === params.username; + const isAdmin = fromJwt.admin; + + if (isAdmin || isSameUser) { + return this.userService.updateUser( + { username: params.username }, + updatedUser, + ); + } + + throw new Error('Not allowed to update this user'); + } } diff --git a/api/src/users/user.entity.ts b/api/src/users/user.entity.ts index 16560441..d5750aff 100644 --- a/api/src/users/user.entity.ts +++ b/api/src/users/user.entity.ts @@ -3,6 +3,10 @@ import { Entity, Column, PrimaryColumn, JoinTable, ManyToMany } from 'typeorm'; import { ReallianceIdJwt } from './jwt'; import { Group } from '../groups/group.entity'; +export class UpdateUser { + description?: string; +} + @Entity() export class User { @PrimaryColumn() diff --git a/api/src/users/users.module.ts b/api/src/users/users.module.ts index ddaab850..70b82d52 100644 --- a/api/src/users/users.module.ts +++ b/api/src/users/users.module.ts @@ -2,20 +2,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './user.entity'; import { UsersService } from './users.service'; -import { UserController } from './user.controller'; import { JwtStrategy } from './jwt.strategy'; import { PassportModule } from '@nestjs/passport'; +import { UserController } from './user.controller'; @Module({ // forFeature is what triggers the entity to be loaded into the database management system as a persistant object type imports: [ TypeOrmModule.forFeature([User]), - PassportModule.register({ defaultStrategy: 'jwt' }) - ], - providers: [ - UsersService, - JwtStrategy, + PassportModule.register({ defaultStrategy: 'jwt' }), ], + providers: [UsersService, JwtStrategy], controllers: [UserController], exports: [UsersService], }) diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 6b757150..c57a2195 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { FindOptionsWhere, Repository } from 'typeorm'; import { User } from './user.entity'; import { ReallianceIdJwt } from './jwt'; @@ -11,7 +11,7 @@ export class UsersService { private usersRepository: Repository, ) {} - async create(user: User): Promise { + async create(user: User): Promise { return this.usersRepository.save(user); } @@ -33,7 +33,10 @@ export class UsersService { await this.usersRepository.delete(id); } - async updateUser(id: string, newUser: Partial): Promise { + async updateUser( + id: string | FindOptionsWhere, + newUser: Partial, + ): Promise { await this.usersRepository.update(id, newUser); } -} \ No newline at end of file +} diff --git a/api/src/utils/types.ts b/api/src/utils/types.ts new file mode 100644 index 00000000..1695429a --- /dev/null +++ b/api/src/utils/types.ts @@ -0,0 +1,3 @@ +export interface GetByIdParameter { + id: string; +} diff --git a/frontend/package.json b/frontend/package.json index edd4e9d9..1758d455 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite --open --port 8080", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "genSpec": "openapi-typescript http://localhost:3000/api-json -o ./src/util/v1.d.ts" }, "dependencies": { "@types/js-cookie": "^3.0.6", @@ -18,6 +19,7 @@ "js-cookie": "^3.0.5", "match-sorter": "^6.3.1", "oauth4webapi": "^2.4.0", + "openapi-fetch": "^0.8.2", "postcss": "^8.4.32", "react": "^18.2.0", "react-cookie": "^6.1.1", @@ -36,6 +38,7 @@ "eslint": "^8.53.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", + "openapi-typescript": "^6.7.3", "typescript": "^5.2.2", "vite": "^5.0.0" } diff --git a/frontend/src/components/TextInputWithLabel.tsx b/frontend/src/components/TextInputWithLabel.tsx new file mode 100644 index 00000000..c4b96af5 --- /dev/null +++ b/frontend/src/components/TextInputWithLabel.tsx @@ -0,0 +1,37 @@ +import { Label, TextInput } from 'flowbite-react'; + +interface TextInputWithLabelProps { + id: string; + name: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; + value?: string; + helperText?: React.ReactNode; +} + +export function TextInputWithLabel({ + id, + name, + placeholder, + required, + disabled, + value, + helperText, +}: TextInputWithLabelProps) { + return ( +
+
+
+ +
+ ); +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 5dd23671..3c0b289d 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,10 +1,9 @@ import { ReactNode, createContext, useEffect, useState } from 'react'; -import { profile as getProfile } from '../util/api'; +import { User, profile as getProfile } from '../util/api'; import { useCookies } from 'react-cookie'; import { decodeJwt, JWTPayload } from 'jose'; -import { User } from '../util/user'; -const MIN_WAIT_MS = 500; +const MIN_WAIT_MS = 200; export interface AuthedContext { loading: boolean; @@ -49,10 +48,6 @@ export function AuthContextProvider({ children }: AuthContextProps) { console.warn('Failure while syncing token and cookies', e); removeCookie('token'); } - // A load attempted, and no token, make sure no cookie - } else if (!loading && !token && cookies.token) { - console.log('Clearing Cookie'); - removeCookie('token'); } else if (!loading && token && !cookies.token) { console.log('Updated token'); setCookie('token', token, { @@ -66,15 +61,19 @@ export function AuthContextProvider({ children }: AuthContextProps) { console.log('Getting profile claims'); const run = async () => { const now = Date.now(); - const res = await getProfile(token); + const { data, error } = await getProfile(token); + + if (error) { + console.error(error); + return; + } - const json = await res.json(); const diff = Date.now() - now; const minWait = MIN_WAIT_MS - diff; const wait = Math.max(0, minWait); setTimeout(() => { - setProfile(json); + setProfile(data); setLoading(false); }, wait); }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index aa89ee3c..b772cbec 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; -import { ProfilePage } from './pages/Profile'; import App from './pages/App'; import { AuthContextProvider } from './contexts/AuthContext'; import { loader as profileLoader } from './util/user'; @@ -10,12 +9,15 @@ import { GroupList } from './pages/GroupList'; import { loadAllGroups, loadGroup } from './util/group'; import { GroupNew } from './pages/GroupNew'; import { GroupShow } from './pages/GroupShow'; +import { Error } from './pages/Error'; +import { UserUpdate } from './pages/UserUpdate'; +import { ProfilePage } from './pages/Profile'; const router = createBrowserRouter([ { path: '/', element: , - errorElement:
Not found
, + errorElement: , children: [ { path: '/', @@ -36,6 +38,11 @@ const router = createBrowserRouter([ element: , loader: profileLoader, }, + { + path: 'user/:username/edit', + element: , + loader: profileLoader, + }, ], }, ]); diff --git a/frontend/src/pages/App.tsx b/frontend/src/pages/App.tsx index 53197d06..a662f027 100644 --- a/frontend/src/pages/App.tsx +++ b/frontend/src/pages/App.tsx @@ -19,11 +19,15 @@ function App() { const profileItem = useMemo(() => { if (loading) { return ( -
+
); } else if (profile) { return ( - + {profile.displayName}{' '} {profile.admin && Admin} diff --git a/frontend/src/pages/Error.tsx b/frontend/src/pages/Error.tsx new file mode 100644 index 00000000..c1b94b17 --- /dev/null +++ b/frontend/src/pages/Error.tsx @@ -0,0 +1,16 @@ +import { useRouteError } from 'react-router-dom'; + +export function Error() { + const error = useRouteError(); + + console.log(error); + + return ( +
+
+

Oopsie

+

An error occured

+
+
+ ); +} diff --git a/frontend/src/pages/GroupNew.tsx b/frontend/src/pages/GroupNew.tsx index de2a45b1..616b35a3 100644 --- a/frontend/src/pages/GroupNew.tsx +++ b/frontend/src/pages/GroupNew.tsx @@ -1,12 +1,10 @@ -import { Button, Label, TextInput } from 'flowbite-react'; +import { Button } from 'flowbite-react'; import { FormEvent, useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '../contexts/AuthContext'; import { createNewGroup } from '../util/group'; - -interface Value { - value: T; -} +import { TextInputWithLabel } from '../components/TextInputWithLabel'; +import { Value } from '../util/events'; interface EventTargets { name: Value; @@ -45,12 +43,7 @@ export function GroupNew() {

New Group

-
-
-
- -
+ diff --git a/frontend/src/pages/GroupShow.tsx b/frontend/src/pages/GroupShow.tsx index ff961089..2b6e1b55 100644 --- a/frontend/src/pages/GroupShow.tsx +++ b/frontend/src/pages/GroupShow.tsx @@ -1,16 +1,22 @@ import { useLoaderData } from 'react-router-dom'; -import { Group } from '../util/group'; import { Button } from 'flowbite-react'; -import { joinGroup } from '../util/api'; -import { useContext, useMemo } from 'react'; +import { Group, joinGroup } from '../util/api'; +import { useContext, useEffect, useMemo } from 'react'; import { AuthContext } from '../contexts/AuthContext'; +import { beginAuthFlow } from '../util/oauth'; export function GroupShow() { const { token, profile } = useContext(AuthContext); const group = useLoaderData() as Group; + useEffect(() => { + if (!group && !token) { + beginAuthFlow(); + } + }, [group, token]); + const joined = useMemo( - () => group.users.find((user) => user.id === profile?.id) !== undefined, + () => group?.users?.find((user) => user.id === profile?.id) !== null, [group, profile], ); @@ -22,7 +28,7 @@ export function GroupShow() { return (
-

{group.name}

+

{group?.name}

diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 3ba1790a..33b57c51 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,14 +1,31 @@ -import { useLoaderData } from 'react-router-dom'; -import { User } from '../util/user'; +import { Link, useLoaderData } from 'react-router-dom'; import { Card } from 'flowbite-react'; +import { User } from '../util/api'; +import { useContext, useMemo } from 'react'; +import { canEdit } from '../util/user'; +import { AuthContext } from '../contexts/AuthContext'; export function ProfilePage() { + const { profile: contextProfile } = useContext(AuthContext); const profile = useLoaderData() as User; + const edit = useMemo( + () => canEdit(contextProfile, profile?.username), + [contextProfile, profile], + ); + return (

{profile.displayName}

user/{profile.username}

+ {edit && ( + + Edit Profile + + )} {profile.description ?? 'No description'}
); diff --git a/frontend/src/pages/UserUpdate.tsx b/frontend/src/pages/UserUpdate.tsx new file mode 100644 index 00000000..e4469a68 --- /dev/null +++ b/frontend/src/pages/UserUpdate.tsx @@ -0,0 +1,154 @@ +import { useLoaderData, useNavigate } from 'react-router-dom'; +import { User, updateUser } from '../util/api'; +import { + FormEvent, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { AuthContext } from '../contexts/AuthContext'; +import { TextInputWithLabel } from '../components/TextInputWithLabel'; +import { Button, Label, Textarea } from 'flowbite-react'; +import { Value } from '../util/events'; +import { canEdit } from '../util/user'; + +function LoadingAnimation() { + return ( + <> +
+ {Array(5) + .fill(0) + .map(() => ( + <> +
+
+ + ))} + + ); +} + +function AccessDenied() { + return ( + <> +

Access Denied

+

You are now allowed to edit this user.

+ + ); +} + +function CannotBeModifiedHelperText() { + return ( + <> + This cannot be modified here. Please do so on + + Realliance ID + + . + + ); +} + +interface EventTargets { + description: Value; +} + +export function UserUpdate() { + const { loading, profile, token } = useContext(AuthContext); + const user = useLoaderData() as User; + const navigate = useNavigate(); + + const [description, setDescription] = useState(undefined); + + const allowUserEdit = canEdit(profile, user?.username); + + useEffect(() => { + if (description === undefined && user.description) { + setDescription(user.description ?? ''); + } + }, [description, user.description]); + + const onSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + if (token) { + const eventTargets = e.target as unknown as EventTargets; + const description = eventTargets.description.value; + + const { error } = await updateUser(token, user.username, { + description, + }); + if (error) { + console.error(error); + return; + } else { + navigate(`/user/${user.username}`); + } + } + }, + [token, navigate, user.username], + ); + + const userForm = useMemo( + () => ( + <> +

Edit {user.displayName}

+ + } + /> + } + /> +
+
+
+