-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 홈 API 연동 및 인증/캐러셀 개선 #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
64c1610
89b511a
40933da
3696852
0079a59
a13bdfa
8225e6b
902c964
f10dc5b
310b0d5
6d1d78b
9a04747
34f0061
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,61 @@ import { TokenRefreshReqDto, TokenRefreshResDto } from '@/types/domain/auth'; | |
| import { ApiResponse } from '@/types/common'; | ||
|
|
||
| const TOKEN_REFRESH_BUFFER = 60 * 1000; | ||
| const REDACTED = '[REDACTED]'; | ||
| const SENSITIVE_TOKEN_KEYS = new Set([ | ||
| 'accessToken', | ||
| 'refreshToken', | ||
| 'access_token', | ||
| 'refresh_token', | ||
| 'id_token', | ||
| 'token', | ||
| 'email', | ||
| 'password', | ||
| 'username', | ||
| ]); | ||
|
|
||
| function maskSensitiveUrl(url: string): string { | ||
| try { | ||
| const parsedUrl = new URL(url); | ||
| if (parsedUrl.searchParams.has('code')) { | ||
| parsedUrl.searchParams.set('code', REDACTED); | ||
| } | ||
| return parsedUrl.toString(); | ||
| } catch { | ||
| return url.replace(/([?&]code=)[^&]*/i, `$1${REDACTED}`); | ||
| } | ||
| } | ||
|
|
||
| function maskTokenField(value: unknown): string { | ||
| if (typeof value !== 'string') return REDACTED; | ||
| if (value.length <= 10) return REDACTED; | ||
| return `${value.slice(0, 4)}...${value.slice(-4)}`; | ||
| } | ||
|
|
||
| function sanitizeLogData(value: unknown, seen = new WeakSet<object>): unknown { | ||
| if (Array.isArray(value)) { | ||
| return value.map((entry) => sanitizeLogData(entry, seen)); | ||
| } | ||
|
Comment on lines
+40
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 배열에 대한 순환 참조 검사 누락.
🛡️ 제안: 배열도 순환 참조 검사 추가 function sanitizeLogData(value: unknown, seen = new WeakSet<object>): unknown {
+ if (value && typeof value === 'object') {
+ if (seen.has(value as object)) {
+ return '[Circular]';
+ }
+ seen.add(value as object);
+ }
+
if (Array.isArray(value)) {
return value.map((entry) => sanitizeLogData(entry, seen));
}
- if (value && typeof value === 'object') {
- if (seen.has(value as object)) {
- return '[Circular]';
- }
- seen.add(value as object);
-
+ if (value && typeof value === 'object') {
return Object.entries(value as Record<string, unknown>).reduce(🤖 Prompt for AI Agents |
||
|
|
||
| if (value && typeof value === 'object') { | ||
| if (seen.has(value as object)) { | ||
| return '[Circular]'; | ||
| } | ||
| seen.add(value as object); | ||
|
|
||
| return Object.entries(value as Record<string, unknown>).reduce( | ||
| (acc, [key, entryValue]) => { | ||
| acc[key] = SENSITIVE_TOKEN_KEYS.has(key) | ||
| ? maskTokenField(entryValue) | ||
| : sanitizeLogData(entryValue, seen); | ||
| return acc; | ||
| }, | ||
| {} as Record<string, unknown>, | ||
| ); | ||
| } | ||
|
|
||
| return value; | ||
| } | ||
|
|
||
| async function refreshAccessToken(token: JWT): Promise<JWT | null> { | ||
| try { | ||
|
|
@@ -58,21 +113,35 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ | |
| if (!parsed.success) return null; | ||
| const { provider, code } = parsed.data; | ||
|
|
||
| const backendUrl = `${process.env.BACKEND_API_URL}/auth/oauth/${provider}?code=${code}`; | ||
| console.log(backendUrl); | ||
| const backendUrl = `${process.env.BACKEND_API_URL}/auth/oauth/${provider}?code=${encodeURIComponent(code)}`; | ||
| const method = provider === 'google' ? 'GET' : 'POST'; | ||
| console.log( | ||
| `[${provider}] OAuth Request:`, | ||
| method, | ||
| maskSensitiveUrl(backendUrl), | ||
| ); | ||
|
|
||
| try { | ||
| // Google uses GET, Kakao uses POST | ||
| const res = await fetch(backendUrl, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| method, | ||
| ...(method === 'POST' && { | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }), | ||
| }); | ||
|
|
||
| console.log(`[${provider}] Response status:`, res.status); | ||
|
|
||
| if (!res.ok) { | ||
| const error = await res.json(); | ||
| console.log(error); | ||
| console.error( | ||
| `[${provider}] Backend error:`, | ||
| sanitizeLogData(error), | ||
| ); | ||
| throw new Error('Backend verification failed'); | ||
| } | ||
| const { data } = await res.json(); | ||
| console.log(data); | ||
| console.log(`[${provider}] Success data:`, sanitizeLogData(data)); | ||
|
|
||
| return { | ||
| userId: data.userId, | ||
|
|
@@ -115,7 +184,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ | |
|
|
||
| if (!res.ok) throw new Error('Invalid credentials'); | ||
| const { data } = await res.json(); | ||
| console.log(data); | ||
| console.log('Credentials login success:', sanitizeLogData(data)); | ||
|
|
||
| return { | ||
| userId: data.userId, | ||
|
|
@@ -148,6 +217,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ | |
| }; | ||
| } | ||
|
|
||
| // Only attempt refresh if we have a refresh token (user is logged in) | ||
| if (!token.refreshToken) { | ||
| return token; | ||
| } | ||
|
|
||
| if (Date.now() < (token.accessTokenExpires ?? 0) - TOKEN_REFRESH_BUFFER) { | ||
| return token; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { api } from '@/lib/api-client'; | ||
| import { Advertisement } from '@/types/domain/advertisement'; | ||
| import CarouselWithDots from './carousel-with-dots'; | ||
|
|
||
| export default async function BannerCarouselContainer() { | ||
| const advertisements = await api.get<Advertisement[]>('/advertisements/home'); | ||
|
|
||
| return <CarouselWithDots advertisements={advertisements} />; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,76 @@ | ||||||||||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import { useState } from 'react'; | ||||||||||||||||||||||||||||||||||
| import Image from 'next/image'; | ||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||
| Carousel, | ||||||||||||||||||||||||||||||||||
| CarouselContent, | ||||||||||||||||||||||||||||||||||
| CarouselItem, | ||||||||||||||||||||||||||||||||||
| type CarouselApi, | ||||||||||||||||||||||||||||||||||
| } from '@/components/ui/carousel'; | ||||||||||||||||||||||||||||||||||
| import { useCarouselDots } from './use-carousel-dots'; | ||||||||||||||||||||||||||||||||||
| import { DotNavigation } from './dot-navigation'; | ||||||||||||||||||||||||||||||||||
| import Autoplay from 'embla-carousel-autoplay'; | ||||||||||||||||||||||||||||||||||
| import { Advertisement } from '@/types/domain/advertisement'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| interface CarouselWithDotsProps { | ||||||||||||||||||||||||||||||||||
| advertisements: Advertisement[]; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export default function CarouselWithDots({ | ||||||||||||||||||||||||||||||||||
| advertisements, | ||||||||||||||||||||||||||||||||||
| }: CarouselWithDotsProps) { | ||||||||||||||||||||||||||||||||||
| if (advertisements.length === 0) return null; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const [api, setApi] = useState<CarouselApi>(); | ||||||||||||||||||||||||||||||||||
| const { current, count, scrollTo } = useCarouselDots(api); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+20
to
+26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Rules of Hooks 위반 — early return이 Line 23의 early return이 Line 25의 early return을 hooks 호출 이후로 이동시켜야 합니다. 제안 export default function CarouselWithDots({
advertisements,
}: CarouselWithDotsProps) {
- if (advertisements.length === 0) return null;
-
const [api, setApi] = useState<CarouselApi>();
const { current, count, scrollTo } = useCarouselDots(api);
+ if (advertisements.length === 0) return null;
+
return (Based on learnings: "ALWAYS use 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||
| className="w-screen max-w-200" | ||||||||||||||||||||||||||||||||||
| role="region" | ||||||||||||||||||||||||||||||||||
| aria-roledescription="carousel" | ||||||||||||||||||||||||||||||||||
| aria-label="배너 광고" | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| <Carousel | ||||||||||||||||||||||||||||||||||
| setApi={setApi} | ||||||||||||||||||||||||||||||||||
| opts={{ | ||||||||||||||||||||||||||||||||||
| loop: true, | ||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||
| plugins={[ | ||||||||||||||||||||||||||||||||||
| Autoplay({ | ||||||||||||||||||||||||||||||||||
| delay: 3000, | ||||||||||||||||||||||||||||||||||
| stopOnInteraction: false, | ||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||
| ]} | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| <CarouselContent className="m-0 max-h-125 w-full"> | ||||||||||||||||||||||||||||||||||
| {advertisements.map((item, index) => ( | ||||||||||||||||||||||||||||||||||
| <CarouselItem key={item.id} className="p-0"> | ||||||||||||||||||||||||||||||||||
| <div className="relative aspect-380/275 h-full w-full overflow-hidden"> | ||||||||||||||||||||||||||||||||||
| <Image | ||||||||||||||||||||||||||||||||||
| src={item.imageUrl} | ||||||||||||||||||||||||||||||||||
| alt={item.title} | ||||||||||||||||||||||||||||||||||
| fill | ||||||||||||||||||||||||||||||||||
| className="object-cover" | ||||||||||||||||||||||||||||||||||
| priority={index === 0} | ||||||||||||||||||||||||||||||||||
| sizes="(max-width: 800px) 100vw, 800px" | ||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||
| <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" /> | ||||||||||||||||||||||||||||||||||
| <div className="absolute bottom-0 left-0 right-0 p-6 text-white"> | ||||||||||||||||||||||||||||||||||
| <h3 className="mb-2 text-2xl font-black break-keep"> | ||||||||||||||||||||||||||||||||||
| {item.title} | ||||||||||||||||||||||||||||||||||
| </h3> | ||||||||||||||||||||||||||||||||||
| <p className="text-lg font-medium break-keep opacity-90"> | ||||||||||||||||||||||||||||||||||
| {item.description} | ||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| </CarouselItem> | ||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||
| </CarouselContent> | ||||||||||||||||||||||||||||||||||
| </Carousel> | ||||||||||||||||||||||||||||||||||
| <DotNavigation count={count} current={current} onDotClick={scrollTo} /> | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.