This commit is contained in:
Jakob Kordež
2023-09-07 18:46:32 +02:00
parent f24d39b4e1
commit 08a27fb4fc
46 changed files with 1065 additions and 72 deletions

View File

@ -18,7 +18,7 @@ module.exports = {
ignorePatterns: ['.eslintrc.js'], ignorePatterns: ['.eslintrc.js'],
rules: { rules: {
'@typescript-eslint/interface-name-prefix': 'off', '@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/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off' '@typescript-eslint/no-explicit-any': 'off'
}, },

View File

@ -4,3 +4,53 @@
yarn install yarn install
yarn start 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;
}
```

View File

@ -6,6 +6,7 @@ import { MongooseModule } from '@nestjs/mongoose';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AccessAuthGuard } from './auth/guards/access-auth.guard'; import { AccessAuthGuard } from './auth/guards/access-auth.guard';
import { RolesGuard } from './guards/roles.guard'; import { RolesGuard } from './guards/roles.guard';
import { EventsModule } from './events/events.module';
@Module({ @Module({
imports: [ imports: [
@ -24,6 +25,7 @@ import { RolesGuard } from './guards/roles.guard';
UsersModule, UsersModule,
AuthModule, AuthModule,
EventsModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

View File

@ -5,6 +5,7 @@ import { RequestUser } from 'src/decorators/request-user.decorator';
import { LocalAuthGuard } from './guards/local-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard';
import { RefreshAuthGuard } from './guards/refresh-auth.guard'; import { RefreshAuthGuard } from './guards/refresh-auth.guard';
import { UserTokenData } from './interfaces/user-token-data.interface'; import { UserTokenData } from './interfaces/user-token-data.interface';
import { LoginResponse } from './interfaces/login-response.interface';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -13,14 +14,14 @@ export class AuthController {
@Public() @Public()
@UseGuards(LocalAuthGuard) @UseGuards(LocalAuthGuard)
@Post('login') @Post('login')
login(@RequestUser() user: UserTokenData) { login(@RequestUser() user: UserTokenData): Promise<LoginResponse> {
return this.authService.login(user); return this.authService.login(user);
} }
@Public() @Public()
@UseGuards(RefreshAuthGuard) @UseGuards(RefreshAuthGuard)
@Get('refresh') @Get('refresh')
refresh(@RequestUser() user: UserTokenData) { refresh(@RequestUser() user: UserTokenData): Promise<LoginResponse> {
return this.authService.login(user); return this.authService.login(user);
} }

View File

@ -6,6 +6,7 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { UserTokenData } from './interfaces/user-token-data.interface'; import { UserTokenData } from './interfaces/user-token-data.interface';
import { isMongoId } from 'class-validator'; import { isMongoId } from 'class-validator';
import { LoginResponse } from './interfaces/login-response.interface';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -30,7 +31,7 @@ export class AuthService {
* @param user User to login * @param user User to login
* @returns Access and refresh tokens * @returns Access and refresh tokens
*/ */
async login(user: UserTokenData) { async login(user: UserTokenData): Promise<LoginResponse> {
const payload: UserTokenPayload = { const payload: UserTokenPayload = {
username: user.username, username: user.username,
sub: user.id, sub: user.id,
@ -57,7 +58,7 @@ export class AuthService {
* Sets refresh token to `null` * Sets refresh token to `null`
* @param userId User to logout * @param userId User to logout
*/ */
async logout(userId: string) { async logout(userId: string): Promise<void> {
await this.usersService.setRefreshToken(userId, null); await this.usersService.setRefreshToken(userId, null);
} }

View File

@ -1,6 +1,7 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from 'src/decorators/public.decorator'; import { IS_PUBLIC_KEY } from 'src/decorators/public.decorator';
@Injectable() @Injectable()
@ -9,7 +10,9 @@ export class AccessAuthGuard extends AuthGuard('jwt') {
super(); super();
} }
canActivate(context: ExecutionContext) { canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),

View File

@ -0,0 +1,4 @@
export interface LoginResponse {
accessToken: string;
refreshToken: string;
}

View File

@ -2,6 +2,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { UserTokenData } from '../interfaces/user-token-data.interface';
@Injectable() @Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt') { 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<UserTokenData> {
return { id: payload.sub, username: payload.username }; return { id: payload.sub, username: payload.username };
} }
} }

View File

@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
import { Request } from 'express'; import { Request } from 'express';
import { UserTokenPayload } from '../interfaces/user-token-payload.interface'; import { UserTokenPayload } from '../interfaces/user-token-payload.interface';
import { UserTokenData } from '../interfaces/user-token-data.interface';
@Injectable() @Injectable()
export class JwtRefreshStrategy extends PassportStrategy( 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<UserTokenData> {
const isValid = await this.authService.validateRefreshToken( const isValid = await this.authService.validateRefreshToken(
ExtractJwt.fromAuthHeaderAsBearerToken()(req), ExtractJwt.fromAuthHeaderAsBearerToken()(req),
); );

View File

@ -1,4 +1,4 @@
import { SetMetadata } from '@nestjs/common'; import { CustomDecorator, SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic'; export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); export const Public = (): CustomDecorator => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -1,5 +1,6 @@
import { SetMetadata } from '@nestjs/common'; import { CustomDecorator, SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum'; import { Role } from '../enums/role.enum';
export const ROLES_KEY = 'roles'; export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); export const Roles = (...roles: Role[]): CustomDecorator =>
SetMetadata(ROLES_KEY, roles);

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateEventDto } from './create-event.dto';
export class UpdateEventDto extends PartialType(CreateEventDto) {}

View File

@ -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>(EventsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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<Event> {
return this.eventsService.create(createEventDto);
}
@Roles(Role.Admin)
@Get('all')
findAll(): Promise<Event[]> {
return this.eventsService.findAll();
}
@Get()
findCurrent(): Promise<Event[]> {
return this.eventsService.findCurrent();
}
@Get(':id')
findOne(@Param('id', MongoIdPipe) id: string): Promise<Event> {
return this.eventsService.findOne(id);
}
@Roles(Role.Admin)
@Patch(':id')
update(
@Param('id', MongoIdPipe) id: string,
@Body() updateEventDto: UpdateEventDto,
): Promise<Event> {
return this.eventsService.update(id, updateEventDto);
}
@Roles(Role.Admin)
@Delete(':id')
remove(@Param('id', MongoIdPipe) id: string): Promise<Event> {
return this.eventsService.setDeleted(id);
}
}

View File

@ -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 {}

View File

@ -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>(EventsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<Event>,
) {}
create(createEventDto: CreateEventDto): Promise<Event> {
const event = new this.eventModel(createEventDto);
return event.save();
}
findAll(): Promise<Event[]> {
return this.eventModel.find().exec();
}
findCurrent(): Promise<Event[]> {
const now = new Date();
return this.eventModel
.find({
$and: [{ fromDateTime: { $lte: now } }, { toDateTime: { $gte: now } }],
})
.exec();
}
findOne(id: string): Promise<Event> {
return this.eventModel.findById(id).exec();
}
update(id: string, updateEventDto: UpdateEventDto): Promise<Event> {
return this.eventModel
.findByIdAndUpdate(id, updateEventDto, { new: true })
.exec();
}
setDeleted(id: string): Promise<Event> {
return this.eventModel
.findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true })
.exec();
}
}

View File

@ -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);

View File

@ -3,7 +3,7 @@ import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import helmet from 'helmet'; import helmet from 'helmet';
async function bootstrap() { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe()); app.useGlobalPipes(new ValidationPipe());
app.enableCors(); app.enableCors();

View File

@ -3,7 +3,7 @@ import { isValidObjectId } from 'mongoose';
@Injectable() @Injectable()
export class MongoIdPipe implements PipeTransform { export class MongoIdPipe implements PipeTransform {
transform(value: any) { transform(value: any): string {
if (!isValidObjectId(value)) throw new BadRequestException('Invalid id'); if (!isValidObjectId(value)) throw new BadRequestException('Invalid id');
return value; return value;

View File

@ -41,6 +41,18 @@ export class User {
set: (value: string) => bcrypt.hashSync(value, 10), set: (value: string) => bcrypt.hashSync(value, 10),
}) })
auth: string; auth: string;
@Prop({
required: true,
default: Date.now,
})
createdAt: Date;
@Prop({
required: true,
default: false,
})
isDeleted: boolean;
} }
export const UserSchema = SchemaFactory.createForClass(User); export const UserSchema = SchemaFactory.createForClass(User);

View File

@ -14,42 +14,42 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { MongoIdPipe } from 'src/pipes/mongo-id.pipe'; import { MongoIdPipe } from 'src/pipes/mongo-id.pipe';
import { Role } from 'src/enums/role.enum'; import { Role } from 'src/enums/role.enum';
import { Roles } from 'src/decorators/roles.decorator'; import { Roles } from 'src/decorators/roles.decorator';
import { Public } from 'src/decorators/public.decorator';
import { RequestUser } from 'src/decorators/request-user.decorator'; import { RequestUser } from 'src/decorators/request-user.decorator';
import { UserTokenData } from 'src/auth/interfaces/user-token-data.interface'; import { UserTokenData } from 'src/auth/interfaces/user-token-data.interface';
import { User } from './schemas/user.schema';
@Controller('users') @Controller('users')
export class UsersController { export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
@Roles(Role.Admin) @Roles(Role.Admin)
@Public()
@Post() @Post()
create(@Body() createUserDto: CreateUserDto) { create(@Body() createUserDto: CreateUserDto): Promise<User> {
return this.usersService.create(createUserDto); return this.usersService.create(createUserDto);
} }
@Roles(Role.Admin)
@Get() @Get()
findAll() { findAll(): Promise<User[]> {
return this.usersService.findAll(); return this.usersService.findAll();
} }
@Get('me') @Get('me')
getSelf(@RequestUser() userReq: UserTokenData) { getSelf(@RequestUser() userReq: UserTokenData): Promise<User> {
const user = this.usersService.findOne(userReq.id); const user = this.usersService.findOne(userReq.id);
if (!user) throw new NotFoundException('User not found'); if (!user) throw new NotFoundException('User not found');
return user; return user;
} }
@Get('search/:username') @Get('search/:username')
findByUsername(@Param('username') username: string) { findByUsername(@Param('username') username: string): Promise<User> {
const user = this.usersService.findByUsername(username); const user = this.usersService.findByUsername(username);
if (!user) throw new NotFoundException('User not found'); if (!user) throw new NotFoundException('User not found');
return user; return user;
} }
@Get(':id') @Get(':id')
findOne(@Param('id', MongoIdPipe) id: string) { findOne(@Param('id', MongoIdPipe) id: string): Promise<User> {
return this.usersService.findOne(id); return this.usersService.findOne(id);
} }
@ -58,7 +58,7 @@ export class UsersController {
update( update(
@Param('id', MongoIdPipe) id: string, @Param('id', MongoIdPipe) id: string,
@Body() updateUserDto: UpdateUserDto, @Body() updateUserDto: UpdateUserDto,
) { ): Promise<User> {
const user = this.usersService.update(id, updateUserDto); const user = this.usersService.update(id, updateUserDto);
if (!user) throw new NotFoundException('User not found'); if (!user) throw new NotFoundException('User not found');
return user; return user;
@ -66,8 +66,8 @@ export class UsersController {
@Roles(Role.Admin) @Roles(Role.Admin)
@Delete(':id') @Delete(':id')
remove(@Param('id', MongoIdPipe) id: string) { async remove(@Param('id', MongoIdPipe) id: string): Promise<void> {
const user = this.usersService.remove(id); const user = this.usersService.setDeleted(id);
if (!user) throw new NotFoundException('User not found'); if (!user) throw new NotFoundException('User not found');
} }
} }

View File

@ -17,7 +17,7 @@ export class UsersService {
} }
findAll(): Promise<User[]> { findAll(): Promise<User[]> {
return this.userModel.find().exec(); return this.userModel.find({ isDeleted: { $in: [false, null] } }).exec();
} }
findOne(id: string): Promise<User> { findOne(id: string): Promise<User> {
@ -34,8 +34,10 @@ export class UsersService {
.exec(); .exec();
} }
remove(id: string): Promise<User> { setDeleted(id: string): Promise<User> {
return this.userModel.findByIdAndDelete(id).exec(); return this.userModel
.findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true })
.exec();
} }
setRefreshToken(id: string, token: string): Promise<User> { setRefreshToken(id: string, token: string): Promise<User> {

View File

@ -9,6 +9,10 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "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/node": "20.5.8",
"@types/react": "18.2.21", "@types/react": "18.2.21",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
@ -21,12 +25,14 @@
"postcss": "8.4.29", "postcss": "8.4.29",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-no-ssr": "^1.1.0",
"react-secure-storage": "^1.3.0", "react-secure-storage": "^1.3.0",
"tailwindcss": "3.3.3", "tailwindcss": "3.3.3",
"typescript": "5.2.2", "typescript": "5.2.2",
"zustand": "^4.4.1" "zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react-no-ssr": "^1.1.3",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.4", "prettier-plugin-tailwindcss": "^0.5.4",
"sass": "^1.66.1" "sass": "^1.66.1"

View File

@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { User } from './interfaces/user.interface'; import { User } from './interfaces/user.interface';
import { CreateUserDto } from './interfaces/create-user-dto.interface';
const baseURL = 'http://localhost:3001/'; const baseURL = 'http://localhost:3001/';
@ -16,6 +17,7 @@ interface LoginResponse {
} }
export const apiFunctions = { export const apiFunctions = {
// Auth
login: async (username: string, password: string) => { login: async (username: string, password: string) => {
return await api.post<LoginResponse>('/auth/login', { username, password }); return await api.post<LoginResponse>('/auth/login', { username, password });
}, },
@ -24,18 +26,78 @@ export const apiFunctions = {
headers: { Authorization: `Bearer ${refreshToken}` }, headers: { Authorization: `Bearer ${refreshToken}` },
}); });
}, },
getMe: async (accessToken: string) => {
return await api.get<User>('/users/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
},
logout: async (accessToken: string) => { logout: async (accessToken: string) => {
return await api.get('/auth/logout', { return await api.get('/auth/logout', {
headers: { headers: { Authorization: `Bearer ${accessToken}` },
Authorization: `Bearer ${accessToken}`, });
}, },
// Users
createUser: async (accessToken: string, user: CreateUserDto) => {
return await api.post<User>('/users', user, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getAllUsers: async (accessToken: string) => {
return await api.get<User[]>('/users', {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getMe: async (accessToken: string) => {
return await api.get<User>('/users/me', {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
findByUsername: async (accessToken: string, username: string) => {
return await api.get<User>(`/users/search/${username}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getUser: async (accessToken: string, id: string) => {
return await api.get<User>(`/users/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
updateUser: async (accessToken: string, id: string, user: User) => {
return await api.patch<User>(`/users/${id}`, user, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
deleteUser: async (accessToken: string, id: string) => {
return await api.delete<User>(`/users/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
// Events
createEvent: async (accessToken: string, event: Event) => {
return await api.post<Event>('/events', event, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getAllEvents: async (accessToken: string) => {
return await api.get<Event[]>('/events/all', {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getCurrentEvents: async (accessToken: string) => {
return await api.get<Event[]>('/events', {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getEvent: async (accessToken: string, id: string) => {
return await api.get<Event>(`/events/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
updateEvent: async (accessToken: string, id: string, event: Event) => {
return await api.patch<Event>(`/events/${id}`, event, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
deleteEvent: async (accessToken: string, id: string) => {
return await api.delete<Event>(`/events/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}); });
}, },
}; };

View File

@ -0,0 +1,3 @@
export default function AdminEvents() {
return <h2>Events</h2>;
}

View File

@ -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 (
<>
<div className="container flex flex-col gap-8 py-10">
<h1 className="text-4xl font-medium">Admin Page</h1>
<div className="flex flex-row flex-wrap gap-3">
{subPages.map(({ name, href }) => (
<Link
key={href}
href={href}
className={`rounded-md border border-gray-300 px-5 py-2 font-medium shadow dark:border-gray-600 ${
href === pathname
? 'bg-black/10 dark:bg-white/20'
: 'bg-black/5 hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/20'
}`}
>
{name}
</Link>
))}
</div>
{children}
</div>
</>
);
}

View File

@ -0,0 +1,3 @@
export default function AdminPage() {
return <></>;
}

View File

@ -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 (
<div className="flex flex-col gap-4 rounded border border-gray-500 p-6">
<div className="flex flex-col gap-1">
<label htmlFor="username">Uporabniško ime (klicni znak)</label>
<input
type="text"
id="username"
className="text-input"
placeholder="S50HQ"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password">Geslo</label>
<input
type="password"
id="password"
className="text-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name">Ime</label>
<input
type="text"
id="name"
className="text-input"
placeholder="Ime in priimek"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
className="text-input"
placeholder="s50hq@hamradio.si"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="phone">Telefon</label>
<input
type="tel"
id="phone"
className="text-input"
placeholder="+386 40 555 555"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<button className="button" onClick={submit}>
Ustvari
</button>
</div>
);
}

View File

@ -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 (
<Dialog
as="div"
open={!!user}
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={onCancel}
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-900 dark:bg-opacity-75" />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<FontAwesomeIcon
icon={faExclamationTriangle}
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-base font-semibold leading-6 text-gray-900"
>
Deactivate account
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to deactivate the account "
<strong className="text-black">{user?.username}</strong>
"? This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="px-4 pb-5 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"
onClick={async () => {
const token = await getAccessToken();
if (!token) return;
apiFunctions.deleteUser(token, user!._id);
onCancel();
}}
>
Deactivate
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
onClick={onCancel}
ref={cancelButtonRef}
>
Cancel
</button>
</div>
</Dialog.Panel>
</div>
</div>
</Dialog>
);
}

View File

@ -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 (
<>
<h2 className="text-2xl font-medium">Create user</h2>
<CreateUserForm />
<h2 className="text-2xl font-medium">Users</h2>
<NoSSR>
<UsersList />
</NoSSR>
</>
);
}

