Compare commits
2 Commits
d18ed72493
...
12182823b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 12182823b0 | |||
| 3058b93c66 |
96
.agent/skills/find-skills/SKILL.md
Normal file
96
.agent/skills/find-skills/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: find-skills
|
||||||
|
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", or "is there a skill that can help with X".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Find Skills
|
||||||
|
|
||||||
|
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when the user:
|
||||||
|
|
||||||
|
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||||
|
- Says "find a skill for X" or "is there a skill for X"
|
||||||
|
- Asks "can you do X" where X is a specialized capability
|
||||||
|
- Expresses interest in extending agent capabilities
|
||||||
|
- Wants to search for tools, templates, or workflows
|
||||||
|
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||||
|
|
||||||
|
## What is the Skills CLI?
|
||||||
|
|
||||||
|
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||||
|
|
||||||
|
**Key commands:**
|
||||||
|
|
||||||
|
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||||
|
- `npx skills add` - Install a skill from GitHub or other sources
|
||||||
|
- `npx skills check` - Check for skill updates
|
||||||
|
- `npx skills update` - Update all installed skills
|
||||||
|
|
||||||
|
**Browse skills at:** <https://skills.sh/>
|
||||||
|
|
||||||
|
## How to Help Users Find Skills
|
||||||
|
|
||||||
|
### Step 1: Understand What They Need
|
||||||
|
|
||||||
|
When a user asks for help with something, identify:
|
||||||
|
|
||||||
|
1. The domain (e.g., React, testing, design, deployment)
|
||||||
|
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||||
|
3. Whether this is a common enough task that a skill likely exists
|
||||||
|
|
||||||
|
### Step 2: Search for Skills
|
||||||
|
|
||||||
|
Run the find command with a relevant query:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills find [query]
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||||
|
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||||
|
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||||
|
|
||||||
|
### Step 3: Present Recommendations
|
||||||
|
|
||||||
|
When you find relevant skills, present them to the user with:
|
||||||
|
|
||||||
|
1. The skill name and what it does
|
||||||
|
2. The installation command
|
||||||
|
3. A link to the skill's page
|
||||||
|
|
||||||
|
**Example response:**
|
||||||
|
|
||||||
|
> I found a skill that might help!
|
||||||
|
>
|
||||||
|
> **vercel-react-best-practices**
|
||||||
|
> Vercel's official React performance guidelines for AI agents.
|
||||||
|
>
|
||||||
|
> To install it:
|
||||||
|
> `npx skills add vercel-labs/agent-skills@vercel-react-best-practices`
|
||||||
|
>
|
||||||
|
> Learn more: <https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices>
|
||||||
|
|
||||||
|
If the user wants to proceed, you can install the skill for them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify Installation (Optional)
|
||||||
|
|
||||||
|
After installing, you can verify it was installed correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills list
|
||||||
|
```
|
||||||
|
|
||||||
|
## When No Skills Are Found
|
||||||
|
|
||||||
|
1. Try a broader search term
|
||||||
|
2. Check the [skills.sh](https://skills.sh/) website manually if you suspect a network issue
|
||||||
|
3. Suggest the user could create their own skill with `npx skills init`
|
||||||
65
.agent/skills/nextjs-app-router-patterns/SKILL.md
Normal file
65
.agent/skills/nextjs-app-router-patterns/SKILL.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: nextjs-app-router-patterns
|
||||||
|
description: Best practices and patterns for building applications with Next.js App Router (v13+).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Next.js App Router Patterns
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### Server-First by Default
|
||||||
|
|
||||||
|
- **Use Server Components** for everything possible (data fetching, layout, static content).
|
||||||
|
- **Use Client Components** (`"use client"`) only when interactivity (hooks, event listeners) is needed.
|
||||||
|
- **Pass Data Down**: Fetch data in Server Components and pass it as props to Client Components.
|
||||||
|
- **Composition**: Wrap Client Components around Server Components to avoid "rendering undefined" issues or waterfall de-opts.
|
||||||
|
|
||||||
|
### Routing & Layouts
|
||||||
|
|
||||||
|
- **File Structure**:
|
||||||
|
- `page.tsx`: Route UI.
|
||||||
|
- `layout.tsx`: Shared UI (wraps pages).
|
||||||
|
- `loading.tsx`: Loading state (Suspense).
|
||||||
|
- `error.tsx`: Error boundary.
|
||||||
|
- `not-found.tsx`: 404 UI.
|
||||||
|
- `template.tsx`: Layout that re-mounts on navigation.
|
||||||
|
- **Parallel Routes**: Use `@folder` for parallel UI (e.g. dashboards).
|
||||||
|
- **Intercepting Routes**: Use `(..)` to intercept navigation (e.g. modals).
|
||||||
|
- **Route Groups**: Use `(group)` to organize routes without affecting the URL path.
|
||||||
|
|
||||||
|
## Data Fetching Patterns
|
||||||
|
|
||||||
|
### Server Side
|
||||||
|
|
||||||
|
- **Direct Async/Await**: `const data = await fetch(...)` inside the component.
|
||||||
|
- **Request Memoization**: `fetch` is automatically memoized. For DB calls, use `React.cache`.
|
||||||
|
- **Data Caching**:
|
||||||
|
- `fetch(url, { next: { revalidate: 3600 } })` for ISR.
|
||||||
|
- `fetch(url, { cache: 'no-store' })` for SSR.
|
||||||
|
- Use `unstable_cache` for caching DB results.
|
||||||
|
|
||||||
|
### Client Side
|
||||||
|
|
||||||
|
- Use **SWR** or **TanStack Query** for client-side fetching.
|
||||||
|
- Avoid `useEffect` for data fetching to prevent waterfalls.
|
||||||
|
- Prefetch data using `queryClient.prefetchQuery` in Server Components and hydrate on client.
|
||||||
|
|
||||||
|
## Server Actions
|
||||||
|
|
||||||
|
- Use **Server Actions** (`"use server"`) for mutations (form submissions, button clicks).
|
||||||
|
- Define actions in separate files (e.g. `actions.ts`) for better organization and security.
|
||||||
|
- Use `useFormState` (or `useActionState` in React 19) to handle loading/error states.
|
||||||
|
|
||||||
|
## Optimization
|
||||||
|
|
||||||
|
- **Images**: Use `next/image` for automatic resizing and format conversion.
|
||||||
|
- **Fonts**: Use `next/font` to eliminate layout shift (CLS).
|
||||||
|
- **Scripts**: Use `next/script` with `strategy="afterInteractive"`.
|
||||||
|
- **Streaming**: Use `<Suspense>` to stream parts of the UI (e.g. slow data fetches).
|
||||||
|
|
||||||
|
## Common Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
1. **Fetching in Client Components without cache lib**: Leads to waterfalls.
|
||||||
|
2. **"use client" at top level layout**: Forces the entire tree to be client-side.
|
||||||
|
3. **Prop Drilling**: specialized `Context` should be used sparingly; prefer Composition.
|
||||||
|
4. **Large Barrel Files**: Avoid `index.ts` exporting everything; import directly to aid tree-shaking.
|
||||||
95
.agent/skills/vercel-react-best-practices/SKILL.md
Normal file
95
.agent/skills/vercel-react-best-practices/SKILL.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: vercel-react-best-practices
|
||||||
|
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vercel React Best Practices
|
||||||
|
|
||||||
|
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Reference these guidelines when:
|
||||||
|
|
||||||
|
- Writing new React components or Next.js pages
|
||||||
|
- Implementing data fetching (client or server-side)
|
||||||
|
- Reviewing code for performance issues
|
||||||
|
- Refactoring existing React/Next.js code
|
||||||
|
- Optimizing bundle size or load times
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Prefix |
|
||||||
|
| -------- | ------------------------- | ----------- | ------------ |
|
||||||
|
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||||
|
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||||
|
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||||
|
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||||
|
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||||
|
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||||
|
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||||
|
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Eliminating Waterfalls (CRITICAL)
|
||||||
|
|
||||||
|
- `async-defer-await` - Move await into branches where actually used
|
||||||
|
- `async-parallel` - Use Promise.all() for independent operations
|
||||||
|
- `async-dependencies` - Use better-all for partial dependencies
|
||||||
|
- `async-api-routes` - Start promises early, await late in API routes
|
||||||
|
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||||
|
|
||||||
|
### 2. Bundle Size Optimization (CRITICAL)
|
||||||
|
|
||||||
|
- `bundle-barrel-imports` - Avoid large barrel files; use direct imports
|
||||||
|
- `bundle-large-libraries` - Optimize heavy deps (e.g. framer-motion, lucide)
|
||||||
|
- `bundle-conditional` - Lazy load conditional components
|
||||||
|
- `bundle-route-split` - Split huge page components
|
||||||
|
- `bundle-dynamic-imports` - Use next/dynamic for heavy client components
|
||||||
|
|
||||||
|
### 3. Server-Side Performance (HIGH)
|
||||||
|
|
||||||
|
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||||
|
- `server-cache-next` - Use unstable_cache for data coaching
|
||||||
|
- `server-only-utils` - Mark server-only code with 'server-only' package
|
||||||
|
- `server-component-boundaries` - Keep client components at leaves
|
||||||
|
- `server-image-optimization` - Use next/image with proper sizing
|
||||||
|
|
||||||
|
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||||
|
|
||||||
|
- `client-use-swr` - Use SWR/TanStack Query for client-side data
|
||||||
|
- `client-no-effect-fetch` - Avoid fetching in useEffect without caching libraries
|
||||||
|
- `client-prefetch-link` - Use next/link prefetching
|
||||||
|
- `client-caching-headers` - Respect cache-control headers
|
||||||
|
|
||||||
|
### 5. Re-render Optimization (MEDIUM)
|
||||||
|
|
||||||
|
- `rerender-memo-props` - Memoize complex props
|
||||||
|
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||||
|
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||||
|
- `rerender-context-split` - Split context to avoid wide re-renders
|
||||||
|
|
||||||
|
### 6. Rendering Performance (MEDIUM)
|
||||||
|
|
||||||
|
- `rendering-image-priority` - Priority load LCP images
|
||||||
|
- `rendering-list-virtualization` - Virtualize long lists
|
||||||
|
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||||
|
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||||
|
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||||
|
|
||||||
|
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||||
|
|
||||||
|
- `js-batch-dom-css` - Group CSS changes
|
||||||
|
- `js-index-maps` - Build Map for repeated lookups
|
||||||
|
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||||
|
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||||
|
|
||||||
|
### 8. Advanced Patterns (LOW)
|
||||||
|
|
||||||
|
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||||
|
- `advanced-init-once` - Initialize app once per app load
|
||||||
121
.gitignore
vendored
Normal file
121
.gitignore
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# ========================================
|
||||||
|
# Dependencies (의존성)
|
||||||
|
# ========================================
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Build outputs (빌드 출력물)
|
||||||
|
# ========================================
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Testing (테스트)
|
||||||
|
# ========================================
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Environment files (환경변수 파일)
|
||||||
|
# ========================================
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# IDE & Editor (에디터 설정)
|
||||||
|
# ========================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# OS generated files (OS 생성 파일)
|
||||||
|
# ========================================
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Debug logs (디버그 로그)
|
||||||
|
# ========================================
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# TypeScript (타입스크립트)
|
||||||
|
# ========================================
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Turbopack (터보팩)
|
||||||
|
# ========================================
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Vercel (배포 관련)
|
||||||
|
# ========================================
|
||||||
|
.vercel/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# PWA files (PWA 관련)
|
||||||
|
# ========================================
|
||||||
|
public/sw.js
|
||||||
|
public/workbox-*.js
|
||||||
|
public/worker-*.js
|
||||||
|
public/sw.js.map
|
||||||
|
public/workbox-*.js.map
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Misc (기타)
|
||||||
|
# ========================================
|
||||||
|
*.pem
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Lock files (선택 - 협업 시 주석 해제)
|
||||||
|
# ========================================
|
||||||
|
# package-lock.json
|
||||||
|
# yarn.lock
|
||||||
|
# pnpm-lock.yaml
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Sentry (에러 모니터링)
|
||||||
|
# ========================================
|
||||||
|
.sentryclirc
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Storybook (스토리북)
|
||||||
|
# ========================================
|
||||||
|
storybook-static/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Local files (로컬 전용)
|
||||||
|
# ========================================
|
||||||
|
*.local
|
||||||
|
.cache/
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Playwright MCP
|
||||||
|
.playwright-mcp/
|
||||||
0
.vscode/settings.json
vendored
Normal file
0
.vscode/settings.json
vendored
Normal file
37
README.md
37
README.md
@@ -1 +1,36 @@
|
|||||||
# auto-trade
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|||||||
57
app/auth/callback/route.ts
Normal file
57
app/auth/callback/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [인증 콜백 라우트 핸들러]
|
||||||
|
*
|
||||||
|
* Supabase 인증 이메일(회원가입 확인, 비밀번호 재설정 등)에서
|
||||||
|
* 리다이렉트될 때 호출되는 API 라우트입니다.
|
||||||
|
*
|
||||||
|
* PKCE(Proof Key for Code Exchange) 흐름:
|
||||||
|
* 1. 사용자가 이메일 링크 클릭
|
||||||
|
* 2. Supabase 서버가 토큰 검증 후 이 라우트로 `code` 파라미터와 함께 리다이렉트
|
||||||
|
* 3. 이 라우트에서 `code`를 세션으로 교환
|
||||||
|
* 4. 원래 목적지(next 파라미터)로 리다이렉트
|
||||||
|
*
|
||||||
|
* @param request - Next.js Request 객체
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams, origin } = new URL(request.url);
|
||||||
|
|
||||||
|
// 1. URL에서 code와 next(리다이렉트 목적지) 추출
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const next = searchParams.get("next") ?? "/";
|
||||||
|
|
||||||
|
// 2. code가 있으면 세션으로 교환
|
||||||
|
if (code) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
// 3. 세션 교환 성공 - 원래 목적지로 리다이렉트
|
||||||
|
// next가 절대 URL(http://...)이면 그대로 사용, 아니면 origin + next
|
||||||
|
const forwardedHost = request.headers.get("x-forwarded-host"); // 프록시 환경 대응
|
||||||
|
const isLocalEnv = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
if (isLocalEnv) {
|
||||||
|
// 개발 환경: localhost 사용
|
||||||
|
return NextResponse.redirect(`${origin}${next}`);
|
||||||
|
} else if (forwardedHost) {
|
||||||
|
// 프로덕션 + 프록시: x-forwarded-host 사용
|
||||||
|
return NextResponse.redirect(`https://${forwardedHost}${next}`);
|
||||||
|
} else {
|
||||||
|
// 프로덕션: origin 사용
|
||||||
|
return NextResponse.redirect(`${origin}${next}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 발생 시 로그 출력
|
||||||
|
console.error("Auth callback error:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. code가 없거나 교환 실패 시 에러 페이지로 리다이렉트
|
||||||
|
const errorMessage = encodeURIComponent(
|
||||||
|
"인증 링크가 만료되었거나 유효하지 않습니다.",
|
||||||
|
);
|
||||||
|
return NextResponse.redirect(`${origin}/login?message=${errorMessage}`);
|
||||||
|
}
|
||||||
76
app/auth/confirm/route.ts
Normal file
76
app/auth/confirm/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { AUTH_ERROR_MESSAGES } from "@/features/auth/constants";
|
||||||
|
import { type EmailOtpType } from "@supabase/supabase-js";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 상수 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 비밀번호 재설정 후 이동할 경로 */
|
||||||
|
const RESET_PASSWORD_PATH = "/reset-password";
|
||||||
|
|
||||||
|
/** 인증 실패 시 리다이렉트할 경로 */
|
||||||
|
const LOGIN_PATH = "/login";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 라우트 핸들러
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [이메일 인증 확인 라우트]
|
||||||
|
*
|
||||||
|
* Supabase 이메일 템플릿의 인증 링크를 처리합니다.
|
||||||
|
* - 회원가입 이메일 확인
|
||||||
|
* - 비밀번호 재설정
|
||||||
|
*
|
||||||
|
* @example Supabase 이메일 템플릿 형식
|
||||||
|
* {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// ========== 파라미터 추출 ==========
|
||||||
|
const tokenHash = searchParams.get("token_hash");
|
||||||
|
const type = searchParams.get("type") as EmailOtpType | null;
|
||||||
|
const rawNext = searchParams.get("next");
|
||||||
|
|
||||||
|
// 보안: 외부 URL 리다이렉트 방지 (상대 경로만 허용)
|
||||||
|
const nextPath = rawNext?.startsWith("/") ? rawNext : "/";
|
||||||
|
|
||||||
|
// ========== 토큰 검증 ==========
|
||||||
|
if (!tokenHash || !type) {
|
||||||
|
return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.verifyOtp({
|
||||||
|
type,
|
||||||
|
token_hash: tokenHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("[Auth Confirm] verifyOtp 실패:", error.message);
|
||||||
|
return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 검증 성공 - 적절한 페이지로 리다이렉트 ==========
|
||||||
|
if (type === "recovery") {
|
||||||
|
redirect(RESET_PASSWORD_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(nextPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 메시지와 함께 로그인 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
function redirectWithError(message: string): never {
|
||||||
|
const encodedMessage = encodeURIComponent(message);
|
||||||
|
redirect(`${LOGIN_PATH}?message=${encodedMessage}`);
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
108
app/forgot-password/page.tsx
Normal file
108
app/forgot-password/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import { requestPasswordReset } from "@/features/auth/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 찾기 페이지]
|
||||||
|
*
|
||||||
|
* 사용자가 이메일을 입력하면 비밀번호 재설정 링크를 이메일로 발송합니다.
|
||||||
|
* 로그인/회원가입 페이지와 동일한 디자인 시스템 사용
|
||||||
|
*
|
||||||
|
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||||
|
*/
|
||||||
|
export default async function ForgotPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message: string }>;
|
||||||
|
}) {
|
||||||
|
// URL에서 메시지 파라미터 추출
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 px-4 py-12 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950">
|
||||||
|
{/* ========== 배경 그라디언트 ========== */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-indigo-200/40 via-purple-200/20 to-transparent dark:from-indigo-900/30 dark:via-purple-900/20" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_var(--tw-gradient-stops))] from-pink-200/40 via-purple-200/20 to-transparent dark:from-pink-900/30 dark:via-purple-900/20" />
|
||||||
|
|
||||||
|
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||||
|
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-indigo-400/30 blur-3xl dark:bg-indigo-600/20" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-purple-400/30 blur-3xl delay-700 dark:bg-purple-600/20" />
|
||||||
|
|
||||||
|
{/* ========== 메인 콘텐츠 ========== */}
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{/* 에러/성공 메시지 */}
|
||||||
|
<FormMessage message={message} />
|
||||||
|
|
||||||
|
{/* ========== 비밀번호 찾기 카드 ========== */}
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
{/* 아이콘 */}
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg">
|
||||||
|
<span className="text-4xl">🔑</span>
|
||||||
|
</div>
|
||||||
|
{/* 페이지 제목 */}
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
비밀번호 찾기
|
||||||
|
</CardTitle>
|
||||||
|
{/* 페이지 설명 */}
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
가입하신 이메일 주소를 입력하시면
|
||||||
|
<br />
|
||||||
|
비밀번호 재설정 링크를 보내드립니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* ========== 폼 영역 ========== */}
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 비밀번호 재설정 요청 폼 */}
|
||||||
|
<form className="space-y-5">
|
||||||
|
{/* ========== 이메일 입력 필드 ========== */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-sm font-medium">
|
||||||
|
이메일
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="h-11 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 재설정 링크 발송 버튼 ========== */}
|
||||||
|
<Button
|
||||||
|
formAction={requestPasswordReset}
|
||||||
|
className="h-11 w-full bg-gradient-to-r from-indigo-600 to-purple-600 font-semibold text-white shadow-lg transition-all hover:from-indigo-700 hover:to-purple-700 hover:shadow-xl"
|
||||||
|
>
|
||||||
|
재설정 링크 보내기
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ========== 로그인 페이지로 돌아가기 ========== */}
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
← 로그인 페이지로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
app/globals.css
Normal file
125
app/globals.css
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/layout.tsx
Normal file
34
app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
app/login/page.tsx
Normal file
224
app/login/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { login } from "@/features/auth/actions";
|
||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [로그인 페이지 컴포넌트]
|
||||||
|
*
|
||||||
|
* Modern UI with glassmorphism effect (유리 형태 디자인)
|
||||||
|
* - 투명 배경 + 블러 효과로 깊이감 표현
|
||||||
|
* - 그라디언트 배경으로 생동감 추가
|
||||||
|
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
||||||
|
*
|
||||||
|
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||||
|
*/
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message: string }>;
|
||||||
|
}) {
|
||||||
|
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 px-4 py-12 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950">
|
||||||
|
{/* ========== 배경 그라디언트 레이어 ========== */}
|
||||||
|
{/* 웹 페이지 전체 배경을 그라디언트로 채웁니다 */}
|
||||||
|
{/* 라이트 모드: 부드러운 파스텔 톤 (indigo → purple → pink) */}
|
||||||
|
{/* 다크 모드: 진한 어두운 톤으로 눈부심 방지 */}
|
||||||
|
|
||||||
|
{/* 추가 그라디언트 효과 1: 우상단에서 시작하는 원형 그라디언트 */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-indigo-200/40 via-purple-200/20 to-transparent dark:from-indigo-900/30 dark:via-purple-900/20" />
|
||||||
|
|
||||||
|
{/* 추가 그라디언트 효과 2: 좌하단에서 시작하는 원형 그라디언트 */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_var(--tw-gradient-stops))] from-pink-200/40 via-purple-200/20 to-transparent dark:from-pink-900/30 dark:via-purple-900/20" />
|
||||||
|
|
||||||
|
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||||
|
{/* 부드럽게 깜빡이는 원형 블러로 생동감 표현 */}
|
||||||
|
{/* animate-pulse: 1.5초 주기로 opacity 변화 */}
|
||||||
|
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-indigo-400/30 blur-3xl dark:bg-indigo-600/20" />
|
||||||
|
{/* delay-700: 700ms 지연으로 교차 애니메이션 효과 */}
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-purple-400/30 blur-3xl delay-700 dark:bg-purple-600/20" />
|
||||||
|
|
||||||
|
{/* ========== 메인 콘텐츠 영역 ========== */}
|
||||||
|
{/* z-10: 배경보다 위에 표시 */}
|
||||||
|
{/* animate-in: 페이지 로드 시 fade-in + slide-up 애니메이션 */}
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{/* 에러/성공 메시지 표시 영역 */}
|
||||||
|
{/* URL 파라미터에 message가 있으면 표시됨 */}
|
||||||
|
<FormMessage message={message} />
|
||||||
|
|
||||||
|
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
|
||||||
|
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
|
||||||
|
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
{/* ========== 카드 헤더 영역 ========== */}
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
{/* 아이콘 배경: 그라디언트 원형 */}
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg">
|
||||||
|
<span className="text-4xl">👋</span>
|
||||||
|
</div>
|
||||||
|
{/* 페이지 제목 */}
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
환영합니다!
|
||||||
|
</CardTitle>
|
||||||
|
{/* 페이지 설명 */}
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
서비스 이용을 위해 로그인해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 로그인 폼 - formAction으로 서버 액션(login) 연결 */}
|
||||||
|
<form className="space-y-5">
|
||||||
|
{/* ========== 이메일 입력 필드 ========== */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* htmlFor와 Input의 id 연결로 접근성 향상 */}
|
||||||
|
<Label htmlFor="email" className="text-sm font-medium">
|
||||||
|
이메일
|
||||||
|
</Label>
|
||||||
|
{/* autoComplete="email": 브라우저 자동완성 기능 활성화 */}
|
||||||
|
{/* required: HTML5 필수 입력 검증 */}
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="h-11 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 비밀번호 입력 필드 ========== */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-sm font-medium">
|
||||||
|
비밀번호
|
||||||
|
</Label>
|
||||||
|
{/* pattern: 최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 */}
|
||||||
|
{/* 참고: HTML pattern에서는 <, >, {, } 등 일부 특수문자 사용 시 브라우저 호환성 문제 발생 */}
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
|
||||||
|
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
|
||||||
|
className="h-11 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 유지 & 비밀번호 찾기 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox id="remember" name="remember-me" />
|
||||||
|
<Label
|
||||||
|
htmlFor="remember"
|
||||||
|
className="cursor-pointer text-sm font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
로그인 유지
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{/* 비밀번호 찾기 링크 */}
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
비밀번호 찾기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 버튼 */}
|
||||||
|
<Button
|
||||||
|
formAction={login}
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full bg-gradient-to-r from-indigo-600 to-purple-600 font-semibold shadow-lg transition-all duration-200 hover:from-indigo-700 hover:to-purple-700 hover:shadow-xl"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 회원가입 링크 */}
|
||||||
|
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
계정이 없으신가요?{" "}
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="font-semibold text-indigo-600 transition-colors hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
회원가입 하기
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 소셜 로그인 구분선 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||||
|
또는 소셜 로그인
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소셜 로그인 버튼들 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 border-gray-200 bg-white shadow-sm transition-all duration-200 hover:bg-gray-50 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750"
|
||||||
|
>
|
||||||
|
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 border-none bg-[#FEE500] font-semibold text-[#3C1E1E] shadow-sm transition-all duration-200 hover:bg-[#FDD835] hover:shadow-md"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mr-2 h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm-1.5 14.5h-3v-9h3v9zm3 0h-3v-5h3v5zm0-6h-3v-3h3v3z" />
|
||||||
|
</svg>
|
||||||
|
Kakao
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
app/page.tsx
Normal file
114
app/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { signout } from "@/features/auth/actions";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [메인 페이지 컴포넌트]
|
||||||
|
*
|
||||||
|
* 로그인한 사용자만 접근 가능 (Middleware에서 보호)
|
||||||
|
* - 사용자 정보 표시 (이메일, 프로필 아바타)
|
||||||
|
* - 로그아웃 버튼 제공
|
||||||
|
*/
|
||||||
|
export default async function Home() {
|
||||||
|
// 현재 로그인한 사용자 정보 가져오기
|
||||||
|
// Middleware에서 이미 인증을 확인했으므로 여기서는 user가 항상 존재함
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||||
|
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||||
|
{/* ========== 헤더: 로그인 정보 및 로그아웃 버튼 ========== */}
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{/* 사용자 프로필 표시 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 프로필 아바타: 이메일 첫 글자 표시 */}
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white font-semibold">
|
||||||
|
{user?.email?.charAt(0).toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
|
{/* 이메일 및 로그인 상태 텍스트 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{user?.email || "사용자"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
로그인됨
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 로그아웃 폼 */}
|
||||||
|
{/* formAction: 서버 액션(signout)을 호출하여 로그아웃 처리 */}
|
||||||
|
<form>
|
||||||
|
<Button
|
||||||
|
formAction={signout}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
className="dark:invert"
|
||||||
|
src="/next.svg"
|
||||||
|
alt="Next.js logo"
|
||||||
|
width={100}
|
||||||
|
height={20}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||||
|
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||||
|
To get started, edit the page.tsx file.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||||
|
Looking for a starting point or more instructions? Head over to{" "}
|
||||||
|
<a
|
||||||
|
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Templates
|
||||||
|
</a>{" "}
|
||||||
|
or the{" "}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Learning
|
||||||
|
</a>{" "}
|
||||||
|
center.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||||
|
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="dark:invert"
|
||||||
|
src="/vercel.svg"
|
||||||
|
alt="Vercel logomark"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
/>
|
||||||
|
Deploy Now
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||||
|
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
app/reset-password/page.tsx
Normal file
145
app/reset-password/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import { updatePassword } from "@/features/auth/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 재설정 페이지]
|
||||||
|
*
|
||||||
|
* 이메일 링크를 통해 접근한 사용자만 새 비밀번호를 설정할 수 있습니다.
|
||||||
|
* Supabase recovery 세션 검증으로 직접 URL 접근 차단
|
||||||
|
*
|
||||||
|
* PKCE 플로우:
|
||||||
|
* 1. 사용자가 비밀번호 재설정 이메일의 링크 클릭
|
||||||
|
* 2. Supabase가 토큰 검증 후 이 페이지로 ?code=xxx 파라미터와 함께 리다이렉트
|
||||||
|
* 3. 이 페이지에서 code를 세션으로 교환
|
||||||
|
* 4. 유효한 세션이 있으면 비밀번호 재설정 폼 표시
|
||||||
|
*
|
||||||
|
* @param searchParams - URL 쿼리 파라미터 (code: PKCE 코드, message: 에러/성공 메시지)
|
||||||
|
*/
|
||||||
|
export default async function ResetPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message?: string; code?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// 1. 이메일 링크에서 code 파라미터 확인
|
||||||
|
// Supabase는 이메일 링크를 통해 ?code=xxx 형태로 PKCE code를 전달합니다
|
||||||
|
if (params.code) {
|
||||||
|
// code를 세션으로 교환
|
||||||
|
const { error } = await supabase.auth.exchangeCodeForSession(params.code);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// code 교환 실패 (만료되었거나 유효하지 않음)
|
||||||
|
console.error("Password reset code exchange error:", error.message);
|
||||||
|
const message = encodeURIComponent(
|
||||||
|
"비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
|
||||||
|
);
|
||||||
|
redirect(`/login?message=${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// code 교환 성공 - code 없이 같은 페이지로 리다이렉트
|
||||||
|
// (URL을 깨끗하게 유지하고 세션 쿠키가 설정된 상태로 리로드)
|
||||||
|
redirect("/reset-password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 세션 확인
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
// 3. 유효한 세션이 없으면 로그인 페이지로 리다이렉트
|
||||||
|
if (!user) {
|
||||||
|
const message = encodeURIComponent(
|
||||||
|
"비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
|
||||||
|
);
|
||||||
|
redirect(`/login?message=${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL에서 메시지 파라미터 추출
|
||||||
|
const { message } = params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 px-4 py-12 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950">
|
||||||
|
{/* ========== 배경 그라디언트 ========== */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-indigo-200/40 via-purple-200/20 to-transparent dark:from-indigo-900/30 dark:via-purple-900/20" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_var(--tw-gradient-stops))] from-pink-200/40 via-purple-200/20 to-transparent dark:from-pink-900/30 dark:via-purple-900/20" />
|
||||||
|
|
||||||
|
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||||
|
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-indigo-400/30 blur-3xl dark:bg-indigo-600/20" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-purple-400/30 blur-3xl delay-700 dark:bg-purple-600/20" />
|
||||||
|
|
||||||
|
{/* ========== 메인 콘텐츠 ========== */}
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{/* 에러/성공 메시지 */}
|
||||||
|
{message && <FormMessage message={message} />}
|
||||||
|
|
||||||
|
{/* ========== 비밀번호 재설정 카드 ========== */}
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
{/* 아이콘 */}
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg">
|
||||||
|
<span className="text-4xl">🔐</span>
|
||||||
|
</div>
|
||||||
|
{/* 페이지 제목 */}
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
비밀번호 재설정
|
||||||
|
</CardTitle>
|
||||||
|
{/* 페이지 설명 */}
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
새로운 비밀번호를 입력해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* ========== 폼 영역 ========== */}
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 비밀번호 업데이트 폼 */}
|
||||||
|
<form className="space-y-5">
|
||||||
|
{/* ========== 새 비밀번호 입력 ========== */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-sm font-medium">
|
||||||
|
새 비밀번호
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
|
||||||
|
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
|
||||||
|
className="h-11 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
최소 8자 이상, 대문자, 소문자, 숫자, 특수문자 포함
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 비밀번호 변경 버튼 ========== */}
|
||||||
|
<Button
|
||||||
|
formAction={updatePassword}
|
||||||
|
className="h-11 w-full bg-gradient-to-r from-indigo-600 to-purple-600 font-semibold text-white shadow-lg transition-all hover:from-indigo-700 hover:to-purple-700 hover:shadow-xl"
|
||||||
|
>
|
||||||
|
비밀번호 변경
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
app/signup/page.tsx
Normal file
116
app/signup/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { signup } from "@/features/auth/actions";
|
||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function SignupPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message: string }>;
|
||||||
|
}) {
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 px-4 py-12 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950">
|
||||||
|
{/* 배경 그라데이션 효과 */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-indigo-200/40 via-purple-200/20 to-transparent dark:from-indigo-900/30 dark:via-purple-900/20" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_var(--tw-gradient-stops))] from-pink-200/40 via-purple-200/20 to-transparent dark:from-pink-900/30 dark:via-purple-900/20" />
|
||||||
|
|
||||||
|
{/* 애니메이션 블러 효과 */}
|
||||||
|
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-indigo-400/30 blur-3xl dark:bg-indigo-600/20" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-purple-400/30 blur-3xl delay-700 dark:bg-purple-600/20" />
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{/* 메시지 알림 */}
|
||||||
|
<FormMessage message={message} />
|
||||||
|
|
||||||
|
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||||
|
<CardHeader className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg">
|
||||||
|
<span className="text-4xl">🚀</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
회원가입
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
몇 가지 정보만 입력하면 바로 시작할 수 있습니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form className="space-y-5">
|
||||||
|
{/* 이메일 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-sm font-medium">
|
||||||
|
이메일
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
className="h-11 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-sm font-medium">
|
||||||
|
비밀번호
|
||||||
|
</Label>
|
||||||
|
{/* pattern: 최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 */}
|
||||||
|
{/* 참고: HTML pattern에서는 <, >, {, } 등 일부 특수문자 사용 시 브라우저 호환성 문제 발생 */}
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
pattern="^(?=.*[0-9])(?=.*[!@#$%^&*]).{6,}$"
|
||||||
|
title="비밀번호는 최소 6자 이상, 숫자와 특수문자를 각각 1개 이상 포함해야 합니다."
|
||||||
|
className="h-11 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
최소 6자 이상, 숫자, 특수문자 포함 (한글 가능)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회원가입 버튼 */}
|
||||||
|
<Button
|
||||||
|
formAction={signup}
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full bg-gradient-to-r from-indigo-600 to-purple-600 font-semibold shadow-lg transition-all duration-200 hover:from-indigo-700 hover:to-purple-700 hover:shadow-xl"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
회원가입 완료
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 로그인 링크 */}
|
||||||
|
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
이미 계정이 있으신가요?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="font-semibold text-indigo-600 transition-colors hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
로그인 하러 가기
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
51
components/form-message.tsx
Normal file
51
components/form-message.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [FormMessage 컴포넌트]
|
||||||
|
* - 로그인/회원가입 실패 메시지를 보여줍니다.
|
||||||
|
* - [UX 개선] 메시지가 보인 후, URL에서 ?message=... 부분을 지워서
|
||||||
|
* 새로고침 시 메시지가 다시 뜨지 않도록 합니다.
|
||||||
|
*/
|
||||||
|
export default function FormMessage({ message }: { message: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 메시지가 있고, URL에 message 파라미터가 있다면
|
||||||
|
if (message && searchParams.has("message")) {
|
||||||
|
// 1. 현재 URL 파라미터 복사
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
// 2. message 파라미터 삭제
|
||||||
|
params.delete("message");
|
||||||
|
|
||||||
|
// 3. URL 업데이트 (페이지 새로고침 없이 주소만 변경)
|
||||||
|
// replaceState를 사용하여 히스토리에 남기지 않고 주소창만 깔끔하게 바꿉니다.
|
||||||
|
const newUrl = params.toString()
|
||||||
|
? `${pathname}?${params.toString()}`
|
||||||
|
: pathname;
|
||||||
|
window.history.replaceState(null, "", newUrl);
|
||||||
|
}
|
||||||
|
}, [message, pathname, searchParams]);
|
||||||
|
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
// 에러 메시지인지 성공 메시지인지 대략적으로 판단 (성공 메시지는 보통 '확인', '완료' 등이 포함됨)
|
||||||
|
// 여기서는 간단하게 모든 메시지를 동일한 스타일로 보여주되, 필요하면 분기 가능합니다.
|
||||||
|
const isError = !message.includes("완료") && !message.includes("확인");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-md p-4 text-sm ${
|
||||||
|
isError
|
||||||
|
? "bg-red-50 text-red-700 dark:bg-red-900/50 dark:text-red-200"
|
||||||
|
: "bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/ui/button.tsx
Normal file
64
components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
32
doc-rule.md
Normal file
32
doc-rule.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Antigravity Rules
|
||||||
|
|
||||||
|
This document defines the coding and behavior rules for the Antigravity agent.
|
||||||
|
|
||||||
|
## General Rules
|
||||||
|
|
||||||
|
- **Language**: All output, explanations, and commit messages MUST be in **Korean (한국어)**.
|
||||||
|
- **Tone**: Professional, helpful, and concise.
|
||||||
|
|
||||||
|
## Documentation Rules
|
||||||
|
|
||||||
|
### JSX Comments
|
||||||
|
|
||||||
|
- Mandatory use of section comments in JSX to delineate logical blocks.
|
||||||
|
- Format: `{/* ========== SECTION NAME ========== */}`
|
||||||
|
|
||||||
|
### JSDoc Tags
|
||||||
|
|
||||||
|
- **@see**: Mandatory for function/component documentation. Must include calling file, function/event name, and purpose.
|
||||||
|
- **@author**: Mandatory file-level tag. Use `@author jihoon87.lee`.
|
||||||
|
|
||||||
|
### Inline Comments
|
||||||
|
|
||||||
|
- High density of inline comments required for:
|
||||||
|
- State definitions
|
||||||
|
- Event handlers
|
||||||
|
- Complex logic in JSX
|
||||||
|
- Balance conciseness with clarity.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Follow Project-specific linting and formatting rules.
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
387
features/auth/actions.ts
Normal file
387
features/auth/actions.ts
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import {
|
||||||
|
AUTH_ERROR_MESSAGES,
|
||||||
|
type AuthFormData,
|
||||||
|
type AuthError,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [FormData 추출 헬퍼]
|
||||||
|
*
|
||||||
|
* FormData에서 이메일과 비밀번호를 안전하게 추출합니다.
|
||||||
|
* - 이메일은 trim()으로 공백 제거
|
||||||
|
* - null/undefined 방지를 위해 기본값 "" 사용
|
||||||
|
*
|
||||||
|
* @param formData - HTML form에서 전달된 FormData 객체
|
||||||
|
* @returns AuthFormData - 추출된 이메일과 비밀번호
|
||||||
|
*/
|
||||||
|
function extractAuthData(formData: FormData): AuthFormData {
|
||||||
|
const email = (formData.get("email") as string)?.trim() || "";
|
||||||
|
const password = (formData.get("password") as string) || "";
|
||||||
|
|
||||||
|
return { email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 강도 검증 함수]
|
||||||
|
*
|
||||||
|
* 안전한 비밀번호인지 검사합니다.
|
||||||
|
*
|
||||||
|
* 비밀번호 정책:
|
||||||
|
* - 최소 8자 이상
|
||||||
|
* - 대문자 1개 이상 포함
|
||||||
|
* - 소문자 1개 이상 포함
|
||||||
|
* - 숫자 1개 이상 포함
|
||||||
|
* - 특수문자 1개 이상 포함 (!@#$%^&*(),.?":{}|<> 등)
|
||||||
|
*
|
||||||
|
* @param password - 검증할 비밀번호
|
||||||
|
* @returns AuthError | null - 에러가 있으면 에러 객체, 없으면 null
|
||||||
|
*/
|
||||||
|
function validatePassword(password: string): AuthError | null {
|
||||||
|
// 1. 최소 길이 체크 (6자 이상)
|
||||||
|
if (password.length < 6) {
|
||||||
|
return {
|
||||||
|
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT,
|
||||||
|
type: "validation",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 숫자 포함 여부
|
||||||
|
if (!/[0-9]/.test(password)) {
|
||||||
|
return {
|
||||||
|
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
|
||||||
|
type: "validation",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 특수문자 포함 여부
|
||||||
|
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
||||||
|
return {
|
||||||
|
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
|
||||||
|
type: "validation",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 검증 통과
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [입력값 검증 함수]
|
||||||
|
*
|
||||||
|
* 사용자가 입력한 이메일과 비밀번호의 유효성을 검사합니다.
|
||||||
|
*
|
||||||
|
* 검증 항목:
|
||||||
|
* 1. 빈 값 체크 - 이메일 또는 비밀번호가 비어있는지 확인
|
||||||
|
* 2. 이메일 형식 - '@' 포함 여부로 간단한 형식 검증
|
||||||
|
* 3. 비밀번호 길이 - 최소 6자 이상인지 확인 (Supabase 기본 요구사항)
|
||||||
|
*
|
||||||
|
* @param email - 사용자 이메일
|
||||||
|
* @param password - 사용자 비밀번호
|
||||||
|
* @returns AuthError | null - 에러가 있으면 에러 객체, 없으면 null
|
||||||
|
*/
|
||||||
|
function validateAuthInput(email: string, password: string): AuthError | null {
|
||||||
|
// 1. 빈 값 체크
|
||||||
|
if (!email || !password) {
|
||||||
|
return {
|
||||||
|
message: AUTH_ERROR_MESSAGES.EMPTY_FIELDS,
|
||||||
|
type: "validation",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 이메일 형식 체크 (간단한 @ 포함 여부 확인)
|
||||||
|
if (!email.includes("@")) {
|
||||||
|
return {
|
||||||
|
message: AUTH_ERROR_MESSAGES.INVALID_EMAIL,
|
||||||
|
type: "validation",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 비밀번호 강도 체크
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (passwordValidation) {
|
||||||
|
return passwordValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 검증 통과
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [에러 메시지 번역 헬퍼]
|
||||||
|
*
|
||||||
|
* Supabase의 영문 에러 메시지를 사용자 친화적인 한글로 변환합니다.
|
||||||
|
*
|
||||||
|
* @param error - Supabase에서 받은 에러 메시지
|
||||||
|
* @returns string - 한글로 번역된 에러 메시지
|
||||||
|
*/
|
||||||
|
function getErrorMessage(error: string): string {
|
||||||
|
// Supabase 에러 메시지 패턴 매칭
|
||||||
|
if (error.includes("Invalid login credentials")) {
|
||||||
|
return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS;
|
||||||
|
}
|
||||||
|
if (error.includes("User already registered")) {
|
||||||
|
return AUTH_ERROR_MESSAGES.USER_EXISTS;
|
||||||
|
}
|
||||||
|
if (error.includes("Password should be at least")) {
|
||||||
|
return AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT;
|
||||||
|
}
|
||||||
|
if (error.includes("Email not confirmed")) {
|
||||||
|
return AUTH_ERROR_MESSAGES.EMAIL_NOT_CONFIRMED;
|
||||||
|
}
|
||||||
|
if (error.toLowerCase().includes("email rate limit exceeded")) {
|
||||||
|
return AUTH_ERROR_MESSAGES.EMAIL_RATE_LIMIT_DETAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알 수 없는 에러는 기본 메시지 반환
|
||||||
|
return AUTH_ERROR_MESSAGES.DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Server Actions (서버 액션)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [로그인 액션]
|
||||||
|
*
|
||||||
|
* 사용자가 입력한 이메일/비밀번호로 로그인을 시도합니다.
|
||||||
|
*
|
||||||
|
* 처리 과정:
|
||||||
|
* 1. FormData에서 이메일/비밀번호 추출
|
||||||
|
* 2. 입력값 유효성 검증 (빈 값, 이메일 형식, 비밀번호 길이)
|
||||||
|
* 3. Supabase Auth를 통한 로그인 시도
|
||||||
|
* 4. 성공 시 메인 페이지로 리다이렉트
|
||||||
|
* 5. 실패 시 에러 메시지와 함께 로그인 페이지로 리다이렉트
|
||||||
|
*
|
||||||
|
* @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함)
|
||||||
|
*/
|
||||||
|
export async function login(formData: FormData) {
|
||||||
|
// 1. FormData에서 이메일/비밀번호 추출
|
||||||
|
const { email, password } = extractAuthData(formData);
|
||||||
|
|
||||||
|
// 2. 입력값 유효성 검증
|
||||||
|
const validationError = validateAuthInput(email, password);
|
||||||
|
if (validationError) {
|
||||||
|
return redirect(
|
||||||
|
`/login?message=${encodeURIComponent(validationError.message)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Supabase 클라이언트 생성 및 로그인 시도
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 로그인 실패 시 에러 처리
|
||||||
|
if (error) {
|
||||||
|
const message = getErrorMessage(error.message);
|
||||||
|
return redirect(`/login?message=${encodeURIComponent(message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 로그인 성공 - 캐시 무효화 및 메인 페이지로 리다이렉트
|
||||||
|
// revalidatePath: Next.js 캐시를 무효화하여 최신 인증 상태 반영
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [회원가입 액션]
|
||||||
|
*
|
||||||
|
* 새로운 사용자를 등록합니다.
|
||||||
|
*
|
||||||
|
* 처리 과정:
|
||||||
|
* 1. FormData에서 이메일/비밀번호 추출
|
||||||
|
* 2. 입력값 유효성 검증
|
||||||
|
* 3. Supabase Auth를 통한 회원가입 시도
|
||||||
|
* 4. 이메일 인증 리다이렉트 URL 설정 (확인 링크 클릭 시 돌아올 주소)
|
||||||
|
* 5-1. 즉시 세션 생성 시: 메인 페이지로 리다이렉트 (바로 로그인됨)
|
||||||
|
* 5-2. 이메일 인증 필요 시: 로그인 페이지로 리다이렉트 (안내 메시지 포함)
|
||||||
|
*
|
||||||
|
* @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함)
|
||||||
|
*/
|
||||||
|
export async function signup(formData: FormData) {
|
||||||
|
// 1. FormData에서 이메일/비밀번호 추출
|
||||||
|
const { email, password } = extractAuthData(formData);
|
||||||
|
|
||||||
|
// 2. 입력값 유효성 검증
|
||||||
|
const validationError = validateAuthInput(email, password);
|
||||||
|
if (validationError) {
|
||||||
|
return redirect(
|
||||||
|
`/signup?message=${encodeURIComponent(validationError.message)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Supabase 클라이언트 생성 및 회원가입 시도
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
// 이메일 인증 완료 후 리다이렉트될 URL
|
||||||
|
// 로컬 개발 환경: http://localhost:3001/auth/callback
|
||||||
|
// 프로덕션: NEXT_PUBLIC_BASE_URL 환경 변수에 설정된 주소
|
||||||
|
emailRedirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 회원가입 실패 시 에러 처리
|
||||||
|
if (error) {
|
||||||
|
const message = getErrorMessage(error.message);
|
||||||
|
return redirect(`/signup?message=${encodeURIComponent(message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 회원가입 성공 - 세션 생성 여부에 따라 분기 처리
|
||||||
|
if (data.session) {
|
||||||
|
// 5-1. 즉시 세션이 생성된 경우 (이메일 인증 불필요)
|
||||||
|
// → 바로 메인 페이지로 이동
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5-2. 이메일 인증이 필요한 경우
|
||||||
|
// → 로그인 페이지로 이동 + 안내 메시지
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect(
|
||||||
|
`/login?message=${encodeURIComponent("회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해 주세요.")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [로그아웃 액션]
|
||||||
|
*
|
||||||
|
* 현재 세션을 종료하고 로그인 페이지로 이동합니다.
|
||||||
|
*
|
||||||
|
* 처리 과정:
|
||||||
|
* 1. Supabase Auth 세션 종료
|
||||||
|
* 2. 캐시 무효화하여 인증 상태 갱신
|
||||||
|
* 3. 로그인 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
export async function signout() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// 1. Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제)
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
|
||||||
|
// 2. Next.js 캐시 무효화
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
|
||||||
|
// 3. 로그인 페이지로 리다이렉트
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 재설정 요청 액션]
|
||||||
|
*
|
||||||
|
* 사용자 이메일로 비밀번호 재설정 링크를 발송합니다.
|
||||||
|
*
|
||||||
|
* 보안 고려사항:
|
||||||
|
* - 이메일 존재 여부와 관계없이 동일한 성공 메시지 표시
|
||||||
|
* - 이메일 열거 공격(Email Enumeration) 방지
|
||||||
|
*
|
||||||
|
* 처리 과정:
|
||||||
|
* 1. FormData에서 이메일 추출
|
||||||
|
* 2. 이메일 형식 검증
|
||||||
|
* 3. Supabase를 통한 재설정 링크 발송
|
||||||
|
* 4. 성공 메시지와 함께 로그인 페이지로 리다이렉트
|
||||||
|
*
|
||||||
|
* @param formData - 이메일이 포함된 FormData
|
||||||
|
*/
|
||||||
|
export async function requestPasswordReset(formData: FormData) {
|
||||||
|
// 1. FormData에서 이메일 추출
|
||||||
|
const email = (formData.get("email") as string)?.trim() || "";
|
||||||
|
|
||||||
|
// 2. 이메일 검증
|
||||||
|
if (!email) {
|
||||||
|
return redirect(
|
||||||
|
`/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.EMPTY_EMAIL)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.includes("@")) {
|
||||||
|
return redirect(
|
||||||
|
`/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.INVALID_EMAIL)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Supabase를 통한 재설정 링크 발송
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/reset-password`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 에러 처리
|
||||||
|
if (error) {
|
||||||
|
console.error("Password reset error:", error.message);
|
||||||
|
|
||||||
|
// Rate limit 오류는 사용자에게 알려줌 (보안과 무관)
|
||||||
|
if (error.message.toLowerCase().includes("rate limit")) {
|
||||||
|
return redirect(
|
||||||
|
`/forgot-password?message=${encodeURIComponent(
|
||||||
|
"이메일 발송 제한을 초과했습니다. Supabase 무료 플랜은 시간당 이메일 발송 횟수가 제한됩니다. 약 1시간 후에 다시 시도해 주세요.",
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외 에러는 보안상 동일한 메시지 표시
|
||||||
|
// (이메일 존재 여부를 외부에 노출하지 않음)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 성공 메시지 표시
|
||||||
|
redirect(
|
||||||
|
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SENT)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 업데이트 액션]
|
||||||
|
*
|
||||||
|
* 비밀번호 재설정 링크를 통해 접근한 사용자의 비밀번호를 업데이트합니다.
|
||||||
|
*
|
||||||
|
* 처리 과정:
|
||||||
|
* 1. FormData에서 새 비밀번호 추출
|
||||||
|
* 2. 비밀번호 길이 검증
|
||||||
|
* 3. Supabase를 통한 비밀번호 업데이트
|
||||||
|
* 4. 성공 시 로그인 페이지로 리다이렉트
|
||||||
|
*
|
||||||
|
* @param formData - 새 비밀번호가 포함된 FormData
|
||||||
|
*/
|
||||||
|
export async function updatePassword(formData: FormData) {
|
||||||
|
// 1. FormData에서 새 비밀번호 추출
|
||||||
|
const password = (formData.get("password") as string) || "";
|
||||||
|
|
||||||
|
// 2. 비밀번호 강도 검증
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (passwordValidation) {
|
||||||
|
return redirect(
|
||||||
|
`/reset-password?message=${encodeURIComponent(passwordValidation.message)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Supabase를 통한 비밀번호 업데이트
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.updateUser({
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 에러 처리
|
||||||
|
if (error) {
|
||||||
|
const message = getErrorMessage(error.message);
|
||||||
|
return redirect(`/reset-password?message=${encodeURIComponent(message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 성공 - 캐시 무효화 및 로그인 페이지로 리다이렉트
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect(
|
||||||
|
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
114
features/auth/constants.ts
Normal file
114
features/auth/constants.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* [인증 관련 상수 정의]
|
||||||
|
*
|
||||||
|
* 인증 모듈 전체에서 공통으로 사용하는 상수들을 정의합니다.
|
||||||
|
* - 에러 메시지
|
||||||
|
* - 라우트 경로
|
||||||
|
* - 검증 규칙
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 에러 메시지 상수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 에러 메시지 매핑
|
||||||
|
* Supabase의 영문 에러를 한글로 변환하기 위한 매핑 테이블
|
||||||
|
*/
|
||||||
|
export const AUTH_ERROR_MESSAGES = {
|
||||||
|
// === 로그인/회원가입 관련 ===
|
||||||
|
INVALID_CREDENTIALS: "이메일 또는 비밀번호가 일치하지 않습니다.",
|
||||||
|
USER_EXISTS: "이미 가입된 이메일 주소입니다.",
|
||||||
|
EMAIL_NOT_CONFIRMED: "이메일 인증이 완료되지 않았습니다.",
|
||||||
|
|
||||||
|
// === 입력값 검증 ===
|
||||||
|
EMPTY_FIELDS: "이메일과 비밀번호를 모두 입력해 주세요.",
|
||||||
|
EMPTY_EMAIL: "이메일을 입력해 주세요.",
|
||||||
|
INVALID_EMAIL: "올바른 이메일 형식이 아닙니다.",
|
||||||
|
|
||||||
|
// === 비밀번호 관련 ===
|
||||||
|
PASSWORD_TOO_SHORT: "비밀번호는 최소 6자 이상이어야 합니다.",
|
||||||
|
PASSWORD_TOO_WEAK:
|
||||||
|
"비밀번호는 최소 6자 이상, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.",
|
||||||
|
PASSWORD_SAME_AS_OLD: "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
|
||||||
|
|
||||||
|
// === 비밀번호 재설정 ===
|
||||||
|
PASSWORD_RESET_SENT: "비밀번호 재설정 링크를 이메일로 발송했습니다.",
|
||||||
|
PASSWORD_RESET_SUCCESS: "비밀번호가 성공적으로 변경되었습니다.",
|
||||||
|
PASSWORD_RESET_FAILED: "비밀번호 변경에 실패했습니다.",
|
||||||
|
|
||||||
|
// === 인증 링크 ===
|
||||||
|
INVALID_AUTH_LINK: "인증 링크가 만료되었거나 유효하지 않습니다.",
|
||||||
|
|
||||||
|
// === Rate Limit ===
|
||||||
|
EMAIL_RATE_LIMIT:
|
||||||
|
"이메일 발송 제한을 초과했습니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
EMAIL_RATE_LIMIT_DETAILED:
|
||||||
|
"이메일 발송 제한을 초과했습니다. Supabase 무료 플랜은 시간당 이메일 발송 횟수가 제한됩니다. 약 1시간 후에 다시 시도해 주세요.",
|
||||||
|
|
||||||
|
// === 기타 ===
|
||||||
|
DEFAULT: "요청을 처리하는 중 오류가 발생했습니다.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 라우트 경로 상수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 관련 라우트 경로
|
||||||
|
*/
|
||||||
|
export const AUTH_ROUTES = {
|
||||||
|
LOGIN: "/login",
|
||||||
|
SIGNUP: "/signup",
|
||||||
|
FORGOT_PASSWORD: "/forgot-password",
|
||||||
|
RESET_PASSWORD: "/reset-password",
|
||||||
|
AUTH_CONFIRM: "/auth/confirm",
|
||||||
|
AUTH_CALLBACK: "/auth/callback",
|
||||||
|
HOME: "/",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 없이 접근 가능한 페이지 목록
|
||||||
|
* 미들웨어에서 라우트 보호에 사용
|
||||||
|
*/
|
||||||
|
export const PUBLIC_AUTH_PAGES = [
|
||||||
|
AUTH_ROUTES.LOGIN,
|
||||||
|
AUTH_ROUTES.SIGNUP,
|
||||||
|
AUTH_ROUTES.FORGOT_PASSWORD,
|
||||||
|
AUTH_ROUTES.RESET_PASSWORD,
|
||||||
|
AUTH_ROUTES.AUTH_CONFIRM,
|
||||||
|
AUTH_ROUTES.AUTH_CALLBACK,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 검증 규칙 상수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 검증 규칙
|
||||||
|
*/
|
||||||
|
export const PASSWORD_RULES = {
|
||||||
|
MIN_LENGTH: 6,
|
||||||
|
REQUIRE_NUMBER: true,
|
||||||
|
REQUIRE_SPECIAL_CHAR: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 정의
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 폼 데이터 타입
|
||||||
|
*/
|
||||||
|
export type AuthFormData = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 에러 타입
|
||||||
|
*/
|
||||||
|
export type AuthError = {
|
||||||
|
message: string;
|
||||||
|
type: "validation" | "auth" | "unknown";
|
||||||
|
};
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
33
middleware.ts
Normal file
33
middleware.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
import { updateSession } from "@/utils/supabase/middleware";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Next.js 미들웨어 진입점]
|
||||||
|
*
|
||||||
|
* 웹사이트의 모든 요청은 이 함수를 가장 먼저 거쳐갑니다.
|
||||||
|
* 여기서 로그인 여부를 체크하거나 세션을 갱신합니다.
|
||||||
|
*/
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
// 방금 만든 updateSession 함수를 호출하여 쿠키/세션을 관리합니다.
|
||||||
|
return await updateSession(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [미들웨어 설정]
|
||||||
|
*
|
||||||
|
* 미들웨어가 '어떤 경로'에서 실행될지, '어떤 경로는 무시할지' 정하는 규칙입니다.
|
||||||
|
*/
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* 아래 정규식(Regex)은 다음 파일들을 제외(exclude)하고 모든 요청을 미들웨어로 보냅니다:
|
||||||
|
* - _next/static (이미 빌드된 정적 파일들)
|
||||||
|
* - _next/image (이미지 최적화 API)
|
||||||
|
* - favicon.ico (파비콘 아이콘)
|
||||||
|
* - .svg, .png, .jpg 등 이미지 파일들
|
||||||
|
*
|
||||||
|
* 즉, html 페이지 요청이나 데이터 요청에만 미들웨어가 작동하도록 하여 성능을 최적화합니다.
|
||||||
|
*/
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
8
next.config.ts
Normal file
8
next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
reactCompiler: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7143
package-lock.json
generated
Normal file
7143
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "auto-trade",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@supabase/ssr": "^0.8.0",
|
||||||
|
"@supabase/supabase-js": "^2.93.3",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
BIN
temp-app/node_modules/@tailwindcss/oxide-win32-x64-msvc/tailwindcss-oxide.win32-x64-msvc.node
generated
vendored
Normal file
BIN
temp-app/node_modules/@tailwindcss/oxide-win32-x64-msvc/tailwindcss-oxide.win32-x64-msvc.node
generated
vendored
Normal file
Binary file not shown.
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
19
utils/supabase/client.ts
Normal file
19
utils/supabase/client.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [클라이언트 컴포넌트용 Supabase 클라이언트 생성 함수]
|
||||||
|
*
|
||||||
|
* 이 함수는 브라우저(Front-end)에서 동작하는 컴포넌트(useEffect, onClick 등)에서 사용합니다.
|
||||||
|
* @supabase/ssr 패키지의 createBrowserClient를 사용하면 알아서 브라우저 쿠키를 관리해줍니다.
|
||||||
|
*/
|
||||||
|
export function createClient() {
|
||||||
|
/**
|
||||||
|
* createBrowserClient: 브라우저 환경에 최적화된 싱글톤(Singleton) 클라이언트를 반환합니다.
|
||||||
|
* - 브라우저는 보안상 'service_role' 같은 비밀 키를 절대 사용하면 안 됩니다.
|
||||||
|
* - 반드시 'NEXT_PUBLIC_'으로 시작하는 URL과 ANON KEY만 사용해야 합니다.
|
||||||
|
*/
|
||||||
|
return createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
);
|
||||||
|
}
|
||||||
67
utils/supabase/middleware.ts
Normal file
67
utils/supabase/middleware.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { PUBLIC_AUTH_PAGES, AUTH_ROUTES } from "@/features/auth/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [미들웨어용 세션 업데이트 및 라우트 보호 함수]
|
||||||
|
*
|
||||||
|
* 모든 페이지 요청이 서버에 도달하기 전에 가장 먼저 실행됩니다.
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* 1. 만료된 로그인 토큰 자동 갱신 (Refresh)
|
||||||
|
* 2. 인증 상태에 따른 라우트 보호
|
||||||
|
*/
|
||||||
|
export async function updateSession(request: NextRequest) {
|
||||||
|
// ========== 초기 응답 생성 ==========
|
||||||
|
let supabaseResponse = NextResponse.next({ request });
|
||||||
|
|
||||||
|
// ========== Supabase 클라이언트 생성 ==========
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
// 응답 객체 재생성
|
||||||
|
supabaseResponse = NextResponse.next({ request });
|
||||||
|
// 응답에 쿠키 설정
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
supabaseResponse.cookies.set(name, value, options),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== 사용자 인증 정보 확인 ==========
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const isAuthPage = PUBLIC_AUTH_PAGES.some((page) =>
|
||||||
|
pathname.startsWith(page),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== 라우트 보호 ==========
|
||||||
|
|
||||||
|
// 비로그인 사용자 → 보호된 페이지 접근 시 로그인으로 리다이렉트
|
||||||
|
if (!user && !isAuthPage) {
|
||||||
|
return NextResponse.redirect(new URL(AUTH_ROUTES.LOGIN, request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 사용자 → 인증 페이지 접근 시 홈으로 리다이렉트
|
||||||
|
// 단, 비밀번호 재설정 페이지는 예외
|
||||||
|
if (user && isAuthPage && pathname !== AUTH_ROUTES.RESET_PASSWORD) {
|
||||||
|
return NextResponse.redirect(new URL(AUTH_ROUTES.HOME, request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabaseResponse;
|
||||||
|
}
|
||||||
47
utils/supabase/server.ts
Normal file
47
utils/supabase/server.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [서버 컴포넌트용 Supabase 클라이언트 생성 함수]
|
||||||
|
*
|
||||||
|
* 이 함수는 Next.js의 SSR(서버 사이드 렌더링) 환경에서 Supabase에 접근할 때 사용합니다.
|
||||||
|
* 서버 컴포넌트, 서버 액션(Server Actions), 라우트 핸들러(Route Handlers)에서 호출됩니다.
|
||||||
|
*/
|
||||||
|
export async function createClient() {
|
||||||
|
// Next.js의 쿠키 저장소에 접근합니다. (await 필수)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createServerClient: 서버 환경에서 안전하게 Supabase 클라이언트를 생성합니다.
|
||||||
|
* 첫 번째 인자: Supabase 프로젝트 URL
|
||||||
|
* 두 번째 인자: Supabase 익명(Anon) 키 (공개되어도 안전한 키)
|
||||||
|
* 세 번째 인자: 쿠키 제어 옵션
|
||||||
|
*/
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
// 1. Supabase가 쿠키를 읽어야 할 때 실행됩니다.
|
||||||
|
// 현재 요청(Request)에 있는 모든 쿠키를 가져와서 Supabase에 전달합니다.
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. Supabase가 쿠키를 새로 써야 할 때(로그인, 로그아웃, 토큰 갱신 등) 실행됩니다.
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
// Supabase가 요청한 쿠키들을 하나씩 브라우저에 저장하도록 설정합니다.
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// [주의] 이 부분은 '서버 컴포넌트'에서 쿠키를 쓰려고 할 때 발생하는 에러를 무시하기 위함입니다.
|
||||||
|
// Next.js 규칙상 '서버 컴포넌트'는 렌더링 중에 쿠키를 직접 쓸 수 없습니다.
|
||||||
|
// 대신 미들웨어(middleware)가 토큰 갱신을 담당하므로 여기서는 에러를 무시해도 안전합니다.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user