From f1e340d9f1325a48f354184c729e64be98559e3d Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Thu, 5 Feb 2026 16:36:42 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?dashboard=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/forgot-password/page.tsx | 3 +- app/(auth)/signup/page.tsx | 3 +- app/(home)/page.tsx | 126 ++++++++++++++++++++++++ app/(main)/dashboard/page.tsx | 103 +++++++++++++++++++ app/(main)/page.tsx | 63 ------------ app/layout.tsx | 4 +- features/auth/actions.ts | 2 +- features/auth/components/login-form.tsx | 5 +- features/auth/constants.ts | 1 + features/layout/components/header.tsx | 5 +- package-lock.json | 64 ++++++++++++ package.json | 1 + playwright-report/index.html | 85 ++++++++++++++++ playwright.config.ts | 42 ++++++++ test-results/.last-run.json | 4 + tests/e2e/auth.spec.ts | 29 ++++++ utils/supabase/middleware.ts | 30 +++++- 17 files changed, 497 insertions(+), 73 deletions(-) create mode 100644 app/(home)/page.tsx create mode 100644 app/(main)/dashboard/page.tsx delete mode 100644 app/(main)/page.tsx create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/auth.spec.ts diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index 17586e3..cb91677 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -11,6 +11,7 @@ import { CardTitle, } from "@/components/ui/card"; import Link from "next/link"; +import { AUTH_ROUTES } from "@/features/auth/constants"; /** * [비밀번호 찾기 페이지] @@ -72,7 +73,7 @@ export default async function ForgotPasswordPage({
로그인 페이지로 돌아가기 diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index c0d21e2..21a124a 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { AUTH_ROUTES } from "@/features/auth/constants"; import FormMessage from "@/components/form-message"; import SignupForm from "@/features/auth/components/signup-form"; import { @@ -42,7 +43,7 @@ export default async function SignupPage({

이미 계정이 있으신가요?{" "} 로그인 하러 가기 diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx new file mode 100644 index 0000000..929885b --- /dev/null +++ b/app/(home)/page.tsx @@ -0,0 +1,126 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { createClient } from "@/utils/supabase/server"; +import { UserMenu } from "@/features/layout/components/user-menu"; +import { AUTH_ROUTES } from "@/features/auth/constants"; + +export default async function HomePage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + return ( +

+
+ +
+ + AutoTrade + + +
+ {user ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ +
+
+
+

+ 투자의 미래,{" "} + + 자동화하세요 + +

+

+ AutoTrade는 최첨단 알고리즘을 통해 당신의 암호화폐 투자를 24시간 + 관리합니다. 감정에 휘둘리지 않는 투자를 경험하세요. +

+
+ {user ? ( + + ) : ( + + )} + {!user && ( + + )} +
+
+
+ +
+
+

+ 주요 기능 +

+

+ 성공적인 투자를 위해 필요한 모든 도구가 준비되어 있습니다. +

+
+
+ {[ + { + title: "실시간 모니터링", + description: + "시장 상황을 실시간으로 분석하고 최적의 타이밍을 포착합니다.", + }, + { + title: "알고리즘 트레이딩", + description: + "검증된 전략을 기반으로 자동으로 매수와 매도를 실행합니다.", + }, + { + title: "포트폴리오 관리", + description: + "자산 분배와 리스크 관리를 통해 안정적인 수익을 추구합니다.", + }, + ].map((feature, index) => ( +
+
+
+

{feature.title}

+

+ {feature.description} +

+
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..4b731e7 --- /dev/null +++ b/app/(main)/dashboard/page.tsx @@ -0,0 +1,103 @@ +import { createClient } from "@/utils/supabase/server"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Activity, CreditCard, DollarSign, Users } from "lucide-react"; + +export default async function DashboardPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + return ( +
+
+

대시보드

+
+
+ + + 총 수익 + + + +
$45,231.89
+

지난달 대비 +20.1%

+
+
+ + + 구독자 + + + +
+2350
+

지난달 대비 +180.1%

+
+
+ + + 판매량 + + + +
+12,234
+

지난달 대비 +19%

+
+
+ + + 현재 활동 중 + + + +
+573
+

지난 시간 대비 +201

+
+
+
+
+ + + 개요 + + + {/* Chart placeholder */} +
+ 차트 영역 (준비 중) +
+
+
+ + + 최근 활동 +
+ 이번 달 265건의 거래가 있었습니다. +
+
+ +
+
+
+

