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는 최첨단 알고리즘을 통해 당신의 암호화폐 투자를 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 (
-
-
-
대시보드
-
- 자동매매 시스템 현황을 한눈에 확인하세요.
-
-
-
- {/* 예시 카드들 */}
-
-
-
-
-
-
- {/* 차트나 로그 영역 예시 */}
-
-
-
매매 추입
-
- 차트 영역 준비중...
-
-
-
-
최근 활동
-
- 로그 영역 준비중...
-
-
-
-
- );
-}
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;
}