refactor(admin): CRM을 apps/admin 독립 프로젝트로 분리

- apps/web에서 admin 라우트/가드/sidebar 링크 제거
- apps/admin: 독립 Next.js 앱 (포트 3001)
  - 자체 login/unauthorized/auth callback
  - admin-sidebar: Overview/Users/Subscriptions/Usage
  - requireAdmin() 가드: profile.role='admin' 체크
- monorepo workspace에 apps/admin 등록
This commit is contained in:
윤찬 2026-04-12 20:36:24 +09:00
parent daed9f90d5
commit 46673ee941
23 changed files with 536 additions and 343 deletions

View file

@ -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<React.ReactElement> {
await requireAdmin()
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<AdminSidebar />
<Box component="main" sx={{ flex: 1, overflow: 'auto', p: 4 }}>
{children}
</Box>
</Box>
)
}

View file

@ -0,0 +1,64 @@
// apps/admin/src/app/(admin)/page.tsx
// CRM 대시보드 — 요약 카드 4개
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette } from '@d3ro/ui/theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
interface StatCard {
label: string
value: string | number
color?: string
}
async function loadStats(): Promise<StatCard[]> {
const supabase = await getSupabaseServerClient()
const [profilesRes, paidRes, usageRes, expiringRes] = await Promise.all([
supabase.from('profiles').select('id', { count: 'exact', head: true }),
supabase.from('subscriptions').select('id', { count: 'exact', head: true })
.neq('tier', 'free').eq('status', 'active'),
supabase.from('daily_usage').select('count')
.eq('date', new Date().toISOString().split('T')[0]),
supabase.from('subscriptions').select('id', { count: 'exact', head: true })
.eq('status', 'active').eq('payment_provider', 'payple')
.lte('current_period_end', new Date(Date.now() + 7 * 86400000).toISOString()),
])
const todayUsage = (usageRes.data as Array<{ count: number }> | null)
?.reduce((sum, r) => sum + (r.count ?? 0), 0) ?? 0
return [
{ label: 'TOTAL USERS', value: profilesRes.count ?? 0 },
{ label: 'PAID SUBSCRIBERS', value: paidRes.count ?? 0, color: d3roPalette.tag.green },
{ label: 'TODAY API CALLS', value: todayUsage, color: d3roPalette.accent.amber },
{ label: 'EXPIRING (7D)', value: expiringRes.count ?? 0, color: d3roPalette.tag.red },
]
}
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
const stats = await loadStats()
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>OVERVIEW</PhosphorText>
<Grid container spacing={2}>
{stats.map((stat) => (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={stat.label}>
<MetalCard>
<Box sx={{ textAlign: 'center', py: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block', color: d3roPalette.text.label }}>
{stat.label}
</PhosphorText>
<PhosphorText variant="hero" sx={{ color: stat.color ?? d3roPalette.text.primary }}>
{stat.value}
</PhosphorText>
</Box>
</MetalCard>
</Grid>
))}
</Grid>
</Box>
)
}

View file

