mirror of
https://github.com/jakobkordez/ham-reserve.git
synced 2025-08-03 19:57:42 +00:00
Initial commit
This commit is contained in:
5
nest-api/.env
Normal file
5
nest-api/.env
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
MONGODB_URI=mongodb://localhost:27017/ham-reserve
|
||||||
|
JWT_REFRESH_SECRET=
|
||||||
|
JWT_REFRESH_EXPIRE=30d
|
||||||
|
JWT_ACCESS_SECRET=
|
||||||
|
JWT_ACCESS_EXPIRE=5m
|
25
nest-api/.eslintrc.js
Normal file
25
nest-api/.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off'
|
||||||
|
},
|
||||||
|
};
|
4
nest-api/.prettierrc
Normal file
4
nest-api/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
6
nest-api/README.md
Normal file
6
nest-api/README.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Nest.js backend API
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
yarn start
|
||||||
|
```
|
8
nest-api/nest-cli.json
Normal file
8
nest-api/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
85
nest-api/package.json
Normal file
85
nest-api/package.json
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"name": "nest-api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.0.1",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/jwt": "^10.1.1",
|
||||||
|
"@nestjs/mapped-types": "*",
|
||||||
|
"@nestjs/mongoose": "^10.0.1",
|
||||||
|
"@nestjs/passport": "^10.0.1",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"helmet": "^7.0.0",
|
||||||
|
"mongoose": "^7.5.0",
|
||||||
|
"passport": "^0.6.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jest": "^29.5.2",
|
||||||
|
"@types/node": "^20.3.1",
|
||||||
|
"@types/passport-jwt": "^3.0.9",
|
||||||
|
"@types/passport-local": "^1.0.35",
|
||||||
|
"@types/supertest": "^2.0.12",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-loader": "^9.4.3",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.1.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
40
nest-api/src/app.module.ts
Normal file
40
nest-api/src/app.module.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: async (configService: ConfigService) => ({
|
||||||
|
uri: configService.get<string>('MONGODB_URI'),
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
envFilePath: ['.env.local', '.env'],
|
||||||
|
isGlobal: true,
|
||||||
|
cache: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
UsersModule,
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
|
controllers: [],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: AccessAuthGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
18
nest-api/src/auth/auth.controller.spec.ts
Normal file
18
nest-api/src/auth/auth.controller.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
|
||||||
|
describe('AuthController', () => {
|
||||||
|
let controller: AuthController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
31
nest-api/src/auth/auth.controller.ts
Normal file
31
nest-api/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { Public } from 'src/decorators/public.decorator';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@UseGuards(LocalAuthGuard)
|
||||||
|
@Post('login')
|
||||||
|
login(@RequestUser() user: UserTokenData) {
|
||||||
|
return this.authService.login(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@UseGuards(RefreshAuthGuard)
|
||||||
|
@Get('refresh')
|
||||||
|
refresh(@RequestUser() user: UserTokenData) {
|
||||||
|
return this.authService.login(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('logout')
|
||||||
|
logout(@RequestUser() user: UserTokenData): Promise<void> {
|
||||||
|
return this.authService.logout(user.id);
|
||||||
|
}
|
||||||
|
}
|
20
nest-api/src/auth/auth.module.ts
Normal file
20
nest-api/src/auth/auth.module.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersModule } from 'src/users/users.module';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
|
import { JwtAccessStrategy } from './strategies/jwt-access.strategy';
|
||||||
|
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UsersModule, JwtModule],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
LocalStrategy,
|
||||||
|
JwtAccessStrategy,
|
||||||
|
JwtRefreshStrategy,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
18
nest-api/src/auth/auth.service.spec.ts
Normal file
18
nest-api/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AuthService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
96
nest-api/src/auth/auth.service.ts
Normal file
96
nest-api/src/auth/auth.service.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UsersService } from 'src/users/users.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { UserTokenPayload } from './interfaces/user-token-payload.interface';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { UserTokenData } from './interfaces/user-token-data.interface';
|
||||||
|
import { isMongoId } from 'class-validator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private usersService: UsersService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateUser(username: string, pass: string): Promise<any> {
|
||||||
|
const user = await this.usersService.findByUsername(username);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const match = await bcrypt.compare(pass, user.password);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use to login or refresh tokens
|
||||||
|
* @param user User to login
|
||||||
|
* @returns Access and refresh tokens
|
||||||
|
*/
|
||||||
|
async login(user: UserTokenData) {
|
||||||
|
const payload: UserTokenPayload = {
|
||||||
|
username: user.username,
|
||||||
|
sub: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
|
this.getAccessToken(payload),
|
||||||
|
this.getRefreshToken(payload),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update refresh token
|
||||||
|
await this.usersService.setRefreshToken(
|
||||||
|
user.id,
|
||||||
|
refreshToken.split('.')[2],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets refresh token to `null`
|
||||||
|
* @param userId User to logout
|
||||||
|
*/
|
||||||
|
async logout(userId: string) {
|
||||||
|
await this.usersService.setRefreshToken(userId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if user exists and if refresh token matches
|
||||||
|
* @param token Refresh token to validate
|
||||||
|
* @returns `true` if valid, `false` otherwise
|
||||||
|
*/
|
||||||
|
async validateRefreshToken(token: string): Promise<boolean> {
|
||||||
|
const payload = this.jwtService.decode(token) as UserTokenPayload;
|
||||||
|
if (!payload) return false;
|
||||||
|
|
||||||
|
const userId = payload.sub;
|
||||||
|
if (!userId || !isMongoId(userId)) return false;
|
||||||
|
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
return bcrypt.compare(token.split('.')[2], user.auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIVATE FUNCTIONS
|
||||||
|
private async getAccessToken(payload: UserTokenPayload): Promise<string> {
|
||||||
|
return this.jwtService.signAsync(payload, {
|
||||||
|
expiresIn: this.configService.get<string>('JWT_ACCESS_EXPIRE'),
|
||||||
|
secret: this.configService.get<string>('JWT_ACCESS_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRefreshToken(payload: UserTokenPayload): Promise<string> {
|
||||||
|
return this.jwtService.signAsync(payload, {
|
||||||
|
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRE'),
|
||||||
|
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
22
nest-api/src/auth/guards/access-auth.guard.ts
Normal file
22
nest-api/src/auth/guards/access-auth.guard.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IS_PUBLIC_KEY } from 'src/decorators/public.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccessAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
5
nest-api/src/auth/guards/local-auth.guard.ts
Normal file
5
nest-api/src/auth/guards/local-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalAuthGuard extends AuthGuard('local') {}
|
5
nest-api/src/auth/guards/refresh-auth.guard.ts
Normal file
5
nest-api/src/auth/guards/refresh-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RefreshAuthGuard extends AuthGuard('jwt-refresh') {}
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface UserTokenData {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface UserTokenPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
}
|
19
nest-api/src/auth/strategies/jwt-access.strategy.ts
Normal file
19
nest-api/src/auth/strategies/jwt-access.strategy.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('JWT_ACCESS_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: any) {
|
||||||
|
return { id: payload.sub, username: payload.username };
|
||||||
|
}
|
||||||
|
}
|
34
nest-api/src/auth/strategies/jwt-refresh.strategy.ts
Normal file
34
nest-api/src/auth/strategies/jwt-refresh.strategy.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { UserTokenPayload } from '../interfaces/user-token-payload.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtRefreshStrategy extends PassportStrategy(
|
||||||
|
Strategy,
|
||||||
|
'jwt-refresh',
|
||||||
|
) {
|
||||||
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
|
||||||
|
passReqToCallback: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(req: Request, payload: UserTokenPayload) {
|
||||||
|
const isValid = await this.authService.validateRefreshToken(
|
||||||
|
ExtractJwt.fromAuthHeaderAsBearerToken()(req),
|
||||||
|
);
|
||||||
|
if (!isValid) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
return { id: payload.sub, username: payload.username };
|
||||||
|
}
|
||||||
|
}
|
18
nest-api/src/auth/strategies/local.strategy.ts
Normal file
18
nest-api/src/auth/strategies/local.strategy.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Strategy } from 'passport-local';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(private authService: AuthService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(username: string, password: string): Promise<any> {
|
||||||
|
const user = await this.authService.validateUser(username, password);
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
4
nest-api/src/decorators/public.decorator.ts
Normal file
4
nest-api/src/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
8
nest-api/src/decorators/request-user.decorator.ts
Normal file
8
nest-api/src/decorators/request-user.decorator.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const RequestUser = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
5
nest-api/src/decorators/roles.decorator.ts
Normal file
5
nest-api/src/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { Role } from '../enums/role.enum';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
4
nest-api/src/enums/role.enum.ts
Normal file
4
nest-api/src/enums/role.enum.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum Role {
|
||||||
|
User = 'user',
|
||||||
|
Admin = 'admin',
|
||||||
|
}
|
26
nest-api/src/guards/roles.guard.ts
Normal file
26
nest-api/src/guards/roles.guard.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { UserTokenData } from 'src/auth/interfaces/user-token-data.interface';
|
||||||
|
import { ROLES_KEY } from 'src/decorators/roles.decorator';
|
||||||
|
import { Role } from 'src/enums/role.enum';
|
||||||
|
import { UsersService } from 'src/users/users.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private userService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!requiredRoles) return true;
|
||||||
|
|
||||||
|
const userData: UserTokenData = context.switchToHttp().getRequest().user;
|
||||||
|
const user = await this.userService.findOne(userData.id);
|
||||||
|
return requiredRoles.some((role) => user?.roles?.includes(role));
|
||||||
|
}
|
||||||
|
}
|
13
nest-api/src/main.ts
Normal file
13
nest-api/src/main.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.useGlobalPipes(new ValidationPipe());
|
||||||
|
app.enableCors();
|
||||||
|
app.use(helmet());
|
||||||
|
await app.listen(3001);
|
||||||
|
}
|
||||||
|
bootstrap();
|
7
nest-api/src/pipes/mongo-id.pipe.spec.ts
Normal file
7
nest-api/src/pipes/mongo-id.pipe.spec.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { MongoIdPipe } from './mongo-id.pipe';
|
||||||
|
|
||||||
|
describe('MongoIdPipe', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(new MongoIdPipe()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
11
nest-api/src/pipes/mongo-id.pipe.ts
Normal file
11
nest-api/src/pipes/mongo-id.pipe.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
||||||
|
import { isValidObjectId } from 'mongoose';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoIdPipe implements PipeTransform {
|
||||||
|
transform(value: any) {
|
||||||
|
if (!isValidObjectId(value)) throw new BadRequestException('Invalid id');
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
29
nest-api/src/users/dto/create-user.dto.ts
Normal file
29
nest-api/src/users/dto/create-user.dto.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsOptional,
|
||||||
|
IsPhoneNumber,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsPhoneNumber()
|
||||||
|
phone: string;
|
||||||
|
}
|
4
nest-api/src/users/dto/update-user.dto.ts
Normal file
4
nest-api/src/users/dto/update-user.dto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateUserDto } from './create-user.dto';
|
||||||
|
|
||||||
|
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
46
nest-api/src/users/schemas/user.schema.ts
Normal file
46
nest-api/src/users/schemas/user.schema.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument, ObjectId } from 'mongoose';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { Role } from 'src/enums/role.enum';
|
||||||
|
|
||||||
|
export type UserDocument = HydratedDocument<User>;
|
||||||
|
|
||||||
|
@Schema()
|
||||||
|
export class User {
|
||||||
|
_id: ObjectId;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
required: true,
|
||||||
|
transform: () => undefined,
|
||||||
|
set: (value: string) => bcrypt.hashSync(value, 10),
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
default: [Role.User],
|
||||||
|
type: [
|
||||||
|
{ type: String, enum: [Role.User.toString(), Role.Admin.toString()] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
roles: Role[];
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
transform: () => undefined,
|
||||||
|
set: (value: string) => bcrypt.hashSync(value, 10),
|
||||||
|
})
|
||||||
|
auth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserSchema = SchemaFactory.createForClass(User);
|
20
nest-api/src/users/users.controller.spec.ts
Normal file
20
nest-api/src/users/users.controller.spec.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
describe('UsersController', () => {
|
||||||
|
let controller: UsersController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<UsersController>(UsersController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
73
nest-api/src/users/users.controller.ts
Normal file
73
nest-api/src/users/users.controller.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Roles(Role.Admin)
|
||||||
|
@Public()
|
||||||
|
@Post()
|
||||||
|
create(@Body() createUserDto: CreateUserDto) {
|
||||||
|
return this.usersService.create(createUserDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.usersService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
getSelf(@RequestUser() userReq: UserTokenData) {
|
||||||
|
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) {
|
||||||
|
const user = this.usersService.findByUsername(username);
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id', MongoIdPipe) id: string) {
|
||||||
|
return this.usersService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(Role.Admin)
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id', MongoIdPipe) id: string,
|
||||||
|
@Body() updateUserDto: UpdateUserDto,
|
||||||
|
) {
|
||||||
|
const user = this.usersService.update(id, updateUserDto);
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(Role.Admin)
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id', MongoIdPipe) id: string) {
|
||||||
|
const user = this.usersService.remove(id);
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
}
|
15
nest-api/src/users/users.module.ts
Normal file
15
nest-api/src/users/users.module.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { User, UserSchema } from './schemas/user.schema';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
||||||
|
],
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
18
nest-api/src/users/users.service.spec.ts
Normal file
18
nest-api/src/users/users.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
describe('UsersService', () => {
|
||||||
|
let service: UsersService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [UsersService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UsersService>(UsersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
44
nest-api/src/users/users.service.ts
Normal file
44
nest-api/src/users/users.service.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { User } from './schemas/user.schema';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(User.name) private readonly userModel: Model<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(createUserDto: CreateUserDto): Promise<User> {
|
||||||
|
const user = new this.userModel(createUserDto);
|
||||||
|
return user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(): Promise<User[]> {
|
||||||
|
return this.userModel.find().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
findOne(id: string): Promise<User> {
|
||||||
|
return this.userModel.findById(id).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
findByUsername(username: string): Promise<User> {
|
||||||
|
return this.userModel.findOne({ username }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
|
||||||
|
return this.userModel
|
||||||
|
.findByIdAndUpdate(id, updateUserDto, { new: true })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): Promise<User> {
|
||||||
|
return this.userModel.findByIdAndDelete(id).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefreshToken(id: string, token: string): Promise<User> {
|
||||||
|
return this.userModel.findByIdAndUpdate(id, { auth: token }).exec();
|
||||||
|
}
|
||||||
|
}
|
24
nest-api/test/app.e2e-spec.ts
Normal file
24
nest-api/test/app.e2e-spec.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
9
nest-api/test/jest-e2e.json
Normal file
9
nest-api/test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
4
nest-api/tsconfig.build.json
Normal file
4
nest-api/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
21
nest-api/tsconfig.json
Normal file
21
nest-api/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
5546
nest-api/yarn.lock
Normal file
5546
nest-api/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
6
next-app/.eslintrc.json
Normal file
6
next-app/.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "prettier"],
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "warn"
|
||||||
|
}
|
||||||
|
}
|
4
next-app/.prettierrc.json
Normal file
4
next-app/.prettierrc.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
1
next-app/README.md
Normal file
1
next-app/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Ham reserve front-end
|
4
next-app/next.config.js
Normal file
4
next-app/next.config.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
34
next-app/package.json
Normal file
34
next-app/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "next-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "20.5.8",
|
||||||
|
"@types/react": "18.2.21",
|
||||||
|
"@types/react-dom": "18.2.7",
|
||||||
|
"autoprefixer": "10.4.15",
|
||||||
|
"axios": "^1.5.0",
|
||||||
|
"eslint": "8.48.0",
|
||||||
|
"eslint-config-next": "13.4.19",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"next": "13.4.19",
|
||||||
|
"postcss": "8.4.29",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-secure-storage": "^1.3.0",
|
||||||
|
"tailwindcss": "3.3.3",
|
||||||
|
"typescript": "5.2.2",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||||
|
"sass": "^1.66.1"
|
||||||
|
}
|
||||||
|
}
|
6
next-app/postcss.config.js
Normal file
6
next-app/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
41
next-app/src/api.ts
Normal file
41
next-app/src/api.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { User } from './interfaces/user.interface';
|
||||||
|
|
||||||
|
const baseURL = 'http://localhost:3001/';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiFunctions = {
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
return await api.post<LoginResponse>('/auth/login', { username, password });
|
||||||
|
},
|
||||||
|
refresh: async (refreshToken: string) => {
|
||||||
|
return await api.get<LoginResponse>('/auth/refresh', {
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
BIN
next-app/src/app/favicon.ico
Normal file
BIN
next-app/src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
11
next-app/src/app/globals.scss
Normal file
11
next-app/src/app/globals.scss
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
@import 'tailwindcss/base';
|
||||||
|
@import 'tailwindcss/components';
|
||||||
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
@apply rounded border border-gray-400 px-4 py-2 dark:bg-[#343434] dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@apply rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700;
|
||||||
|
}
|
28
next-app/src/app/layout.tsx
Normal file
28
next-app/src/app/layout.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Header } from '@/components/header';
|
||||||
|
import './globals.scss';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Create Next App',
|
||||||
|
description: 'Generated by create next app',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${inter.className} dark:bg-[#232323] dark:text-[#eeeeee]`}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
<main>{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
59
next-app/src/app/login/page.tsx
Normal file
59
next-app/src/app/login/page.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { apiFunctions } from '@/api';
|
||||||
|
import { useAuthState } from '@/state/auth-state';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const isValid = useAuthState((s) => s.isValid);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isValid().then((r) => {
|
||||||
|
if (r) router.replace('/');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto my-10 flex max-w-2xl flex-col gap-4 rounded-xl bg-gray-100 p-10 dark:bg-[#454545]">
|
||||||
|
<h1 className="text-2xl font-bold">Prijava</h1>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="username">Uporabniško ime</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="username"
|
||||||
|
className="text-input"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="password">Geslo</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
className="text-input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="button" onClick={() => login(username, password)}>
|
||||||
|
Prijava
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const res = (await apiFunctions.login(username, password)).data;
|
||||||
|
useAuthState.setState(res);
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
};
|
7
next-app/src/app/page.tsx
Normal file
7
next-app/src/app/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>Wassup</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
62
next-app/src/components/header.tsx
Normal file
62
next-app/src/components/header.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { User } from '@/interfaces/user.interface';
|
||||||
|
import { useAuthState } from '@/state/auth-state';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-16 select-none flex-row justify-between bg-gray-100 shadow dark:bg-[#454545]">
|
||||||
|
<Link href="/" className="my-auto ml-4 text-2xl font-semibold">
|
||||||
|
Ham Reserve
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<UserHeader />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserHeader() {
|
||||||
|
const [user, setUser] = useState<User>();
|
||||||
|
const [getUser, logout] = useAuthState((s) => [s.getUser, s.logout]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUser().then((u) => setUser(u || undefined));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return user ? (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<button
|
||||||
|
className="flex h-full items-center p-6 hover:bg-white/20"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<div>{user.username.toUpperCase()}</div>
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Odjava
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex h-full items-center p-6 hover:bg-white/20"
|
||||||
|
>
|
||||||
|
Prijava
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
next-app/src/interfaces/user.interface.ts
Normal file
5
next-app/src/interfaces/user.interface.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
}
|
89
next-app/src/state/auth-state.ts
Normal file
89
next-app/src/state/auth-state.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { apiFunctions } from '@/api';
|
||||||
|
import { User } from '@/interfaces/user.interface';
|
||||||
|
import jwtDecode from 'jwt-decode';
|
||||||
|
import secureLocalStorage from 'react-secure-storage';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
user: User | null;
|
||||||
|
|
||||||
|
getUser: () => Promise<User | null>;
|
||||||
|
isValid: () => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthState = create(
|
||||||
|
persist<AuthState>(
|
||||||
|
(set, get) => ({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
|
||||||
|
getUser: async (): Promise<User | null> => {
|
||||||
|
const { user, isValid } = get();
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
if (!(await isValid())) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = (await apiFunctions.getMe(get().accessToken!)).data;
|
||||||
|
set({ user });
|
||||||
|
return user;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isValid: async () => {
|
||||||
|
const { accessToken, refreshToken } = get();
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
const { exp } = jwtDecode(accessToken) as { exp: number };
|
||||||
|
console.log('Access token exp', exp);
|
||||||
|
if (Date.now() < exp * 1000) return true;
|
||||||
|
else set({ accessToken: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
const { exp } = jwtDecode(refreshToken) as { exp: number };
|
||||||
|
console.log('Refresh token exp', exp);
|
||||||
|
if (Date.now() < exp * 1000) {
|
||||||
|
// Try to refresh
|
||||||
|
try {
|
||||||
|
set((await apiFunctions.refresh(refreshToken)).data);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
set({ refreshToken: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ refreshToken: null, accessToken: null, user: null });
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
set({ accessToken: null, refreshToken: null, user: null });
|
||||||
|
if (await get().isValid()) apiFunctions.logout(get().accessToken!);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: 'auth-storage',
|
||||||
|
// storage: {
|
||||||
|
// getItem: (key) => {
|
||||||
|
// const value = secureLocalStorage.getItem(key) as string;
|
||||||
|
// return value ? JSON.parse(value) : null;
|
||||||
|
// },
|
||||||
|
// setItem: (key, value) => {
|
||||||
|
// secureLocalStorage.setItem(key, JSON.stringify(value));
|
||||||
|
// },
|
||||||
|
// removeItem: secureLocalStorage.removeItem,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
),
|
||||||
|
);
|
12
next-app/tailwind.config.ts
Normal file
12
next-app/tailwind.config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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}",
|
||||||
|
],
|
||||||
|
theme: {},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
27
next-app/tsconfig.json
Normal file
27
next-app/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
2584
next-app/yarn.lock
Normal file
2584
next-app/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user