diff --git a/.env.example b/.env.example index c6370f7..4675caf 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,23 @@ # Discord Bot Configuration DISCORD_TOKEN=your_discord_bot_token DISCORD_CLIENT_ID=your_discord_client_id +DISCORD_CLIENT_SECRET=your_discord_client_secret DISCORD_GUILD_ID=your_discord_guild_id ADMIN_DISCORD_IDS=discord_id_1,discord_id_2 # Supabase Configuration -SUPABASE_URL=https://your-project.supabase.co -SUPABASE_ANON_KEY=your_supabase_anon_key +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key SUPABASE_SERVICE_KEY=your_supabase_service_key DATABASE_URL=postgresql://user:password@host:5432/database -# Web Authentication -JWT_SECRET=your_jwt_secret_key_min_32_chars -SESSION_EXPIRY=7d - -# Email (Resend) -RESEND_API_KEY=your_resend_api_key -EMAIL_FROM=noreply@yourdomain.com - # Application APP_URL=http://localhost:3000 NODE_ENV=development +# OpenAI (AI 기능용) +OPENAI_API_KEY=your_openai_api_key + # Study Configuration (optional, can be set via admin commands) STUDY_START_DATE=2024-01-01 TOTAL_ROUNDS=10 diff --git a/CLAUDE.md b/CLAUDE.md index a336b92..87c978f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ ``` packages/ -├── bot/ # Discord 봇 (discord.js v14) → Railway 배포 -├── web/ # Next.js 15 대시보드 → Vercel 배포 +├── bot/ # Discord 봇 (discord.js v14) → AWS EC2 배포 +├── web/ # Next.js 14 대시보드 → Vercel 배포 └── shared/ # 공유 코드 (DB 스키마, 타입, 유틸) ``` @@ -17,13 +17,13 @@ packages/ | 영역 | 기술 | |------|------| -| Runtime | Node.js 20 LTS, TypeScript 5.x | -| Bot | discord.js v14, feedsmith (RSS), pg-boss (job queue) | -| Web | Next.js 15 App Router, shadcn/ui, Tailwind CSS v4 | -| DB | Supabase PostgreSQL + Drizzle ORM + pgvector | -| Auth | Supabase Auth (Discord OAuth) | -| AI | OpenAI GPT-4o-mini (요약/키워드), text-embedding-3-small (벡터) | -| 배포 | Railway (bot), Vercel (web), Supabase (DB) | +| Runtime | Node.js 22, TypeScript 5.x | +| Bot | discord.js v14, rss-parser (→ feedsmith 예정), node-cron (→ pg-boss 예정) | +| Web | Next.js 14 App Router, React 18, shadcn/ui, Tailwind CSS v3 | +| DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) | +| Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` | +| AI | OpenAI GPT-4o-mini + text-embedding-3-small (예정) | +| 배포 | AWS EC2 (bot), Vercel (web), Supabase (DB + Auth) | ## 개발 명령어 @@ -33,11 +33,14 @@ pnpm dev:bot # 봇 로컬 실행 pnpm dev:web # 웹 로컬 실행 (localhost:3000) # 빌드/테스트 -pnpm build # 전체 빌드 +pnpm build # 전체 빌드 (shared → bot/web) pnpm test # 전체 테스트 pnpm lint # 전체 린트 pnpm typecheck # 타입 체크 +# shared 패키지 변경 시 +pnpm --filter @blog-study/shared build # 반드시 리빌드 + # 봇 전용 pnpm --filter @blog-study/bot deploy-commands # 슬래시 커맨드 등록 pnpm --filter @blog-study/bot init-rounds # 회차 초기화 @@ -58,13 +61,24 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화 | 파일 | 설명 | |------|------| | `packages/shared/src/db/schema.ts` | 전체 DB 스키마 (Drizzle) | -| `packages/shared/src/types/index.ts` | 공유 enum/타입 | +| `packages/shared/src/db/index.ts` | DB 연결 (Transaction Pooler, `prepare: false`) | +| `packages/web/src/lib/supabase/client.ts` | 브라우저용 Supabase 클라이언트 | +| `packages/web/src/lib/supabase/server.ts` | 서버용 Supabase 클라이언트 (cookies) | +| `packages/web/src/lib/supabase/middleware.ts` | 미들웨어용 세션 갱신 | +| `packages/web/middleware.ts` | 라우트 보호 (protected/admin/auth) | +| `packages/web/src/app/auth/callback/route.ts` | OAuth 콜백 | +| `packages/web/src/lib/admin.ts` | 관리자 권한 체크 | +| `packages/web/src/app/` | Next.js 페이지/라우트 | | `packages/bot/src/bot.ts` | Discord 클라이언트 초기화 | | `packages/bot/src/commands/index.ts` | 커맨드 레지스트리 | -| `packages/bot/src/services/` | 비즈니스 로직 서비스 | -| `packages/bot/src/schedulers/` | 크론 작업 (RSS, 출석, 벌금) | -| `packages/web/src/app/` | Next.js 페이지/라우트 | -| `packages/web/middleware.ts` | 인증 미들웨어 | + +## 인증 구조 + +- **웹**: Supabase Auth → Discord OAuth → `user.identities[].id` (Discord ID) → `members.discord_id` 매칭 +- **봇**: `service_role` key로 직접 DB 접근, `interaction.user.id`로 Discord ID 획득 +- **미들웨어**: `@supabase/ssr`의 `updateSession()`으로 세션 자동 갱신 +- **관리자**: `ADMIN_DISCORD_IDS` 환경변수로 Discord ID 기반 권한 체크 +- **API Route**: `createClient()` → `getUser()` → `identities` 배열에서 Discord ID 추출 ## UI 디자인 시스템 @@ -108,16 +122,20 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화 ## 환경 변수 `.env.example` 참조. 필수: -- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_GUILD_ID` -- `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`, `DATABASE_URL` -- `OPENAI_API_KEY` (AI 기능용) +- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` (Supabase) +- `SUPABASE_SERVICE_KEY`, `DATABASE_URL` (DB) +- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_GUILD_ID` +- `ADMIN_DISCORD_IDS` (관리자 Discord ID, 쉼표 구분) +- `OPENAI_API_KEY` (AI 기능용, 예정) + +**주의**: `packages/web/.env.local`에도 동일 환경변수 필요 (Next.js는 패키지 디렉토리 기준) ## 문서 | 문서 | 설명 | |------|------| -| `docs/ARCHITECTURE.md` | 시스템 아키텍처 | -| `docs/TECH-DECISIONS.md` | 기술 선택 근거 | +| `docs/ARCHITECTURE.md` | 시스템 아키텍처 (Mermaid 다이어그램) | +| `docs/TECH-DECISIONS.md` | 기술 선택 근거 (ADR) | | `docs/UI-DESIGN-SYSTEM.md` | UI 디자인 시스템 스펙 | | `docs/DEVELOPMENT.md` | 개발 환경 설정 | | `docs/CHECKLIST.md` | 구현 체크리스트 | diff --git a/packages/shared/src/config/env.ts b/packages/shared/src/config/env.ts index dd0299f..57ca4a8 100644 --- a/packages/shared/src/config/env.ts +++ b/packages/shared/src/config/env.ts @@ -24,18 +24,6 @@ const supabaseEnvSchema = z.object({ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), }); -// Web authentication schema -const authEnvSchema = z.object({ - JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'), - SESSION_EXPIRY: z.string().default('7d'), -}); - -// Email configuration schema -const emailEnvSchema = z.object({ - RESEND_API_KEY: z.string().min(1, 'RESEND_API_KEY is required'), - EMAIL_FROM: z.string().email('EMAIL_FROM must be a valid email'), -}); - // Application configuration schema const appEnvSchema = z.object({ APP_URL: z.string().url('APP_URL must be a valid URL').default('http://localhost:3000'), @@ -55,8 +43,6 @@ const studyEnvSchema = z.object({ const envSchema = z.object({ ...discordEnvSchema.shape, ...supabaseEnvSchema.shape, - ...authEnvSchema.shape, - ...emailEnvSchema.shape, ...appEnvSchema.shape, ...studyEnvSchema.shape, }); @@ -72,8 +58,6 @@ const botEnvSchema = z.object({ // Partial schema for web-only usage const webEnvSchema = z.object({ ...supabaseEnvSchema.shape, - ...authEnvSchema.shape, - ...emailEnvSchema.shape, ...appEnvSchema.shape, }); diff --git a/packages/shared/src/db/index.ts b/packages/shared/src/db/index.ts index 93552d5..52c3a17 100644 --- a/packages/shared/src/db/index.ts +++ b/packages/shared/src/db/index.ts @@ -28,6 +28,7 @@ export function getDb(connectionString?: string) { max: 10, // Maximum number of connections idle_timeout: 20, // Close idle connections after 20 seconds connect_timeout: 10, // Connection timeout in seconds + prepare: false, // Transaction pooler (Supavisor)는 PREPARE 미지원 }); db = drizzle(client, { schema }); diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 2fe80ca..bad4889 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -94,35 +94,7 @@ export const members = pgTable( }) ); -/** - * 웹 사용자 (Users) - * 이메일로 웹사이트에 가입한 사용자 - */ -export const users = pgTable('users', { - id: uuid('id').primaryKey().defaultRandom(), - email: varchar('email', { length: 255 }).notNull().unique(), - passwordHash: varchar('password_hash', { length: 255 }).notNull(), - emailVerified: boolean('email_verified').default(false), - emailVerifyToken: varchar('email_verify_token', { length: 255 }), - emailVerifyExpires: timestamp('email_verify_expires', { withTimezone: true }), - memberId: uuid('member_id').references(() => members.id), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), -}); - -/** - * 세션 토큰 (Sessions) - * 웹 로그인 세션 관리 - */ -export const sessions = pgTable('sessions', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - token: varchar('token', { length: 255 }).notNull().unique(), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), -}); +// users, sessions 테이블 제거됨 — Supabase Auth가 대체 /** * 스터디 회차 (Rounds) @@ -276,29 +248,10 @@ export const config = pgTable('config', { // Relations // ============================================ -export const membersRelations = relations(members, ({ many, one }) => ({ +export const membersRelations = relations(members, ({ many }) => ({ posts: many(posts), attendance: many(attendance), fines: many(fines), - user: one(users, { - fields: [members.id], - references: [users.memberId], - }), -})); - -export const usersRelations = relations(users, ({ one, many }) => ({ - member: one(members, { - fields: [users.memberId], - references: [members.id], - }), - sessions: many(sessions), -})); - -export const sessionsRelations = relations(sessions, ({ one }) => ({ - user: one(users, { - fields: [sessions.userId], - references: [users.id], - }), })); export const roundsRelations = relations(rounds, ({ many }) => ({ @@ -359,12 +312,6 @@ export const curationItemsRelations = relations(curationItems, ({ one }) => ({ export type Member = typeof members.$inferSelect; export type NewMember = typeof members.$inferInsert; -export type User = typeof users.$inferSelect; -export type NewUser = typeof users.$inferInsert; - -export type Session = typeof sessions.$inferSelect; -export type NewSession = typeof sessions.$inferInsert; - export type Round = typeof rounds.$inferSelect; export type NewRound = typeof rounds.$inferInsert; diff --git a/packages/web/middleware.ts b/packages/web/middleware.ts index 73a7ae3..ce8ad67 100644 --- a/packages/web/middleware.ts +++ b/packages/web/middleware.ts @@ -1,7 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { jwtVerify } from 'jose'; +import { type NextRequest, NextResponse } from 'next/server'; +import { updateSession } from '@/lib/supabase/middleware'; +import { isAdminDiscordId } from '@/lib/admin'; -// Routes that require authentication const protectedRoutes = [ '/dashboard', '/posts', @@ -10,111 +10,59 @@ const protectedRoutes = [ '/profile', ]; -// Routes that require admin access -const adminRoutes = [ - '/admin', -]; +const adminRoutes = ['/admin']; -// Routes that should redirect to dashboard if already logged in -const authRoutes = [ - '/login', - '/register', -]; +const authRoutes = ['/login']; -/** - * Verify JWT token using jose library (Edge runtime compatible) - */ -async function verifyJWT(token: string): Promise<{ userId: string; email: string } | null> { - try { - const secret = new TextEncoder().encode( - process.env.JWT_SECRET || 'development-secret-key-min-32-chars' - ); - - const { payload } = await jwtVerify(token, secret); - - return { - userId: payload.userId as string, - email: payload.email as string, - }; - } catch { - return null; - } -} - -/** - * Middleware for authentication and route protection - * Requirement: 17.9 - */ export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - - // Get auth token from cookie - const authToken = request.cookies.get('auth-token')?.value; - - // Verify token if present - let user: { userId: string; email: string } | null = null; - if (authToken) { - user = await verifyJWT(authToken); - } - - // Check if token is expired (Requirement 17.9) + + const { user, supabaseResponse } = await updateSession(request); const isAuthenticated = !!user; - - // Handle auth routes (login, register) - if (authRoutes.some(route => pathname.startsWith(route))) { + + // 인증 완료 시 로그인 페이지 → 대시보드 리다이렉트 + if (authRoutes.some((route) => pathname.startsWith(route))) { if (isAuthenticated) { - // Redirect to dashboard if already logged in return NextResponse.redirect(new URL('/dashboard', request.url)); } - return NextResponse.next(); + return supabaseResponse; } - - // Handle protected routes - if (protectedRoutes.some(route => pathname.startsWith(route))) { + + // 보호된 라우트 — 미인증 시 로그인 리다이렉트 + if (protectedRoutes.some((route) => pathname.startsWith(route))) { if (!isAuthenticated) { - // Redirect to login if not authenticated const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('redirect', pathname); return NextResponse.redirect(loginUrl); } - - // Add user info to headers for downstream use - const response = NextResponse.next(); - response.headers.set('x-user-id', user!.userId); - response.headers.set('x-user-email', user!.email); - return response; + return supabaseResponse; } - - // Handle admin routes - if (adminRoutes.some(route => pathname.startsWith(route))) { + + // 관리자 라우트 — 미인증 시 로그인, 비관리자 시 대시보드 리다이렉트 + if (adminRoutes.some((route) => pathname.startsWith(route))) { if (!isAuthenticated) { - // Redirect to login if not authenticated const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('redirect', pathname); return NextResponse.redirect(loginUrl); } - - // Note: Admin check is done at the API/page level using Discord ID - // The middleware just ensures the user is authenticated - const response = NextResponse.next(); - response.headers.set('x-user-id', user!.userId); - response.headers.set('x-user-email', user!.email); - return response; + + const discordIdentity = user?.identities?.find( + (identity) => identity.provider === 'discord' + ); + const discordId = discordIdentity?.id; + + if (!discordId || !isAdminDiscordId(discordId)) { + return NextResponse.redirect(new URL('/dashboard', request.url)); + } + + return supabaseResponse; } - - return NextResponse.next(); + + return supabaseResponse; } export const config = { matcher: [ - /* - * Match all request paths except: - * - api routes (handled separately) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public files (images, etc.) - */ '/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|_next).*)', ], }; diff --git a/packages/web/package.json b/packages/web/package.json index 9a0e600..3311280 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -23,25 +23,21 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", - "bcryptjs": "^2.4.3", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.97.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "drizzle-orm": "^0.33.0", - "jose": "^5.2.0", - "jsonwebtoken": "^9.0.2", "lucide-react": "^0.575.0", "next": "^14.2.4", "next-themes": "^0.4.6", "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.12.7", - "resend": "^3.4.0", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@types/bcryptjs": "^2.4.6", - "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/packages/web/src/app/(admin)/layout.tsx b/packages/web/src/app/(admin)/layout.tsx index c40b9b1..b416b81 100644 --- a/packages/web/src/app/(admin)/layout.tsx +++ b/packages/web/src/app/(admin)/layout.tsx @@ -40,9 +40,9 @@ export default function AdminLayout({ if (userResponse.ok) { const userData = await userResponse.json(); setUser({ - name: userData.email.split('@')[0], - email: userData.email, - imageUrl: userData.profileImageUrl, + name: userData.name || userData.discordUsername || userData.email?.split('@')[0] || '', + email: userData.email || '', + imageUrl: userData.profileImageUrl || userData.avatarUrl, }); } } catch { diff --git a/packages/web/src/app/(auth)/login/page.tsx b/packages/web/src/app/(auth)/login/page.tsx index d29d68b..e167811 100644 --- a/packages/web/src/app/(auth)/login/page.tsx +++ b/packages/web/src/app/(auth)/login/page.tsx @@ -1,48 +1,35 @@ 'use client'; -import { Suspense, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; +import { useState, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { createClient } from '@/lib/supabase/client'; function LoginForm() { - const router = useRouter(); const searchParams = useSearchParams(); - const redirect = searchParams.get('redirect') || '/dashboard'; - - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); + const error = searchParams.get('error'); const [isLoading, setIsLoading] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); + const handleDiscordLogin = async () => { setIsLoading(true); - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), + const supabase = createClient(); + const redirectPath = searchParams.get('redirect') ?? '/dashboard'; + + const { error: oauthError } = await supabase.auth.signInWithOAuth({ + provider: 'discord', + options: { + redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(redirectPath)}`, + }, }); - const data = await response.json(); - - if (!response.ok) { - setError(data.message || '로그인에 실패했습니다.'); - return; + if (oauthError) { + console.error('OAuth error:', oauthError); + setIsLoading(false); } - - // Redirect to intended page or dashboard - router.push(redirect); - router.refresh(); - } catch { - setError('서버 오류가 발생했습니다.'); - } finally { + } catch (e) { + console.error('Login error:', e); setIsLoading(false); } }; @@ -50,7 +37,6 @@ function LoginForm() { return ( - {/* BS Logo */}
@@ -59,7 +45,6 @@ function LoginForm() {
- {/* Heading */}

로그인 @@ -70,67 +55,28 @@ function LoginForm() {

-
- - {error && ( -
- {error} -
- )} - -
- - setEmail(e.target.value)} - required - disabled={isLoading} - className="h-9" - /> + + {error && ( +
+ 로그인에 실패했습니다. 다시 시도해 주세요.
- -
- - setPassword(e.target.value)} - required - disabled={isLoading} - className="h-9" - /> -
-
- - - - -

- 계정이 없으신가요?{' '} - - 회원가입 - -

-
- + + + {isLoading ? '로그인 중...' : 'Discord로 로그인'} + + ); } diff --git a/packages/web/src/app/(auth)/register/page.tsx b/packages/web/src/app/(auth)/register/page.tsx deleted file mode 100644 index acc4ebf..0000000 --- a/packages/web/src/app/(auth)/register/page.tsx +++ /dev/null @@ -1,165 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; - -export default function RegisterPage() { - const router = useRouter(); - - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setIsLoading(true); - - // Validate password match - if (password !== confirmPassword) { - setError('비밀번호가 일치하지 않습니다.'); - setIsLoading(false); - return; - } - - // Validate password length - if (password.length < 8) { - setError('비밀번호는 최소 8자 이상이어야 합니다.'); - setIsLoading(false); - return; - } - - try { - const response = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - const data = await response.json(); - - if (!response.ok) { - setError(data.message || '회원가입에 실패했습니다.'); - return; - } - - setSuccess(true); - } catch { - setError('서버 오류가 발생했습니다.'); - } finally { - setIsLoading(false); - } - }; - - if (success) { - return ( -
- - - 이메일 인증 필요 - - -
-
📧
-

- {email}로 인증 이메일을 발송했습니다. -

-

- 이메일을 확인하여 인증을 완료해 주세요. -
- 인증 링크는 24시간 동안 유효합니다. -

-
-
- - - -
-
- ); - } - - return ( -
- - - 회원가입 - - 블로그 스터디에 가입하세요 - - -
- - {error && ( -
- {error} -
- )} -
- - setEmail(e.target.value)} - required - disabled={isLoading} - /> -
-
- - setPassword(e.target.value)} - required - disabled={isLoading} - /> -

