diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/admin/next.config.mjs b/apps/admin/next.config.mjs new file mode 100644 index 0000000..e2aa6e9 --- /dev/null +++ b/apps/admin/next.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: ['@d3ro/core', '@d3ro/ui', '@d3ro/api-client'], + experimental: { + optimizePackageImports: ['@mui/material', '@mui/icons-material', '@d3ro/ui'] + } +} + +export default nextConfig diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..7124681 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,33 @@ +{ + "name": "@d3ro/admin", + "version": "1.0.0", + "private": true, + "description": "D3RO Voice Admin CRM — SaaS 관리 도구", + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@d3ro/api-client": "*", + "@d3ro/core": "*", + "@d3ro/ui": "*", + "@emotion/cache": "^11.14.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.0.0", + "@mui/material": "^7.0.0", + "@mui/material-nextjs": "^7.0.0", + "@supabase/ssr": "^0.10.0", + "@supabase/supabase-js": "^2.103.0", + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" + } +} diff --git a/apps/admin/src/app/(admin)/layout.tsx b/apps/admin/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..2958c1d --- /dev/null +++ b/apps/admin/src/app/(admin)/layout.tsx @@ -0,0 +1,23 @@ +// apps/admin/src/app/(admin)/layout.tsx +// Admin 인증 가드 + Sidebar 레이아웃 + +import { Box } from '@mui/material' +import { requireAdmin } from '@/lib/admin-guard' +import { AdminSidebar } from '@/components/admin-sidebar' + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode +}): Promise { + await requireAdmin() + + return ( + + + + {children} + + + ) +} diff --git a/apps/web/src/app/(app)/admin/page.tsx b/apps/admin/src/app/(admin)/page.tsx similarity index 63% rename from apps/web/src/app/(app)/admin/page.tsx rename to apps/admin/src/app/(admin)/page.tsx index c23f030..d09398c 100644 --- a/apps/web/src/app/(app)/admin/page.tsx +++ b/apps/admin/src/app/(admin)/page.tsx @@ -1,5 +1,5 @@ -// apps/web/src/app/(app)/admin/page.tsx -// Admin CRM 대시보드 — 요약 카드 4개 +// apps/admin/src/app/(admin)/page.tsx +// CRM 대시보드 — 요약 카드 4개 import { Box, Grid } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' @@ -37,25 +37,28 @@ async function loadStats(): Promise { ] } -export default async function AdminPage(): Promise { +export default async function AdminOverviewPage(): Promise { const stats = await loadStats() return ( - - {stats.map((stat) => ( - - - - - {stat.label} - - - {stat.value} - - - - - ))} - + + OVERVIEW + + {stats.map((stat) => ( + + + + + {stat.label} + + + {stat.value} + + + + + ))} + + ) } diff --git a/apps/web/src/app/(app)/admin/subscriptions/page.tsx b/apps/admin/src/app/(admin)/subscriptions/page.tsx similarity index 68% rename from apps/web/src/app/(app)/admin/subscriptions/page.tsx rename to apps/admin/src/app/(admin)/subscriptions/page.tsx index a8e9d93..c22a049 100644 --- a/apps/web/src/app/(app)/admin/subscriptions/page.tsx +++ b/apps/admin/src/app/(admin)/subscriptions/page.tsx @@ -1,5 +1,5 @@ -// apps/web/src/app/(app)/admin/subscriptions/page.tsx -// Admin 구독 목록 — active/canceled/past_due/expired 필터 +// apps/admin/src/app/(admin)/subscriptions/page.tsx +// 구독 목록 — active/canceled/past_due/expired 필터 import { Box } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' @@ -41,7 +41,6 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps const { data: rawSubs } = await subQuery const rawSubsArr = (rawSubs ?? []) as Array> - // profiles name 조회 const userIds = rawSubsArr.map((s) => s.user_id as string) const { data: rawProfiles } = userIds.length > 0 ? await supabase.from('profiles').select('id, name').in('id', userIds) @@ -64,17 +63,16 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps return ( + SUBSCRIPTIONS + {['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => ( - - + + {s.toUpperCase().replace('_', ' ')} @@ -82,14 +80,11 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps - + USERTIERSTATUSPROVIDEREXPIRESCANCELFAILS @@ -97,7 +92,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps {subs.map((s) => ( - + {s.profile_name ?? s.user_id.substring(0, 8)} @@ -108,15 +103,9 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps {s.status.toUpperCase()} {s.payment_provider} - - {s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'} - - - {s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'} - - 0 ? d3roPalette.tag.red : d3roPalette.text.muted }}> - {s.renewal_failures} - + {s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'} + {s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'} + 0 ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.renewal_failures} ))} diff --git a/apps/web/src/app/(app)/admin/usage/page.tsx b/apps/admin/src/app/(admin)/usage/page.tsx similarity index 60% rename from apps/web/src/app/(app)/admin/usage/page.tsx rename to apps/admin/src/app/(admin)/usage/page.tsx index be13951..d40f0d8 100644 --- a/apps/web/src/app/(app)/admin/usage/page.tsx +++ b/apps/admin/src/app/(admin)/usage/page.tsx @@ -1,23 +1,11 @@ -// apps/web/src/app/(app)/admin/usage/page.tsx -// Admin 사용량 집계 — feature별, 날짜 범위 +// apps/admin/src/app/(admin)/usage/page.tsx +// 사용량 집계 — feature별, 날짜 범위 import { Box, Grid } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { getSupabaseServerClient } from '@/lib/supabase-server' -interface UsageSummary { - feature: string - total: number - uniqueUsers: number -} - -interface DailyRow { - date: string - feature: string - total: number -} - interface PageProps { searchParams: Promise<{ days?: string }> } @@ -28,7 +16,6 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0] const supabase = await getSupabaseServerClient() - const { data: rawData } = await supabase .from('daily_usage') .select('date, feature, count, user_id') @@ -37,7 +24,6 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi const rows = (rawData ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }> - // Feature별 집계 const featureMap = new Map }>() for (const r of rows) { const entry = featureMap.get(r.feature) ?? { total: 0, users: new Set() } @@ -46,11 +32,10 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi featureMap.set(r.feature, entry) } - const summaries: UsageSummary[] = Array.from(featureMap.entries()) + const summaries = Array.from(featureMap.entries()) .map(([feature, { total, users }]) => ({ feature, total, uniqueUsers: users.size })) .sort((a, b) => b.total - a.total) - // 일별 집계 const dailyMap = new Map>() for (const r of rows) { const dayEntry = dailyMap.get(r.date) ?? new Map() @@ -58,7 +43,7 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi dailyMap.set(r.date, dayEntry) } - const dailyRows: DailyRow[] = [] + const dailyRows: Array<{ date: string; feature: string; total: number }> = [] for (const [date, features] of dailyMap) { for (const [feature, total] of features) { dailyRows.push({ date, feature, total }) @@ -67,60 +52,43 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi return ( - {/* Period Filter */} + USAGE + {[7, 14, 30].map((d) => ( - - - {d}D - + + {d}D ))} - {/* Feature Summaries */} {summaries.map((s) => ( - - {s.feature.toUpperCase()} - - - {s.total.toLocaleString()} - - - {s.uniqueUsers} users - + {s.feature.toUpperCase()} + {s.total.toLocaleString()} + {s.uniqueUsers} users ))} - {/* Daily Detail Table */} DAILY BREAKDOWN - - - DATEFEATURECALLS - + + DATEFEATURECALLS {dailyRows.map((r, i) => ( @@ -130,11 +98,7 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi ))} {dailyRows.length === 0 && ( - - - No usage data - - + No usage data )} diff --git a/apps/web/src/app/(app)/admin/users/[id]/page.tsx b/apps/admin/src/app/(admin)/users/[id]/page.tsx similarity index 78% rename from apps/web/src/app/(app)/admin/users/[id]/page.tsx rename to apps/admin/src/app/(admin)/users/[id]/page.tsx index 510639a..f169730 100644 --- a/apps/web/src/app/(app)/admin/users/[id]/page.tsx +++ b/apps/admin/src/app/(admin)/users/[id]/page.tsx @@ -1,5 +1,5 @@ -// apps/web/src/app/(app)/admin/users/[id]/page.tsx -// Admin 유저 상세 — 프로필 + 구독 + 30일 사용량 +// apps/admin/src/app/(admin)/users/[id]/page.tsx +// 유저 상세 — 프로필 + 구독 + 30일 사용량 import { Box, Grid } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' @@ -29,13 +29,13 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis const sub = subRes.data as Record | null const usage = (usageRes.data ?? []) as Array> - const tier = (profile.tier as string) ?? 'free' const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber return ( - {/* Profile Card */} + USER DETAIL + @@ -45,27 +45,22 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis - - SUBSCRIPTION {sub ? ( - + - - ) : ( No subscription @@ -75,24 +70,18 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis - {/* Usage (30 days) */} USAGE (30 DAYS) {usage.length === 0 ? ( No usage data ) : ( - - - DATEFEATURECOUNT - + + DATEFEATURECOUNT {usage.map((row, i) => ( diff --git a/apps/web/src/app/(app)/admin/users/page.tsx b/apps/admin/src/app/(admin)/users/page.tsx similarity index 50% rename from apps/web/src/app/(app)/admin/users/page.tsx rename to apps/admin/src/app/(admin)/users/page.tsx index 945f1b0..a743ee3 100644 --- a/apps/web/src/app/(app)/admin/users/page.tsx +++ b/apps/admin/src/app/(admin)/users/page.tsx @@ -1,5 +1,5 @@ -// apps/web/src/app/(app)/admin/users/page.tsx -// Admin 유저 목록 — profiles JOIN subscriptions, 검색/필터/페이지네이션 +// apps/admin/src/app/(admin)/users/page.tsx +// 유저 목록 — profiles + subscriptions, 검색/필터/페이지네이션 import { Box } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' @@ -12,7 +12,6 @@ const PAGE_SIZE = 20 interface UserRow { id: string name: string | null - email: string | null tier: string role: string created_at: string @@ -23,11 +22,9 @@ interface UserRow { async function loadUsers(page: number, search: string, tierFilter: string): Promise<{ users: UserRow[]; total: number }> { const supabase = await getSupabaseServerClient() - // profiles 조회 (DB 타입에 role/조인이 미정의이므로 raw cast) const from = page * PAGE_SIZE const to = from + PAGE_SIZE - 1 - // 단순 profiles 조회 + 별도 subscriptions 조회 const profileQuery = supabase .from('profiles') .select('id, name, tier, created_at', { count: 'exact' }) @@ -37,7 +34,6 @@ async function loadUsers(page: number, search: string, tierFilter: string): Prom if (search) { profileQuery.ilike('name', `%${search}%`) } - if (tierFilter && tierFilter !== 'all') { profileQuery.eq('tier', tierFilter as 'free' | 'pro' | 'pro_plus') } @@ -45,27 +41,26 @@ async function loadUsers(page: number, search: string, tierFilter: string): Prom const { data: rawProfiles, count } = await profileQuery const profiles = (rawProfiles ?? []) as Array> - // 해당 유저들의 구독 정보 조회 const userIds = profiles.map((p) => p.id as string) const { data: rawSubs } = userIds.length > 0 ? await supabase.from('subscriptions').select('user_id, status, payment_provider').in('user_id', userIds) : { data: [] } - const subs = (rawSubs ?? []) as Array> - const subMap = new Map(subs.map((s) => [s.user_id as string, s])) + const subMap = new Map( + ((rawSubs ?? []) as Array>).map((s) => [s.user_id as string, s]) + ) - // role 조회 (별도 raw query — DB 타입에 role 미정의) const { data: rawRoles } = userIds.length > 0 ? await supabase.from('profiles').select('id, role').in('id', userIds) : { data: [] } - const roles = (rawRoles ?? []) as Array> - const roleMap = new Map(roles.map((r) => [r.id as string, (r.role as string) ?? 'user'])) + const roleMap = new Map( + ((rawRoles ?? []) as Array>).map((r) => [r.id as string, (r.role as string) ?? 'user']) + ) const users: UserRow[] = profiles.map((row) => { const sub = subMap.get(row.id as string) return { id: row.id as string, name: row.name as string | null, - email: null, tier: (row.tier as string) ?? 'free', role: roleMap.get(row.id as string) ?? 'user', created_at: row.created_at as string, @@ -91,110 +86,66 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi return ( - {/* Filters */} + USERS + - - {total} USERS - + {total} USERS {['all', 'free', 'pro', 'pro_plus'].map((t) => ( - - + + {t === 'all' ? 'ALL' : t === 'pro_plus' ? 'PRO+' : t.toUpperCase()} ))} - {/* Table */} - + - - NAME - TIER - ROLE - STATUS - PROVIDER - JOINED - + NAMETIERROLESTATUSPROVIDERJOINED {users.map((u) => ( - + {u.name ?? u.id.substring(0, 8)} - - - - - {u.role.toUpperCase()} - - - {u.subscription_status?.toUpperCase() ?? '-'} - - - {u.payment_provider ?? '-'} - - - {new Date(u.created_at).toLocaleDateString()} + + {u.tier === 'pro_plus' ? 'PRO+' : u.tier.toUpperCase()} + {u.role.toUpperCase()} + {u.subscription_status?.toUpperCase() ?? '-'} + {u.payment_provider ?? '-'} + {new Date(u.created_at).toLocaleDateString()} ))} {users.length === 0 && ( - - - No users found - - + No users found )} - {/* Pagination */} {totalPages > 1 && ( {Array.from({ length: Math.min(totalPages, 10) }, (_, i) => ( - - - {i + 1} - + + {i + 1} ))} @@ -202,9 +153,3 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi ) } - -function TierBadge({ tier }: { tier: string }): React.ReactElement { - const color = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary - const label = tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase() - return {label} -} diff --git a/apps/admin/src/app/auth/callback/route.ts b/apps/admin/src/app/auth/callback/route.ts new file mode 100644 index 0000000..7573902 --- /dev/null +++ b/apps/admin/src/app/auth/callback/route.ts @@ -0,0 +1,42 @@ +// apps/admin/src/app/auth/callback/route.ts +// OAuth 콜백 핸들러 + +import { NextResponse, type NextRequest } from 'next/server' +import { createServerClient, type CookieOptions } from '@supabase/ssr' +import type { Database } from '@d3ro/api-client' + +export async function GET(request: NextRequest): Promise { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + + if (code) { + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) { + cookiesToSet.forEach(({ name, value, options }) => { + request.cookies.set({ name, value, ...options }) + }) + }, + }, + } + ) + + const { error } = await supabase.auth.exchangeCodeForSession(code) + if (!error) { + const response = NextResponse.redirect(`${origin}/`) + // 세션 쿠키를 응답에 복사 + request.cookies.getAll().forEach((cookie) => { + response.cookies.set(cookie.name, cookie.value) + }) + return response + } + } + + return NextResponse.redirect(`${origin}/login`) +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..6223299 --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,30 @@ +'use client' + +// apps/admin/src/app/layout.tsx +// Admin CRM Root Layout — 항상 dark 모드 + +import { useMemo } from 'react' +import { ThemeProvider, CssBaseline } from '@mui/material' +import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter' +import { getTheme } from '@d3ro/ui/theme' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}): React.ReactElement { + const theme = useMemo(() => getTheme('dark', true), []) + + return ( + + + + + + {children} + + + + + ) +} diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx new file mode 100644 index 0000000..19d27f1 --- /dev/null +++ b/apps/admin/src/app/login/page.tsx @@ -0,0 +1,45 @@ +'use client' + +// apps/admin/src/app/login/page.tsx +// Admin 로그인 — Google OAuth + +import { Box, Button } from '@mui/material' +import GoogleIcon from '@mui/icons-material/Google' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +export default function AdminLoginPage(): React.ReactElement { + const handleGoogleLogin = async (): Promise => { + const supabase = getSupabaseBrowserClient() + await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: `${window.location.origin}/auth/callback`, + }, + }) + } + + return ( + + + D3RO ADMIN + SaaS Management Console + + + + ) +} diff --git a/apps/admin/src/app/unauthorized/page.tsx b/apps/admin/src/app/unauthorized/page.tsx new file mode 100644 index 0000000..514ecd9 --- /dev/null +++ b/apps/admin/src/app/unauthorized/page.tsx @@ -0,0 +1,18 @@ +// apps/admin/src/app/unauthorized/page.tsx + +import { Box } from '@mui/material' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette } from '@d3ro/ui/theme' + +export default function UnauthorizedPage(): React.ReactElement { + return ( + + + ACCESS DENIED + + Admin privileges required. Contact system administrator. + + + + ) +} diff --git a/apps/admin/src/components/admin-sidebar.tsx b/apps/admin/src/components/admin-sidebar.tsx new file mode 100644 index 0000000..2967c5e --- /dev/null +++ b/apps/admin/src/components/admin-sidebar.tsx @@ -0,0 +1,96 @@ +'use client' + +// apps/admin/src/components/admin-sidebar.tsx +// Admin CRM 사이드바 + +import { usePathname, useRouter } from 'next/navigation' +import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Button } from '@mui/material' +import DashboardIcon from '@mui/icons-material/Dashboard' +import PeopleIcon from '@mui/icons-material/People' +import SubscriptionsIcon from '@mui/icons-material/Subscriptions' +import BarChartIcon from '@mui/icons-material/BarChart' +import LogoutIcon from '@mui/icons-material/Logout' +import { PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +const NAV_ITEMS = [ + { key: 'overview', path: '/', label: 'Overview', icon: }, + { key: 'users', path: '/users', label: 'Users', icon: }, + { key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions', icon: }, + { key: 'usage', path: '/usage', label: 'Usage', icon: }, +] + +export function AdminSidebar(): React.ReactElement { + const pathname = usePathname() + const router = useRouter() + + const handleLogout = async (): Promise => { + const supabase = getSupabaseBrowserClient() + await supabase.auth.signOut() + router.replace('/login') + } + + return ( + + + D3RO ADMIN + CRM Console + + + + {NAV_ITEMS.map((item) => { + const active = item.path === '/' + ? pathname === '/' + : pathname.startsWith(item.path) + return ( + + router.push(item.path)} + sx={{ + fontFamily: d3roFontMono, + '&.Mui-selected': { + bgcolor: d3roPalette.bg.inset, + borderLeft: `3px solid ${d3roPalette.accent.amber}`, + }, + }} + > + + {item.icon} + + + + + ) + })} + + + + + + + ) +} diff --git a/apps/admin/src/lib/admin-guard.ts b/apps/admin/src/lib/admin-guard.ts new file mode 100644 index 0000000..c813b98 --- /dev/null +++ b/apps/admin/src/lib/admin-guard.ts @@ -0,0 +1,49 @@ +// apps/admin/src/lib/admin-guard.ts +// RSC용 admin 가드 — profile.role='admin' 체크 + +import { redirect } from 'next/navigation' +import { getSupabaseServerClient } from './supabase-server' + +export interface AdminUser { + id: string + email: string | null + name: string | null +} + +export async function requireAdmin(): Promise { + const supabase = await getSupabaseServerClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + redirect('/login') + } + + // role은 DB 타입에 미정의이므로 raw 캐스팅 + const { data: profile } = await supabase + .from('profiles') + .select('id, name') + .eq('id', user.id) + .maybeSingle() + + if (!profile) { + redirect('/login') + } + + // role 별도 조회 (DB 타입에 role 컬럼 미정의) + const { data: roleData } = await supabase + .from('profiles') + .select('role' as 'id') + .eq('id', user.id) + .maybeSingle() + + const role = (roleData as unknown as { role: string } | null)?.role + if (role !== 'admin') { + redirect('/unauthorized') + } + + return { + id: user.id, + email: user.email ?? null, + name: (profile as { name: string | null }).name, + } +} diff --git a/apps/admin/src/lib/supabase-browser.ts b/apps/admin/src/lib/supabase-browser.ts new file mode 100644 index 0000000..ab3326e --- /dev/null +++ b/apps/admin/src/lib/supabase-browser.ts @@ -0,0 +1,17 @@ +// apps/admin/src/lib/supabase-browser.ts +// 클라이언트 컴포넌트용 Supabase 클라이언트 + +import { createBrowserClient } from '@supabase/ssr' +import type { Database } from '@d3ro/api-client' + +let cachedClient: ReturnType> | null = null + +export function getSupabaseBrowserClient(): ReturnType> { + if (cachedClient) return cachedClient + + const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '' + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '' + + cachedClient = createBrowserClient(url, key) + return cachedClient +} diff --git a/apps/admin/src/lib/supabase-server.ts b/apps/admin/src/lib/supabase-server.ts new file mode 100644 index 0000000..47c1db2 --- /dev/null +++ b/apps/admin/src/lib/supabase-server.ts @@ -0,0 +1,30 @@ +// apps/admin/src/lib/supabase-server.ts +// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션) + +import { cookies } from 'next/headers' +import { createServerClient, type CookieOptions } from '@supabase/ssr' +import type { Database } from '@d3ro/api-client' + +export async function getSupabaseServerClient(): Promise>> { + const cookieStore = await cookies() + + const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '' + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '' + + return createServerClient(url, key, { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) { + try { + cookiesToSet.forEach(({ name, value, options }) => { + cookieStore.set(name, value, options) + }) + } catch { + // RSC에서 set은 실패 가능 + } + } + } + }) +} diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..65eb694 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/web/src/app/(app)/admin/admin-nav.tsx b/apps/web/src/app/(app)/admin/admin-nav.tsx deleted file mode 100644 index 058f21e..0000000 --- a/apps/web/src/app/(app)/admin/admin-nav.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client' - -// apps/web/src/app/(app)/admin/admin-nav.tsx -// Admin 하위 네비게이션 탭 - -import { usePathname, useRouter } from 'next/navigation' -import { Box } from '@mui/material' -import { PhosphorText } from '@d3ro/ui/components/ds' -import { d3roPalette } from '@d3ro/ui/theme' - -interface NavTab { - path: string - label: string -} - -const TABS: NavTab[] = [ - { path: '/admin', label: 'OVERVIEW' }, - { path: '/admin/users', label: 'USERS' }, - { path: '/admin/subscriptions', label: 'SUBSCRIPTIONS' }, - { path: '/admin/usage', label: 'USAGE' }, -] - -export function AdminNav(): React.ReactElement { - const pathname = usePathname() - const router = useRouter() - - return ( - - {TABS.map((tab) => { - const active = tab.path === '/admin' - ? pathname === '/admin' - : pathname.startsWith(tab.path) - return ( - router.push(tab.path)} - sx={{ - px: 2, - py: 0.75, - cursor: 'pointer', - borderRadius: 1, - bgcolor: active ? d3roPalette.bg.inset : 'transparent', - borderBottom: active ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent', - transition: 'all 0.15s ease', - '&:hover': { bgcolor: d3roPalette.bg.inset }, - }} - > - - {tab.label} - - - ) - })} - - ) -} diff --git a/apps/web/src/app/(app)/admin/layout.tsx b/apps/web/src/app/(app)/admin/layout.tsx deleted file mode 100644 index ad4e468..0000000 --- a/apps/web/src/app/(app)/admin/layout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -// apps/web/src/app/(app)/admin/layout.tsx -// Admin 전용 레이아웃 — requireAdmin() 가드 적용 - -import { Box } from '@mui/material' -import { PhosphorText } from '@d3ro/ui/components/ds' -import { d3roPalette } from '@d3ro/ui/theme' -import { requireAdmin } from '@/lib/admin-guard' -import { AdminNav } from './admin-nav' - -export default async function AdminLayout({ - children, -}: { - children: React.ReactNode -}): Promise { - await requireAdmin() - - return ( - - - ADMIN - - - - {children} - - ) -} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 2b2e2bb..dd67355 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -25,7 +25,6 @@ import LibraryBooksIcon from '@mui/icons-material/LibraryBooks' import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' import GroupsIcon from '@mui/icons-material/Groups' import PaymentIcon from '@mui/icons-material/Payment' -import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings' import LogoutIcon from '@mui/icons-material/Logout' import PaletteIcon from '@mui/icons-material/Palette' import type { ThemeMode } from '@d3ro/core/types' @@ -100,12 +99,6 @@ export function Sidebar(): React.ReactElement { path: '/billing', label: t('nav.billing') ?? 'Billing', icon: - }, - { - key: 'admin', - path: '/admin', - label: 'Admin', - icon: } ] diff --git a/apps/web/src/lib/admin-guard.ts b/apps/web/src/lib/admin-guard.ts deleted file mode 100644 index 4bb913e..0000000 --- a/apps/web/src/lib/admin-guard.ts +++ /dev/null @@ -1,32 +0,0 @@ -// apps/web/src/lib/admin-guard.ts -// RSC용 admin 가드 — profile.role='admin' 체크, 실패 시 redirect - -import { redirect } from 'next/navigation' -import { getSupabaseServerClient } from './supabase-server' - -interface AdminProfile { - id: string - name: string | null - role: string -} - -export async function requireAdmin(): Promise { - const supabase = await getSupabaseServerClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - redirect('/login') - } - - const { data: profile } = await supabase - .from('profiles') - .select('id, name, role') - .eq('id', user.id) - .maybeSingle() - - if (!profile || (profile as { role: string }).role !== 'admin') { - redirect('/dashboard') - } - - return profile as AdminProfile -} diff --git a/package.json b/package.json index 98ddeff..8d66c7f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "workspaces": [ "apps/desktop", "apps/web", + "apps/admin", "packages/*" ], "scripts": {