View File

@ -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<User[]>();
const [me, setMe] = useState<User>();
const [deleteUser, setDeleteUser] = useState<User>();
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 <div>Loading...</div>;
return (
<div>
{users.map((user: User) => (
<div
key={user._id}
className="flex flex-row border-b border-b-gray-500 px-4 py-2 last:border-b-0"
>
<div className="my-auto flex-1">
<div className="text-xl">
{user.username.toUpperCase()} - {user.name}
</div>{' '}
<div className="text-xs opacity-80">{user._id}</div>
</div>
<div className="my-auto flex-1">
<div className="text-sm opacity-80">Email: {user.email}</div>
<div className="text-sm opacity-80">Phone: {user.phone}</div>
</div>
<button
className={`h-14 w-14 rounded-full hover:bg-yellow-500/30 disabled:hover:bg-transparent ${
user.roles.includes(Role.Admin)
? 'text-yellow-400'
: 'text-gray-400'
}`}
disabled={user._id === me?._id}
>
<FontAwesomeIcon icon={faCrown} className="h-5 w-5 leading-none" />
</button>
<button
className={`h-14 w-14 rounded-full text-red-500 hover:bg-red-500/30 ${
user._id === me?._id ? 'invisible' : ''
}`}
onClick={() => setDeleteUser(user)}
>
<FontAwesomeIcon icon={faTrash} className="h-5 w-5 leading-none" />
</button>
</div>
))}
<DeleteUserDialog
user={deleteUser}
onCancel={() => setDeleteUser(undefined)}
/>
</div>
);
}