@ -0,0 +1,116 @@
// 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'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import Link from 'next/link'
interface SubRow {
id: string
user_id: string
tier: string
status: string
payment_provider: string
current_period_end: string | null
cancel_at: string | null
renewal_failures: number
profile_name: string | null
}
interface PageProps {
searchParams: Promise<{ status?: string }>
}
export default async function AdminSubscriptionsPage({ searchParams }: PageProps): Promise<React.ReactElement> {
const params = await searchParams
const statusFilter = params.status ?? 'all'
const supabase = await getSupabaseServerClient()
const subQuery = supabase
.from('subscriptions')
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at')
.order('current_period_end', { ascending: true })
.limit(100)
if (statusFilter !== 'all') {
subQuery.eq('status', statusFilter)
}
const { data: rawSubs } = await subQuery
const rawSubsArr = (rawSubs ?? []) as Array<Record<string, unknown>>
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)
: { data: [] }
const profileMap = new Map(
((rawProfiles ?? []) as Array<Record<string, unknown>>).map((p) => [p.id as string, (p.name as string) ?? null])
)
const subs: SubRow[] = rawSubsArr.map((row) => ({
id: row.id as string,
user_id: row.user_id as string,
tier: (row.tier as string) ?? 'free',
status: (row.status as string) ?? 'unknown',
payment_provider: (row.payment_provider as string) ?? 'none',
current_period_end: row.current_period_end as string | null,
cancel_at: row.cancel_at as string | null,
renewal_failures: (row.renewal_failures as number | undefined) ?? 0,
profile_name: profileMap.get(row.user_id as string) ?? null,
}))
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>SUBSCRIPTIONS</PhosphorText>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
<Link key={s} href={`/subscriptions?status=${s}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: statusFilter === s ? d3roPalette.bg.inset : 'transparent',
color: statusFilter === s ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>
{s.toUpperCase().replace('_', ' ')}
</PhosphorText>
</Link>
))}
</Box>
<MetalCard sx={{ overflow: 'auto' }}>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.75, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}>
<thead>
<tr><th>USER</th><th>TIER</th><th>STATUS</th><th>PROVIDER</th><th>EXPIRES</th><th>CANCEL</th><th>FAILS</th></tr>
</thead>
<tbody>
{subs.map((s) => (
<tr key={s.id}>
<td>
<Link href={`/users/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
{s.profile_name ?? s.user_id.substring(0, 8)}
</Link>
</td>
<td style={{ color: s.tier === 'pro_plus' ? d3roPalette.tag.purple : s.tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary }}>
{s.tier === 'pro_plus' ? 'PRO+' : s.tier.toUpperCase()}
</td>
<td style={{ color: s.status === 'active' ? d3roPalette.tag.green : s.status === 'expired' ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
{s.status.toUpperCase()}
</td>
<td>{s.payment_provider}</td>
<td style={{ color: d3roPalette.text.muted }}>{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'}</td>
<td style={{ color: s.cancel_at ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'}</td>
<td style={{ color: s.renewal_failures > 0 ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.renewal_failures}</td>
</tr>
))}
</tbody>
</Box>
</MetalCard>
</Box>
)
}

View file

@ -0,0 +1,109 @@
// 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 PageProps {
searchParams: Promise<{ days?: string }>
}
export default async function AdminUsagePage({ searchParams }: PageProps): Promise<React.ReactElement> {
const params = await searchParams
const days = parseInt(params.days ?? '7', 10)
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')
.gte('date', since)
.order('date', { ascending: false })
const rows = (rawData ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }>
const featureMap = new Map<string, { total: number; users: Set<string> }>()
for (const r of rows) {
const entry = featureMap.get(r.feature) ?? { total: 0, users: new Set<string>() }
entry.total += r.count
entry.users.add(r.user_id)
featureMap.set(r.feature, entry)
}
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<string, Map<string, number>>()
for (const r of rows) {
const dayEntry = dailyMap.get(r.date) ?? new Map<string, number>()
dayEntry.set(r.feature, (dayEntry.get(r.feature) ?? 0) + r.count)
dailyMap.set(r.date, dayEntry)
}
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 })
}
}
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>USAGE</PhosphorText>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{[7, 14, 30].map((d) => (
<a key={d} href={`/usage?days=${d}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: days === d ? d3roPalette.bg.inset : 'transparent',
color: days === d ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>{d}D</PhosphorText>
</a>
))}
</Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
{summaries.map((s) => (
<Grid size={{ xs: 6, md: 3 }} key={s.feature}>
<MetalCard>
<Box sx={{ textAlign: 'center', py: 1 }}>
<PhosphorText variant="label" sx={{ mb: 0.5, display: 'block', color: d3roPalette.text.label }}>{s.feature.toUpperCase()}</PhosphorText>
<PhosphorText variant="value" sx={{ color: d3roPalette.accent.amber }}>{s.total.toLocaleString()}</PhosphorText>
<PhosphorText variant="dim" sx={{ display: 'block', mt: 0.5 }}>{s.uniqueUsers} users</PhosphorText>
</Box>
</MetalCard>
</Grid>
))}
</Grid>
<MetalCard sx={{ overflow: 'auto' }}>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY BREAKDOWN</PhosphorText>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.5, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}>
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th></tr></thead>
<tbody>
{dailyRows.map((r, i) => (
<tr key={i}>
<td style={{ color: d3roPalette.text.muted }}>{r.date}</td>
<td>{r.feature}</td>
<td style={{ color: d3roPalette.accent.amber }}>{r.total.toLocaleString()}</td>
</tr>
))}
{dailyRows.length === 0 && (
<tr><td colSpan={3} style={{ textAlign: 'center', color: d3roPalette.text.muted }}>No usage data</td></tr>
)}
</tbody>
</Box>
</Box>
</MetalCard>
</Box>
)
}

View file

@ -0,0 +1,109 @@
// apps/admin/src/app/(admin)/users/[id]/page.tsx
// 유저 상세 — 프로필 + 구독 + 30일 사용량
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'
import { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{ id: string }>
}
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
const { id } = await params
const supabase = await getSupabaseServerClient()
const [profileRes, subRes, usageRes] = await Promise.all([
supabase.from('profiles').select('*').eq('id', id).maybeSingle(),
supabase.from('subscriptions').select('*').eq('user_id', id).maybeSingle(),
supabase.from('daily_usage').select('*')
.eq('user_id', id)
.gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0])
.order('date', { ascending: false }),
])
const profile = profileRes.data as Record<string, unknown> | null
if (!profile) notFound()
const sub = subRes.data as Record<string, unknown> | null
const usage = (usageRes.data ?? []) as Array<Record<string, unknown>>
const tier = (profile.tier as string) ?? 'free'
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>USER DETAIL</PhosphorText>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PROFILE</PhosphorText>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
<Row label="ID" value={id} />
<Row label="NAME" value={(profile.name as string) ?? '-'} />
<Row label="TIER" value={tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()} valueColor={tierColor} />
<Row label="LOCALE" value={(profile.locale as string) ?? '-'} />
<Row label="JOINED" value={new Date(profile.created_at as string).toLocaleDateString()} />
</Box>
</Box>
</MetalCard>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>SUBSCRIPTION</PhosphorText>
{sub ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} valueColor={(sub.status as string) === 'active' ? d3roPalette.tag.green : d3roPalette.tag.red} />
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? '-').toUpperCase()} />
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
<Row label="CANCEL AT" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : '-'} />
</Box>
) : (
<PhosphorText variant="dim">No subscription</PhosphorText>
)}
</Box>
</MetalCard>
</Grid>
</Grid>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>USAGE (30 DAYS)</PhosphorText>
{usage.length === 0 ? (
<PhosphorText variant="dim">No usage data</PhosphorText>
) : (
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}>
<thead><tr><th>DATE</th><th>FEATURE</th><th>COUNT</th></tr></thead>
<tbody>
{usage.map((row, i) => (
<tr key={i}>
<td>{row.date as string}</td>
<td>{row.feature as string}</td>
<td style={{ color: d3roPalette.accent.amber }}>{row.count as number}</td>
</tr>
))}
</tbody>
</Box>
)}
</Box>
</MetalCard>
</Box>
)
}
function Row({ label, value, valueColor }: { label: string; value: string; valueColor?: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: valueColor ?? d3roPalette.text.primary }}>{value}</span>
</Box>
)
}

View file

@ -0,0 +1,155 @@
// apps/admin/src/app/(admin)/users/page.tsx
// 유저 목록 — profiles + subscriptions, 검색/필터/페이지네이션
import { Box } 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'
import Link from 'next/link'
const PAGE_SIZE = 20
interface UserRow {
id: string
name: string | null
tier: string
role: string
created_at: string
subscription_status: string | null
payment_provider: string | null
}
async function loadUsers(page: number, search: string, tierFilter: string): Promise<{ users: UserRow[]; total: number }> {
const supabase = await getSupabaseServerClient()
const from = page * PAGE_SIZE
const to = from + PAGE_SIZE - 1
const profileQuery = supabase
.from('profiles')
.select('id, name, tier, created_at', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to)
if (search) {
profileQuery.ilike('name', `%${search}%`)
}
if (tierFilter && tierFilter !== 'all') {
profileQuery.eq('tier', tierFilter as 'free' | 'pro' | 'pro_plus')
}
const { data: rawProfiles, count } = await profileQuery
const profiles = (rawProfiles ?? []) as Array<Record<string, unknown>>
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 subMap = new Map(
((rawSubs ?? []) as Array<Record<string, unknown>>).map((s) => [s.user_id as string, s])
)
const { data: rawRoles } = userIds.length > 0
? await supabase.from('profiles').select('id, role').in('id', userIds)
: { data: [] }
const roleMap = new Map(
((rawRoles ?? []) as Array<Record<string, unknown>>).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,
tier: (row.tier as string) ?? 'free',
role: roleMap.get(row.id as string) ?? 'user',
created_at: row.created_at as string,
subscription_status: (sub?.status as string) ?? null,
payment_provider: (sub?.payment_provider as string) ?? null,
}
})
return { users, total: count ?? 0 }
}
interface PageProps {
searchParams: Promise<{ page?: string; search?: string; tier?: string }>
}
export default async function AdminUsersPage({ searchParams }: PageProps): Promise<React.ReactElement> {
const params = await searchParams
const page = parseInt(params.page ?? '0', 10)
const search = params.search ?? ''
const tierFilter = params.tier ?? 'all'
const { users, total } = await loadUsers(page, search, tierFilter)
const totalPages = Math.ceil(total / PAGE_SIZE)
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>USERS</PhosphorText>
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.label }}>{total} USERS</PhosphorText>
<Box sx={{ flex: 1 }} />
{['all', 'free', 'pro', 'pro_plus'].map((t) => (
<Link key={t} href={`/users?tier=${t}&search=${search}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: tierFilter === t ? d3roPalette.bg.inset : 'transparent',
color: tierFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>
{t === 'all' ? 'ALL' : t === 'pro_plus' ? 'PRO+' : t.toUpperCase()}
</PhosphorText>
</Link>
))}
</Box>
<MetalCard sx={{ overflow: 'auto' }}>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 1, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, fontWeight: d3roTypo.label.weight, textTransform: 'uppercase' },
}}>
<thead>
<tr><th>NAME</th><th>TIER</th><th>ROLE</th><th>STATUS</th><th>PROVIDER</th><th>JOINED</th></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<Link href={`/users/${u.id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
{u.name ?? u.id.substring(0, 8)}
</Link>
</td>
<td style={{ color: u.tier === 'pro_plus' ? d3roPalette.tag.purple : u.tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary }}>
{u.tier === 'pro_plus' ? 'PRO+' : u.tier.toUpperCase()}
</td>
<td style={{ color: u.role === 'admin' ? d3roPalette.tag.purple : d3roPalette.text.secondary }}>{u.role.toUpperCase()}</td>
<td style={{ color: u.subscription_status === 'active' ? d3roPalette.tag.green : d3roPalette.text.muted }}>{u.subscription_status?.toUpperCase() ?? '-'}</td>
<td style={{ color: d3roPalette.text.secondary }}>{u.payment_provider ?? '-'}</td>
<td style={{ color: d3roPalette.text.muted }}>{new Date(u.created_at).toLocaleDateString()}</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: '16px 0' }}>No users found</td></tr>
)}
</tbody>
</Box>
</MetalCard>
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
<Link key={i} href={`/users?page=${i}&tier=${tierFilter}&search=${search}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1, py: 0.5, borderRadius: 1,
bgcolor: page === i ? d3roPalette.accent.amber : d3roPalette.bg.inset,
color: page === i ? d3roPalette.bg.app : d3roPalette.text.secondary,
}}>{i + 1}</PhosphorText>
</Link>
))}
</Box>
)}
</Box>
)
}