+ 비트코인 매수 +

+

BTC/USDT

+
+
+$1,999.00
+
+
+
+

+ 이더리움 매도 +

+

ETH/USDT

+
+
+$39.00
+
+
+
+
+
+
+ ); +} diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx deleted file mode 100644 index e4128fa..0000000 --- a/app/(main)/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -export default function DashboardPage() { - return ( -
-
-

대시보드

-

- 자동매매 시스템 현황을 한눈에 확인하세요. -

-
-
- {/* 예시 카드들 */} -
-
- - 총 자산 - - ₩ 0 -
-
-
-
- - 평가 손익 - - +0% -
-
-
-
- - 실현 손익 - - ₩ 0 -
-
-
-
- - 가동 봇 - - 0개 -
-
-
- - {/* 차트나 로그 영역 예시 */} -
-
-
매매 추입
-
- 차트 영역 준비중... -
-
-
-
최근 활동
-
- 로그 영역 준비중... -
-
-
-
- ); -} diff --git a/app/layout.tsx b/app/layout.tsx index ded5050..f823401 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "AutoTrade", + description: "Automated Crypto Trading Platform", }; export default function RootLayout({ diff --git a/features/auth/actions.ts b/features/auth/actions.ts index 3456e37..b763964 100644 --- a/features/auth/actions.ts +++ b/features/auth/actions.ts @@ -285,7 +285,7 @@ export async function signout() { revalidatePath("/", "layout"); // 3. 로그인 페이지로 리다이렉트 - redirect("/login"); + redirect("/"); } /** diff --git a/features/auth/components/login-form.tsx b/features/auth/components/login-form.tsx index bc6e0d0..347a0cb 100644 --- a/features/auth/components/login-form.tsx +++ b/features/auth/components/login-form.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import Link from "next/link"; +import { AUTH_ROUTES } from "@/features/auth/constants"; import { login, signInWithGoogle, @@ -122,7 +123,7 @@ export default function LoginForm() {
{/* 비밀번호 찾기 링크 */} 비밀번호 찾기 @@ -150,7 +151,7 @@ export default function LoginForm() {

계정이 없으신가요?{" "} 회원가입 하기 diff --git a/features/auth/constants.ts b/features/auth/constants.ts index b42a628..3ba0c98 100644 --- a/features/auth/constants.ts +++ b/features/auth/constants.ts @@ -180,6 +180,7 @@ export const AUTH_ROUTES = { AUTH_CONFIRM: "/auth/confirm", AUTH_CALLBACK: "/auth/callback", HOME: "/", + DASHBOARD: "/dashboard", } as const; /** diff --git a/features/layout/components/header.tsx b/features/layout/components/header.tsx index df176d9..f25dbac 100644 --- a/features/layout/components/header.tsx +++ b/features/layout/components/header.tsx @@ -2,6 +2,7 @@ import { createClient } from "@/utils/supabase/server"; import Link from "next/link"; import { UserMenu } from "./user-menu"; import { Button } from "@/components/ui/button"; +import { AUTH_ROUTES } from "@/features/auth/constants"; export async function Header() { const supabase = await createClient(); @@ -12,7 +13,7 @@ export async function Header() { return (

- +
AutoTrade @@ -25,7 +26,7 @@ export async function Header() { ) : ( )}
diff --git a/package-lock.json b/package-lock.json index a11b384..3c3cc73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -1298,6 +1299,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -6972,6 +6989,21 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8715,6 +8747,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index dde2098..19f7a04 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..f78bb3e --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..1e5f767 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * See https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: "./tests/e2e", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:3001", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run dev", + url: "http://localhost:3001", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..822cd0f --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Authentication Flow", () => { + test("Guest should see Landing Page", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/AutoTrade/i); + await expect( + page.getByRole("heading", { name: "투자의 미래, 자동화하세요" }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "로그인" }).first(), + ).toBeVisible(); + }); + + test("Guest trying to access /dashboard should be redirected to /login", async ({ + page, + }) => { + await page.goto("/dashboard"); + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole("button", { name: "로그인" })).toBeVisible(); + }); + + test("Login page should load correctly", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByLabel("이메일", { exact: true })).toBeVisible(); + await expect(page.getByLabel("비밀번호")).toBeVisible(); + await expect(page.getByRole("button", { name: "로그인" })).toBeVisible(); + }); +}); diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts index a914257..44fe577 100644 --- a/utils/supabase/middleware.ts +++ b/utils/supabase/middleware.ts @@ -9,17 +9,24 @@ import { /** * 서버 사이드 인증 상태를 관리하고 보호된 라우트를 처리합니다. */ +// 서버 사이드 인증 상태를 관리하고 보호된 라우트를 처리합니다. export async function updateSession(request: NextRequest) { + // 1. 초기 Supabase 응답 객체 생성 + // request 헤더 등을 포함하여 초기 상태 설정 let supabaseResponse = NextResponse.next({ request }); + // 2. Supabase 클라이언트 생성 (SSR 전용) + // 쿠키 조작을 위한 setAll/getAll 메서드 오버라이딩 포함 const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { + // 쿠키 가져오기 getAll() { return request.cookies.getAll(); }, + // 쿠키 설정하기 (요청 및 응답 객체 모두에 적용) setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value), @@ -33,13 +40,18 @@ export async function updateSession(request: NextRequest) { }, ); + // 3. 현재 사용자 정보 조회 + // getUser() 사용이 보안상 안전함 (getSession보다 권장됨) const { data: { user }, } = await supabase.auth.getUser(); + // 4. 현재 요청 URL과 복구용 쿠키 확인 const { pathname } = request.nextUrl; const recoveryCookie = request.cookies.get(RECOVERY_COOKIE_NAME)?.value; + // 5. 복구 쿠키가 있는데 로그인이 안 된 경우 (세션 만료 등) + // 로그인 페이지로 강제 리다이렉트 후 복구 쿠키 삭제 if (recoveryCookie && !user) { const response = NextResponse.redirect( new URL(AUTH_ROUTES.LOGIN, request.url), @@ -48,24 +60,39 @@ export async function updateSession(request: NextRequest) { return response; } + // 6. 현재 페이지가 비밀번호 재설정 관련 라우트인지 확인 const isRecoveryRoute = pathname.startsWith(AUTH_ROUTES.RESET_PASSWORD) || pathname.startsWith(AUTH_ROUTES.AUTH_CONFIRM); + // 7. 복구 쿠키가 있는데 재설정 라우트가 아닌 다른 곳으로 가려는 경우 + // 강제로 비밀번호 재설정 페이지로 리다이렉트 (보안 조치) if (recoveryCookie && !isRecoveryRoute) { return NextResponse.redirect( new URL(AUTH_ROUTES.RESET_PASSWORD, request.url), ); } + // 8. 현재 페이지가 로그인/회원가입 등 공용 인증 페이지인지 확인 const isAuthPage = PUBLIC_AUTH_PAGES.some((page) => pathname.startsWith(page), ); - if (!user && !isAuthPage) { + // 9. 비로그인 사용자 접근 제어 + // - 유저가 없음 (!user) + // - 인증 페이지 아님 (!isAuthPage) + // - 메인 페이지(홈) 아님 (pathname !== AUTH_ROUTES.HOME) + // -> 로그인 페이지로 리다이렉트 + if (!user && !isAuthPage && pathname !== AUTH_ROUTES.HOME) { return NextResponse.redirect(new URL(AUTH_ROUTES.LOGIN, request.url)); } + // 10. 로그인 사용자 접근 제어 (인증 페이지 접근 시) + // - 유저가 있음 (user) + // - 인증 페이지 접근 시도 (isAuthPage) - 예: 이미 로그인했는데 /login 접근 + // - 비밀번호 재설정은 아님 + // - 복구 모드 아님 + // -> 메인 페이지로 리다이렉트 if ( user && isAuthPage && @@ -75,5 +102,6 @@ export async function updateSession(request: NextRequest) { return NextResponse.redirect(new URL(AUTH_ROUTES.HOME, request.url)); } + // 11. 최종 응답 반환 (변경된 쿠키 등이 포함됨) return supabaseResponse; }