diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-production.yml similarity index 65% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-production.yml index f1ca50c3..cf0c3cd9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy-production.yml @@ -2,9 +2,9 @@ name: deploy on: push: - branches: [main, alpha] + branches: [main] pull_request: - branches: [main, alpha] + branches: [main] env: DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/juga-docker @@ -13,6 +13,13 @@ env: jobs: build-and-deploy: runs-on: ubuntu-latest + strategy: + matrix: + app: + [ + { name: 'be', dir: 'BE', port: 3000, container: 'juga-docker-be' }, + { name: 'fe', dir: 'FE', port: 5173, container: 'juga-docker-fe' }, + ] steps: - uses: actions/checkout@v3 @@ -22,29 +29,29 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: ./BE/package-lock.json + cache-dependency-path: ./${{matrix.app.dir}}/package-lock.json - name: Create .env file run: | - touch ./BE/.env - echo "${{ secrets.ENV }}" > ./BE/.env + touch ./${{ matrix.app.dir }}/.env + echo "${{ secrets.ENV }}" > ./${{matrix.app.dir}}/.env - name: Install dependencies - working-directory: ./BE + working-directory: ./${{matrix.app.dir}} run: npm ci - name: Run tests - working-directory: ./BE + working-directory: ./${{matrix.app.dir}} run: npm test env: CI: true - name: Run linter - working-directory: ./BE + working-directory: ./${{matrix.app.dir}} run: npm run lint - name: Build application - working-directory: ./BE + working-directory: ./${{matrix.app.dir}} run: npm run build - name: Login to Docker Hub @@ -54,16 +61,16 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Docker Image - working-directory: ./BE + working-directory: ./${{ matrix.app.dir }} env: NCP_ACCESS_KEY: ${{ secrets.NCP_ACCESS_KEY }} NCP_SECRET_KEY: ${{ secrets.NCP_SECRET_KEY }} run: | - docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} . - docker tag ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} ${{ env.DOCKER_IMAGE }}:latest + docker build -t ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} . + docker tag ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest - docker push ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} - docker push ${{ env.DOCKER_IMAGE }}:latest + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest - name: Get Github Actions IP id: ip @@ -95,15 +102,15 @@ jobs: script: | docker system prune -af echo "${{ secrets.ENV }}" > .env - - docker pull ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} - docker stop juga-docker || true - docker rm juga-docker || true + + docker pull ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker stop ${{ matrix.app.container }} || true + docker rm ${{ matrix.app.container }} || true docker run -d \ - --name juga-docker \ - -p 3000:3000 \ + --name ${{ matrix.app.container }} \ + -p ${{ matrix.app.port }}:${{ matrix.app.port }} \ --env-file .env \ - ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} + ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} - name: Remove Github Action Ip to Security group run: | diff --git a/.github/workflows/deply-alpha.yml b/.github/workflows/deply-alpha.yml new file mode 100644 index 00000000..cab94eb5 --- /dev/null +++ b/.github/workflows/deply-alpha.yml @@ -0,0 +1,121 @@ +name: deploy + +on: + push: + branches: [alpha] + pull_request: + branches: [alpha] + +env: + DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/juga-docker + DOCKER_TAG: ${{ github.sha }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + strategy: + matrix: + app: + [ + { name: 'be', dir: 'BE', port: 3000, container: 'juga-docker-be' }, + { name: 'fe', dir: 'FE', port: 5173, container: 'juga-docker-fe' }, + ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ./${{matrix.app.dir}}/package-lock.json + + - name: Create .env file + run: | + touch ./${{ matrix.app.dir }}/.env + echo "${{ secrets.ENV }}" > ./${{matrix.app.dir}}/.env + + - name: Install dependencies + working-directory: ./${{matrix.app.dir}} + continue-on-error: true + run: npm ci + + - name: Run tests + if: ${{ matrix.app.name == 'be' }} # BE일 때만 실행 + working-directory: ./${{matrix.app.dir}} + run: npm test + env: + CI: true + + - name: Run linter + working-directory: ./${{matrix.app.dir}} + run: npm run lint + + - name: Build application + working-directory: ./${{matrix.app.dir}} + run: npm run build + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and Push Docker Image + working-directory: ./${{ matrix.app.dir }} + env: + NCP_ACCESS_KEY: ${{ secrets.NCP_ACCESS_KEY }} + NCP_SECRET_KEY: ${{ secrets.NCP_SECRET_KEY }} + run: | + docker build -t ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} . + docker tag ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest + + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest + + - name: Get Github Actions IP + id: ip + uses: haythem/public-ip@v1.2 + + - name: Setting NCP CLI & Credentials + run: | + cd ~ + wget https://www.ncloud.com/api/support/download/5/65 + unzip 65 + mkdir ~/.ncloud + echo -e "[DEFAULT]\nncloud_access_key_id = ${{ secrets.NCP_ACCESS_KEY }}\nncloud_secret_access_key = ${{ secrets.NCP_SECRET_KEY }}\nncloud_api_url = ${{ secrets.NCP_API_URI }}" >> ~/.ncloud/configure + + - name: Add Github Action Ip to Security group + run: | + cd ~ + ls -la + chmod -R 777 ~/cli_linux + cd ~/cli_linux + ./ncloud vserver addAccessControlGroupInboundRule --regionCode KR --vpcNo ${{ secrets.NCP_VPC_ID }} --accessControlGroupNo ${{ secrets.NCP_ACG_ID }} --accessControlGroupRuleList "protocolTypeCode='TCP', ipBlock='${{ steps.ip.outputs.ipv4 }}/32', portRange='${{ secrets.SSH_PORT }}'" + + - name: Deploy to NCP Server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.NCP_ALPHA_SERVER_HOST }} + username: ${{ secrets.NCP_ALPHA_SERVER_USERNAME }} + key: ${{ secrets.NCP_ALPHA_SERVER_SSH_KEY }} + port: 22 + script: | + docker system prune -af + echo "${{ secrets.ENV }}" > .env + + docker pull ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker stop ${{ matrix.app.container }} || true + docker rm ${{ matrix.app.container }} || true + docker run -d \ + --name ${{ matrix.app.container }} \ + -p ${{ matrix.app.port }}:${{ matrix.app.port }} \ + --env-file .env \ + ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + + - name: Remove Github Action Ip to Security group + run: | + chmod -R 777 ~/cli_linux + cd ~/cli_linux + ./ncloud vserver removeAccessControlGroupInboundRule --regionCode KR --vpcNo ${{ secrets.NCP_VPC_ID }} --accessControlGroupNo ${{ secrets.NCP_ACG_ID }} --accessControlGroupRuleList "protocolTypeCode='TCP', ipBlock='${{ steps.ip.outputs.ipv4 }}/32', portRange='${{ secrets.SSH_PORT }}'" diff --git a/BE/package-lock.json b/BE/package-lock.json index cb72a103..f9b6300f 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -20,18 +20,22 @@ "@nestjs/swagger": "^8.0.1", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "@types/cookie-parser": "^1.4.7", "@types/passport-jwt": "^4.0.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", "cross-env": "^7.0.3", "docker": "^1.0.0", "dotenv": "^16.4.5", + "express": "^4.21.1", "fastify-swagger": "^5.1.1", "mysql2": "^3.11.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "passport-kakao": "^1.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1", @@ -1961,6 +1965,15 @@ "version": "0.4.1", "license": "MIT" }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "dev": true, @@ -3782,6 +3795,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "license": "MIT" @@ -5099,6 +5134,8 @@ }, "node_modules/express": { "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -8299,6 +8336,12 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/oauth-sign": { "version": "0.8.2", "license": "Apache-2.0", @@ -8611,6 +8654,29 @@ "passport-strategy": "^1.0.0" } }, + "node_modules/passport-kakao": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/passport-kakao/-/passport-kakao-1.0.1.tgz", + "integrity": "sha512-uItaYRVrTHL6iGPMnMZvPa/O1GrAdh/V6EMjOHcFlQcVroZ9wgG7BZ5PonMNJCxfHQ3L2QVNRnzhKWUzSsumbw==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "~1.1.2", + "pkginfo": "~0.3.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.1.2.tgz", + "integrity": "sha512-wpsGtJDHHQUjyc9WcV9FFB0bphFExpmKtzkQrxpH1vnSr6RcWa3ZEGHx/zGKAh2PN7Po9TKYB1fJeOiIBspNPA==", + "dependencies": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -8769,6 +8835,15 @@ "node": ">=8" } }, + "node_modules/pkginfo": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz", + "integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "dev": true, @@ -10901,6 +10976,12 @@ "node": ">=8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/unbounded": { "version": "1.3.0", "license": "MIT", diff --git a/BE/package.json b/BE/package.json index 224dc019..86c3538b 100644 --- a/BE/package.json +++ b/BE/package.json @@ -30,19 +30,23 @@ "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^8.0.1", "@nestjs/typeorm": "^10.0.2", - "@types/passport-jwt": "^4.0.1", "@nestjs/websockets": "^10.4.7", + "@types/cookie-parser": "^1.4.7", + "@types/passport-jwt": "^4.0.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", "cross-env": "^7.0.3", "docker": "^1.0.0", "dotenv": "^16.4.5", + "express": "^4.21.1", "fastify-swagger": "^5.1.1", "mysql2": "^3.11.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "passport-kakao": "^1.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1", diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index dee9d8fa..d3c27a75 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -5,13 +5,13 @@ import { ScheduleModule } from '@nestjs/schedule'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; -import { User } from './auth/user.entity'; import { StockIndexModule } from './stock/index/stock-index.module'; import { StockTopfiveModule } from './stock/topfive/stock-topfive.module'; import { KoreaInvestmentModule } from './koreaInvestment/korea-investment.module'; import { SocketModule } from './websocket/socket.module'; import { StockOrderModule } from './stock/order/stock-order.module'; import { Order } from './stock/order/stock-order.entity'; +import { typeOrmConfig } from './configs/typeorm.config'; @Module({ imports: [ @@ -27,6 +27,7 @@ import { Order } from './stock/order/stock-order.entity'; entities: [User, Order], synchronize: true, }), + TypeOrmModule.forRoot(typeOrmConfig), KoreaInvestmentModule, AuthModule, StockIndexModule, diff --git a/BE/src/auth/auth.controller.ts b/BE/src/auth/auth.controller.ts index aee104f0..684384be 100644 --- a/BE/src/auth/auth.controller.ts +++ b/BE/src/auth/auth.controller.ts @@ -3,18 +3,25 @@ import { Post, Get, Body, - Req, ValidationPipe, UseGuards, + Req, + Res, + UnauthorizedException, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiOperation } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { AuthCredentialsDto } from './dto/auth-credentials.dto'; @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private configService: ConfigService, + ) {} @ApiOperation({ summary: '회원 가입 API' }) @Post('/signup') @@ -24,16 +31,56 @@ export class AuthController { @ApiOperation({ summary: '로그인 API' }) @Post('/login') - loginWithCredentials( + async loginWithCredentials( @Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto, + @Res() res: Response, ) { - return this.authService.loginUser(authCredentialsDto); + const { accessToken, refreshToken } = + await this.authService.loginUser(authCredentialsDto); + + res.cookie('refreshToken', refreshToken, { httpOnly: true }); + res.cookie('isRefreshToken', true, { httpOnly: true }); + return res.status(200).json({ accessToken }); } @ApiOperation({ summary: 'Token 인증 테스트 API' }) @Get('/test') - @UseGuards(AuthGuard()) + @UseGuards(AuthGuard('jwt')) test(@Req() req: Request) { return req; } + + @ApiOperation({ summary: 'Kakao 로그인 API' }) + @Get('/kakao') + @UseGuards(AuthGuard('kakao')) + async kakaoLogin( + @Body() authCredentialsDto: AuthCredentialsDto, + @Res() res: Response, + ) { + const { accessToken, refreshToken } = + await this.authService.kakaoLoginUser(authCredentialsDto); + + res.cookie('refreshToken', refreshToken, { httpOnly: true }); + res.cookie('isRefreshToken', true, { httpOnly: true }); + return res.status(200).json({ accessToken }); + } + + @ApiOperation({ summary: 'Refresh Token 요청 API' }) + @Get('/refresh') + async refresh(@Req() req: Request, @Res() res: Response) { + if ( + typeof req.cookies.refreshToken !== 'string' || + typeof req.cookies.accessToken !== 'string' + ) { + throw new UnauthorizedException('Invalid refresh token'); + } + + const { refreshToken } = req.cookies; + + const newAccessToken = await this.authService.refreshToken(refreshToken); + + res.cookie('refreshToken', refreshToken, { httpOnly: true }); + res.cookie('isRefreshToken', true, { httpOnly: true }); + return res.status(200).json({ accessToken: newAccessToken }); + } } diff --git a/BE/src/auth/auth.module.ts b/BE/src/auth/auth.module.ts index a3ba6f04..522e5199 100644 --- a/BE/src/auth/auth.module.ts +++ b/BE/src/auth/auth.module.ts @@ -2,25 +2,32 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { User } from './user.entity'; import { UserRepository } from './user.repository'; -import { JwtStrategy } from './jwt.strategy'; +import { JwtStrategy } from './strategy/jwt.strategy'; +import { KakaoStrategy } from './strategy/kakao.strategy'; @Module({ imports: [ TypeOrmModule.forFeature([User]), + ConfigModule, PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secret: 'Juga16', - signOptions: { - expiresIn: 3600, - }, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_ACCESS_EXPIRATION_TIME'), + }, + }), + inject: [ConfigService], }), ], controllers: [AuthController], - providers: [AuthService, UserRepository, JwtStrategy], + providers: [AuthService, UserRepository, JwtStrategy, KakaoStrategy], exports: [JwtStrategy, PassportModule], }) export class AuthModule {} diff --git a/BE/src/auth/auth.service.ts b/BE/src/auth/auth.service.ts index 6f751e1b..2de79cd8 100644 --- a/BE/src/auth/auth.service.ts +++ b/BE/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { ConfigService } from '@nestjs/config'; import { UserRepository } from './user.repository'; import { AuthCredentialsDto } from './dto/auth-credentials.dto'; @@ -11,6 +12,7 @@ export class AuthService { @InjectRepository(UserRepository) private userRepository: UserRepository, private jwtService: JwtService, + private readonly configService: ConfigService, ) {} async signUp(authCredentialsDto: AuthCredentialsDto): Promise { @@ -19,15 +21,111 @@ export class AuthService { async loginUser( authCredentialsDto: AuthCredentialsDto, - ): Promise<{ accessToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string }> { const { email, password } = authCredentialsDto; const user = await this.userRepository.findOne({ where: { email } }); if (user && (await bcrypt.compare(password, user.password))) { - const payload = { email }; - const accessToken = this.jwtService.sign(payload); - return { accessToken }; + const { accessToken, refreshToken } = + await this.getJWTToken(authCredentialsDto); + + await this.setCurrentRefreshToken(refreshToken, user.id); + + return { accessToken, refreshToken }; } throw new UnauthorizedException('Please check your login credentials'); } + + async kakaoLoginUser( + authCredentialsDto: AuthCredentialsDto, + ): Promise<{ accessToken: string; refreshToken: string }> { + return this.getJWTToken(authCredentialsDto); + } + + async getJWTToken(authCredentialsDto: AuthCredentialsDto) { + const accessToken = await this.generateAccessToken(authCredentialsDto); + const refreshToken = await this.generateRefreshToken(authCredentialsDto); + return { accessToken, refreshToken }; + } + + async generateAccessToken( + authCredentialsDto: AuthCredentialsDto, + ): Promise { + return authCredentialsDto.email + ? this.jwtService.signAsync({ email: authCredentialsDto.email }) + : this.jwtService.signAsync({ kakaoId: authCredentialsDto.kakaoId }); + } + + async generateRefreshToken( + authCredentialsDto: AuthCredentialsDto, + ): Promise { + if (authCredentialsDto.email) { + return this.jwtService.signAsync( + { email: authCredentialsDto.email }, + { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: this.configService.get( + 'JWT_REFRESH_EXPIRATION_TIME', + ), + }, + ); + } + return this.jwtService.signAsync( + { kakaoId: authCredentialsDto.kakaoId }, + { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: this.configService.get( + 'JWT_REFRESH_EXPIRATION_TIME', + ), + }, + ); + } + + async setCurrentRefreshToken(refreshToken: string, userId: number) { + const currentDate = new Date(); + const salt = await bcrypt.genSalt(); + const currentRefreshToken = await bcrypt.hash(refreshToken, salt); + const currentRefreshTokenExpiresAt = new Date( + currentDate.getTime() + + parseInt( + this.configService.get('JWT_REFRESH_EXPIRATION_TIME'), + 10, + ), + ); + + await this.userRepository.update(userId, { + currentRefreshToken, + currentRefreshTokenExpiresAt, + }); + } + + async refreshToken(refreshToken: string): Promise { + try { + const decodedRefreshToken = this.jwtService.verify(refreshToken, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + }); + + const user = decodedRefreshToken.email + ? await this.userRepository.findOne({ + where: { email: decodedRefreshToken.email }, + }) + : await this.userRepository.findOne({ + where: { kakaoId: decodedRefreshToken.kakaoId }, + }); + + const isRefreshTokenMatching = await bcrypt.compare( + refreshToken, + user.currentRefreshToken, + ); + + if (!isRefreshTokenMatching) { + throw new UnauthorizedException('Invalid Token'); + } + + const accessToken = this.generateAccessToken(user.toAuthCredentialsDto()); + return await accessToken; + } catch (error) { + throw new UnauthorizedException('Invalid Token'); + } + } } diff --git a/BE/src/auth/dto/auth-credentials.dto.ts b/BE/src/auth/dto/auth-credentials.dto.ts index fb1199ed..31bc69b6 100644 --- a/BE/src/auth/dto/auth-credentials.dto.ts +++ b/BE/src/auth/dto/auth-credentials.dto.ts @@ -1,27 +1,41 @@ -import { IsString, Matches, MaxLength, MinLength } from 'class-validator'; +import { + IsString, + Matches, + MaxLength, + MinLength, + IsOptional, +} from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class AuthCredentialsDto { @ApiProperty({ description: '유저 이메일', - minLength: 4, - maxLength: 20, - type: 'string', }) @IsString() - @MinLength(4) - @MaxLength(20) - email: string; + email?: string; @ApiProperty({ description: '유저 비밀번호', - minLength: 4, - maxLength: 20, - type: 'string', }) @IsString() @MinLength(4) @MaxLength(20) - @Matches(/^[a-zA-Z0-9]*$/) - password: string; + @Matches(/^[a-zA-Z0-9]*$/, { + message: '비밀번호는 영문과 숫자만 사용가능합니다', + }) + password?: string; + + @ApiProperty({ + description: '카카오 ID', + }) + @IsString() + @IsOptional() + kakaoId?: string; + + @ApiProperty({ + description: '카카오 액세스 토큰', + }) + @IsString() + @IsOptional() + kakaoAccessToken?: string; } diff --git a/BE/src/auth/jwt.strategy.ts b/BE/src/auth/strategy/jwt.strategy.ts similarity index 73% rename from BE/src/auth/jwt.strategy.ts rename to BE/src/auth/strategy/jwt.strategy.ts index 350d622d..e6e85d05 100644 --- a/BE/src/auth/jwt.strategy.ts +++ b/BE/src/auth/strategy/jwt.strategy.ts @@ -2,16 +2,18 @@ import { PassportStrategy } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { UserRepository } from './user.repository'; -import { User } from './user.entity'; +import { ConfigService } from '@nestjs/config'; +import { UserRepository } from '../user.repository'; +import { User } from '../user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( @InjectRepository(UserRepository) private userRepository: UserRepository, + private readonly configService: ConfigService, ) { super({ - secretOrKey: 'Juga16', + secretOrKey: configService.get('JWT_SECRET'), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), }); } diff --git a/BE/src/auth/strategy/kakao.strategy.ts b/BE/src/auth/strategy/kakao.strategy.ts new file mode 100644 index 00000000..e69805dc --- /dev/null +++ b/BE/src/auth/strategy/kakao.strategy.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Profile, Strategy } from 'passport-kakao'; + +interface KakaoStrategyOptions { + clientID: string; + clientSecret: string; + callbackURL: string; +} + +interface KakaoProfile extends Profile { + id: number; + _json: { + id: number; + }; +} + +interface KakaoUser { + kakaoId: number; +} + +@Injectable() +export class KakaoStrategy extends PassportStrategy( + Strategy, + 'kakao', +) { + constructor(private readonly configService: ConfigService) { + const options: KakaoStrategyOptions = { + clientID: configService.get('KAKAO_CLIENT_ID') || '', + clientSecret: '', + callbackURL: `${configService.get('BACKEND_URL') || ''}/auth/kakao`, + }; + + super(options); + } + + validate( + accessToken: string, + refreshToken: string, + profile: KakaoProfile, + done: (error: Error, user?: KakaoUser) => void, + ) { + try { + // eslint-disable-next-line no-underscore-dangle + const kakaoId = profile._json.id; + const user = { + kakaoId, + }; + done(null, user); + } catch (error) { + done(error instanceof Error ? error : new Error(String(error))); + } + } +} diff --git a/BE/src/auth/user.entity.ts b/BE/src/auth/user.entity.ts index cf6b130a..8c574cd4 100644 --- a/BE/src/auth/user.entity.ts +++ b/BE/src/auth/user.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { AuthCredentialsDto } from './dto/auth-credentials.dto'; @Entity() export class User extends BaseEntity { @@ -16,4 +17,21 @@ export class User extends BaseEntity { @Column({ default: -1 }) kakaoId: number; + + @Column({ default: '' }) + currentRefreshToken: string; + + @Column({ type: 'datetime', nullable: true }) + currentRefreshTokenExpiresAt: Date; + + toAuthCredentialsDto(): AuthCredentialsDto { + if (this.kakaoId === -1) { + return { + email: this.email, + password: this.password, + }; + } + + throw new Error('Cannot convert Kakao user to auth credentials'); + } } diff --git a/BE/src/auth/user.repository.ts b/BE/src/auth/user.repository.ts index 429437e7..442c73f4 100644 --- a/BE/src/auth/user.repository.ts +++ b/BE/src/auth/user.repository.ts @@ -18,4 +18,17 @@ export class UserRepository extends Repository { const user = this.create({ email, password: hashedPassword }); await this.save(user); } + + async updateUserWithRefreshToken( + id: number, + { + refreshToken, + refreshTokenExpiresAt, + }: { refreshToken: string; refreshTokenExpiresAt: Date }, + ) { + const user = await this.findOne({ where: { id } }); + user.currentRefreshToken = refreshToken; + user.currentRefreshTokenExpiresAt = refreshTokenExpiresAt; + await this.save(user); + } } diff --git a/BE/src/configs/typeorm.config.ts b/BE/src/configs/typeorm.config.ts new file mode 100644 index 00000000..c10d56ef --- /dev/null +++ b/BE/src/configs/typeorm.config.ts @@ -0,0 +1,15 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const typeOrmConfig: TypeOrmModuleOptions = { + type: 'mysql', + host: process.env.DB_HOST, + port: 3306, + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWD, + database: process.env.DB_DATABASE, + entities: [`${__dirname}/../**/*.entity{.js,.ts}`], + synchronize: true, +}; diff --git a/BE/src/main.ts b/BE/src/main.ts index bbc099aa..bc11d55f 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { Logger } from '@nestjs/common'; +import * as cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; import { setupSwagger } from './util/swagger'; @@ -14,6 +15,7 @@ async function bootstrap() { optionsSuccessStatus: 204, }); + app.use(cookieParser()); await app.listen(process.env.PORT ?? 3000); } diff --git a/BE/src/types/express.d.ts b/BE/src/types/express.d.ts new file mode 100644 index 00000000..9cf89153 --- /dev/null +++ b/BE/src/types/express.d.ts @@ -0,0 +1,11 @@ +import { Request as Req } from 'express'; +import { UUID } from 'crypto'; + +declare module 'express' { + interface Request extends Req { + user: { + kakaoId?: number; + userId?: UUID; + }; + } +} diff --git a/BE/tsconfig.json b/BE/tsconfig.json index 95f5641c..52a4d6a7 100644 --- a/BE/tsconfig.json +++ b/BE/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "typeRoots": ["node_modules/@types", "./src/types"] } } diff --git a/FE/.prettierrc.json b/FE/.prettierrc.json new file mode 100644 index 00000000..a22a75fa --- /dev/null +++ b/FE/.prettierrc.json @@ -0,0 +1 @@ +{"semi": true,"singleQuote": true,"trailingComma": "all","printWidth": 80,"tabWidth": 2,"bracketSpacing": true,"jsxSingleQuote": true,"jsxBracketSameLine": false,"arrowParens": "always","plugins": ["prettier-plugin-tailwindcss"]} \ No newline at end of file diff --git a/FE/Dockerfile b/FE/Dockerfile new file mode 100644 index 00000000..6773e6a4 --- /dev/null +++ b/FE/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20 + +RUN mkdir -p /var/app +WORKDIR /var/app + +COPY package*.json ./ +RUN npm install + +COPY . . + +RUN npm run build + +RUN npm install -g serve + +EXPOSE 5173 + +CMD ["serve", "-s", "dist", "-l", "5173"] \ No newline at end of file diff --git a/FE/eslint.config.js b/FE/eslint.config.js index 092408a9..4a1ba46b 100644 --- a/FE/eslint.config.js +++ b/FE/eslint.config.js @@ -1,28 +1,29 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ["dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], + quotes: ["error", "single"], }, }, -) +); diff --git a/FE/package-lock.json b/FE/package-lock.json index 7395e6fe..11fde835 100644 --- a/FE/package-lock.json +++ b/FE/package-lock.json @@ -8,6 +8,7 @@ "name": "fe", "version": "0.0.0", "dependencies": { + "@heroicons/react": "^2.1.5", "@tanstack/react-query": "^4.36.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -26,6 +27,8 @@ "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", "postcss": "^8.4.47", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", "tailwindcss": "^3.4.14", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", @@ -815,6 +818,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.1.5.tgz", + "integrity": "sha512-FuzFN+BsHa+7OxbvAERtgBTNeZpUjgM/MIizfVkSCL2/edriN0Hx/DWRCR//aPYwO5QX/YlgLGXk+E3PcfZwjA==", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3212,6 +3223,99 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.8.tgz", + "integrity": "sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==", + "dev": true, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/FE/package.json b/FE/package.json index ad0edbc5..0cdda5d9 100644 --- a/FE/package.json +++ b/FE/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.1.5", "@tanstack/react-query": "^4.36.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -28,6 +29,8 @@ "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", "postcss": "^8.4.47", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", "tailwindcss": "^3.4.14", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", diff --git a/FE/public/Logo.png b/FE/public/Logo.png new file mode 100644 index 00000000..7708651b Binary files /dev/null and b/FE/public/Logo.png differ diff --git a/FE/src/App.tsx b/FE/src/App.tsx index 1858facc..59645bba 100644 --- a/FE/src/App.tsx +++ b/FE/src/App.tsx @@ -6,7 +6,7 @@ function App() { return ( - } /> + } /> ); diff --git a/FE/src/components/Header.tsx b/FE/src/components/Header.tsx index 4d58efc9..b2b294fb 100644 --- a/FE/src/components/Header.tsx +++ b/FE/src/components/Header.tsx @@ -1,3 +1,55 @@ +import useAuthStore from 'store/authStore'; +import useLoginModalStore from 'store/useLoginModalStore'; + export default function Header() { - return
header
; + const { toggleModal } = useLoginModalStore(); + const { isLogin, resetToken } = useAuthStore(); + + return ( +
+
+
+ +

JuGa

+
+ +
+ +
+ +
+
+
+ {isLogin ? ( + + ) : ( + <> + + {/* */} + + )} +
+
+
+ ); } diff --git a/FE/src/components/Login/Input.tsx b/FE/src/components/Login/Input.tsx new file mode 100644 index 00000000..b6b78ab1 --- /dev/null +++ b/FE/src/components/Login/Input.tsx @@ -0,0 +1,12 @@ +import { ComponentProps } from 'react'; + +type LoginInputProps = ComponentProps<'input'>; + +export default function Input({ ...props }: LoginInputProps) { + return ( + + ); +} diff --git a/FE/src/components/Login/index.tsx b/FE/src/components/Login/index.tsx new file mode 100644 index 00000000..fbe4e418 --- /dev/null +++ b/FE/src/components/Login/index.tsx @@ -0,0 +1,72 @@ +import useLoginModalStore from 'store/useLoginModalStore'; +import Input from './Input'; +import { ChatBubbleOvalLeftIcon } from '@heroicons/react/16/solid'; +import { FormEvent, useEffect, useState } from 'react'; +import { login } from 'service/auth'; +import useAuthStore from 'store/authStore'; + +export default function Login() { + const { isOpen, toggleModal } = useLoginModalStore(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const { setAccessToken } = useAuthStore(); + + useEffect(() => { + setEmail(''); + setPassword(''); + }, [isOpen]); + + if (!isOpen) return; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const res = await login(email, password); + + if ('error' in res) { + return; + } + + setAccessToken(res.accessToken); + toggleModal(); + }; + + return ( + <> + toggleModal()} /> +
+

JuGa

+
+
+ setEmail(e.target.value)} + autoComplete='username' + /> + setPassword(e.target.value)} + autoComplete='current-password' + /> +
+ +
+ +
+ + ); +} + +function Overay({ onClick }: { onClick: () => void }) { + return ( +
+ ); +} diff --git a/FE/src/components/StockIndex/Chart.tsx b/FE/src/components/StockIndex/Chart.tsx new file mode 100644 index 00000000..2ab43205 --- /dev/null +++ b/FE/src/components/StockIndex/Chart.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from 'react'; +import { drawChart } from 'utils/chart'; + +const X_LENGTH = 79; + +type StockIndexChartProps = { + name: string; +}; + +export function Chart({ name }: StockIndexChartProps) { + const [prices, setPrices] = useState([50, 54]); + const canvasRef = useRef(null); + + useEffect(() => { + const interval = setInterval(() => { + if (prices.length === X_LENGTH) { + clearInterval(interval); + return; + } + setPrices((prev) => [...prev, Math.floor(Math.random() * 50) + 25]); + }, 500); + + return () => clearInterval(interval); + }, [prices.length]); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!ctx) return; + + drawChart(ctx, prices); + }, [prices]); + + return ( +
+
+

{name}

+

2562.4

+

-31.55(-1.2%)

+
+ +
+ ); +} diff --git a/FE/src/components/StockIndex/index.tsx b/FE/src/components/StockIndex/index.tsx new file mode 100644 index 00000000..a5fead6b --- /dev/null +++ b/FE/src/components/StockIndex/index.tsx @@ -0,0 +1,10 @@ +import { Chart } from './Chart'; + +export default function StockIndex() { + return ( +
+ + +
+ ); +} diff --git a/FE/src/components/TopFive/Card.tsx b/FE/src/components/TopFive/Card.tsx new file mode 100644 index 00000000..3ca284e1 --- /dev/null +++ b/FE/src/components/TopFive/Card.tsx @@ -0,0 +1,43 @@ +type CardProps = { + name: string; + price: string; + changePercentage: string; + changePrice: string; + index: number; +}; + +export default function Card({ + name, + price, + changePercentage, + changePrice, + index, +}: CardProps) { + const changeValue = + typeof changePercentage === 'string' + ? Number(changePercentage) + : changePercentage; + const changeColor = + changeValue > 0 ? 'text-juga-red-60' : 'text-juga-blue-50'; + + return ( +
+
{index + 1}
+
+

{name}

+
+
+

+ {price?.toLocaleString()} +

+
+
+

+ {changeValue > 0 + ? `${changePrice}(${changeValue}%)` + : `${changePrice}(${Math.abs(changeValue)}%)`} +

+
+
+ ); +} diff --git a/FE/src/components/TopFive/List.tsx b/FE/src/components/TopFive/List.tsx new file mode 100644 index 00000000..cc53b801 --- /dev/null +++ b/FE/src/components/TopFive/List.tsx @@ -0,0 +1,42 @@ +import Card from './Card'; +import { SkeletonCard } from './SkeletonCard.tsx'; +import { StockData } from './type.ts'; + +type ListProps = { + listTitle: string; + data: StockData[]; + isLoading: boolean; +}; + +export default function List({ listTitle, data, isLoading }: ListProps) { + return ( +
+
+ {listTitle} +
+
+
종목
+
현재가
+
등락
+
+ +
    + {isLoading + ? Array.from({ length: 5 }).map((_, index) => ( + + )) + : data.map((stock: StockData, index) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/FE/src/components/TopFive/Nav.tsx b/FE/src/components/TopFive/Nav.tsx new file mode 100644 index 00000000..5fc2bef3 --- /dev/null +++ b/FE/src/components/TopFive/Nav.tsx @@ -0,0 +1,53 @@ +import { useSearchParams } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; +import { MarketType } from './type.ts'; + +export default function Nav() { + const [searchParams, setSearchParams] = useSearchParams(); + const currentMarket = searchParams.get('top') || '전체'; + const indicatorRef = useRef(null); + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const markets: MarketType[] = ['전체', '코스피', '코스닥', '코스피200']; + + const handleMarketChange = (market: MarketType) => { + if (market === '전체') { + searchParams.delete('top'); + setSearchParams(searchParams); + } else { + setSearchParams({ top: market }); + } + }; + + useEffect(() => { + const currentButton = + buttonRefs.current[markets.indexOf(currentMarket as MarketType)]; + const indicator = indicatorRef.current; + + if (currentButton && indicator) { + indicator.style.left = `${currentButton.offsetLeft}px`; + indicator.style.width = `${currentButton.offsetWidth}px`; + } + }, [currentMarket]); + + return ( +
+
+ + {markets.map((market, index) => ( + + ))} +
+ ); +} diff --git a/FE/src/components/TopFive/SkeletonCard.tsx b/FE/src/components/TopFive/SkeletonCard.tsx new file mode 100644 index 00000000..79b9066c --- /dev/null +++ b/FE/src/components/TopFive/SkeletonCard.tsx @@ -0,0 +1,12 @@ +export function SkeletonCard() { + return ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ); +} diff --git a/FE/src/components/TopFive/TopFive.tsx b/FE/src/components/TopFive/TopFive.tsx new file mode 100644 index 00000000..90f54a63 --- /dev/null +++ b/FE/src/components/TopFive/TopFive.tsx @@ -0,0 +1,43 @@ +import List from './List'; +import Nav from './Nav'; +import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { MarketType } from './type.ts'; + +const paramsMap = { + 전체: 'ALL', + 코스피: 'KOSPI', + 코스닥: 'KOSDAQ', + 코스피200: 'KOSPI200', +}; + +export default function TopFive() { + const [searchParams] = useSearchParams(); + const currentMarket = (searchParams.get('top') || '전체') as MarketType; + + const { data, isLoading } = useQuery({ + queryKey: ['topfive', currentMarket], + queryFn: () => + fetch( + `http://223.130.151.42:3000/api/stocks/topfive?market=${paramsMap[currentMarket]}`, + ).then((res) => res.json()), + keepPreviousData: true, + }); + return ( +
    +
    + ); +} diff --git a/FE/src/components/TopFive/type.ts b/FE/src/components/TopFive/type.ts new file mode 100644 index 00000000..3f72aee4 --- /dev/null +++ b/FE/src/components/TopFive/type.ts @@ -0,0 +1,9 @@ +export type StockData = { + hts_kor_isnm: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; +}; + +export type MarketType = '전체' | '코스피' | '코스닥' | '코스피200'; diff --git a/FE/src/main.tsx b/FE/src/main.tsx index df655eae..94fb016c 100644 --- a/FE/src/main.tsx +++ b/FE/src/main.tsx @@ -2,9 +2,14 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); createRoot(document.getElementById('root')!).render( - - + + + + , ); diff --git a/FE/src/page/Home.tsx b/FE/src/page/Home.tsx index 6078bdcb..4d5954c0 100644 --- a/FE/src/page/Home.tsx +++ b/FE/src/page/Home.tsx @@ -1,9 +1,17 @@ import Header from 'components/Header'; +import Login from 'components/Login'; +import TopFive from 'components/TopFive/TopFive'; +import StockIndex from 'components/StockIndex/index.tsx'; export default function Home() { return ( <>
    +
    + + +
    + ); } diff --git a/FE/src/service/auth.ts b/FE/src/service/auth.ts new file mode 100644 index 00000000..e4ecb640 --- /dev/null +++ b/FE/src/service/auth.ts @@ -0,0 +1,15 @@ +import { LoginFailResponse, LoginSuccessResponse } from 'types'; + +export async function login( + email: string, + password: string, +): Promise { + return fetch('http://223.130.151.42:3000/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + password, + }), + }).then((res) => res.json()); +} diff --git a/FE/src/store/authStore.ts b/FE/src/store/authStore.ts new file mode 100644 index 00000000..1a8e8453 --- /dev/null +++ b/FE/src/store/authStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; + +type AuthStore = { + accessToken: string | null; + isLogin: boolean; + setAccessToken: (token: string) => void; + resetToken: () => void; +}; + +const useAuthStore = create((set) => ({ + accessToken: null, + isLogin: false, + setAccessToken: (token: string) => { + set({ accessToken: token, isLogin: token !== null }); + }, + resetToken: () => { + set({ accessToken: null, isLogin: false }); + }, +})); + +export default useAuthStore; diff --git a/FE/src/store/useLoginModalStore.ts b/FE/src/store/useLoginModalStore.ts new file mode 100644 index 00000000..ce0b5fcf --- /dev/null +++ b/FE/src/store/useLoginModalStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +type ModalStore = { + isOpen: boolean; + toggleModal: () => void; +}; + +const useLoginModalStore = create((set) => ({ + isOpen: false, + toggleModal: () => set((state) => ({ isOpen: !state.isOpen })), +})); + +export default useLoginModalStore; diff --git a/FE/src/types.ts b/FE/src/types.ts new file mode 100644 index 00000000..06b1b10b --- /dev/null +++ b/FE/src/types.ts @@ -0,0 +1,9 @@ +export type LoginSuccessResponse = { + accessToken: string; +}; + +export type LoginFailResponse = { + error: string; + message: string[]; + statusCode: number; +}; diff --git a/FE/src/utils/chart.ts b/FE/src/utils/chart.ts new file mode 100644 index 00000000..15b515f9 --- /dev/null +++ b/FE/src/utils/chart.ts @@ -0,0 +1,50 @@ +const X_LENGTH = 79; // 9:00 ~ 15:30 까지 5분 단위의 총 개수 +const MIDDLE = 50; // 상한가, 하한가를 나누는 기준 + +export const drawChart = (ctx: CanvasRenderingContext2D, data: number[]) => { + const canvas = ctx.canvas; + const width = canvas.width; + const height = canvas.height; + + ctx.clearRect(0, 0, width, height); + + const padding = { + top: 10, + right: 10, + bottom: 10, + left: 10, + }; + + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + const yMax = Math.max(...data.map((d) => d)) * 1.1; + const yMin = Math.min(...data.map((d) => d)) * 0.9; + + // 데이터 선 그리기 + if (data.length > 1) { + ctx.beginPath(); + data.forEach((point, i) => { + const x = padding.left + (chartWidth * i) / (X_LENGTH - 1); + const y = + padding.top + + chartHeight - + (chartHeight * (point - yMin)) / (yMax - yMin); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + + const currentValue = data[data.length - 1]; + if (currentValue >= MIDDLE) { + ctx.strokeStyle = '#FF3700'; + } else { + ctx.strokeStyle = '#2175F3'; + } + ctx.lineWidth = 2; + ctx.stroke(); + } +}; diff --git a/FE/tsconfig.app.tsbuildinfo b/FE/tsconfig.app.tsbuildinfo new file mode 100644 index 00000000..bbaf2949 --- /dev/null +++ b/FE/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/header.tsx","./src/page/home.tsx"],"version":"5.6.3"} \ No newline at end of file diff --git a/FE/tsconfig.node.tsbuildinfo b/FE/tsconfig.node.tsbuildinfo new file mode 100644 index 00000000..75ea0011 --- /dev/null +++ b/FE/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/FE/vite.config.ts b/FE/vite.config.ts index 539e2463..be05188f 100644 --- a/FE/vite.config.ts +++ b/FE/vite.config.ts @@ -6,6 +6,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [react(), tsconfigPaths()], server: { + host: true, open: true, }, });