최소 8자 이상 입력해 주세요

-
-
- - setConfirmPassword(e.target.value)} - required - disabled={isLoading} - /> -
-
- - -

- 이미 계정이 있으신가요?{' '} - - 로그인 - -

-
-
-
-
- ); -} diff --git a/packages/web/src/app/(auth)/verify-email/page.tsx b/packages/web/src/app/(auth)/verify-email/page.tsx deleted file mode 100644 index df4f1df..0000000 --- a/packages/web/src/app/(auth)/verify-email/page.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client'; - -import { useEffect, useState, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; - -function VerifyEmailContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams.get('token'); - - const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); - const [message, setMessage] = useState(''); - - useEffect(() => { - if (!token) { - setStatus('error'); - setMessage('인증 토큰이 없습니다.'); - return; - } - - const verifyEmail = async () => { - try { - const response = await fetch(`/api/auth/verify-email?token=${token}`); - const data = await response.json(); - - if (response.ok) { - setStatus('success'); - setMessage(data.message || '이메일 인증이 완료되었습니다.'); - } else { - setStatus('error'); - setMessage(data.message || '이메일 인증에 실패했습니다.'); - } - } catch { - setStatus('error'); - setMessage('서버 오류가 발생했습니다.'); - } - }; - - verifyEmail(); - }, [token]); - - if (status === 'loading') { - return ( -
- - - 이메일 인증 중... - - -
-
-
-
- ); - } - - return ( -
- - - - {status === 'success' ? '인증 완료' : '인증 실패'} - - - -
-
- {status === 'success' ? '✅' : '❌'} -
-

- {message} -

-
-
- - {status === 'success' ? ( - - ) : ( - <> - -

- 인증 이메일을 다시 받으시려면{' '} - - 회원가입 - - 을 다시 시도해 주세요. -

- - )} -
-
-
- ); -} - -export default function VerifyEmailPage() { - return ( - - - - 이메일 인증 중... - - -
-
-
-
- }> - - - ); -} diff --git a/packages/web/src/app/(user)/layout.tsx b/packages/web/src/app/(user)/layout.tsx index 196535b..e44c258 100644 --- a/packages/web/src/app/(user)/layout.tsx +++ b/packages/web/src/app/(user)/layout.tsx @@ -26,9 +26,9 @@ export default function UserLayout({ if (response.ok) { const data = await response.json(); setUser({ - name: data.email.split('@')[0], - email: data.email, - imageUrl: data.profileImageUrl, + name: data.name || data.discordUsername || data.email?.split('@')[0] || '', + email: data.email || '', + imageUrl: data.profileImageUrl || data.avatarUrl, }); } } catch { diff --git a/packages/web/src/app/(user)/posts/page.tsx b/packages/web/src/app/(user)/posts/page.tsx index e0620a7..26c7569 100644 --- a/packages/web/src/app/(user)/posts/page.tsx +++ b/packages/web/src/app/(user)/posts/page.tsx @@ -52,7 +52,7 @@ function PostsContent() { throw new Error('Failed to fetch posts'); } const result = await response.json(); - setData(result); + setData(result.data); } catch (err) { setError('포스트 목록을 불러오는데 실패했습니다.'); console.error(err); diff --git a/packages/web/src/app/api/admin/check/route.ts b/packages/web/src/app/api/admin/check/route.ts index 87a42e2..3e730f7 100644 --- a/packages/web/src/app/api/admin/check/route.ts +++ b/packages/web/src/app/api/admin/check/route.ts @@ -27,7 +27,6 @@ export async function GET() { return NextResponse.json({ isAdmin: adminAuth.isAdmin, userId: adminAuth.userId, - memberId: adminAuth.memberId, discordId: adminAuth.discordId, message: adminAuth.isAdmin ? '관리자 권한이 확인되었습니다.' : '관리자 권한이 없습니다.', }); diff --git a/packages/web/src/app/api/admin/curation/[id]/route.ts b/packages/web/src/app/api/admin/curation/[id]/route.ts index 6d6ee28..5485b90 100644 --- a/packages/web/src/app/api/admin/curation/[id]/route.ts +++ b/packages/web/src/app/api/admin/curation/[id]/route.ts @@ -2,28 +2,22 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { getDb } from '@/lib/db'; import { curationSources, curationItems } from '@blog-study/shared/db'; -import { verifyAdminAccess } from '@/lib/admin'; - -interface RouteParams { - params: Promise<{ id: string }>; -} +import { withAdminAuth } from '@/lib/admin'; /** * GET /api/admin/curation/[id] * Get a single curation source */ -export async function GET(_request: NextRequest, { params }: RouteParams) { +export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - // Check admin access - const adminCheck = await verifyAdminAccess(); - if (!adminCheck.isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const id = new URL(request.url).pathname.split('/').pop(); + if (!id) { + return NextResponse.json({ error: 'Source ID is required' }, { status: 400 }); } - const { id } = await params; - const db = getDb(); + const database = getDb(); - const [source] = await db + const [source] = await database .select() .from(curationSources) .where(eq(curationSources.id, id)) @@ -41,26 +35,24 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { { status: 500 } ); } -} +}); /** * PATCH /api/admin/curation/[id] * Update a curation source (toggle active status, update name, etc.) */ -export async function PATCH(request: NextRequest, { params }: RouteParams) { +export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - // Check admin access - const adminCheck = await verifyAdminAccess(); - if (!adminCheck.isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const id = new URL(request.url).pathname.split('/').pop(); + if (!id) { + return NextResponse.json({ error: 'Source ID is required' }, { status: 400 }); } - const { id } = await params; const body = await request.json(); - const db = getDb(); + const database = getDb(); // Check if source exists - const [existing] = await db + const [existing] = await database .select() .from(curationSources) .where(eq(curationSources.id, id)) @@ -72,7 +64,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { // Build update object const updateData: Partial = {}; - + if (body.name !== undefined) { updateData.name = body.name; } @@ -87,7 +79,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { } // Update source - const [updated] = await db + const [updated] = await database .update(curationSources) .set(updateData) .where(eq(curationSources.id, id)) @@ -101,26 +93,24 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { { status: 500 } ); } -} +}); /** * DELETE /api/admin/curation/[id] * Delete a curation source and all its items * Requirements: 15.5 */ -export async function DELETE(_request: NextRequest, { params }: RouteParams) { +export const DELETE = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - // Check admin access - const adminCheck = await verifyAdminAccess(); - if (!adminCheck.isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const id = new URL(request.url).pathname.split('/').pop(); + if (!id) { + return NextResponse.json({ error: 'Source ID is required' }, { status: 400 }); } - const { id } = await params; - const db = getDb(); + const database = getDb(); // Check if source exists - const [existing] = await db + const [existing] = await database .select() .from(curationSources) .where(eq(curationSources.id, id)) @@ -131,12 +121,12 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { } // Delete all items from this source first - await db + await database .delete(curationItems) .where(eq(curationItems.sourceId, id)); // Delete the source - await db + await database .delete(curationSources) .where(eq(curationSources.id, id)); @@ -148,4 +138,4 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { { status: 500 } ); } -} +}); diff --git a/packages/web/src/app/api/admin/curation/route.ts b/packages/web/src/app/api/admin/curation/route.ts index 77f950f..df223e6 100644 --- a/packages/web/src/app/api/admin/curation/route.ts +++ b/packages/web/src/app/api/admin/curation/route.ts @@ -2,31 +2,25 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq, desc, sql } from 'drizzle-orm'; import { getDb } from '@/lib/db'; import { curationSources, curationItems, CurationCategory } from '@blog-study/shared/db'; -import { verifyAdminAccess } from '@/lib/admin'; +import { withAdminAuth } from '@/lib/admin'; /** * GET /api/admin/curation * Get all curation sources with item counts * Requirements: 15.5 */ -export async function GET() { +export const GET = withAdminAuth(async (_request: NextRequest, _adminAuth) => { try { - // Check admin access - const adminCheck = await verifyAdminAccess(); - if (!adminCheck.isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - const db = getDb(); + const database = getDb(); // Get all sources - const sources = await db + const sources = await database .select() .from(curationSources) .orderBy(desc(curationSources.createdAt)); // Get item counts per source - const itemCounts = await db + const itemCounts = await database .select({ sourceId: curationItems.sourceId, count: sql`count(*)::int`, @@ -49,8 +43,8 @@ export async function GET() { })); // Get summary stats - const totalItems = await db.select({ count: sql`count(*)::int` }).from(curationItems); - const sharedItems = await db + const totalItems = await database.select({ count: sql`count(*)::int` }).from(curationItems); + const sharedItems = await database .select({ count: sql`count(*)::int` }) .from(curationItems) .where(eq(curationItems.isShared, true)); @@ -72,21 +66,15 @@ export async function GET() { { status: 500 } ); } -} +}); /** * POST /api/admin/curation * Add a new curation source * Requirements: 15.5 */ -export async function POST(request: NextRequest) { +export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - // Check admin access - const adminCheck = await verifyAdminAccess(); - if (!adminCheck.isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - const body = await request.json(); const { url, name, category } = body; @@ -107,10 +95,10 @@ export async function POST(request: NextRequest) { ); } - const db = getDb(); + const database = getDb(); // Check for duplicate URL - const [existing] = await db + const [existing] = await database .select() .from(curationSources) .where(eq(curationSources.url, url)) @@ -124,7 +112,7 @@ export async function POST(request: NextRequest) { } // Create new source - const [created] = await db + const [created] = await database .insert(curationSources) .values({ url, @@ -142,4 +130,4 @@ export async function POST(request: NextRequest) { { status: 500 } ); } -} +}); diff --git a/packages/web/src/app/api/admin/members/[id]/route.ts b/packages/web/src/app/api/admin/members/[id]/route.ts index de0a0ae..045b274 100644 --- a/packages/web/src/app/api/admin/members/[id]/route.ts +++ b/packages/web/src/app/api/admin/members/[id]/route.ts @@ -15,7 +15,7 @@ const { members, MemberStatus } = sharedDb; */ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - const id = request.url.split('/').pop(); + const id = new URL(request.url).pathname.split('/').pop(); if (!id) { return NextResponse.json({ message: '멤버 ID가 필요합니다.' }, { status: 400 }); } @@ -45,7 +45,7 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { */ export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - const id = request.url.split('/').pop(); + const id = new URL(request.url).pathname.split('/').pop(); if (!id) { return NextResponse.json({ message: '멤버 ID가 필요합니다.' }, { status: 400 }); } @@ -149,7 +149,7 @@ export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { */ export const DELETE = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - const id = request.url.split('/').pop(); + const id = new URL(request.url).pathname.split('/').pop(); if (!id) { return NextResponse.json({ message: '멤버 ID가 필요합니다.' }, { status: 400 }); } diff --git a/packages/web/src/app/api/admin/settings/route.ts b/packages/web/src/app/api/admin/settings/route.ts index 0601cf0..f732222 100644 --- a/packages/web/src/app/api/admin/settings/route.ts +++ b/packages/web/src/app/api/admin/settings/route.ts @@ -2,26 +2,20 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { getDb } from '@/lib/db'; import { config, rounds } from '@blog-study/shared/db'; -import { verifyAdminAccess } from '@/lib/admin'; +import { withAdminAuth } from '@/lib/admin'; /** * GET /api/admin/settings * Get all study settings * Requirements: 16.10 */ -export async function GET() { +export const GET = withAdminAuth(async (_request: NextRequest, _adminAuth) => { try { - // Check admin access - const adminCheck = await verifyAdminAccess(); - if (!adminCheck.isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - const db = getDb(); + const database = getDb(); // Get all config values - const configRows = await db.select().from(config); - + const configRows = await database.select().from(config); + // Convert to object const settings: Record = {}; for (const row of configRows) { @@ -29,14 +23,14 @@ export async function GET() { } // Get current round info - const [currentRound] = await db + const [currentRound] = await database .select() .from(rounds) .where(eq(rounds.isCurrent, true)) .limit(1); // Get total rounds count - const allRounds = await db.select().from(rounds); + const allRounds = await database.select().from(rounds); return NextResponse.json({ settings: { @@ -57,23 +51,17 @@ export async function GET() { { status: 500 } ); } -} +}); /** * PATCH /api/admin/settings * Update study settings * Requirements: 16.11 */ -export async function PATCH(request: NextRequest) { +export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { - // Check admin access - const adminCheck = await verifyAdminAccess(); - if (!adminCheck.isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - const body = await request.json(); - const db = getDb(); + const database = getDb(); const now = new Date(); // Map of frontend keys to database keys @@ -94,7 +82,7 @@ export async function PATCH(request: NextRequest) { const stringValue = String(value); // Check if key exists - const [existing] = await db + const [existing] = await database .select() .from(config) .where(eq(config.key, dbKey)) @@ -102,13 +90,13 @@ export async function PATCH(request: NextRequest) { if (existing) { // Update existing - await db + await database .update(config) .set({ value: stringValue, updatedAt: now }) .where(eq(config.key, dbKey)); } else { // Insert new - await db.insert(config).values({ + await database.insert(config).values({ key: dbKey, value: stringValue, updatedAt: now, @@ -124,4 +112,4 @@ export async function PATCH(request: NextRequest) { { status: 500 } ); } -} +}); diff --git a/packages/web/src/app/api/auth/login/route.ts b/packages/web/src/app/api/auth/login/route.ts deleted file mode 100644 index 4e4b384..0000000 --- a/packages/web/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { eq } from 'drizzle-orm'; -import { db } from '@/lib/db'; -import { db as sharedDb } from '@blog-study/shared'; - -const { users, sessions } = sharedDb; -import { - isValidEmail, - comparePassword, - generateToken, - getSessionExpiry, -} from '@/lib/auth'; -import { randomUUID } from 'crypto'; - -export interface LoginRequest { - email: string; - password: string; -} - -export interface LoginResponse { - success: boolean; - message: string; - token?: string; -} - -/** - * POST /api/auth/login - * Login with email and password - * Requirements: 17.6, 17.7 - */ -export async function POST(request: NextRequest): Promise> { - try { - const body = (await request.json()) as LoginRequest; - const { email, password } = body; - - // Validate input - if (!email || !isValidEmail(email)) { - return NextResponse.json( - { success: false, message: '올바른 이메일 형식이 아닙니다.' }, - { status: 400 } - ); - } - - if (!password) { - return NextResponse.json( - { success: false, message: '비밀번호를 입력해 주세요.' }, - { status: 400 } - ); - } - - const database = db(); - - // Find user by email - const [user] = await database - .select() - .from(users) - .where(eq(users.email, email.toLowerCase())) - .limit(1); - - if (!user) { - return NextResponse.json( - { success: false, message: '이메일 또는 비밀번호가 올바르지 않습니다.' }, - { status: 401 } - ); - } - - // Verify password (Requirement 17.7) - const isPasswordValid = await comparePassword(password, user.passwordHash); - - if (!isPasswordValid) { - return NextResponse.json( - { success: false, message: '이메일 또는 비밀번호가 올바르지 않습니다.' }, - { status: 401 } - ); - } - - // Check email verification (Requirement 17.6) - if (!user.emailVerified) { - return NextResponse.json( - { success: false, message: '이메일 인증이 필요합니다. 이메일을 확인해 주세요.' }, - { status: 403 } - ); - } - - // Generate JWT token (Requirement 17.7) - const token = generateToken({ - userId: user.id, - email: user.email, - }); - - // Create session record - const sessionToken = randomUUID(); - const expiresAt = getSessionExpiry(); - - await database.insert(sessions).values({ - userId: user.id, - token: sessionToken, - expiresAt, - }); - - // Create response with httpOnly cookie - const response = NextResponse.json( - { - success: true, - message: '로그인되었습니다.', - token, - }, - { status: 200 } - ); - - // Set JWT token as httpOnly cookie - response.cookies.set('auth-token', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 7 * 24 * 60 * 60, // 7 days - }); - - // Set session token as httpOnly cookie - response.cookies.set('session-token', sessionToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 7 * 24 * 60 * 60, // 7 days - }); - - return response; - } catch (error) { - console.error('Login error:', error); - return NextResponse.json( - { success: false, message: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} diff --git a/packages/web/src/app/api/auth/logout/route.ts b/packages/web/src/app/api/auth/logout/route.ts index f77399c..e6b3a30 100644 --- a/packages/web/src/app/api/auth/logout/route.ts +++ b/packages/web/src/app/api/auth/logout/route.ts @@ -1,58 +1,19 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { eq } from 'drizzle-orm'; -import { db } from '@/lib/db'; -import { db as sharedDb } from '@blog-study/shared'; - -const { sessions } = sharedDb; - -export interface LogoutResponse { - success: boolean; - message: string; -} +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; /** * POST /api/auth/logout - * Logout and invalidate session - * Requirement: 17.8 + * Supabase Auth signOut */ -export async function POST(request: NextRequest): Promise> { +export async function POST() { try { - const sessionToken = request.cookies.get('session-token')?.value; - - if (sessionToken) { - const database = db(); - - // Invalidate session token (Requirement 17.8) - await database - .delete(sessions) - .where(eq(sessions.token, sessionToken)); - } + const supabase = await createClient(); + await supabase.auth.signOut(); - // Create response and clear cookies - const response = NextResponse.json( + return NextResponse.json( { success: true, message: '로그아웃되었습니다.' }, { status: 200 } ); - - // Clear auth token cookie - response.cookies.set('auth-token', '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 0, - }); - - // Clear session token cookie - response.cookies.set('session-token', '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 0, - }); - - return response; } catch (error) { console.error('Logout error:', error); return NextResponse.json( diff --git a/packages/web/src/app/api/auth/me/route.ts b/packages/web/src/app/api/auth/me/route.ts index 18e842f..08e1b54 100644 --- a/packages/web/src/app/api/auth/me/route.ts +++ b/packages/web/src/app/api/auth/me/route.ts @@ -1,73 +1,57 @@ import { NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; -import { verifyToken } from '@/lib/auth'; +import { createClient } from '@/lib/supabase/server'; -const { users, members } = sharedDb; +const { members } = sharedDb; /** * GET /api/auth/me - * Get current authenticated user info + * Supabase Auth → Discord ID → members 테이블 조회 */ export async function GET() { try { - const cookieStore = await cookies(); - const authToken = cookieStore.get('auth-token')?.value; + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); - if (!authToken) { + if (error || !user) { return NextResponse.json( { message: '인증이 필요합니다.' }, { status: 401 } ); } - const payload = verifyToken(authToken); - if (!payload) { - return NextResponse.json( - { message: '유효하지 않은 토큰입니다.' }, - { status: 401 } - ); + const discordIdentity = user.identities?.find( + (identity) => identity.provider === 'discord' + ); + const discordId = discordIdentity?.id as string | undefined; + + if (!discordId) { + return NextResponse.json({ + id: user.id, + email: user.email, + discordUsername: user.user_metadata?.full_name, + avatarUrl: user.user_metadata?.avatar_url, + }); } const database = db(); - const [userData] = await database + const [memberData] = await database .select() - .from(users) - .where(eq(users.id, payload.userId)) + .from(members) + .where(eq(members.discordId, discordId)) .limit(1); - if (!userData) { - return NextResponse.json( - { message: '사용자를 찾을 수 없습니다.' }, - { status: 404 } - ); - } - - let memberData = null; - - // If user is linked to a member, get member info - if (userData.memberId) { - const [member] = await database - .select() - .from(members) - .where(eq(members.id, userData.memberId)) - .limit(1); - - if (member) { - memberData = member; - } - } - return NextResponse.json({ - id: userData.id, - email: userData.email, - emailVerified: userData.emailVerified, - memberId: userData.memberId, - profileImageUrl: memberData?.profileImageUrl, - name: memberData?.name, - discordUsername: memberData?.discordUsername, + id: user.id, + email: user.email, + discordUsername: user.user_metadata?.full_name, + avatarUrl: user.user_metadata?.avatar_url, + memberId: memberData?.id ?? null, + profileImageUrl: memberData?.profileImageUrl ?? user.user_metadata?.avatar_url, + name: memberData?.name ?? user.user_metadata?.full_name, + discordId, }); } catch (error) { console.error('Get user error:', error); diff --git a/packages/web/src/app/api/auth/register/route.ts b/packages/web/src/app/api/auth/register/route.ts deleted file mode 100644 index 6b0f143..0000000 --- a/packages/web/src/app/api/auth/register/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { eq } from 'drizzle-orm'; -import { db } from '@/lib/db'; -import { db as sharedDb } from '@blog-study/shared'; - -const { users } = sharedDb; -import { - isValidEmail, - isValidPassword, - hashPassword, - generateVerificationToken, - getVerificationExpiry, -} from '@/lib/auth'; -import { sendVerificationEmail } from '@/lib/email'; - -export interface RegisterRequest { - email: string; - password: string; -} - -export interface RegisterResponse { - success: boolean; - message: string; - userId?: string; -} - -/** - * POST /api/auth/register - * Register a new user with email and password - * Requirements: 17.1, 17.2, 17.3 - */ -export async function POST(request: NextRequest): Promise> { - try { - const body = (await request.json()) as RegisterRequest; - const { email, password } = body; - - // Validate email format (Requirement 17.1) - if (!email || !isValidEmail(email)) { - return NextResponse.json( - { success: false, message: '올바른 이메일 형식이 아닙니다.' }, - { status: 400 } - ); - } - - // Validate password strength (Requirement 17.1) - if (!password || !isValidPassword(password)) { - return NextResponse.json( - { success: false, message: '비밀번호는 최소 8자 이상이어야 합니다.' }, - { status: 400 } - ); - } - - const database = db(); - - // Check if email already exists - const existingUser = await database - .select() - .from(users) - .where(eq(users.email, email.toLowerCase())) - .limit(1); - - if (existingUser.length > 0) { - return NextResponse.json( - { success: false, message: '이미 등록된 이메일입니다.' }, - { status: 409 } - ); - } - - // Hash password using bcrypt (Requirement 17.2) - const passwordHash = await hashPassword(password); - - // Generate verification token (Requirement 17.3) - const verificationToken = generateVerificationToken(); - const verificationExpiry = getVerificationExpiry(); - - // Create user record - const [newUser] = await database - .insert(users) - .values({ - email: email.toLowerCase(), - passwordHash, - emailVerified: false, - emailVerifyToken: verificationToken, - emailVerifyExpires: verificationExpiry, - }) - .returning({ id: users.id }); - - // Send verification email (Requirement 17.3) - const emailResult = await sendVerificationEmail(email, verificationToken); - - if (!emailResult.success) { - console.error('Failed to send verification email:', emailResult.error); - // User is created but email failed - they can request resend later - } - - return NextResponse.json( - { - success: true, - message: '회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해 주세요.', - userId: newUser?.id, - }, - { status: 201 } - ); - } catch (error) { - console.error('Registration error:', error); - return NextResponse.json( - { success: false, message: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} diff --git a/packages/web/src/app/api/auth/verify-email/route.ts b/packages/web/src/app/api/auth/verify-email/route.ts deleted file mode 100644 index fd3044d..0000000 --- a/packages/web/src/app/api/auth/verify-email/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { eq, and, gt } from 'drizzle-orm'; -import { db } from '@/lib/db'; -import { db as sharedDb } from '@blog-study/shared'; - -const { users } = sharedDb; -import { generateVerificationToken, getVerificationExpiry } from '@/lib/auth'; -import { sendVerificationEmail } from '@/lib/email'; - -export interface VerifyEmailRequest { - token: string; -} - -export interface VerifyEmailResponse { - success: boolean; - message: string; -} - -export interface ResendVerificationRequest { - email: string; -} - -/** - * GET /api/auth/verify-email?token=xxx - * Verify email with token - * Requirements: 17.4, 17.5 - */ -export async function GET(request: NextRequest): Promise> { - try { - const { searchParams } = new URL(request.url); - const token = searchParams.get('token'); - - if (!token) { - return NextResponse.json( - { success: false, message: '인증 토큰이 필요합니다.' }, - { status: 400 } - ); - } - - const database = db(); - - // Find user with valid token (Requirement 17.4) - const [user] = await database - .select() - .from(users) - .where( - and( - eq(users.emailVerifyToken, token), - gt(users.emailVerifyExpires, new Date()) - ) - ) - .limit(1); - - // Token expired or invalid (Requirement 17.5) - if (!user) { - return NextResponse.json( - { success: false, message: '유효하지 않거나 만료된 인증 토큰입니다.' }, - { status: 400 } - ); - } - - // Already verified - if (user.emailVerified) { - return NextResponse.json( - { success: true, message: '이미 인증된 이메일입니다.' }, - { status: 200 } - ); - } - - // Mark email as verified (Requirement 17.4) - await database - .update(users) - .set({ - emailVerified: true, - emailVerifyToken: null, - emailVerifyExpires: null, - updatedAt: new Date(), - }) - .where(eq(users.id, user.id)); - - return NextResponse.json( - { success: true, message: '이메일 인증이 완료되었습니다.' }, - { status: 200 } - ); - } catch (error) { - console.error('Email verification error:', error); - return NextResponse.json( - { success: false, message: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} - -/** - * POST /api/auth/verify-email - * Resend verification email (Requirement 17.5) - */ -export async function POST(request: NextRequest): Promise> { - try { - const body = (await request.json()) as ResendVerificationRequest; - const { email } = body; - - if (!email) { - return NextResponse.json( - { success: false, message: '이메일이 필요합니다.' }, - { status: 400 } - ); - } - - const database = db(); - - // Find user by email - const [user] = await database - .select() - .from(users) - .where(eq(users.email, email.toLowerCase())) - .limit(1); - - if (!user) { - // Don't reveal if email exists - return NextResponse.json( - { success: true, message: '인증 이메일이 발송되었습니다.' }, - { status: 200 } - ); - } - - // Already verified - if (user.emailVerified) { - return NextResponse.json( - { success: false, message: '이미 인증된 이메일입니다.' }, - { status: 400 } - ); - } - - // Generate new verification token - const verificationToken = generateVerificationToken(); - const verificationExpiry = getVerificationExpiry(); - - // Update user with new token - await database - .update(users) - .set({ - emailVerifyToken: verificationToken, - emailVerifyExpires: verificationExpiry, - updatedAt: new Date(), - }) - .where(eq(users.id, user.id)); - - // Send verification email - await sendVerificationEmail(email, verificationToken); - - return NextResponse.json( - { success: true, message: '인증 이메일이 발송되었습니다.' }, - { status: 200 } - ); - } catch (error) { - console.error('Resend verification error:', error); - return NextResponse.json( - { success: false, message: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} diff --git a/packages/web/src/app/api/profile/edit/route.ts b/packages/web/src/app/api/profile/edit/route.ts index 6779b62..5d18a6d 100644 --- a/packages/web/src/app/api/profile/edit/route.ts +++ b/packages/web/src/app/api/profile/edit/route.ts @@ -1,57 +1,72 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; -import { verifyToken } from '@/lib/auth'; +import { createClient } from '@/lib/supabase/server'; -const { users, members } = sharedDb; +const { members } = sharedDb; /** * PUT /api/profile/edit - * Update profile information - * Requirement: 20.7 + * Supabase Auth → Discord ID → members 프로필 수정 */ export async function PUT(request: NextRequest) { try { - const cookieStore = await cookies(); - const authToken = cookieStore.get('auth-token')?.value; + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); - if (!authToken) { + if (error || !user) { return NextResponse.json( { message: '인증이 필요합니다.' }, { status: 401 } ); } - const payload = verifyToken(authToken); - if (!payload) { + const discordIdentity = user.identities?.find( + (identity) => identity.provider === 'discord' + ); + const discordId = discordIdentity?.id as string | undefined; + if (!discordId) { return NextResponse.json( - { message: '유효하지 않은 토큰입니다.' }, - { status: 401 } + { message: '스터디원 계정이 연결되어 있지 않습니다.' }, + { status: 400 } ); } const database = db(); - - // Get user and check if linked to member - const [userData] = await database + const [memberData] = await database .select() - .from(users) - .where(eq(users.id, payload.userId)) + .from(members) + .where(eq(members.discordId, discordId)) .limit(1); - if (!userData || !userData.memberId) { + if (!memberData) { return NextResponse.json( - { message: '스터디원 계정이 연결되어 있지 않습니다.' }, - { status: 400 } + { message: '스터디원 정보를 찾을 수 없습니다.' }, + { status: 404 } ); } const body = await request.json(); const { profileImageUrl, bio, interests, resolution } = body; - // Validate bio length + if (profileImageUrl) { + try { + const url = new URL(profileImageUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + return NextResponse.json( + { message: '프로필 이미지 URL은 http 또는 https만 허용됩니다.' }, + { status: 400 } + ); + } + } catch { + return NextResponse.json( + { message: '유효하지 않은 프로필 이미지 URL입니다.' }, + { status: 400 } + ); + } + } + if (bio && bio.length > 200) { return NextResponse.json( { message: '한줄 소개는 200자 이내로 작성해주세요.' }, @@ -59,7 +74,6 @@ export async function PUT(request: NextRequest) { ); } - // Validate resolution length if (resolution && resolution.length > 300) { return NextResponse.json( { message: '다짐은 300자 이내로 작성해주세요.' }, @@ -67,15 +81,21 @@ export async function PUT(request: NextRequest) { ); } - // Validate interests - if (interests && !Array.isArray(interests)) { - return NextResponse.json( - { message: '관심 분야는 배열 형식이어야 합니다.' }, - { status: 400 } - ); + if (interests) { + if (!Array.isArray(interests) || interests.length > 20) { + return NextResponse.json( + { message: '관심 분야는 최대 20개까지 입력 가능합니다.' }, + { status: 400 } + ); + } + if (!interests.every((i: unknown) => typeof i === 'string' && i.length <= 50)) { + return NextResponse.json( + { message: '관심 분야는 각 50자 이내의 문자열이어야 합니다.' }, + { status: 400 } + ); + } } - // Update member profile await database .update(members) .set({ @@ -85,7 +105,7 @@ export async function PUT(request: NextRequest) { resolution: resolution || null, updatedAt: new Date(), }) - .where(eq(members.id, userData.memberId)); + .where(eq(members.id, memberData.id)); return NextResponse.json({ message: '프로필이 수정되었습니다.', diff --git a/packages/web/src/app/api/profile/link/route.ts b/packages/web/src/app/api/profile/link/route.ts deleted file mode 100644 index bc00e5e..0000000 --- a/packages/web/src/app/api/profile/link/route.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { eq } from 'drizzle-orm'; -import { db } from '@/lib/db'; -import { db as sharedDb } from '@blog-study/shared'; -import { verifyToken } from '@/lib/auth'; - -const { users, members } = sharedDb; - -/** - * POST /api/profile/link - * Link user account to a study member by Discord ID - * Requirement: 18.5 - */ -export async function POST(request: NextRequest) { - try { - const cookieStore = await cookies(); - const authToken = cookieStore.get('auth-token')?.value; - - if (!authToken) { - return NextResponse.json( - { message: '인증이 필요합니다.' }, - { status: 401 } - ); - } - - const payload = verifyToken(authToken); - if (!payload) { - return NextResponse.json( - { message: '유효하지 않은 토큰입니다.' }, - { status: 401 } - ); - } - - const body = await request.json(); - const { discordId } = body; - - if (!discordId || typeof discordId !== 'string') { - return NextResponse.json( - { message: 'Discord ID가 필요합니다.' }, - { status: 400 } - ); - } - - const database = db(); - - // Find member by Discord ID - const [member] = await database - .select() - .from(members) - .where(eq(members.discordId, discordId)) - .limit(1); - - if (!member) { - return NextResponse.json( - { message: '해당 Discord ID로 등록된 스터디원을 찾을 수 없습니다.' }, - { status: 404 } - ); - } - - // Check if member is already linked to another user - const [existingLink] = await database - .select() - .from(users) - .where(eq(users.memberId, member.id)) - .limit(1); - - if (existingLink && existingLink.id !== payload.userId) { - return NextResponse.json( - { message: '이 스터디원은 이미 다른 계정에 연결되어 있습니다.' }, - { status: 409 } - ); - } - - // Link member to user - await database - .update(users) - .set({ - memberId: member.id, - updatedAt: new Date(), - }) - .where(eq(users.id, payload.userId)); - - return NextResponse.json({ - message: '스터디원 계정이 연결되었습니다.', - member: { - id: member.id, - discordUsername: member.discordUsername, - name: member.name, - onboardingCompleted: member.onboardingCompleted, - }, - }); - } catch (error) { - console.error('Profile link API error:', error); - return NextResponse.json( - { message: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} - -/** - * DELETE /api/profile/link - * Unlink user account from study member - */ -export async function DELETE() { - try { - const cookieStore = await cookies(); - const authToken = cookieStore.get('auth-token')?.value; - - if (!authToken) { - return NextResponse.json( - { message: '인증이 필요합니다.' }, - { status: 401 } - ); - } - - const payload = verifyToken(authToken); - if (!payload) { - return NextResponse.json( - { message: '유효하지 않은 토큰입니다.' }, - { status: 401 } - ); - } - - const database = db(); - - await database - .update(users) - .set({ - memberId: null, - updatedAt: new Date(), - }) - .where(eq(users.id, payload.userId)); - - return NextResponse.json({ - message: '스터디원 계정 연결이 해제되었습니다.', - }); - } catch (error) { - console.error('Profile unlink API error:', error); - return NextResponse.json( - { message: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts index 57032d1..cc4b30a 100644 --- a/packages/web/src/app/api/profile/onboarding/route.ts +++ b/packages/web/src/app/api/profile/onboarding/route.ts @@ -1,61 +1,72 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; -import { verifyToken } from '@/lib/auth'; +import { createClient } from '@/lib/supabase/server'; -const { users, members } = sharedDb; - -// Allowed file types for profile image (for future use) -// const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; -// const MAX_IMAGE_SIZE = 2 * 1024 * 1024; // 2MB +const { members } = sharedDb; /** * POST /api/profile/onboarding - * Complete onboarding with profile information - * Requirement: 20.1, 20.2, 20.3, 20.4, 20.5 + * Supabase Auth → Discord ID → 온보딩 완료 */ export async function POST(request: NextRequest) { try { - const cookieStore = await cookies(); - const authToken = cookieStore.get('auth-token')?.value; + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); - if (!authToken) { + if (error || !user) { return NextResponse.json( { message: '인증이 필요합니다.' }, { status: 401 } ); } - const payload = verifyToken(authToken); - if (!payload) { + const discordIdentity = user.identities?.find( + (identity) => identity.provider === 'discord' + ); + const discordId = discordIdentity?.id as string | undefined; + if (!discordId) { return NextResponse.json( - { message: '유효하지 않은 토큰입니다.' }, - { status: 401 } + { message: '스터디원 계정이 연결되어 있지 않습니다.' }, + { status: 400 } ); } const database = db(); - - // Get user and check if linked to member - const [userData] = await database + const [memberData] = await database .select() - .from(users) - .where(eq(users.id, payload.userId)) + .from(members) + .where(eq(members.discordId, discordId)) .limit(1); - if (!userData || !userData.memberId) { + if (!memberData) { return NextResponse.json( - { message: '스터디원 계정이 연결되어 있지 않습니다.' }, - { status: 400 } + { message: '스터디원 정보를 찾을 수 없습니다.' }, + { status: 404 } ); } const body = await request.json(); const { profileImageUrl, bio, interests, resolution } = body; - // Validate bio length + if (profileImageUrl) { + try { + const url = new URL(profileImageUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + return NextResponse.json( + { message: '프로필 이미지 URL은 http 또는 https만 허용됩니다.' }, + { status: 400 } + ); + } + } catch { + return NextResponse.json( + { message: '유효하지 않은 프로필 이미지 URL입니다.' }, + { status: 400 } + ); + } + } + if (bio && bio.length > 200) { return NextResponse.json( { message: '한줄 소개는 200자 이내로 작성해주세요.' }, @@ -63,7 +74,6 @@ export async function POST(request: NextRequest) { ); } - // Validate resolution length if (resolution && resolution.length > 300) { return NextResponse.json( { message: '다짐은 300자 이내로 작성해주세요.' }, @@ -71,15 +81,21 @@ export async function POST(request: NextRequest) { ); } - // Validate interests - if (interests && !Array.isArray(interests)) { - return NextResponse.json( - { message: '관심 분야는 배열 형식이어야 합니다.' }, - { status: 400 } - ); + if (interests) { + if (!Array.isArray(interests) || interests.length > 20) { + return NextResponse.json( + { message: '관심 분야는 최대 20개까지 입력 가능합니다.' }, + { status: 400 } + ); + } + if (!interests.every((i: unknown) => typeof i === 'string' && i.length <= 50)) { + return NextResponse.json( + { message: '관심 분야는 각 50자 이내의 문자열이어야 합니다.' }, + { status: 400 } + ); + } } - // Update member profile await database .update(members) .set({ @@ -90,7 +106,7 @@ export async function POST(request: NextRequest) { onboardingCompleted: true, updatedAt: new Date(), }) - .where(eq(members.id, userData.memberId)); + .where(eq(members.id, memberData.id)); return NextResponse.json({ message: '프로필이 저장되었습니다.', @@ -103,14 +119,3 @@ export async function POST(request: NextRequest) { ); } } - -// Image validation helper (commented out for future use when image upload is implemented) -// function validateImageFile(file: File): { valid: boolean; error?: string } { -// if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { -// return { valid: false, error: 'JPG, PNG, WebP 형식의 이미지만 업로드 가능합니다.' }; -// } -// if (file.size > MAX_IMAGE_SIZE) { -// return { valid: false, error: '이미지 크기는 2MB 이하여야 합니다.' }; -// } -// return { valid: true }; -// } diff --git a/packages/web/src/app/api/profile/route.ts b/packages/web/src/app/api/profile/route.ts index b6f4cd7..ea7df60 100644 --- a/packages/web/src/app/api/profile/route.ts +++ b/packages/web/src/app/api/profile/route.ts @@ -1,72 +1,50 @@ import { NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { eq, count, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; -import { verifyToken } from '@/lib/auth'; +import { createClient } from '@/lib/supabase/server'; -const { users, members, posts, attendance, fines, AttendanceStatus, FineStatus } = sharedDb; +const { members, posts, attendance, fines, AttendanceStatus, FineStatus } = sharedDb; /** * GET /api/profile - * Get current user's profile with linked member info - * Requirement: 18.5, 18.6 + * Supabase Auth → Discord ID → members 테이블 + 통계 */ export async function GET() { try { - const cookieStore = await cookies(); - const authToken = cookieStore.get('auth-token')?.value; + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); - if (!authToken) { + if (error || !user) { return NextResponse.json( { message: '인증이 필요합니다.' }, { status: 401 } ); } - const payload = verifyToken(authToken); - if (!payload) { - return NextResponse.json( - { message: '유효하지 않은 토큰입니다.' }, - { status: 401 } - ); - } - - const database = db(); - const [userData] = await database - .select() - .from(users) - .where(eq(users.id, payload.userId)) - .limit(1); - - if (!userData) { - return NextResponse.json( - { message: '사용자를 찾을 수 없습니다.' }, - { status: 404 } - ); - } - + const discordIdentity = user.identities?.find( + (identity) => identity.provider === 'discord' + ); + const discordId = discordIdentity?.id as string | undefined; let memberData = null; let stats = null; - // If user is linked to a member, get member info and stats - if (userData.memberId) { + if (discordId) { + const database = db(); const [member] = await database .select() .from(members) - .where(eq(members.id, userData.memberId)) + .where(eq(members.discordId, discordId)) .limit(1); if (member) { memberData = member; - // Get post count const [postCount] = await database .select({ count: count() }) .from(posts) .where(eq(posts.memberId, member.id)); - // Get attendance stats const attendanceStats = await database .select({ total: count(), @@ -77,7 +55,6 @@ export async function GET() { .from(attendance) .where(eq(attendance.memberId, member.id)); - // Get fine stats const fineStats = await database .select({ totalFines: sql`COALESCE(SUM(${fines.amount}), 0)`, @@ -95,8 +72,8 @@ export async function GET() { submittedRounds: attStats.submitted, lateRounds: attStats.late, absentRounds: attStats.absent, - attendanceRate: attStats.total > 0 - ? Math.round((attStats.submitted / attStats.total) * 100) + attendanceRate: attStats.total > 0 + ? Math.round((attStats.submitted / attStats.total) * 100) : 0, totalFines: fStats.totalFines, unpaidFines: fStats.unpaidFines, @@ -106,10 +83,11 @@ export async function GET() { return NextResponse.json({ user: { - id: userData.id, - email: userData.email, - emailVerified: userData.emailVerified, - memberId: userData.memberId, + id: user.id, + email: user.email, + discordUsername: user.user_metadata?.full_name, + avatarUrl: user.user_metadata?.avatar_url, + memberId: memberData?.id ?? null, }, member: memberData ? { id: memberData.id, diff --git a/packages/web/src/app/auth/callback/route.ts b/packages/web/src/app/auth/callback/route.ts new file mode 100644 index 0000000..7652a0a --- /dev/null +++ b/packages/web/src/app/auth/callback/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get('code'); + const rawNext = searchParams.get('next') ?? '/dashboard'; + const next = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/dashboard'; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + + if (!error) { + return NextResponse.redirect(`${origin}${next}`); + } + } + + return NextResponse.redirect(`${origin}/login?error=auth`); +} diff --git a/packages/web/src/lib/admin.ts b/packages/web/src/lib/admin.ts index f64117a..7733b81 100644 --- a/packages/web/src/lib/admin.ts +++ b/packages/web/src/lib/admin.ts @@ -1,15 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { eq } from 'drizzle-orm'; -import { db } from '@/lib/db'; -import { db as sharedDb } from '@blog-study/shared'; -import { verifyToken } from '@/lib/auth'; - -const { users, members } = sharedDb; +import { createClient } from '@/lib/supabase/server'; /** * Get admin Discord IDs from environment variable - * Format: comma-separated list of Discord IDs */ export function getAdminDiscordIds(): string[] { const adminIds = process.env.ADMIN_DISCORD_IDS || ''; @@ -18,40 +11,30 @@ export function getAdminDiscordIds(): string[] { /** * Check if a Discord ID is in the admin list - * Requirement: 16.2 */ export function isAdminDiscordId(discordId: string): boolean { const adminIds = getAdminDiscordIds(); return adminIds.includes(discordId); } -/** - * Admin authentication result - */ export interface AdminAuthResult { isAuthenticated: boolean; isAdmin: boolean; userId?: string; email?: string; - memberId?: string; discordId?: string; error?: string; } /** - * Verify admin access for API routes - * Checks if the authenticated user is linked to a member with admin Discord ID - * Requirements: 16.2, 16.3 - * - * @returns AdminAuthResult with authentication and admin status + * Supabase Auth → Discord ID → admin 체크 */ export async function verifyAdminAccess(): Promise { try { - // Get auth token from cookies - const cookieStore = await cookies(); - const authToken = cookieStore.get('auth-token')?.value; + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); - if (!authToken) { + if (error || !user) { return { isAuthenticated: false, isAdmin: false, @@ -59,71 +42,29 @@ export async function verifyAdminAccess(): Promise { }; } - // Verify JWT token - const payload = verifyToken(authToken); - if (!payload) { - return { - isAuthenticated: false, - isAdmin: false, - error: '유효하지 않은 토큰입니다.', - }; - } - - // Get user from database - const database = db(); - const [userData] = await database - .select() - .from(users) - .where(eq(users.id, payload.userId)) - .limit(1); + const discordIdentity = user.identities?.find( + (identity) => identity.provider === 'discord' + ); + const discordId = discordIdentity?.id as string | undefined; - if (!userData) { - return { - isAuthenticated: false, - isAdmin: false, - error: '사용자를 찾을 수 없습니다.', - }; - } - - // Check if user is linked to a member - if (!userData.memberId) { + if (!discordId) { return { isAuthenticated: true, isAdmin: false, - userId: userData.id, - email: userData.email, - error: '스터디 멤버와 연결되지 않았습니다.', + userId: user.id, + email: user.email, + error: 'Discord 계정 정보를 찾을 수 없습니다.', }; } - // Get member info to check Discord ID - const [memberData] = await database - .select() - .from(members) - .where(eq(members.id, userData.memberId)) - .limit(1); - - if (!memberData) { - return { - isAuthenticated: true, - isAdmin: false, - userId: userData.id, - email: userData.email, - memberId: userData.memberId, - error: '멤버 정보를 찾을 수 없습니다.', - }; - } - - // Check if member's Discord ID is in admin list - const isAdmin = isAdminDiscordId(memberData.discordId); + const isAdmin = isAdminDiscordId(discordId); return { isAuthenticated: true, isAdmin, - userId: userData.id, - email: userData.email, - memberId: userData.memberId, - discordId: memberData.discordId, + userId: user.id, + email: user.email, + discordId, error: isAdmin ? undefined : '관리자 권한이 없습니다.', }; } catch (error) { @@ -136,10 +77,6 @@ export async function verifyAdminAccess(): Promise { } } -/** - * Create a 403 Forbidden response for non-admin users - * Requirement: 16.3 - */ export function createForbiddenResponse(message?: string): NextResponse { return NextResponse.json( { message: message || '관리자 권한이 필요합니다.' }, @@ -147,9 +84,6 @@ export function createForbiddenResponse(message?: string): NextResponse { ); } -/** - * Create a 401 Unauthorized response - */ export function createUnauthorizedResponse(message?: string): NextResponse { return NextResponse.json( { message: message || '인증이 필요합니다.' }, @@ -158,16 +92,7 @@ export function createUnauthorizedResponse(message?: string): NextResponse { } /** - * Higher-order function to wrap API route handlers with admin check - * Requirements: 16.2, 16.3 - * - * Usage: - * ```typescript - * export const GET = withAdminAuth(async (request, adminAuth) => { - * // Your handler code here - * // adminAuth contains user info - * }); - * ``` + * 관리자 인증 래퍼 */ export function withAdminAuth( handler: (request: NextRequest, adminAuth: AdminAuthResult) => Promise @@ -175,40 +100,14 @@ export function withAdminAuth( return async (request: NextRequest): Promise => { const adminAuth = await verifyAdminAccess(); - // Check authentication if (!adminAuth.isAuthenticated) { return createUnauthorizedResponse(adminAuth.error); } - // Check admin permission (Requirement: 16.3) if (!adminAuth.isAdmin) { return createForbiddenResponse(adminAuth.error); } - // Call the actual handler return handler(request, adminAuth); }; } - -/** - * Check admin status for a specific Discord ID - * Useful for checking admin status without full authentication flow - */ -export async function checkAdminByDiscordId(discordId: string): Promise { - return isAdminDiscordId(discordId); -} - -/** - * Get admin status for the current user (for client-side use) - * Returns admin info that can be safely exposed to the client - */ -export async function getAdminStatus(): Promise<{ - isAdmin: boolean; - discordId?: string; -}> { - const result = await verifyAdminAccess(); - return { - isAdmin: result.isAdmin, - discordId: result.discordId, - }; -} diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts deleted file mode 100644 index ef3c98d..0000000 --- a/packages/web/src/lib/auth.ts +++ /dev/null @@ -1,122 +0,0 @@ -import bcrypt from 'bcryptjs'; -import jwt from 'jsonwebtoken'; -import { randomUUID } from 'crypto'; - -const SALT_ROUNDS = 12; -const JWT_SECRET = process.env.JWT_SECRET || 'development-secret-key-min-32-chars'; -const SESSION_EXPIRY = process.env.SESSION_EXPIRY || '7d'; - -/** - * Email validation regex - * Validates standard email format - */ -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -/** - * Password requirements: - * - Minimum 8 characters - */ -const MIN_PASSWORD_LENGTH = 8; - -export interface JWTPayload { - userId: string; - email: string; - iat?: number; - exp?: number; -} - -/** - * Validate email format - */ -export function isValidEmail(email: string): boolean { - return EMAIL_REGEX.test(email); -} - -/** - * Validate password strength - * Requirements: minimum 8 characters - */ -export function isValidPassword(password: string): boolean { - return password.length >= MIN_PASSWORD_LENGTH; -} - -/** - * Hash password using bcrypt - */ -export async function hashPassword(password: string): Promise { - return bcrypt.hash(password, SALT_ROUNDS); -} - -/** - * Compare password with hash - */ -export async function comparePassword(password: string, hash: string): Promise { - return bcrypt.compare(password, hash); -} - -/** - * Generate JWT token - */ -export function generateToken(payload: Omit): string { - const expirySeconds = Math.floor(parseSessionExpiry(SESSION_EXPIRY) / 1000); - return jwt.sign(payload, JWT_SECRET, { expiresIn: expirySeconds }); -} - -/** - * Verify and decode JWT token - */ -export function verifyToken(token: string): JWTPayload | null { - try { - return jwt.verify(token, JWT_SECRET) as JWTPayload; - } catch { - return null; - } -} - -/** - * Generate email verification token - */ -export function generateVerificationToken(): string { - return randomUUID(); -} - -/** - * Calculate verification token expiry (24 hours from now) - */ -export function getVerificationExpiry(): Date { - const expiry = new Date(); - expiry.setHours(expiry.getHours() + 24); - return expiry; -} - -/** - * Parse session expiry string to milliseconds - */ -export function parseSessionExpiry(expiry: string): number { - const match = expiry.match(/^(\d+)([dhms])$/); - if (!match || !match[1] || !match[2]) return 7 * 24 * 60 * 60 * 1000; // Default 7 days - - const value = parseInt(match[1], 10); - const unit = match[2]; - - switch (unit) { - case 'd': - return value * 24 * 60 * 60 * 1000; - case 'h': - return value * 60 * 60 * 1000; - case 'm': - return value * 60 * 1000; - case 's': - return value * 1000; - default: - return 7 * 24 * 60 * 60 * 1000; - } -} - -/** - * Get session expiry date - */ -export function getSessionExpiry(): Date { - const expiryMs = parseSessionExpiry(SESSION_EXPIRY || '7d'); - return new Date(Date.now() + expiryMs); -} diff --git a/packages/web/src/lib/email.ts b/packages/web/src/lib/email.ts deleted file mode 100644 index b825f4e..0000000 --- a/packages/web/src/lib/email.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Resend } from 'resend'; - -// Lazy initialization to avoid build-time errors when API key is not set -let resendInstance: Resend | null = null; - -function getResend(): Resend { - if (!resendInstance) { - const apiKey = process.env.RESEND_API_KEY; - if (!apiKey) { - throw new Error('RESEND_API_KEY environment variable is not set'); - } - resendInstance = new Resend(apiKey); - } - return resendInstance; -} - -const EMAIL_FROM = process.env.EMAIL_FROM || 'noreply@example.com'; -const APP_URL = process.env.APP_URL || 'http://localhost:3000'; - -export interface SendEmailResult { - success: boolean; - error?: string; -} - -/** - * Send email verification email - */ -export async function sendVerificationEmail( - email: string, - token: string -): Promise { - const verificationUrl = `${APP_URL}/verify-email?token=${token}`; - - try { - const { error } = await getResend().emails.send({ - from: EMAIL_FROM, - to: email, - subject: '블로그 스터디 - 이메일 인증', - html: ` -
-

이메일 인증

-

블로그 스터디에 가입해 주셔서 감사합니다!

-

아래 버튼을 클릭하여 이메일 인증을 완료해 주세요.

- - 이메일 인증하기 - -

- 버튼이 작동하지 않으면 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요: -

-

- ${verificationUrl} -

-

- 이 링크는 24시간 동안 유효합니다. -

-
- `, - }); - - if (error) { - console.error('Failed to send verification email:', error); - return { success: false, error: error.message }; - } - - return { success: true }; - } catch (err) { - console.error('Error sending verification email:', err); - return { success: false, error: 'Failed to send email' }; - } -} - -/** - * Send password reset email (for future use) - */ -export async function sendPasswordResetEmail( - email: string, - token: string -): Promise { - const resetUrl = `${APP_URL}/reset-password?token=${token}`; - - try { - const { error } = await getResend().emails.send({ - from: EMAIL_FROM, - to: email, - subject: '블로그 스터디 - 비밀번호 재설정', - html: ` -
-

비밀번호 재설정

-

비밀번호 재설정을 요청하셨습니다.

-

아래 버튼을 클릭하여 새 비밀번호를 설정해 주세요.

- - 비밀번호 재설정 - -

- 버튼이 작동하지 않으면 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요: -

-

- ${resetUrl} -

-

- 이 링크는 1시간 동안 유효합니다. 비밀번호 재설정을 요청하지 않으셨다면 이 이메일을 무시해 주세요. -

-
- `, - }); - - if (error) { - console.error('Failed to send password reset email:', error); - return { success: false, error: error.message }; - } - - return { success: true }; - } catch (err) { - console.error('Error sending password reset email:', err); - return { success: false, error: 'Failed to send email' }; - } -} diff --git a/packages/web/src/lib/supabase/client.ts b/packages/web/src/lib/supabase/client.ts new file mode 100644 index 0000000..e6db2a1 --- /dev/null +++ b/packages/web/src/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from '@supabase/ssr'; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} diff --git a/packages/web/src/lib/supabase/middleware.ts b/packages/web/src/lib/supabase/middleware.ts new file mode 100644 index 0000000..94b4369 --- /dev/null +++ b/packages/web/src/lib/supabase/middleware.ts @@ -0,0 +1,37 @@ +import { createServerClient } from '@supabase/ssr'; +import { NextResponse, type NextRequest } from 'next/server'; + +export async function updateSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }); + + 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(); + + return { user, supabaseResponse }; +} diff --git a/packages/web/src/lib/supabase/server.ts b/packages/web/src/lib/supabase/server.ts new file mode 100644 index 0000000..14a2ffa --- /dev/null +++ b/packages/web/src/lib/supabase/server.ts @@ -0,0 +1,28 @@ +import { createServerClient } from '@supabase/ssr'; +import { cookies } from 'next/headers'; + +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // setAll이 Server Component에서 호출된 경우 무시 + // 미들웨어에서 세션 갱신을 처리함 + } + }, + }, + } + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce56aa4..5246798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,9 +142,12 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - bcryptjs: - specifier: ^2.4.3 - version: 2.4.3 + '@supabase/ssr': + specifier: ^0.8.0 + version: 0.8.0(@supabase/supabase-js@2.97.0) + '@supabase/supabase-js': + specifier: ^2.97.0 + version: 2.97.0 class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -154,12 +157,6 @@ importers: drizzle-orm: specifier: ^0.33.0 version: 0.33.0(@types/react@18.3.27)(postgres@3.4.8)(react@18.3.1) - jose: - specifier: ^5.2.0 - version: 5.10.0 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.2 lucide-react: specifier: ^0.575.0 version: 0.575.0(react@18.3.1) @@ -178,9 +175,6 @@ importers: recharts: specifier: ^2.12.7 version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - resend: - specifier: ^3.4.0 - version: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^2.3.0 version: 2.6.0 @@ -188,12 +182,6 @@ importers: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)) devDependencies: - '@types/bcryptjs': - specifier: ^2.4.6 - version: 2.4.6 - '@types/jsonwebtoken': - specifier: ^9.0.6 - version: 9.0.10 '@types/node': specifier: ^20.14.0 version: 20.19.25 @@ -881,10 +869,6 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -971,13 +955,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@one-ini/wasm@0.1.1': - resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1400,13 +1377,6 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@react-email/render@0.0.16': - resolution: {integrity: sha512-wDaMy27xAq1cJHtSFptp0DTKPuV2GYhloqia95ub/DH9Dea1aWYsbdM918MOc/b/HvVS3w1z8DWzfAk13bGStQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^18.2.0 - react-dom: ^18.2.0 - '@rollup/rollup-android-arm-eabi@4.53.3': resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} cpu: [arm] @@ -1529,21 +1499,44 @@ packages: resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@selderee/plugin-htmlparser2@0.11.0': - resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@supabase/auth-js@2.97.0': + resolution: {integrity: sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.97.0': + resolution: {integrity: sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==} + engines: {node: '>=20.0.0'} + + '@supabase/postgrest-js@2.97.0': + resolution: {integrity: sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.97.0': + resolution: {integrity: sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==} + engines: {node: '>=20.0.0'} + + '@supabase/ssr@0.8.0': + resolution: {integrity: sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==} + peerDependencies: + '@supabase/supabase-js': ^2.76.1 + + '@supabase/storage-js@2.97.0': + resolution: {integrity: sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.97.0': + resolution: {integrity: sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==} + engines: {node: '>=20.0.0'} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} - '@types/bcryptjs@2.4.6': - resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} - '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1574,12 +1567,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} @@ -1589,6 +1576,9 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/phoenix@1.6.7': + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -1683,10 +1673,6 @@ packages: resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - abbrev@2.0.0: - resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1708,10 +1694,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1720,10 +1702,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1769,9 +1747,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - bcryptjs@2.4.3: - resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1794,9 +1769,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1879,10 +1851,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - commander@10.0.1: - resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} - engines: {node: '>=14'} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1893,13 +1861,14 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - config-chain@1.1.13: - resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1982,10 +1951,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2135,26 +2100,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - editorconfig@1.0.4: - resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} - engines: {node: '>=14'} - hasBin: true - electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -2270,9 +2218,6 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} - fast-deep-equal@2.0.1: - resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2333,10 +2278,6 @@ packages: debug: optional: true - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -2385,10 +2326,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - hasBin: true - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -2427,20 +2364,17 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - html-to-text@9.0.5: - resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} - engines: {node: '>=14'} - htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - htmlparser2@8.0.2: - resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} - human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2464,9 +2398,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -2483,10 +2414,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2506,29 +2433,14 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true - jose@5.10.0: - resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-beautify@1.15.4: - resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} - engines: {node: '>=14'} - hasBin: true - - js-cookie@3.0.5: - resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} - engines: {node: '>=14'} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2548,22 +2460,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - leac@0.6.0: - resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2587,30 +2486,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} @@ -2624,9 +2502,6 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lucide-react@0.575.0: resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} peerDependencies: @@ -2668,18 +2543,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@9.0.1: - resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} - engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -2728,11 +2595,6 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nopt@7.2.1: - resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - hasBin: true - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2775,9 +2637,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2791,9 +2650,6 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parseley@0.12.1: - resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2813,10 +2669,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2830,9 +2682,6 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - peberminta@0.9.0: - resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2926,9 +2775,6 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - proto-list@1.2.4: - resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -2953,9 +2799,6 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-promise-suspense@0.3.4: - resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3023,10 +2866,6 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - resend@3.5.0: - resolution: {integrity: sha512-bKu4LhXSecP6krvhfDzyDESApYdNfjirD5kykkT1xO0Cj9TKSiGh5Void4pGTs3Am+inSnp4dg0B5XzdwHBJOQ==} - engines: {node: '>=18'} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3063,9 +2902,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3075,9 +2911,6 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - selderee@0.11.0: - resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} - semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -3127,22 +2960,10 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -3430,14 +3251,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3873,15 +3686,6 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -3941,11 +3745,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@one-ini/wasm@0.1.1': {} - - '@pkgjs/parseargs@0.11.0': - optional: true - '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4352,14 +4151,6 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-email/render@0.0.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - html-to-text: 9.0.5 - js-beautify: 1.15.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-promise-suspense: 0.3.4 - '@rollup/rollup-android-arm-eabi@4.53.3': optional: true @@ -4435,12 +4226,50 @@ snapshots: '@sapphire/snowflake@3.5.3': {} - '@selderee/plugin-htmlparser2@0.11.0': + '@sinclair/typebox@0.27.8': {} + + '@supabase/auth-js@2.97.0': dependencies: - domhandler: 5.0.3 - selderee: 0.11.0 + tslib: 2.8.1 - '@sinclair/typebox@0.27.8': {} + '@supabase/functions-js@2.97.0': + dependencies: + tslib: 2.8.1 + + '@supabase/postgrest-js@2.97.0': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.97.0': + dependencies: + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/ssr@0.8.0(@supabase/supabase-js@2.97.0)': + dependencies: + '@supabase/supabase-js': 2.97.0 + cookie: 1.1.1 + + '@supabase/storage-js@2.97.0': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.97.0': + dependencies: + '@supabase/auth-js': 2.97.0 + '@supabase/functions-js': 2.97.0 + '@supabase/postgrest-js': 2.97.0 + '@supabase/realtime-js': 2.97.0 + '@supabase/storage-js': 2.97.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate '@swc/counter@0.1.3': {} @@ -4449,8 +4278,6 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.8.1 - '@types/bcryptjs@2.4.6': {} - '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -4477,13 +4304,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 20.19.25 - - '@types/ms@2.1.0': {} - '@types/node-cron@3.0.11': {} '@types/node@20.19.25': @@ -4494,6 +4314,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/phoenix@1.6.7': {} + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -4623,8 +4445,6 @@ snapshots: '@vladfrangu/async_event_emitter@2.4.7': {} - abbrev@2.0.0: {} - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4644,16 +4464,12 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - any-promise@1.3.0: {} anymatch@3.1.3: @@ -4696,8 +4512,6 @@ snapshots: baseline-browser-mapping@2.10.0: {} - bcryptjs@2.4.3: {} - binary-extensions@2.3.0: {} boolbase@1.0.0: {} @@ -4723,8 +4537,6 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-equal-constant-time@1.0.1: {} - buffer-from@1.1.2: {} bundle-require@5.1.0(esbuild@0.27.0): @@ -4827,21 +4639,16 @@ snapshots: dependencies: delayed-stream: 1.0.0 - commander@10.0.1: {} - commander@4.1.1: {} concat-map@0.0.1: {} confbox@0.1.8: {} - config-chain@1.1.13: - dependencies: - ini: 1.3.8 - proto-list: 1.2.4 - consola@3.4.2: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4912,8 +4719,6 @@ snapshots: deep-is@0.1.4: {} - deepmerge@4.3.1: {} - delayed-stream@1.0.0: {} detect-node-es@1.1.0: {} @@ -4999,25 +4804,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - editorconfig@1.0.4: - dependencies: - '@one-ini/wasm': 0.1.1 - commander: 10.0.1 - minimatch: 9.0.1 - semver: 7.7.3 - electron-to-chromium@1.5.302: {} - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -5253,8 +5041,6 @@ snapshots: dependencies: pure-rand: 6.1.0 - fast-deep-equal@2.0.1: {} - fast-deep-equal@3.1.3: {} fast-equals@5.3.3: {} @@ -5308,11 +5094,6 @@ snapshots: follow-redirects@1.15.11: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -5366,15 +5147,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -5415,14 +5187,6 @@ snapshots: dependencies: function-bind: 1.1.2 - html-to-text@9.0.5: - dependencies: - '@selderee/plugin-htmlparser2': 0.11.0 - deepmerge: 4.3.1 - dom-serializer: 2.0.0 - htmlparser2: 8.0.2 - selderee: 0.11.0 - htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -5430,15 +5194,10 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 - htmlparser2@8.0.2: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 4.5.0 - human-signals@5.0.0: {} + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -5459,8 +5218,6 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - internmap@2.0.3: {} is-binary-path@2.1.0: @@ -5473,8 +5230,6 @@ snapshots: is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5487,28 +5242,10 @@ snapshots: isexe@2.0.0: {} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jiti@1.21.7: {} - jose@5.10.0: {} - joycon@3.1.1: {} - js-beautify@1.15.4: - dependencies: - config-chain: 1.1.13 - editorconfig: 1.0.4 - glob: 10.5.0 - js-cookie: 3.0.5 - nopt: 7.2.1 - - js-cookie@3.0.5: {} - js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -5523,36 +5260,10 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.3 - - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 - leac@0.6.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5573,22 +5284,8 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.includes@4.3.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - lodash.merge@4.6.2: {} - lodash.once@4.1.1: {} - lodash.snakecase@4.1.1: {} lodash@4.17.21: {} @@ -5601,8 +5298,6 @@ snapshots: dependencies: get-func-name: 2.0.2 - lru-cache@10.4.3: {} - lucide-react@0.575.0(react@18.3.1): dependencies: react: 18.3.1 @@ -5636,16 +5331,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 - minimatch@9.0.1: - dependencies: - brace-expansion: 2.0.2 - minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 - minipass@7.1.2: {} - mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -5701,10 +5390,6 @@ snapshots: node-releases@2.0.27: {} - nopt@7.2.1: - dependencies: - abbrev: 2.0.0 - normalize-path@3.0.0: {} npm-run-path@5.3.0: @@ -5748,8 +5433,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-json-from-dist@1.0.1: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5767,11 +5450,6 @@ snapshots: dependencies: entities: 6.0.1 - parseley@0.12.1: - dependencies: - leac: 0.6.0 - peberminta: 0.9.0 - path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5782,11 +5460,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - path-type@4.0.0: {} pathe@1.1.2: {} @@ -5795,8 +5468,6 @@ snapshots: pathval@1.1.1: {} - peberminta@0.9.0: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5875,8 +5546,6 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - proto-list@1.2.4: {} - proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -5895,10 +5564,6 @@ snapshots: react-is@18.3.1: {} - react-promise-suspense@0.3.4: - dependencies: - fast-deep-equal: 2.0.1 - react-remove-scroll-bar@2.3.8(@types/react@18.3.27)(react@18.3.1): dependencies: react: 18.3.1 @@ -5974,13 +5639,6 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 - resend@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@react-email/render': 0.0.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - transitivePeerDependencies: - - react - - react-dom - resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -6036,8 +5694,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} sax@1.4.3: {} @@ -6046,10 +5702,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - selderee@0.11.0: - dependencies: - parseley: 0.12.1 - semver@7.7.3: {} shebang-command@2.0.0: @@ -6081,26 +5733,10 @@ snapshots: streamsearch@1.1.0: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - strip-final-newline@3.0.0: {} strip-json-comments@3.1.1: {} @@ -6447,18 +6083,6 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - wrappy@1.0.2: {} ws@8.18.3: {}