mirror of
https://github.com/jakobkordez/ham-reserve.git
synced 2025-08-06 05:07:40 +00:00
WIP
This commit is contained in:
@ -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'
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
```
|
||||
|
@ -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: [
|
||||
|
@ -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<LoginResponse> {
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(RefreshAuthGuard)
|
||||
@Get('refresh')
|
||||
refresh(@RequestUser() user: UserTokenData) {
|
||||
refresh(@RequestUser() user: UserTokenData): Promise<LoginResponse> {
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
|
@ -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<LoginResponse> {
|
||||
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<void> {
|
||||
await this.usersService.setRefreshToken(userId, null);
|
||||
}
|
||||
|
||||
|
@ -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<boolean> | Observable<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
|
4
nest-api/src/auth/interfaces/login-response.interface.ts
Normal file
4
nest-api/src/auth/interfaces/login-response.interface.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
@ -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<UserTokenData> {
|
||||
return { id: payload.sub, username: payload.username };
|
||||
}
|
||||
}
|
||||
|
@ -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<UserTokenData> {
|
||||
const isValid = await this.authService.validateRefreshToken(
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken()(req),
|
||||
);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
17
nest-api/src/events/dto/create-event.dto.ts
Normal file
17
nest-api/src/events/dto/create-event.dto.ts
Normal 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;
|
||||
}
|
4
nest-api/src/events/dto/update-event.dto.ts
Normal file
4
nest-api/src/events/dto/update-event.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateEventDto } from './create-event.dto';
|
||||
|
||||
export class UpdateEventDto extends PartialType(CreateEventDto) {}
|
20
nest-api/src/events/events.controller.spec.ts
Normal file
20
nest-api/src/events/events.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
58
nest-api/src/events/events.controller.ts
Normal file
58
nest-api/src/events/events.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
14
nest-api/src/events/events.module.ts
Normal file
14
nest-api/src/events/events.module.ts
Normal 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 {}
|
18
nest-api/src/events/events.service.spec.ts
Normal file
18
nest-api/src/events/events.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
47
nest-api/src/events/events.service.ts
Normal file
47
nest-api/src/events/events.service.ts
Normal 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();
|
||||
}
|
||||
}
|
41
nest-api/src/events/schemas/event.schema.ts
Normal file
41
nest-api/src/events/schemas/event.schema.ts
Normal 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);
|
@ -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<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
app.enableCors();
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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<User> {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
@Roles(Role.Admin)
|
||||
@Get()
|
||||
findAll() {
|
||||
findAll(): Promise<User[]> {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
getSelf(@RequestUser() userReq: UserTokenData) {
|
||||
getSelf(@RequestUser() userReq: UserTokenData): Promise<User> {
|
||||
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<User> {
|
||||
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<User> {
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ export class UsersController {
|
||||
update(
|
||||
@Param('id', MongoIdPipe) id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
) {
|
||||
): Promise<User> {
|
||||
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<void> {
|
||||
const user = this.usersService.setDeleted(id);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
findAll(): Promise<User[]> {
|
||||
return this.userModel.find().exec();
|
||||
return this.userModel.find({ isDeleted: { $in: [false, null] } }).exec();
|
||||
}
|
||||
|
||||
findOne(id: string): Promise<User> {
|
||||
@ -34,8 +34,10 @@ export class UsersService {
|
||||
.exec();
|
||||
}
|
||||
|
||||
remove(id: string): Promise<User> {
|
||||
return this.userModel.findByIdAndDelete(id).exec();
|
||||
setDeleted(id: string): Promise<User> {
|
||||
return this.userModel
|
||||
.findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true })
|
||||
.exec();
|
||||
}
|
||||
|
||||
setRefreshToken(id: string, token: string): Promise<User> {
|
||||
|
@ -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"
|
||||
|
@ -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<LoginResponse>('/auth/login', { username, password });
|
||||
},
|
||||
@ -24,18 +26,78 @@ export const apiFunctions = {
|
||||
headers: { Authorization: `Bearer ${refreshToken}` },
|
||||
});
|
||||
},
|
||||
getMe: async (accessToken: string) => {
|
||||
return await api.get<User>('/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<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}` },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
3
next-app/src/app/admin/events/page.tsx
Normal file
3
next-app/src/app/admin/events/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function AdminEvents() {
|
||||
return <h2>Events</h2>;
|
||||
}
|
59
next-app/src/app/admin/layout.tsx
Normal file
59
next-app/src/app/admin/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
3
next-app/src/app/admin/page.tsx
Normal file
3
next-app/src/app/admin/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function AdminPage() {
|
||||
return <></>;
|
||||
}
|
102
next-app/src/app/admin/users/create-user-form.tsx
Normal file
102
next-app/src/app/admin/users/create-user-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
88
next-app/src/app/admin/users/delete-user-dialog.tsx
Normal file
88
next-app/src/app/admin/users/delete-user-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
next-app/src/app/admin/users/page.tsx
Normal file
22
next-app/src/app/admin/users/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
85
next-app/src/app/admin/users/users-list.tsx
Normal file
85
next-app/src/app/admin/users/users-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${inter.className} dark:bg-[#232323] dark:text-[#eeeeee]`}
|
||||
>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return <LayoutComponent children={children} />;
|
||||
}
|
||||
|
28
next-app/src/app/layout_component.tsx
Normal file
28
next-app/src/app/layout_component.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<>
|
||||
<div>Wassup</div>
|
||||
</>
|
||||
<div className="container flex flex-col gap-10 py-8">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
<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">
|
||||
Ham Reserve
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
@ -30,24 +48,42 @@ function UserHeader() {
|
||||
return user ? (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
className="flex h-full items-center p-6 hover:bg-white/20"
|
||||
className="header-button flex items-center"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<div>{user.username.toUpperCase()}</div>
|
||||
<FontAwesomeIcon icon={faUserCircle} className="mr-2 h-5" />
|
||||
<span>{user.username.toUpperCase()}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md border-gray-500 bg-white/20 py-2 shadow-md">
|
||||
<div
|
||||
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
|
||||
onClick={() => {
|
||||
logout();
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-full">
|
||||
|
4
next-app/src/enums/role.enum.ts
Normal file
4
next-app/src/enums/role.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum Role {
|
||||
User = 'user',
|
||||
Admin = 'admin',
|
||||
}
|
7
next-app/src/interfaces/create-user-dto.interface.ts
Normal file
7
next-app/src/interfaces/create-user-dto.interface.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface CreateUserDto {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
12
next-app/src/interfaces/event.interface.ts
Normal file
12
next-app/src/interfaces/event.interface.ts
Normal 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;
|
||||
}
|
@ -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[];
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ export interface AuthState {
|
||||
|
||||
getUser: () => Promise<User | null>;
|
||||
isValid: () => Promise<boolean>;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
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();
|
||||
|
||||
|
24
next-app/src/state/theme-state.ts
Normal file
24
next-app/src/state/theme-state.ts
Normal 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',
|
||||
},
|
||||
),
|
||||
);
|
@ -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;
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user