View file

@ -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<NextResponse> {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
if (code) {
const supabase = createServerClient<Database>(
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`)
}

View file

@ -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 (
<html lang="ko">
<body>
<AppRouterCacheProvider options={{ key: 'mui' }}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
)
}

View file

@ -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<void> => {
const supabase = getSupabaseBrowserClient()
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', bgcolor: d3roPalette.bg.app }}>
<MetalCard sx={{ p: 5, maxWidth: 400, textAlign: 'center' }}>
<PhosphorText variant="title" sx={{ mb: 1 }}>D3RO ADMIN</PhosphorText>
<PhosphorText variant="dim" sx={{ mb: 4, display: 'block' }}>SaaS Management Console</PhosphorText>
<Button
variant="contained"
startIcon={<GoogleIcon />}
onClick={() => void handleGoogleLogin()}
fullWidth
sx={{
bgcolor: d3roPalette.accent.amber,
color: d3roPalette.bg.app,
fontWeight: 600,
'&:hover': { bgcolor: d3roPalette.accent.amber, filter: 'brightness(1.1)' },
}}
>
Sign in with Google
</Button>
</MetalCard>
</Box>
)
}

View file

@ -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 (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', bgcolor: d3roPalette.bg.app }}>
<MetalCard sx={{ p: 5, maxWidth: 400, textAlign: 'center' }}>
<PhosphorText variant="title" sx={{ mb: 2, color: d3roPalette.tag.red }}>ACCESS DENIED</PhosphorText>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
Admin privileges required. Contact system administrator.
</PhosphorText>
</MetalCard>
</Box>
)
}

View file

@ -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: <DashboardIcon /> },
{ key: 'users', path: '/users', label: 'Users', icon: <PeopleIcon /> },
{ key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions', icon: <SubscriptionsIcon /> },
{ key: 'usage', path: '/usage', label: 'Usage', icon: <BarChartIcon /> },
]
export function AdminSidebar(): React.ReactElement {
const pathname = usePathname()
const router = useRouter()
const handleLogout = async (): Promise<void> => {
const supabase = getSupabaseBrowserClient()
await supabase.auth.signOut()
router.replace('/login')
}
return (
<Box sx={{
width: 220,
minHeight: '100vh',
bgcolor: d3roPalette.bg.sidebar,
borderRight: `1px solid ${d3roPalette.border.default}`,
display: 'flex',
flexDirection: 'column',
}}>
<Box sx={{ p: 2.5, borderBottom: `1px solid ${d3roPalette.border.default}` }}>
<PhosphorText variant="heading">D3RO ADMIN</PhosphorText>
<PhosphorText variant="dim" sx={{ display: 'block', mt: 0.5, fontSize: 10 }}>CRM Console</PhosphorText>
</Box>
<List sx={{ flex: 1, py: 1 }}>
{NAV_ITEMS.map((item) => {
const active = item.path === '/'
? pathname === '/'
: pathname.startsWith(item.path)
return (
<ListItem key={item.key} disablePadding>
<ListItemButton
selected={active}
onClick={() => router.push(item.path)}
sx={{
fontFamily: d3roFontMono,
'&.Mui-selected': {
bgcolor: d3roPalette.bg.inset,
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
},
}}
>
<ListItemIcon sx={{ minWidth: 36, color: active ? d3roPalette.accent.amber : d3roPalette.text.inactive }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontFamily: d3roFontMono,
fontSize: 13,
color: active ? d3roPalette.text.primary : d3roPalette.text.secondary,
}}
/>
</ListItemButton>
</ListItem>
)
})}
</List>
<Box sx={{ p: 2, borderTop: `1px solid ${d3roPalette.border.default}` }}>
<Button
fullWidth
size="small"
startIcon={<LogoutIcon />}
onClick={() => void handleLogout()}
sx={{ fontFamily: d3roFontMono, fontSize: 12, color: d3roPalette.text.secondary }}
>
Logout
</Button>
</Box>
</Box>
)
}

View file

@ -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<AdminUser> {
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,
}
}

View file

@ -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<typeof createBrowserClient<Database>> | null = null
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
if (cachedClient) return cachedClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
cachedClient = createBrowserClient<Database>(url, key)
return cachedClient
}

View file

@ -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<ReturnType<typeof createServerClient<Database>>> {
const cookieStore = await cookies()
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
return createServerClient<Database>(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은 실패 가능
}
}
}
})
}