diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 4b571613..ce9b9d95 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -5,6 +5,7 @@ import { ThrottlerGuard } from '@nestjs/throttler'; import { Response } from 'express'; import { GetUserId } from '@/common/decorator/get-userId.decorator'; +import { CookieConfig } from '@/config/cookie.config'; import { AuthService } from './auth.service'; import { LocalLoginRequestDto } from './dto/localLoginRequest.dto'; @@ -17,10 +18,15 @@ import { LocalAuthGuard } from './local/local-auth.guard'; @Controller('auth') @ApiTags('Auth') export class AuthController { + private readonly redirectUrl: string; + constructor( - private authService: AuthService, - private configService: ConfigService - ) {} + private readonly authService: AuthService, + private readonly configService: ConfigService, + private readonly cookieConfig: CookieConfig + ) { + this.redirectUrl = this.configService.get('LOGIN_REDIRECT_URL'); + } @Post('signup') @ApiOperation({ summary: '로컬 회원가입' }) @@ -38,16 +44,16 @@ export class AuthController { @ApiResponse({ status: 401 }) @UseGuards(LocalAuthGuard) localLogin(@GetUserId() userId: number, @Res() response: Response) { - this.cookieInsertJWT(response, userId); + this.loginProcess(response, userId); } - @Post('guest/login') + @Get('guest/login') @ApiOperation({ summary: '게스트 로그인' }) @ApiResponse({ status: 302, description: '홈으로 리다이렉션' }) @UseGuards(ThrottlerGuard) async guestLogin(@Res() response: Response) { const guestUser = await this.authService.createGuestUser(); - this.cookieInsertJWT(response, guestUser.id); + this.loginProcess(response, guestUser.id); } @Get('google/login') @@ -60,7 +66,7 @@ export class AuthController { @Get('google/callback') @UseGuards(GoogleAuthGuard) googleAuthCallback(@GetUserId() userId: number, @Res() response: Response) { - this.cookieInsertJWT(response, userId); + this.loginProcess(response, userId); } @Get('github/login') @@ -73,25 +79,24 @@ export class AuthController { @Get('github/callback') @UseGuards(GitHubAuthGuard) githubAuthCallback(@GetUserId() userId: number, @Res() response: Response) { - this.cookieInsertJWT(response, userId); + this.loginProcess(response, userId); } - private setAuthCookie(response: Response, accessToken: string) { - response.cookie('accessToken', accessToken, { - httpOnly: true, - secure: this.configService.get('NODE_ENV') === 'production', - sameSite: 'lax', - path: '/', - }); + @Get('logout') + @ApiOperation({ summary: '로그아웃' }) + @ApiResponse({ status: 302, description: '홈으로 리다이렉션' }) + logout(@Res() response: Response) { + response.clearCookie('accessToken', this.cookieConfig.getAuthCookieOptions()); + this.redirectToHome(response); } - private cookieInsertJWT( - response: Response, - userId: number, - redirectUrl: string = this.configService.get('LOGIN_REDIRECT_URL') - ) { + private loginProcess(response: Response, userId: number) { const { accessToken } = this.authService.createJWT(userId); - this.setAuthCookie(response, accessToken); - response.redirect(redirectUrl); + response.cookie('accessToken', accessToken, this.cookieConfig.getAuthCookieOptions()); + this.redirectToHome(response); + } + + private redirectToHome(response: Response) { + response.redirect(this.redirectUrl); } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 695fbbe5..827b4e39 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { CookieConfig } from '@/config/cookie.config'; import { UserModule } from '@/user/user.module'; import { AuthController } from './auth.controller'; @@ -27,6 +28,13 @@ import { LocalStrategy } from './local/local.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, LocalStrategy, JwtStrategy, GitHubStrategy, GoogleStrategy], + providers: [ + AuthService, + LocalStrategy, + JwtStrategy, + GitHubStrategy, + GoogleStrategy, + CookieConfig, + ], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 315a586e..2ecfe0d0 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { Provider } from '@repo/types'; import { CreateSocialUserDto } from '@/user/dto/createSocialUser.dto'; import { UserService } from '@/user/user.service'; @@ -15,7 +16,7 @@ export class AuthService { ) {} async signupLocal(signupRequestDto: LocalSignupRequestDto) { - return this.userService.createLocalUser({ provider: 'local', ...signupRequestDto }); + return this.userService.createLocalUser({ provider: Provider.local, ...signupRequestDto }); } async validateLocalLogin(username: string, inputPassword: string) { @@ -32,17 +33,20 @@ export class AuthService { async createGuestUser() { const randomNum = Math.floor(Math.random() * 10000); + const response = await fetch('https://api.thecatapi.com/v1/images/search'); + const catImageUrl = (await response.json())[0].url; + const guestUser = { username: `guest_${randomNum}`, password: `guest_password_${randomNum}`, email: `guet_email@guest.com`, nickname: `guest_${randomNum}`, introduce: `게스트 사용자입니다. `, - profileImageUrl: `https://cataas.com/cat?${Date.now()}`, + profileImageUrl: catImageUrl, }; const user = await this.userService.findUserByUsername(guestUser.username); if (!user) { - return this.userService.createLocalUser({ provider: 'guest', ...guestUser }); + return this.userService.createLocalUser({ provider: Provider.guest, ...guestUser }); } return user; } diff --git a/apps/api/src/auth/github/github.strategy.ts b/apps/api/src/auth/github/github.strategy.ts index cf8c4502..6fdf01c6 100644 --- a/apps/api/src/auth/github/github.strategy.ts +++ b/apps/api/src/auth/github/github.strategy.ts @@ -2,11 +2,12 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Profile, Strategy } from 'passport-github2'; +import { Provider } from '@repo/types'; import { AuthService } from '../auth.service'; @Injectable() -export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { +export class GitHubStrategy extends PassportStrategy(Strategy, Provider.github) { constructor( private configService: ConfigService, private authService: AuthService @@ -23,7 +24,7 @@ export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { const { id, username, emails, photos } = profile; const user = { - provider: 'github', + provider: Provider.github, socialId: id, nickname: username, email: emails[0].value, diff --git a/apps/api/src/auth/google/google.strategy.ts b/apps/api/src/auth/google/google.strategy.ts index 0d32ed2f..a4dd0aa3 100644 --- a/apps/api/src/auth/google/google.strategy.ts +++ b/apps/api/src/auth/google/google.strategy.ts @@ -2,11 +2,12 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Profile, Strategy } from 'passport-google-oauth20'; +import { Provider } from '@repo/types'; import { AuthService } from '../auth.service'; @Injectable() -export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { +export class GoogleStrategy extends PassportStrategy(Strategy, Provider.google) { constructor( private configService: ConfigService, private authService: AuthService @@ -23,7 +24,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { const { id, displayName, emails, photos } = profile; const user = { - provider: 'google', + provider: Provider.google, socialId: id, nickname: displayName, email: emails[0].value, diff --git a/apps/api/src/config/cookie.config.ts b/apps/api/src/config/cookie.config.ts new file mode 100644 index 00000000..60fc1f18 --- /dev/null +++ b/apps/api/src/config/cookie.config.ts @@ -0,0 +1,18 @@ +// config/cookie.config.ts +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CookieOptions } from 'express'; + +@Injectable() +export class CookieConfig { + constructor(private configService: ConfigService) {} + + getAuthCookieOptions(): CookieOptions { + return { + httpOnly: true, + secure: this.configService.get('NODE_ENV') === 'production', + sameSite: 'lax', + path: '/', + }; + } +} diff --git a/apps/api/src/entity/ticle.entity.ts b/apps/api/src/entity/ticle.entity.ts index 145d8dbc..c79d50bc 100644 --- a/apps/api/src/entity/ticle.entity.ts +++ b/apps/api/src/entity/ticle.entity.ts @@ -74,4 +74,7 @@ export class Ticle { }, }) tags: Tag[]; + + @Column({ type: 'varchar', name: 'profile_image_url', nullable: true }) + profileImageUrl: string; } diff --git a/apps/api/src/entity/user.entity.ts b/apps/api/src/entity/user.entity.ts index 43cf31e0..c58c56a7 100644 --- a/apps/api/src/entity/user.entity.ts +++ b/apps/api/src/entity/user.entity.ts @@ -6,6 +6,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { Provider } from '@repo/types'; import { Applicant } from './applicant.entity'; import { Ticle } from './ticle.entity'; @@ -34,7 +35,7 @@ export class User { profileImageUrl: string; @Column({ type: 'varchar', default: 'local' }) - provider: string; + provider: Provider; @Column({ type: 'varchar', name: 'social_id', nullable: true }) socialId: string; diff --git a/apps/api/src/ticle/dto/ticleDetailDto.ts b/apps/api/src/ticle/dto/ticleDetailDto.ts index b81c8ab6..c80ef6a3 100644 --- a/apps/api/src/ticle/dto/ticleDetailDto.ts +++ b/apps/api/src/ticle/dto/ticleDetailDto.ts @@ -7,6 +7,12 @@ export class TickleDetailResponseDto { }) speakerName: string; + @ApiProperty({ + example: 1, + description: '발표자 유저 아이디', + }) + speakerId: number; + @ApiProperty({ example: 'kim@example.com', description: '발표자 이메일', @@ -56,4 +62,16 @@ export class TickleDetailResponseDto { description: '발표자 프로필 이미지 Url', }) speakerImgUrl: string; + + @ApiProperty({ + example: 'true', + description: '티클의 호스트 여부', + }) + isOwner: boolean; + + @ApiProperty({ + example: 'true', + description: '이미 참가신청을 했는지 여부', + }) + alreadyApplied: boolean; } diff --git a/apps/api/src/ticle/ticle.controller.ts b/apps/api/src/ticle/ticle.controller.ts index a02731e6..f2085ed8 100644 --- a/apps/api/src/ticle/ticle.controller.ts +++ b/apps/api/src/ticle/ticle.controller.ts @@ -36,13 +36,13 @@ export class TicleController { return this.ticleService.getTicleList(parsedQuery); } - @Get('search') - getTicleSearchList() {} - @Get(':ticleId') @UseGuards(JwtAuthGuard) - getTicle(@Param('ticleId') ticleId: number): Promise { - return this.ticleService.getTicleByTicleId(ticleId); + getTicle( + @GetUserId() userId: number, + @Param('ticleId') ticleId: number + ): Promise { + return this.ticleService.getTicleByTicleId(userId, ticleId); } @Post(':ticleId/apply') diff --git a/apps/api/src/ticle/ticle.service.ts b/apps/api/src/ticle/ticle.service.ts index 9d794bd9..a2ec54c6 100644 --- a/apps/api/src/ticle/ticle.service.ts +++ b/apps/api/src/ticle/ticle.service.ts @@ -128,12 +128,13 @@ export class TicleService { return user; } - async getTicleByTicleId(ticleId: number): Promise { + async getTicleByTicleId(userId: number, ticleId: number): Promise { const ticle = await this.ticleRepository .createQueryBuilder('ticle') .leftJoinAndSelect('ticle.tags', 'tags') .leftJoinAndSelect('ticle.speaker', 'speaker') - .select(['ticle', 'tags', 'speaker.id', 'speaker.profileImageUrl']) + .leftJoinAndSelect('ticle.applicants', 'applicants') + .select(['ticle', 'tags', 'speaker.id', 'speaker.profileImageUrl', 'applicants']) .where('ticle.id = :id', { id: ticleId }) .getOne(); @@ -142,10 +143,15 @@ export class TicleService { } const { tags, speaker, ...ticleData } = ticle; + const alreadyApplied = ticle.applicants.some((applicnat) => applicnat.id === userId); + return { ...ticleData, + speakerId: ticle.speaker.id, tags: tags.map((tag) => tag.name), speakerImgUrl: speaker.profileImageUrl, + isOwner: speaker.id === userId, + alreadyApplied: alreadyApplied, }; } @@ -161,11 +167,14 @@ export class TicleService { 'ticle.endTime', 'ticle.speakerName', 'ticle.createdAt', + 'ticle.profileImageUrl', ]) .addSelect('GROUP_CONCAT(DISTINCT tags.name)', 'tagNames') .addSelect('COUNT(DISTINCT applicant.id)', 'applicantCount') + .addSelect('speaker.profile_image_url') .leftJoin('ticle.tags', 'tags') .leftJoin('ticle.applicants', 'applicant') + .leftJoin('ticle.speaker', 'speaker') .where('ticle.ticleStatus = :status', { status: isOpen ? TicleStatus.OPEN : TicleStatus.CLOSED, }) @@ -201,6 +210,7 @@ export class TicleService { speakerName: ticle.ticle_speaker_name, applicantsCount: ticle.applicantCount, createdAt: ticle.ticle_created_at, + speakerProfileImageUrl: ticle.profile_image_url, })); const totalPages = Math.ceil(totalTicleCount.count / pageSize); diff --git a/apps/api/src/user/dto/createLocalUser.dto.ts b/apps/api/src/user/dto/createLocalUser.dto.ts index 7460d4eb..fbe976a7 100644 --- a/apps/api/src/user/dto/createLocalUser.dto.ts +++ b/apps/api/src/user/dto/createLocalUser.dto.ts @@ -1,8 +1,10 @@ +import { Provider } from '@repo/types'; + export class CreateLocalUserDto { username: string; password: string; email: string; - provider: string; + provider: Provider; nickname?: string; introduce?: string; profileImageUrl?: string; diff --git a/apps/api/src/user/dto/createSocialUser.dto.ts b/apps/api/src/user/dto/createSocialUser.dto.ts index 5a22f6b7..481a1228 100644 --- a/apps/api/src/user/dto/createSocialUser.dto.ts +++ b/apps/api/src/user/dto/createSocialUser.dto.ts @@ -1,6 +1,8 @@ +import { Provider } from '@repo/types'; + export class CreateSocialUserDto { email: string; - provider: string; + provider: Provider; socialId: string; nickname?: string; introduce?: string; diff --git a/apps/api/src/user/dto/userProfileDto.ts b/apps/api/src/user/dto/userProfileDto.ts new file mode 100644 index 00000000..3387496f --- /dev/null +++ b/apps/api/src/user/dto/userProfileDto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Provider } from '@repo/types'; + +interface TicleInfo { + title: string; + ticleId: number; +} + +export class UserProfileDto { + @ApiProperty({ + example: '1', + description: '유저 아이디', + }) + id: number; + + @ApiProperty({ + example: 'simeunseo', + description: '유저 닉네임', + }) + nickname: string; + + @ApiProperty({ + example: 'https://avatars.githubusercontent.com/u/55528304?v=4', + description: '유저 프로필 사진', + }) + profileImageUrl: string; + + @ApiProperty({ + example: 'github', + description: '유저 소셜 로그인 프로바이더', + }) + provider: Provider; + + @ApiProperty({ + example: [ + { + title: '야, 너두 부캠할 수 있어', + ticleId: 1, + }, + ], + description: '유저가 개설한 티클 목록', + }) + ticleInfo: TicleInfo[]; +} diff --git a/apps/api/src/user/dto/userProfileOfMeDto.ts b/apps/api/src/user/dto/userProfileOfMeDto.ts new file mode 100644 index 00000000..b32b1f1d --- /dev/null +++ b/apps/api/src/user/dto/userProfileOfMeDto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Provider } from '@repo/types'; + +export class UserProfileOfMeDto { + @ApiProperty({ + example: '1', + description: '유저 아이디', + }) + id: number; + + @ApiProperty({ + example: 'simeunseo', + description: '유저 닉네임', + }) + nickname: string; + + @ApiProperty({ + example: 'https://avatars.githubusercontent.com/u/55528304?v=4', + description: '유저 프로필 사진', + }) + profileImageUrl: string; + + @ApiProperty({ + example: 'github', + description: '유저 소셜 로그인 프로바이더', + }) + provider: Provider; +} diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 8687a877..299e6f5b 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -1,12 +1,22 @@ -import { Controller, Get, Patch } from '@nestjs/common'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; + +import { JwtAuthGuard } from '@/auth/jwt/jwt-auth.guard'; +import { GetUserId } from '@/common/decorator/get-userId.decorator'; + +import { UserService } from './user.service'; @Controller('user') export class UserController { - constructor() {} + constructor(private readonly userService: UserService) {} - @Get('profile') - getUserProfile() {} + @Get('me') + @UseGuards(JwtAuthGuard) + async getUserProfile(@GetUserId() userId: number) { + return await this.userService.findUserProfileOfMeByUserId(userId); + } - @Patch('profile') - patchUserProfile() {} + @Get(':userId') + async patchUserProfile(@Param('userId') userId: number) { + return await this.userService.findUserProfileByUserId(userId); + } } diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index 36e17fc9..15685c88 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -1,13 +1,15 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import * as bcrypt from 'bcrypt'; import { Repository } from 'typeorm'; -import { ErrorMessage } from '@repo/types'; +import { ErrorMessage, Provider } from '@repo/types'; import { User } from '@/entity/user.entity'; import { CreateLocalUserDto } from './dto/createLocalUser.dto'; import { CreateSocialUserDto } from './dto/createSocialUser.dto'; +import { UserProfileDto } from './dto/userProfileDto'; +import { UserProfileOfMeDto } from './dto/userProfileOfMeDto'; @Injectable() export class UserService { @@ -55,7 +57,7 @@ export class UserService { return user; } - async findUserBySocialIdAndProvider(socialId: string, provider: string): Promise { + async findUserBySocialIdAndProvider(socialId: string, provider: Provider): Promise { const user = await this.userRepository.findOne({ where: { socialId, provider }, }); @@ -64,4 +66,43 @@ export class UserService { } return user; } + + async findUserProfileOfMeByUserId(userId: number): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'nickname', 'profileImageUrl', 'provider'], + }); + + if (!user) { + throw new NotFoundException(ErrorMessage.USER_NOT_FOUND); + } + + return user; + } + + async findUserProfileByUserId(userId: number): Promise { + const user = await this.userRepository + .createQueryBuilder('user') + .leftJoin('user.ticles', 'ticles') + .addSelect(['ticles.title', 'ticles.id']) + .where('user.id = :userId', { userId: userId }) + .getOne(); + + if (!user) { + throw new NotFoundException(ErrorMessage.USER_NOT_FOUND); + } + + const ticleInfo = user.ticles.map((ticle) => ({ + title: ticle.title, + ticleId: ticle.id, + })); + + return { + id: user.id, + nickname: user.nickname, + profileImageUrl: user.profileImageUrl, + provider: user.provider, + ticleInfo: ticleInfo, + }; + } } diff --git a/apps/media/src/signaling/signaling.gateway.ts b/apps/media/src/signaling/signaling.gateway.ts index 93d7c668..3bdf45e6 100644 --- a/apps/media/src/signaling/signaling.gateway.ts +++ b/apps/media/src/signaling/signaling.gateway.ts @@ -4,14 +4,18 @@ import { OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, + WsException, } from '@nestjs/websockets'; import { Socket } from 'socket.io'; import { SOCKET_EVENTS, STREAM_STATUS } from '@repo/mediasoup'; import type { client, server } from '@repo/mediasoup'; import { MediasoupService } from '@/mediasoup/mediasoup.service'; +import { UseFilters } from '@nestjs/common'; +import { WSExceptionFilter } from '@/wsException.filter'; @WebSocketGateway() +@UseFilters(WSExceptionFilter) export class SignalingGateway implements OnGatewayDisconnect { constructor(private mediasoupService: MediasoupService) {} diff --git a/apps/media/src/wsException.filter.ts b/apps/media/src/wsException.filter.ts new file mode 100644 index 00000000..82478a6b --- /dev/null +++ b/apps/media/src/wsException.filter.ts @@ -0,0 +1,20 @@ +import { Catch, ArgumentsHost } from '@nestjs/common'; +import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; + +@Catch(WsException) +export class WSExceptionFilter extends BaseWsExceptionFilter { + catch(exception: any, host: ArgumentsHost) { + const client = host.switchToWs().getClient(); + const errorMessage = exception.message || 'An unknown error occurred'; + + const errorResponse = { + status: 'error', + error: { + code: 500, + message: errorMessage, + }, + }; + + client.emit('error', errorResponse); + } +} diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index d254cfe8..6ec7ab99 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -11,7 +11,7 @@ module.exports = { require.resolve('@repo/lint'), 'plugin:react/recommended', 'plugin:react-hooks/recommended', - 'plugin:jsx-a11y/recommended', + // 'plugin:jsx-a11y/recommended', 'plugin:react/jsx-runtime', ], plugins: ['react-refresh'], diff --git a/apps/web/index.html b/apps/web/index.html index 631fc2e5..1b550546 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + - Ticle + + TICLE
diff --git a/apps/web/public/favicon.png b/apps/web/public/favicon.png new file mode 100644 index 00000000..cc8c6387 Binary files /dev/null and b/apps/web/public/favicon.png differ diff --git a/apps/web/src/__mocks__/handlers/auth.ts b/apps/web/src/__mocks__/handlers/auth.ts index 7e4a0331..4a8b7771 100644 --- a/apps/web/src/__mocks__/handlers/auth.ts +++ b/apps/web/src/__mocks__/handlers/auth.ts @@ -39,13 +39,3 @@ export const logIn: HttpResponseResolver = async ({ request }, }); }; - -export const signOut: HttpResponseResolver = async () => { - return HttpResponse.json(null, { - status: 302, - headers: { - Location: '/', - 'Set-Cookie': `accessToken=; Path=/; HttpOnly, refreshToken=; Path=/; HttpOnly`, - }, - }); -}; diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 7fc75798..9f0a5fb9 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -1,4 +1,5 @@ import axiosInstance from '@/api/axios'; +import { ENV } from '@/constants/env'; type SignUpDto = { username: string; @@ -19,12 +20,16 @@ const signUp = async (body: SignUpDto) => { return data; }; -const signOut = async () => { - await axiosInstance.post('/auth/logout'); +const logOut = () => { + window.location.href = `${ENV.API_URL}/auth/logout`; }; -const oauthLogin = async (provider: 'google' | 'github') => { - await axiosInstance.get(`/auth/${provider}/login`); +const guestLogin = () => { + window.location.href = `${ENV.API_URL}/auth/guest/login`; }; -export { logIn, signUp, oauthLogin, signOut }; +const oauthLogin = (provider: 'google' | 'github') => { + window.location.href = `${ENV.API_URL}/auth/${provider}/login`; +}; + +export { logIn, signUp, oauthLogin, guestLogin, logOut }; diff --git a/apps/web/src/api/user.ts b/apps/web/src/api/user.ts new file mode 100644 index 00000000..edefbf7e --- /dev/null +++ b/apps/web/src/api/user.ts @@ -0,0 +1,24 @@ +import { + UserProfileOfMeResponse, + UserProfileOfMeSchema, + UserProfileResponse, + UserProfileSchema, +} from '@repo/types'; + +import request from '@/hooks/api/request'; + +export const getUserProfileOfMe = async () => { + return request({ + method: 'GET', + url: '/user/me', + schema: UserProfileOfMeSchema, + }); +}; + +export const getUserProfileByUserId = async (userId: number) => { + return request({ + method: 'GET', + url: `/user/${userId}`, + schema: UserProfileSchema, + }); +}; diff --git a/apps/web/src/assets/icons/chevron-right.svg b/apps/web/src/assets/icons/chevron-right.svg index d5745ea0..119e2f41 100644 --- a/apps/web/src/assets/icons/chevron-right.svg +++ b/apps/web/src/assets/icons/chevron-right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/apps/web/src/assets/icons/github.svg b/apps/web/src/assets/icons/github.svg new file mode 100644 index 00000000..b3813426 --- /dev/null +++ b/apps/web/src/assets/icons/github.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/icons/google.svg b/apps/web/src/assets/icons/google.svg new file mode 100644 index 00000000..d6ae6a45 --- /dev/null +++ b/apps/web/src/assets/icons/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/src/assets/images/ticle-character-badge.png b/apps/web/src/assets/images/ticle-character-badge.png new file mode 100644 index 00000000..cc8c6387 Binary files /dev/null and b/apps/web/src/assets/images/ticle-character-badge.png differ diff --git a/apps/web/src/assets/ticle.svg b/apps/web/src/assets/ticle.svg index e2a09801..ece6c2ee 100644 --- a/apps/web/src/assets/ticle.svg +++ b/apps/web/src/assets/ticle.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/web/src/components/auth/GuestLogin.tsx b/apps/web/src/components/auth/GuestLogin.tsx new file mode 100644 index 00000000..7595ca79 --- /dev/null +++ b/apps/web/src/components/auth/GuestLogin.tsx @@ -0,0 +1,20 @@ +import { guestLogin } from '@/api/auth'; +import ChevronRight from '@/assets/icons/chevron-right.svg?react'; + +function GuestLogin() { + const handleGuestLogin = () => { + guestLogin(); + }; + + return ( + + 게스트 로그인 + + + ); +} + +export default GuestLogin; diff --git a/apps/web/src/components/auth/OAuthLogin.tsx b/apps/web/src/components/auth/OAuthLogin.tsx new file mode 100644 index 00000000..a91c8351 --- /dev/null +++ b/apps/web/src/components/auth/OAuthLogin.tsx @@ -0,0 +1,64 @@ +import { cva } from 'class-variance-authority'; +import { useState } from 'react'; + +import { oauthLogin } from '@/api/auth'; +import GithubIc from '@/assets/icons/github.svg?react'; +import GoogleIc from '@/assets/icons/google.svg?react'; + +import Loading from '../common/Loading'; + +const oauthButton = cva('flex w-96 justify-center gap-3 rounded-lg py-4 shadow-normal', { + variants: { + type: { + github: 'bg-black text-white', + google: 'border border-main bg-white text-alt', + }, + }, + defaultVariants: { + type: 'github', + }, +}); + +const OAUTH_LABEL = { + github: 'Github', + google: 'Google', +} as const; + +type OAuthType = keyof typeof OAUTH_LABEL; + +interface OAuthLoginProps { + type: OAuthType; +} + +function OAuthLogin({ type }: OAuthLoginProps) { + const [loadingOAuthType, setLoadingOAuthType] = useState(null); + + const onLoginBtnClick = (type: OAuthType) => { + setLoadingOAuthType(type); + oauthLogin(type); + }; + + const isCurrentLoading = loadingOAuthType === type; + + return ( + + ); +} + +export default OAuthLogin; diff --git a/apps/web/src/components/auth/index.tsx b/apps/web/src/components/auth/index.tsx new file mode 100644 index 00000000..501b2def --- /dev/null +++ b/apps/web/src/components/auth/index.tsx @@ -0,0 +1,24 @@ +import TicleCharacterBadge from '@/assets/images/ticle-character-badge.png'; +import TicleLogo from '@/assets/ticle.svg?react'; + +import GuestLogin from './GuestLogin'; +import OAuthLogin from './OAuthLogin'; + +function Auth() { + return ( +
+
+ 티클 캐릭터 + + 작은 지식이 모여 큰 성장이 되는 곳 +
+
+ + + +
+
+ ); +} + +export default Auth; diff --git a/apps/web/src/components/common/Avatar/index.tsx b/apps/web/src/components/common/Avatar/index.tsx index 1d4c66ac..c32192f6 100644 --- a/apps/web/src/components/common/Avatar/index.tsx +++ b/apps/web/src/components/common/Avatar/index.tsx @@ -5,6 +5,7 @@ import PersonIc from '@/assets/icons/person.svg?react'; import cn from '@/utils/cn'; const AVATAR_SIZE = { + xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', @@ -15,6 +16,7 @@ const avatarVariants = cva( { variants: { size: { + [AVATAR_SIZE.xs]: 'h-[30px] w-[30px]', [AVATAR_SIZE.sm]: 'h-[50px] w-[50px]', [AVATAR_SIZE.md]: 'h-[84px] w-[84px]', [AVATAR_SIZE.lg]: 'h-[100px] w-[100px]', diff --git a/apps/web/src/components/common/Dialog/index.tsx b/apps/web/src/components/common/Dialog/index.tsx index f515dab9..c800e8c7 100644 --- a/apps/web/src/components/common/Dialog/index.tsx +++ b/apps/web/src/components/common/Dialog/index.tsx @@ -2,7 +2,7 @@ import { ReactNode } from '@tanstack/react-router'; import { cva } from 'class-variance-authority'; import { AnimatePresence, motion } from 'framer-motion'; -import { useRef } from 'react'; +import { MouseEvent, useRef } from 'react'; import CloseIc from '@/assets/icons/close.svg?react'; import useOutsideClick from '@/hooks/useOutsideClick'; @@ -25,6 +25,10 @@ function DialogRoot({ isOpen, onClose, children, className }: DialogRootProps) { if (!isOpen) return null; + const handleInnerClick = (e: MouseEvent) => { + e.stopPropagation(); + }; + return ( @@ -41,6 +45,7 @@ function DialogRoot({ isOpen, onClose, children, className }: DialogRootProps) { className={cn('relative w-80 rounded-lg bg-white px-6 py-7 shadow-normal', className)} initial={{ opacity: 0, y: 50 }} animate={{ opacity: 1, y: 0 }} + onClick={handleInnerClick} > {children} diff --git a/apps/web/src/components/common/Header/User.tsx b/apps/web/src/components/common/Header/User.tsx new file mode 100644 index 00000000..75dafab1 --- /dev/null +++ b/apps/web/src/components/common/Header/User.tsx @@ -0,0 +1,61 @@ +/* eslint-disable react-refresh/only-export-components */ +import { Link } from '@tanstack/react-router'; +import axios from 'axios'; +import { Provider } from '@repo/types'; + +import UserProfileOfMeDialog from '@/components/user/UserProfileOfMeDialog'; +import { useUserProfileOfMe } from '@/hooks/api/user'; +import useModal from '@/hooks/useModal'; + +import Avatar from '../Avatar'; +import Button from '../Button'; + +export const LOGIN_TYPE: Record = { + github: 'Github 로그인', + google: 'Google 로그인', + guest: '게스트 로그인', + local: '티클 로그인', +}; + +function User() { + const { data, error } = useUserProfileOfMe(); + const { isOpen, onOpen, onClose } = useModal(); + + const isUnauthorized = axios.isAxiosError(error) && error.response?.status === 401; + + const loginType = data?.provider && LOGIN_TYPE[data.provider]; + + const AuthorizedContent = () => ( + <> +
+ + {data?.nickname} +
+ {isOpen && data && loginType && ( + + )} + + ); + + const UnauthorizedContent = () => ( + +
+ +
+ + ); + + return ( + + ); +} + +export default User; diff --git a/apps/web/src/components/common/Header/index.tsx b/apps/web/src/components/common/Header/index.tsx index 7f5d41da..2942950d 100644 --- a/apps/web/src/components/common/Header/index.tsx +++ b/apps/web/src/components/common/Header/index.tsx @@ -1,13 +1,14 @@ import { Link } from '@tanstack/react-router'; import TicleLogo from '@/assets/ticle.svg?react'; -import Button from '@/components/common/Button'; + +import User from './User'; const NAV_STYLE = 'hover:text-hover text-title1 text-alt transition [&.active]:text-primary'; function Header() { return ( -
+
{/* TODO: User 로그인시 핸들링 */} -
- -
+
); } diff --git a/apps/web/src/components/live/StreamView/AudioStreams/AudioPlayer.tsx b/apps/web/src/components/live/StreamView/AudioStreams/AudioPlayer.tsx index e45c25ac..c0efb787 100644 --- a/apps/web/src/components/live/StreamView/AudioStreams/AudioPlayer.tsx +++ b/apps/web/src/components/live/StreamView/AudioStreams/AudioPlayer.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/media-has-caption */ import { useEffect, useRef } from 'react'; import { VideoPlayerProps } from '@/components/live/StreamView/List/VideoPlayer'; diff --git a/apps/web/src/components/ticle/detail/index.tsx b/apps/web/src/components/ticle/detail/index.tsx index ba20a923..8b645de8 100644 --- a/apps/web/src/components/ticle/detail/index.tsx +++ b/apps/web/src/components/ticle/detail/index.tsx @@ -5,19 +5,25 @@ import ClockIc from '@/assets/icons/clock.svg?react'; import Avatar from '@/components/common/Avatar'; import Badge from '@/components/common/Badge'; import Button from '@/components/common/Button'; +import UserProfileDialog from '@/components/user/UserProfileDialog'; import { useApplyTicle, useTicle } from '@/hooks/api/ticle'; +import useModal from '@/hooks/useModal'; import { formatDateTimeRange } from '@/utils/date'; function Detail() { const { ticleId } = useParams({ from: '/ticle/$ticleId' }); const { data } = useTicle(ticleId); - const { mutate } = useApplyTicle(); const handleApplyButtonClick = () => { mutate(ticleId); }; + const { isOpen, onOpen, onClose } = useModal(); + const handleProfileClick = () => { + onOpen(); + }; + if (!data) return; const { dateStr, timeRangeStr } = formatDateTimeRange(data.startTime, data.endTime); return ( @@ -51,10 +57,21 @@ function Detail() {

발표자 소개

-
- +
+ {data.speakerName}
+ {isOpen && ( + + )}
{data.speakerIntroduce}
diff --git a/apps/web/src/components/ticle/list/TicleCard.tsx b/apps/web/src/components/ticle/list/TicleCard.tsx index 6e4a13ad..e53b5920 100644 --- a/apps/web/src/components/ticle/list/TicleCard.tsx +++ b/apps/web/src/components/ticle/list/TicleCard.tsx @@ -21,7 +21,7 @@ const TicleCard = ({ speakerProfileImg, }: TicleCardProps) => { return ( -
+

{title}

diff --git a/apps/web/src/components/ticle/list/index.tsx b/apps/web/src/components/ticle/list/index.tsx index 52d602bc..b5ecd948 100644 --- a/apps/web/src/components/ticle/list/index.tsx +++ b/apps/web/src/components/ticle/list/index.tsx @@ -76,11 +76,11 @@ function TicleList() {