Initial commit

This commit is contained in:
Jakob Kordež
2023-09-04 22:10:54 +02:00
commit f24d39b4e1
59 changed files with 9378 additions and 0 deletions

5
nest-api/.env Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

6
nest-api/README.md Normal file
View File

@ -0,0 +1,6 @@
# Nest.js backend API
```
yarn install
yarn start
```

8
nest-api/nest-cli.json Normal file
View 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
View 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"
}
}

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

View 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();
});
});

View 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);
}
}

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

View 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();
});
});

View 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'),
});
}
}

View 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);
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class RefreshAuthGuard extends AuthGuard('jwt-refresh') {}

View File

@ -0,0 +1,4 @@
export interface UserTokenData {
id: string;
username: string;
}

View File

@ -0,0 +1,4 @@
export interface UserTokenPayload {
sub: string;
username: string;
}

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

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

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

View File

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

View 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;
},
);

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

View File

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

View 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
View 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();

View File

@ -0,0 +1,7 @@
import { MongoIdPipe } from './mongo-id.pipe';
describe('MongoIdPipe', () => {
it('should be defined', () => {
expect(new MongoIdPipe()).toBeDefined();
});
});

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

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

View File

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

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

View 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();
});
});

View 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');
}
}

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

View 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();
});
});

View 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();
}
}

View 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!');
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
nest-api/tsconfig.json Normal file
View 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

File diff suppressed because it is too large Load Diff

6
next-app/.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"no-unused-vars": "warn"
}
}

View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"plugins": ["prettier-plugin-tailwindcss"]
}

1
next-app/README.md Normal file
View File

@ -0,0 +1 @@
# Ham reserve front-end

4
next-app/next.config.js Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

34
next-app/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

41
next-app/src/api.ts Normal file
View 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}`,
},
});
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

View 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>
);
}

View 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);
}
};

View File

@ -0,0 +1,7 @@
export default function Home() {
return (
<>
<div>Wassup</div>
</>
);
}

View 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>
);
}

View File

@ -0,0 +1,5 @@
export interface User {
id: string;
username: string;
name: string;
}

View 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,
// },
// },
),
);

View 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
View 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

File diff suppressed because it is too large Load Diff