diff --git a/nest-api/.eslintrc.js b/nest-api/.eslintrc.js index ad6bf12..09a2ed1 100644 --- a/nest-api/.eslintrc.js +++ b/nest-api/.eslintrc.js @@ -18,7 +18,7 @@ module.exports = { ignorePatterns: ['.eslintrc.js'], rules: { '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-function-return-type': 'warn', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off' }, diff --git a/nest-api/README.md b/nest-api/README.md index e9b21c5..4dd8406 100644 --- a/nest-api/README.md +++ b/nest-api/README.md @@ -4,3 +4,53 @@ yarn install yarn start ``` + +## Models + +### User + +```typescript +class User { + _id: string; + username: string; // callsign + password: string; + roles: string[]; + name: string; + email: string; + phone: string; + auth: string; + createdAt: Date; + isDeleted: boolean; +} +``` + +### Event + +```typescript +class Event { + _id: string; + callsign: string; + description: string; + fromDateTime: Date; + toDateTime: Date; + access: User[]; + createdAt: Date; + isDeleted: boolean; +} +``` + +### Reservation + +```typescript +class Reservation { + _id: string; + user: User; + event: Event; + fromDateTime: Date; + toDateTime: Date; + bands: string[]; + modes: string[]; + createdAt: Date; + isDeleted: boolean; +} +``` diff --git a/nest-api/src/app.module.ts b/nest-api/src/app.module.ts index f465ddd..a8088ec 100644 --- a/nest-api/src/app.module.ts +++ b/nest-api/src/app.module.ts @@ -6,6 +6,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { APP_GUARD } from '@nestjs/core'; import { AccessAuthGuard } from './auth/guards/access-auth.guard'; import { RolesGuard } from './guards/roles.guard'; +import { EventsModule } from './events/events.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { RolesGuard } from './guards/roles.guard'; UsersModule, AuthModule, + EventsModule, ], controllers: [], providers: [ diff --git a/nest-api/src/auth/auth.controller.ts b/nest-api/src/auth/auth.controller.ts index 947a6db..90c5c15 100644 --- a/nest-api/src/auth/auth.controller.ts +++ b/nest-api/src/auth/auth.controller.ts @@ -5,6 +5,7 @@ import { RequestUser } from 'src/decorators/request-user.decorator'; import { LocalAuthGuard } from './guards/local-auth.guard'; import { RefreshAuthGuard } from './guards/refresh-auth.guard'; import { UserTokenData } from './interfaces/user-token-data.interface'; +import { LoginResponse } from './interfaces/login-response.interface'; @Controller('auth') export class AuthController { @@ -13,14 +14,14 @@ export class AuthController { @Public() @UseGuards(LocalAuthGuard) @Post('login') - login(@RequestUser() user: UserTokenData) { + login(@RequestUser() user: UserTokenData): Promise { return this.authService.login(user); } @Public() @UseGuards(RefreshAuthGuard) @Get('refresh') - refresh(@RequestUser() user: UserTokenData) { + refresh(@RequestUser() user: UserTokenData): Promise { return this.authService.login(user); } diff --git a/nest-api/src/auth/auth.service.ts b/nest-api/src/auth/auth.service.ts index 80ca9af..ae8ab85 100644 --- a/nest-api/src/auth/auth.service.ts +++ b/nest-api/src/auth/auth.service.ts @@ -6,6 +6,7 @@ import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { UserTokenData } from './interfaces/user-token-data.interface'; import { isMongoId } from 'class-validator'; +import { LoginResponse } from './interfaces/login-response.interface'; @Injectable() export class AuthService { @@ -30,7 +31,7 @@ export class AuthService { * @param user User to login * @returns Access and refresh tokens */ - async login(user: UserTokenData) { + async login(user: UserTokenData): Promise { const payload: UserTokenPayload = { username: user.username, sub: user.id, @@ -57,7 +58,7 @@ export class AuthService { * Sets refresh token to `null` * @param userId User to logout */ - async logout(userId: string) { + async logout(userId: string): Promise { await this.usersService.setRefreshToken(userId, null); } diff --git a/nest-api/src/auth/guards/access-auth.guard.ts b/nest-api/src/auth/guards/access-auth.guard.ts index e2d579e..ec100c1 100644 --- a/nest-api/src/auth/guards/access-auth.guard.ts +++ b/nest-api/src/auth/guards/access-auth.guard.ts @@ -1,6 +1,7 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { Observable } from 'rxjs'; import { IS_PUBLIC_KEY } from 'src/decorators/public.decorator'; @Injectable() @@ -9,7 +10,9 @@ export class AccessAuthGuard extends AuthGuard('jwt') { super(); } - canActivate(context: ExecutionContext) { + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), diff --git a/nest-api/src/auth/interfaces/login-response.interface.ts b/nest-api/src/auth/interfaces/login-response.interface.ts new file mode 100644 index 0000000..6c0b79f --- /dev/null +++ b/nest-api/src/auth/interfaces/login-response.interface.ts @@ -0,0 +1,4 @@ +export interface LoginResponse { + accessToken: string; + refreshToken: string; +} diff --git a/nest-api/src/auth/strategies/jwt-access.strategy.ts b/nest-api/src/auth/strategies/jwt-access.strategy.ts index 1e27f1e..d88fe6e 100644 --- a/nest-api/src/auth/strategies/jwt-access.strategy.ts +++ b/nest-api/src/auth/strategies/jwt-access.strategy.ts @@ -2,6 +2,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { UserTokenData } from '../interfaces/user-token-data.interface'; @Injectable() export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -13,7 +14,7 @@ export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - async validate(payload: any) { + async validate(payload: any): Promise { return { id: payload.sub, username: payload.username }; } } diff --git a/nest-api/src/auth/strategies/jwt-refresh.strategy.ts b/nest-api/src/auth/strategies/jwt-refresh.strategy.ts index 59a16e4..d8380f6 100644 --- a/nest-api/src/auth/strategies/jwt-refresh.strategy.ts +++ b/nest-api/src/auth/strategies/jwt-refresh.strategy.ts @@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config'; import { AuthService } from '../auth.service'; import { Request } from 'express'; import { UserTokenPayload } from '../interfaces/user-token-payload.interface'; +import { UserTokenData } from '../interfaces/user-token-data.interface'; @Injectable() export class JwtRefreshStrategy extends PassportStrategy( @@ -23,7 +24,10 @@ export class JwtRefreshStrategy extends PassportStrategy( }); } - async validate(req: Request, payload: UserTokenPayload) { + async validate( + req: Request, + payload: UserTokenPayload, + ): Promise { const isValid = await this.authService.validateRefreshToken( ExtractJwt.fromAuthHeaderAsBearerToken()(req), ); diff --git a/nest-api/src/decorators/public.decorator.ts b/nest-api/src/decorators/public.decorator.ts index b3845e1..42d5e89 100644 --- a/nest-api/src/decorators/public.decorator.ts +++ b/nest-api/src/decorators/public.decorator.ts @@ -1,4 +1,4 @@ -import { SetMetadata } from '@nestjs/common'; +import { CustomDecorator, SetMetadata } from '@nestjs/common'; export const IS_PUBLIC_KEY = 'isPublic'; -export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); +export const Public = (): CustomDecorator => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/nest-api/src/decorators/roles.decorator.ts b/nest-api/src/decorators/roles.decorator.ts index e108a9c..6946eb3 100644 --- a/nest-api/src/decorators/roles.decorator.ts +++ b/nest-api/src/decorators/roles.decorator.ts @@ -1,5 +1,6 @@ -import { SetMetadata } from '@nestjs/common'; +import { CustomDecorator, SetMetadata } from '@nestjs/common'; import { Role } from '../enums/role.enum'; export const ROLES_KEY = 'roles'; -export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); +export const Roles = (...roles: Role[]): CustomDecorator => + SetMetadata(ROLES_KEY, roles); diff --git a/nest-api/src/events/dto/create-event.dto.ts b/nest-api/src/events/dto/create-event.dto.ts new file mode 100644 index 0000000..f4b7bf7 --- /dev/null +++ b/nest-api/src/events/dto/create-event.dto.ts @@ -0,0 +1,17 @@ +import { IsDate, IsOptional, IsString, MinLength } from 'class-validator'; + +export class CreateEventDto { + @IsString() + @MinLength(3) + callsign: string; + + @IsString() + @IsOptional() + description: string; + + @IsDate() + fromDateTime: Date; + + @IsDate() + toDateTime: Date; +} diff --git a/nest-api/src/events/dto/update-event.dto.ts b/nest-api/src/events/dto/update-event.dto.ts new file mode 100644 index 0000000..304f950 --- /dev/null +++ b/nest-api/src/events/dto/update-event.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateEventDto } from './create-event.dto'; + +export class UpdateEventDto extends PartialType(CreateEventDto) {} diff --git a/nest-api/src/events/events.controller.spec.ts b/nest-api/src/events/events.controller.spec.ts new file mode 100644 index 0000000..576fa84 --- /dev/null +++ b/nest-api/src/events/events.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventsController } from './events.controller'; +import { EventsService } from './events.service'; + +describe('EventsController', () => { + let controller: EventsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [EventsController], + providers: [EventsService], + }).compile(); + + controller = module.get(EventsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/nest-api/src/events/events.controller.ts b/nest-api/src/events/events.controller.ts new file mode 100644 index 0000000..c6aca8a --- /dev/null +++ b/nest-api/src/events/events.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, +} from '@nestjs/common'; +import { EventsService } from './events.service'; +import { CreateEventDto } from './dto/create-event.dto'; +import { UpdateEventDto } from './dto/update-event.dto'; +import { MongoIdPipe } from 'src/pipes/mongo-id.pipe'; +import { Event } from './schemas/event.schema'; +import { Roles } from 'src/decorators/roles.decorator'; +import { Role } from 'src/enums/role.enum'; + +@Controller('events') +export class EventsController { + constructor(private readonly eventsService: EventsService) {} + + @Roles(Role.Admin) + @Post() + create(@Body() createEventDto: CreateEventDto): Promise { + return this.eventsService.create(createEventDto); + } + + @Roles(Role.Admin) + @Get('all') + findAll(): Promise { + return this.eventsService.findAll(); + } + + @Get() + findCurrent(): Promise { + return this.eventsService.findCurrent(); + } + + @Get(':id') + findOne(@Param('id', MongoIdPipe) id: string): Promise { + return this.eventsService.findOne(id); + } + + @Roles(Role.Admin) + @Patch(':id') + update( + @Param('id', MongoIdPipe) id: string, + @Body() updateEventDto: UpdateEventDto, + ): Promise { + return this.eventsService.update(id, updateEventDto); + } + + @Roles(Role.Admin) + @Delete(':id') + remove(@Param('id', MongoIdPipe) id: string): Promise { + return this.eventsService.setDeleted(id); + } +} diff --git a/nest-api/src/events/events.module.ts b/nest-api/src/events/events.module.ts new file mode 100644 index 0000000..5f0a8b2 --- /dev/null +++ b/nest-api/src/events/events.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { EventsService } from './events.service'; +import { EventsController } from './events.controller'; +import { Event, EventSchema } from './schemas/event.schema'; +import { MongooseModule } from '@nestjs/mongoose'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Event.name, schema: EventSchema }]), + ], + controllers: [EventsController], + providers: [EventsService], +}) +export class EventsModule {} diff --git a/nest-api/src/events/events.service.spec.ts b/nest-api/src/events/events.service.spec.ts new file mode 100644 index 0000000..f26bdfe --- /dev/null +++ b/nest-api/src/events/events.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventsService } from './events.service'; + +describe('EventsService', () => { + let service: EventsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [EventsService], + }).compile(); + + service = module.get(EventsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nest-api/src/events/events.service.ts b/nest-api/src/events/events.service.ts new file mode 100644 index 0000000..96ea998 --- /dev/null +++ b/nest-api/src/events/events.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { CreateEventDto } from './dto/create-event.dto'; +import { UpdateEventDto } from './dto/update-event.dto'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Event } from './schemas/event.schema'; + +@Injectable() +export class EventsService { + constructor( + @InjectModel(Event.name) private readonly eventModel: Model, + ) {} + + create(createEventDto: CreateEventDto): Promise { + const event = new this.eventModel(createEventDto); + return event.save(); + } + + findAll(): Promise { + return this.eventModel.find().exec(); + } + + findCurrent(): Promise { + const now = new Date(); + return this.eventModel + .find({ + $and: [{ fromDateTime: { $lte: now } }, { toDateTime: { $gte: now } }], + }) + .exec(); + } + + findOne(id: string): Promise { + return this.eventModel.findById(id).exec(); + } + + update(id: string, updateEventDto: UpdateEventDto): Promise { + return this.eventModel + .findByIdAndUpdate(id, updateEventDto, { new: true }) + .exec(); + } + + setDeleted(id: string): Promise { + return this.eventModel + .findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true }) + .exec(); + } +} diff --git a/nest-api/src/events/schemas/event.schema.ts b/nest-api/src/events/schemas/event.schema.ts new file mode 100644 index 0000000..0b8ebf4 --- /dev/null +++ b/nest-api/src/events/schemas/event.schema.ts @@ -0,0 +1,41 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { User } from 'src/users/schemas/user.schema'; + +export type EventDocument = Event & Document; + +@Schema() +export class Event { + _id: string; + + @Prop({ required: true }) + callsign: string; + + @Prop() + description: string; + + @Prop() + fromDateTime: Date; + + @Prop() + toDateTime: Date; + + @Prop({ + type: [{ type: String, ref: User.name }], + default: [], + }) + access: User[]; + + @Prop({ + required: true, + default: Date.now, + }) + createdAt: Date; + + @Prop({ + required: true, + default: false, + }) + isDeleted: boolean; +} + +export const EventSchema = SchemaFactory.createForClass(Event); diff --git a/nest-api/src/main.ts b/nest-api/src/main.ts index 3b568af..7420589 100644 --- a/nest-api/src/main.ts +++ b/nest-api/src/main.ts @@ -3,7 +3,7 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import helmet from 'helmet'; -async function bootstrap() { +async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe()); app.enableCors(); diff --git a/nest-api/src/pipes/mongo-id.pipe.ts b/nest-api/src/pipes/mongo-id.pipe.ts index 626df40..ddf57bf 100644 --- a/nest-api/src/pipes/mongo-id.pipe.ts +++ b/nest-api/src/pipes/mongo-id.pipe.ts @@ -3,7 +3,7 @@ import { isValidObjectId } from 'mongoose'; @Injectable() export class MongoIdPipe implements PipeTransform { - transform(value: any) { + transform(value: any): string { if (!isValidObjectId(value)) throw new BadRequestException('Invalid id'); return value; diff --git a/nest-api/src/users/schemas/user.schema.ts b/nest-api/src/users/schemas/user.schema.ts index c5dbc70..620e08f 100644 --- a/nest-api/src/users/schemas/user.schema.ts +++ b/nest-api/src/users/schemas/user.schema.ts @@ -41,6 +41,18 @@ export class User { set: (value: string) => bcrypt.hashSync(value, 10), }) auth: string; + + @Prop({ + required: true, + default: Date.now, + }) + createdAt: Date; + + @Prop({ + required: true, + default: false, + }) + isDeleted: boolean; } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/nest-api/src/users/users.controller.ts b/nest-api/src/users/users.controller.ts index b5b7b4c..61dfdc1 100644 --- a/nest-api/src/users/users.controller.ts +++ b/nest-api/src/users/users.controller.ts @@ -14,42 +14,42 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { MongoIdPipe } from 'src/pipes/mongo-id.pipe'; import { Role } from 'src/enums/role.enum'; import { Roles } from 'src/decorators/roles.decorator'; -import { Public } from 'src/decorators/public.decorator'; import { RequestUser } from 'src/decorators/request-user.decorator'; import { UserTokenData } from 'src/auth/interfaces/user-token-data.interface'; +import { User } from './schemas/user.schema'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Roles(Role.Admin) - @Public() @Post() - create(@Body() createUserDto: CreateUserDto) { + create(@Body() createUserDto: CreateUserDto): Promise { return this.usersService.create(createUserDto); } + @Roles(Role.Admin) @Get() - findAll() { + findAll(): Promise { return this.usersService.findAll(); } @Get('me') - getSelf(@RequestUser() userReq: UserTokenData) { + getSelf(@RequestUser() userReq: UserTokenData): Promise { const user = this.usersService.findOne(userReq.id); if (!user) throw new NotFoundException('User not found'); return user; } @Get('search/:username') - findByUsername(@Param('username') username: string) { + findByUsername(@Param('username') username: string): Promise { const user = this.usersService.findByUsername(username); if (!user) throw new NotFoundException('User not found'); return user; } @Get(':id') - findOne(@Param('id', MongoIdPipe) id: string) { + findOne(@Param('id', MongoIdPipe) id: string): Promise { return this.usersService.findOne(id); } @@ -58,7 +58,7 @@ export class UsersController { update( @Param('id', MongoIdPipe) id: string, @Body() updateUserDto: UpdateUserDto, - ) { + ): Promise { const user = this.usersService.update(id, updateUserDto); if (!user) throw new NotFoundException('User not found'); return user; @@ -66,8 +66,8 @@ export class UsersController { @Roles(Role.Admin) @Delete(':id') - remove(@Param('id', MongoIdPipe) id: string) { - const user = this.usersService.remove(id); + async remove(@Param('id', MongoIdPipe) id: string): Promise { + const user = this.usersService.setDeleted(id); if (!user) throw new NotFoundException('User not found'); } } diff --git a/nest-api/src/users/users.service.ts b/nest-api/src/users/users.service.ts index e587006..d13fb97 100644 --- a/nest-api/src/users/users.service.ts +++ b/nest-api/src/users/users.service.ts @@ -17,7 +17,7 @@ export class UsersService { } findAll(): Promise { - return this.userModel.find().exec(); + return this.userModel.find({ isDeleted: { $in: [false, null] } }).exec(); } findOne(id: string): Promise { @@ -34,8 +34,10 @@ export class UsersService { .exec(); } - remove(id: string): Promise { - return this.userModel.findByIdAndDelete(id).exec(); + setDeleted(id: string): Promise { + return this.userModel + .findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true }) + .exec(); } setRefreshToken(id: string, token: string): Promise { diff --git a/next-app/package.json b/next-app/package.json index 5c595bf..cd9c529 100644 --- a/next-app/package.json +++ b/next-app/package.json @@ -9,6 +9,10 @@ "lint": "next lint" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.4.2", + "@fortawesome/free-solid-svg-icons": "^6.4.2", + "@fortawesome/react-fontawesome": "^0.2.0", + "@headlessui/react": "^1.7.17", "@types/node": "20.5.8", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", @@ -21,12 +25,14 @@ "postcss": "8.4.29", "react": "18.2.0", "react-dom": "18.2.0", + "react-no-ssr": "^1.1.0", "react-secure-storage": "^1.3.0", "tailwindcss": "3.3.3", "typescript": "5.2.2", "zustand": "^4.4.1" }, "devDependencies": { + "@types/react-no-ssr": "^1.1.3", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.4", "sass": "^1.66.1" diff --git a/next-app/src/api.ts b/next-app/src/api.ts index 7370860..5aeed69 100644 --- a/next-app/src/api.ts +++ b/next-app/src/api.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { User } from './interfaces/user.interface'; +import { CreateUserDto } from './interfaces/create-user-dto.interface'; const baseURL = 'http://localhost:3001/'; @@ -16,6 +17,7 @@ interface LoginResponse { } export const apiFunctions = { + // Auth login: async (username: string, password: string) => { return await api.post('/auth/login', { username, password }); }, @@ -24,18 +26,78 @@ export const apiFunctions = { headers: { Authorization: `Bearer ${refreshToken}` }, }); }, - getMe: async (accessToken: string) => { - return await api.get('/users/me', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - }, logout: async (accessToken: string) => { return await api.get('/auth/logout', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + + // Users + createUser: async (accessToken: string, user: CreateUserDto) => { + return await api.post('/users', user, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + getAllUsers: async (accessToken: string) => { + return await api.get('/users', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + getMe: async (accessToken: string) => { + return await api.get('/users/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + findByUsername: async (accessToken: string, username: string) => { + return await api.get(`/users/search/${username}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + getUser: async (accessToken: string, id: string) => { + return await api.get(`/users/${id}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + updateUser: async (accessToken: string, id: string, user: User) => { + return await api.patch(`/users/${id}`, user, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + deleteUser: async (accessToken: string, id: string) => { + return await api.delete(`/users/${id}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + + // Events + createEvent: async (accessToken: string, event: Event) => { + return await api.post('/events', event, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + getAllEvents: async (accessToken: string) => { + return await api.get('/events/all', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + getCurrentEvents: async (accessToken: string) => { + return await api.get('/events', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + getEvent: async (accessToken: string, id: string) => { + return await api.get(`/events/${id}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + updateEvent: async (accessToken: string, id: string, event: Event) => { + return await api.patch(`/events/${id}`, event, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + }, + deleteEvent: async (accessToken: string, id: string) => { + return await api.delete(`/events/${id}`, { + headers: { Authorization: `Bearer ${accessToken}` }, }); }, }; diff --git a/next-app/src/app/admin/events/page.tsx b/next-app/src/app/admin/events/page.tsx new file mode 100644 index 0000000..4d1e1fd --- /dev/null +++ b/next-app/src/app/admin/events/page.tsx @@ -0,0 +1,3 @@ +export default function AdminEvents() { + return

Events

; +} diff --git a/next-app/src/app/admin/layout.tsx b/next-app/src/app/admin/layout.tsx new file mode 100644 index 0000000..4ae920f --- /dev/null +++ b/next-app/src/app/admin/layout.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { Role } from '@/enums/role.enum'; +import { useAuthState } from '@/state/auth-state'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useEffect } from 'react'; + +const subPages = [ + { + name: 'Events', + href: '/admin/events', + }, + { + name: 'Users', + href: '/admin/users', + }, +]; + +export default function AdminPageLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + const getUser = useAuthState((s) => s.getUser); + + useEffect(() => { + getUser().then((u) => { + if (!u || !u.roles.includes(Role.Admin)) window.location.replace('/'); + }); + }, []); + + return ( + <> +
+

Admin Page

+ +
+ {subPages.map(({ name, href }) => ( + + {name} + + ))} +
+ + {children} +
+ + ); +} diff --git a/next-app/src/app/admin/page.tsx b/next-app/src/app/admin/page.tsx new file mode 100644 index 0000000..152ec9a --- /dev/null +++ b/next-app/src/app/admin/page.tsx @@ -0,0 +1,3 @@ +export default function AdminPage() { + return <>; +} diff --git a/next-app/src/app/admin/users/create-user-form.tsx b/next-app/src/app/admin/users/create-user-form.tsx new file mode 100644 index 0000000..569a96f --- /dev/null +++ b/next-app/src/app/admin/users/create-user-form.tsx @@ -0,0 +1,102 @@ +'use client'; +import { apiFunctions } from '@/api'; +import { useAuthState } from '@/state/auth-state'; +import { useState } from 'react'; + +export function CreateUserForm() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + + const getAccessToken = useAuthState((s) => s.getAccessToken); + + function submit() { + getAccessToken().then((accessToken) => { + if (!accessToken) throw new Error('No access token'); + apiFunctions + .createUser(accessToken, { + username, + password, + name, + email: email || undefined, + phone: phone || undefined, + }) + .then((res) => { + console.log(res); + setUsername(''); + setPassword(''); + setName(''); + setEmail(''); + setPhone(''); + }) + .catch((err) => { + console.error(err); + }); + }); + } + + return ( +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + setName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPhone(e.target.value)} + /> +
+ + +
+ ); +} diff --git a/next-app/src/app/admin/users/delete-user-dialog.tsx b/next-app/src/app/admin/users/delete-user-dialog.tsx new file mode 100644 index 0000000..62de72e --- /dev/null +++ b/next-app/src/app/admin/users/delete-user-dialog.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { apiFunctions } from '@/api'; +import { User } from '@/interfaces/user.interface'; +import { useAuthState } from '@/state/auth-state'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Dialog, Transition } from '@headlessui/react'; +import { Fragment, useRef } from 'react'; + +export function DeleteUserDialog({ + user, + onCancel, +}: { + user: User | undefined; + onCancel: () => void; +}) { + const getAccessToken = useAuthState((s) => s.getAccessToken); + + const cancelButtonRef = useRef(null); + + return ( + +
+ +
+
+ +
+
+
+
+
+ + Deactivate account + +
+

+ Are you sure you want to deactivate the account " + {user?.username} + "? This action cannot be undone. +

+
+
+
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/next-app/src/app/admin/users/page.tsx b/next-app/src/app/admin/users/page.tsx new file mode 100644 index 0000000..c60b58f --- /dev/null +++ b/next-app/src/app/admin/users/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useAuthState } from '@/state/auth-state'; +import NoSSR from 'react-no-ssr'; +import { CreateUserForm } from './create-user-form'; +import { UsersList } from './users-list'; + +export default function AdminUsers() { + return ( + <> +

Create user

+ + + +

Users

+ + + + + + ); +} diff --git a/next-app/src/app/admin/users/users-list.tsx b/next-app/src/app/admin/users/users-list.tsx new file mode 100644 index 0000000..5b1f034 --- /dev/null +++ b/next-app/src/app/admin/users/users-list.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { apiFunctions } from '@/api'; +import { Role } from '@/enums/role.enum'; +import { User } from '@/interfaces/user.interface'; +import { useAuthState } from '@/state/auth-state'; +import { faCrown, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useEffect, useState } from 'react'; +import { DeleteUserDialog } from './delete-user-dialog'; +import { Dialog } from '@headlessui/react'; + +export function UsersList() { + const [getAccessToken, getMe] = useAuthState((s) => [ + s.getAccessToken, + s.getUser, + ]); + + const [users, setUsers] = useState(); + const [me, setMe] = useState(); + const [deleteUser, setDeleteUser] = useState(); + + useEffect(() => { + const f = async () => { + const accessToken = await getAccessToken(); + if (!accessToken) return; + + const users = (await apiFunctions.getAllUsers(accessToken)).data; + + const me = await getMe(); + if (!me) return; + + setUsers(users); + setMe(me); + }; + + f(); + }, []); + + if (!users || !me) return
Loading...
; + + return ( +
+ {users.map((user: User) => ( +
+
+
+ {user.username.toUpperCase()} - {user.name} +
{' '} +
{user._id}
+
+
+
Email: {user.email}
+
Phone: {user.phone}
+
+ + +
+ ))} + setDeleteUser(undefined)} + /> +
+ ); +} diff --git a/next-app/src/app/globals.scss b/next-app/src/app/globals.scss index d82c8cb..545df2f 100644 --- a/next-app/src/app/globals.scss +++ b/next-app/src/app/globals.scss @@ -7,5 +7,9 @@ } .button { - @apply rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700; + @apply bg-primary rounded px-4 py-2 font-bold text-white hover:opacity-90; +} + +.header-button { + @apply h-full p-6 leading-none hover:bg-black/10 dark:hover:bg-white/20; } diff --git a/next-app/src/app/layout.tsx b/next-app/src/app/layout.tsx index eb6bc95..7083a2b 100644 --- a/next-app/src/app/layout.tsx +++ b/next-app/src/app/layout.tsx @@ -1,13 +1,12 @@ -import { Header } from '@/components/header'; import './globals.scss'; import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; - -const inter = Inter({ subsets: ['latin'] }); +import { LayoutComponent } from './layout_component'; export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: { + default: 'Ham Reserve', + template: '%s | Ham Reserve', + }, }; export default function RootLayout({ @@ -15,14 +14,5 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - return ( - - -
-
{children}
- - - ); + return ; } diff --git a/next-app/src/app/layout_component.tsx b/next-app/src/app/layout_component.tsx new file mode 100644 index 0000000..98c1cf3 --- /dev/null +++ b/next-app/src/app/layout_component.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Header } from '@/components/header'; +import { useThemeState } from '@/state/theme-state'; +import { Inter } from 'next/font/google'; +import { useEffect, useState } from 'react'; + +const inter = Inter({ subsets: ['latin'] }); + +export function LayoutComponent({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState<'light' | 'dark'>('dark'); + + useEffect(() => { + setTheme(useThemeState.getState().theme); + useThemeState.subscribe((s) => setTheme(s.theme)); + }, []); + + return ( + + +
+
{children}
+ + + ); +} diff --git a/next-app/src/app/page.tsx b/next-app/src/app/page.tsx index 35d37a3..a85325f 100644 --- a/next-app/src/app/page.tsx +++ b/next-app/src/app/page.tsx @@ -1,7 +1,77 @@ +import React from 'react'; + +const znaki = [ + { + znak: 'S50YOTA', + od: '1. 12. 2023', + do: '31. 12. 2023', + }, +]; + export default function Home() { return ( - <> -
Wassup
- +
+
+

Trenutni znaki

+ +
+ {znaki.map((znak, i) => ( +
+
+ {znak.znak} +
+
+
Od: {znak.od}
+
Do: {znak.do}
+
+
+ ))} +
+
+ +
+

Kako do rezervacije?

+ +

Registriraj se s klicnim znakom

+
+ +

+ Administratorji morajo odobriti tvojo prošnjo za uporabo klicnega + znaka preden lahko začneš z rezervacijo terminov. +

+
+ +

+ Izberi željen klicni znak, termin in frekvenčna območja na katerih + bi deloval. +

+
+ +

+ Po delu moraš čim prej na sistem objaviti radioamaterski dnevnik v + ADI formatu. Dnevnik mora vsebovati podatke o klicnem znaku, datumu, + času (v UTC), frekvenci (ali samo frekvenčnem pasu), načinu dela. +

+
+
+
+ ); +} + +interface TileProps { + title: string; + children: React.ReactNode; + image?: string; +} + +function Tile({ title, children, image }: TileProps) { + return ( +
+
{title}
+
{children}
+
); } diff --git a/next-app/src/components/header.tsx b/next-app/src/components/header.tsx index c424718..19c8e33 100644 --- a/next-app/src/components/header.tsx +++ b/next-app/src/components/header.tsx @@ -1,17 +1,35 @@ 'use client'; +import { Role } from '@/enums/role.enum'; import { User } from '@/interfaces/user.interface'; import { useAuthState } from '@/state/auth-state'; +import { useThemeState } from '@/state/theme-state'; +import { faMoon, faSun, faUserCircle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Link from 'next/link'; import { useEffect, useState } from 'react'; export function Header() { + const [theme, setTheme] = useState<'light' | 'dark'>('dark'); + const toggleTheme = useThemeState((s) => s.toggleTheme); + + useEffect(() => { + setTheme(useThemeState.getState().theme); + useThemeState.subscribe((s) => setTheme(s.theme)); + }, []); + return ( -
+
Ham Reserve -
+
+
@@ -30,24 +48,42 @@ function UserHeader() { return user ? (
- {isOpen && ( -
+
+
+ {user.roles.includes(Role.Admin) && ( + setIsOpen(false)} + > + Admin panel + + )}
- )} +
) : (
diff --git a/next-app/src/enums/role.enum.ts b/next-app/src/enums/role.enum.ts new file mode 100644 index 0000000..285b9c5 --- /dev/null +++ b/next-app/src/enums/role.enum.ts @@ -0,0 +1,4 @@ +export enum Role { + User = 'user', + Admin = 'admin', +} diff --git a/next-app/src/interfaces/create-user-dto.interface.ts b/next-app/src/interfaces/create-user-dto.interface.ts new file mode 100644 index 0000000..cd82323 --- /dev/null +++ b/next-app/src/interfaces/create-user-dto.interface.ts @@ -0,0 +1,7 @@ +export interface CreateUserDto { + username: string; + password: string; + name: string; + email?: string; + phone?: string; +} diff --git a/next-app/src/interfaces/event.interface.ts b/next-app/src/interfaces/event.interface.ts new file mode 100644 index 0000000..bcba6d4 --- /dev/null +++ b/next-app/src/interfaces/event.interface.ts @@ -0,0 +1,12 @@ +import { User } from './user.interface'; + +export interface Event { + _id: string; + callsign: string; + description: string; + fromDateTime: Date; + toDateTime: Date; + access: User[]; + createdAt: Date; + isDeleted: boolean; +} diff --git a/next-app/src/interfaces/user.interface.ts b/next-app/src/interfaces/user.interface.ts index 077049c..54aec9d 100644 --- a/next-app/src/interfaces/user.interface.ts +++ b/next-app/src/interfaces/user.interface.ts @@ -1,5 +1,10 @@ export interface User { - id: string; + _id: string; username: string; name: string; + email: string; + phone: string; + createdAt: Date; + isDeleted: boolean; + roles: string[]; } diff --git a/next-app/src/state/auth-state.ts b/next-app/src/state/auth-state.ts index 2b57df5..84eff6e 100644 --- a/next-app/src/state/auth-state.ts +++ b/next-app/src/state/auth-state.ts @@ -12,6 +12,7 @@ export interface AuthState { getUser: () => Promise; isValid: () => Promise; + getAccessToken: () => Promise; logout: () => void; } @@ -37,6 +38,10 @@ export const useAuthState = create( return null; } }, + getAccessToken: async () => { + const isValid = await get().isValid(); + return isValid ? get().accessToken : null; + }, isValid: async () => { const { accessToken, refreshToken } = get(); diff --git a/next-app/src/state/theme-state.ts b/next-app/src/state/theme-state.ts new file mode 100644 index 0000000..c32186b --- /dev/null +++ b/next-app/src/state/theme-state.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface ThemeState { + theme: 'light' | 'dark'; + toggleTheme: () => void; +} + +export const useThemeState = create( + persist( + (set, get) => ({ + theme: + typeof window !== 'undefined' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light', + toggleTheme: () => + set({ theme: get().theme === 'dark' ? 'light' : 'dark' }), + }), + { + name: 'theme-storage', + }, + ), +); diff --git a/next-app/tailwind.config.ts b/next-app/tailwind.config.ts index d9394d1..068df4a 100644 --- a/next-app/tailwind.config.ts +++ b/next-app/tailwind.config.ts @@ -1,12 +1,25 @@ -import type { Config } from "tailwindcss"; +import type { Config } from 'tailwindcss'; const config: Config = { content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', ], - theme: {}, + darkMode: 'class', + theme: { + container: { + center: true, + padding: '2rem', + }, + extend: { + colors: { + primary: { + DEFAULT: '#E95635', + }, + }, + }, + }, plugins: [], }; export default config; diff --git a/next-app/yarn.lock b/next-app/yarn.lock index 46873cf..abbf169 100644 --- a/next-app/yarn.lock +++ b/next-app/yarn.lock @@ -51,6 +51,39 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.48.0.tgz#642633964e217905436033a2bd08bf322849b7fb" integrity sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw== +"@fortawesome/fontawesome-common-types@6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5" + integrity sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA== + +"@fortawesome/fontawesome-svg-core@^6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz#37f4507d5ec645c8b50df6db14eced32a6f9be09" + integrity sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg== + dependencies: + "@fortawesome/fontawesome-common-types" "6.4.2" + +"@fortawesome/free-solid-svg-icons@^6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz#33a02c4cb6aa28abea7bc082a9626b7922099df4" + integrity sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA== + dependencies: + "@fortawesome/fontawesome-common-types" "6.4.2" + +"@fortawesome/react-fontawesome@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz#d90dd8a9211830b4e3c08e94b63a0ba7291ddcf4" + integrity sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw== + dependencies: + prop-types "^15.8.1" + +"@headlessui/react@^1.7.17": + version "1.7.17" + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.17.tgz#a0ec23af21b527c030967245fd99776aa7352bc6" + integrity sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow== + dependencies: + client-only "^0.0.1" + "@humanwhocodes/config-array@^0.11.10": version "0.11.11" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844" @@ -214,6 +247,13 @@ dependencies: "@types/react" "*" +"@types/react-no-ssr@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@types/react-no-ssr/-/react-no-ssr-1.1.3.tgz#895baced8f49e270289c8ad11be1a4a50328d243" + integrity sha512-uMR17qGISe0qTTiVFuRfatP+9plEe/Q0beQ47xy0OXatwb3Z2bEj3OW7FC+9PVqCYEsfR4b01LU9tXw2urzBzw== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@18.2.21": version "18.2.21" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9" @@ -468,6 +508,14 @@ axobject-query@^3.1.1: dependencies: dequal "^2.0.3" +babel-runtime@6.x.x: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g== + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -556,7 +604,7 @@ chalk@^4.0.0: optionalDependencies: fsevents "~2.3.2" -client-only@0.0.1: +client-only@0.0.1, client-only@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== @@ -590,6 +638,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +core-js@^2.4.0: + version "2.6.12" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== + cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -2036,6 +2089,13 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-no-ssr@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-no-ssr/-/react-no-ssr-1.1.0.tgz#313b48d2e26020f969ed98e472f10481604e3cc8" + integrity sha512-3td8iPIEFKWXOJ3Ar5xURvZAsv/aIlngJLBH6fP5QC3WhsfuO2pn7WQR0ZlkTE0puWCL0RDEvXtOfAg4qMp+xA== + dependencies: + babel-runtime "6.x.x" + react-secure-storage@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/react-secure-storage/-/react-secure-storage-1.3.0.tgz#b223c3d608aa11d28232d3530323ab9d60361471" @@ -2077,6 +2137,11 @@ reflect.getprototypeof@^1.0.3: globalthis "^1.0.3" which-builtin-type "^1.1.3" +regenerator-runtime@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" + integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== + regenerator-runtime@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"