View File

@ -7,5 +7,9 @@
} }
.button { .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;
} }

View File

@ -1,13 +1,12 @@
import { Header } from '@/components/header';
import './globals.scss'; import './globals.scss';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Inter } from 'next/font/google'; import { LayoutComponent } from './layout_component';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Create Next App', title: {
description: 'Generated by create next app', default: 'Ham Reserve',
template: '%s | Ham Reserve',
},
}; };
export default function RootLayout({ export default function RootLayout({
@ -15,14 +14,5 @@ export default function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return <LayoutComponent children={children} />;
<html lang="en">
<body
className={`${inter.className} dark:bg-[#232323] dark:text-[#eeeeee]`}
>
<Header />
<main>{children}</main>
</body>
</html>
);
} }

View File

@ -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 (
<html lang="en" className={theme}>
<body
className={`${inter.className} dark:bg-[#121212] dark:text-[#d6d6d6]`}
>
<Header />
<main>{children}</main>
</body>
</html>
);
}

View File

@ -1,7 +1,77 @@
import React from 'react';
const znaki = [
{
znak: 'S50YOTA',
od: '1. 12. 2023',
do: '31. 12. 2023',
},
];
export default function Home() { export default function Home() {
return ( return (
<> <div className="container flex flex-col gap-10 py-8">
<div>Wassup</div> <div>
</> <h2 className="mb-4 text-2xl">Trenutni znaki</h2>
<div className="grid grid-cols-4">
{znaki.map((znak, i) => (
<div
key={i}
className="rounded-lg bg-[#f5f5f5] px-6 py-4 shadow-md dark:bg-white/5"
>
<div className="mb-2 font-mono text-2xl font-medium">
{znak.znak}
</div>
<div className="text-sm">
<div>Od: {znak.od}</div>
<div>Do: {znak.do}</div>
</div>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-4">
<h2 className="text-2xl">Kako do rezervacije?</h2>
<Tile title="1. Ustvari račun">
<p>Registriraj se s klicnim znakom</p>
</Tile>
<Tile title="2. Zaprosi za dovoljenje">
<p>
Administratorji morajo odobriti tvojo prošnjo za uporabo klicnega
znaka preden lahko začneš z rezervacijo terminov.
</p>
</Tile>
<Tile title="3. Rezerviraj termin">
<p>
Izberi željen klicni znak, termin in frekvenčna območja na katerih
bi deloval.
</p>
</Tile>
<Tile title="4. Oddaj radioamaterski dnevnik">
<p>
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.
</p>
</Tile>
</div>
</div>
);
}
interface TileProps {
title: string;
children: React.ReactNode;
image?: string;
}
function Tile({ title, children, image }: TileProps) {
return (
<div className="rounded-lg bg-[#f5f5f5] p-6 shadow-md dark:bg-white/5">
<div className="mb-2 text-xl font-medium">{title}</div>
<div>{children}</div>
</div>
); );
} }

View File

@ -1,17 +1,35 @@
'use client'; 'use client';
import { Role } from '@/enums/role.enum';
import { User } from '@/interfaces/user.interface'; import { User } from '@/interfaces/user.interface';
import { useAuthState } from '@/state/auth-state'; 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 Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function Header() { 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 ( return (
<div className="flex h-16 select-none flex-row justify-between bg-gray-100 shadow dark:bg-[#454545]"> <div className="bg-primary flex h-16 select-none flex-row justify-between text-white shadow">
<Link href="/" className="my-auto ml-4 text-2xl font-semibold"> <Link href="/" className="my-auto ml-4 text-2xl font-semibold">
Ham Reserve Ham Reserve
</Link> </Link>
<div className="flex flex-row gap-4"> <div className="flex flex-row">
<button className="header-button" onClick={toggleTheme}>
<FontAwesomeIcon
icon={theme === 'dark' ? faSun : faMoon}
className="w-4"
/>
</button>
<UserHeader /> <UserHeader />
</div> </div>
</div> </div>
@ -30,24 +48,42 @@ function UserHeader() {
return user ? ( return user ? (
<div className="relative h-full"> <div className="relative h-full">
<button <button
className="flex h-full items-center p-6 hover:bg-white/20" className="header-button flex items-center"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<div>{user.username.toUpperCase()}</div> <FontAwesomeIcon icon={faUserCircle} className="mr-2 h-5" />
<span>{user.username.toUpperCase()}</span>
</button> </button>
{isOpen && ( <div
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md border-gray-500 bg-white/20 py-2 shadow-md"> className={`absolute right-2 z-10 mt-2 ${
isOpen ? '' : 'scale-0 delay-100'
}`}
>
<div
className={`w-56 origin-top-right rounded-md bg-[#f8f8f8] py-2 text-black shadow-sm ring-1 ring-inset ring-gray-300 duration-100 dark:bg-[#454545] dark:text-white dark:ring-gray-500 ${
isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
}`}
>
{user.roles.includes(Role.Admin) && (
<Link
href="/admin"
className="block px-4 py-2 text-left text-red-500 hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => setIsOpen(false)}
>
Admin panel
</Link>
)}
<button <button
onClick={() => { onClick={() => {
logout(); logout();
window.location.reload(); window.location.reload();
}} }}
className="w-full px-4 py-2 text-left hover:bg-white/10" className="block w-full px-4 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
> >
Odjava Odjava
</button> </button>
</div> </div>
)} </div>
</div> </div>
) : ( ) : (
<div className="relative h-full"> <div className="relative h-full">

View File

@ -0,0 +1,4 @@
export enum Role {
User = 'user',
Admin = 'admin',
}

View File

@ -0,0 +1,7 @@
export interface CreateUserDto {
username: string;
password: string;
name: string;
email?: string;
phone?: string;
}

View File

@ -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;
}

View File

@ -1,5 +1,10 @@
export interface User { export interface User {
id: string; _id: string;
username: string; username: string;
name: string; name: string;
email: string;
phone: string;
createdAt: Date;
isDeleted: boolean;
roles: string[];
} }

View File

@ -12,6 +12,7 @@ export interface AuthState {
getUser: () => Promise<User | null>; getUser: () => Promise<User | null>;
isValid: () => Promise<boolean>; isValid: () => Promise<boolean>;
getAccessToken: () => Promise<string | null>;
logout: () => void; logout: () => void;
} }
@ -37,6 +38,10 @@ export const useAuthState = create(
return null; return null;
} }
}, },
getAccessToken: async () => {
const isValid = await get().isValid();
return isValid ? get().accessToken : null;
},
isValid: async () => { isValid: async () => {
const { accessToken, refreshToken } = get(); const { accessToken, refreshToken } = get();

View File

@ -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<ThemeState>(
(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',
},
),
);

View File

@ -1,12 +1,25 @@
import type { Config } from "tailwindcss"; import type { Config } from 'tailwindcss';
const config: Config = { const config: Config = {
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", './src/components/**/*.{js,ts,jsx,tsx,mdx}',
"./src/app/**/*.{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: [], plugins: [],
}; };
export default config; export default config;

View File

@ -51,6 +51,39 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.48.0.tgz#642633964e217905436033a2bd08bf322849b7fb" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.48.0.tgz#642633964e217905436033a2bd08bf322849b7fb"
integrity sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw== 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": "@humanwhocodes/config-array@^0.11.10":
version "0.11.11" version "0.11.11"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844"
@ -214,6 +247,13 @@
dependencies: dependencies:
"@types/react" "*" "@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": "@types/react@*", "@types/react@18.2.21":
version "18.2.21" version "18.2.21"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9"
@ -468,6 +508,14 @@ axobject-query@^3.1.1:
dependencies: dependencies:
dequal "^2.0.3" 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: balanced-match@^1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@ -556,7 +604,7 @@ chalk@^4.0.0:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
client-only@0.0.1: client-only@0.0.1, client-only@^0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== 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" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== 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: cross-spawn@^7.0.2:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" 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" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== 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: react-secure-storage@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/react-secure-storage/-/react-secure-storage-1.3.0.tgz#b223c3d608aa11d28232d3530323ab9d60361471" 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" globalthis "^1.0.3"
which-builtin-type "^1.1.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: regenerator-runtime@^0.14.0:
version "0.14.0" version "0.14.0"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"