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:
parent
daed9f90d5
commit
46673ee941
23 changed files with 536 additions and 343 deletions
6
apps/admin/next-env.d.ts
vendored
Normal file
6
apps/admin/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
10
apps/admin/next.config.mjs
Normal file
10
apps/admin/next.config.mjs
Normal file
|
|
@ -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
|
||||
33
apps/admin/package.json
Normal file
33
apps/admin/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
23
apps/admin/src/app/(admin)/layout.tsx
Normal file
23
apps/admin/src/app/(admin)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<StatCard[]> {
|
|||
]
|
||||
}
|
||||
|
||||
export default async function AdminPage(): Promise<React.ReactElement> {
|
||||
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||
const stats = await loadStats()
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<Record<string, unknown>>
|
||||
|
||||
// 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 (
|
||||
<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={`/admin/subscriptions?status=${s}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText
|
||||
variant="label"
|
||||
sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1, cursor: 'pointer',
|
||||
bgcolor: statusFilter === s ? d3roPalette.bg.inset : 'transparent',
|
||||
color: statusFilter === s ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
|
@ -82,14 +80,11 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
</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', letterSpacing: d3roTypo.label.spacing },
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
|
@ -97,7 +92,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
{subs.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<Link href={`/admin/users/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
<Link href={`/users/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
{s.profile_name ?? s.user_id.substring(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
|
|
@ -108,15 +103,9 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
{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>
|
||||
<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>
|
||||
|
|
@ -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<string, { total: number; users: Set<string> }>()
|
||||
for (const r of rows) {
|
||||
const entry = featureMap.get(r.feature) ?? { total: 0, users: new Set<string>() }
|
||||
|
|
@ -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<string, Map<string, number>>()
|
||||
for (const r of rows) {
|
||||
const dayEntry = dailyMap.get(r.date) ?? new Map<string, number>()
|
||||
|
|
@ -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 (
|
||||
<Box>
|
||||
{/* Period Filter */}
|
||||
<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={`/admin/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 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>
|
||||
|
||||
{/* Feature Summaries */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Daily Detail Table */}
|
||||
<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>
|
||||
<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}>
|
||||
|
|
@ -130,11 +98,7 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
</tr>
|
||||
))}
|
||||
{dailyRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ textAlign: 'center', color: d3roPalette.text.muted }}>
|
||||
No usage data
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colSpan={3} style={{ textAlign: 'center', color: d3roPalette.text.muted }}>No usage data</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Box>
|
||||
|
|
@ -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<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>
|
||||
{/* Profile Card */}
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>USER DETAIL</PhosphorText>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
|
|
@ -45,27 +45,22 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
<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="ROLE" value={((profile.role as string) ?? 'user').toUpperCase()} />
|
||||
<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="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 START" value={sub.current_period_start ? new Date(sub.current_period_start as string).toLocaleDateString() : '-'} />
|
||||
<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() : '-'} />
|
||||
<Row label="FAILURES" value={String(sub.renewal_failures ?? 0)} />
|
||||
</Box>
|
||||
) : (
|
||||
<PhosphorText variant="dim">No subscription</PhosphorText>
|
||||
|
|
@ -75,24 +70,18 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Usage (30 days) */}
|
||||
<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>
|
||||
<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}>
|
||||
|
|
@ -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<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 subs = (rawSubs ?? []) as Array<Record<string, unknown>>
|
||||
const subMap = new Map(subs.map((s) => [s.user_id as string, s]))
|
||||
const subMap = new Map(
|
||||
((rawSubs ?? []) as Array<Record<string, unknown>>).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<Record<string, unknown>>
|
||||
const roleMap = new Map(roles.map((r) => [r.id as string, (r.role as string) ?? 'user']))
|
||||
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,
|
||||
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 (
|
||||
<Box>
|
||||
{/* Filters */}
|
||||
<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>
|
||||
<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={`/admin/users?tier=${t}&search=${search}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<PhosphorText
|
||||
variant="label"
|
||||
sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1, cursor: 'pointer',
|
||||
bgcolor: tierFilter === t ? d3roPalette.bg.inset : 'transparent',
|
||||
color: tierFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Table */}
|
||||
<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', letterSpacing: d3roTypo.label.spacing },
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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={`/admin/users/${u.id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
<Link href={`/users/${u.id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
{u.name ?? u.id.substring(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<TierBadge tier={u.tier} />
|
||||
</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 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>
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: '16px 0' }}>No users found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
|
||||
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/admin/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 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>
|
||||
|
|
@ -202,9 +153,3 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 <span style={{ color }}>{label}</span>
|
||||
}
|
||||
42
apps/admin/src/app/auth/callback/route.ts
Normal file
42
apps/admin/src/app/auth/callback/route.ts
Normal 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`)
|
||||
}
|
||||
30
apps/admin/src/app/layout.tsx
Normal file
30
apps/admin/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
apps/admin/src/app/login/page.tsx
Normal file
45
apps/admin/src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
apps/admin/src/app/unauthorized/page.tsx
Normal file
18
apps/admin/src/app/unauthorized/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
apps/admin/src/components/admin-sidebar.tsx
Normal file
96
apps/admin/src/components/admin-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
apps/admin/src/lib/admin-guard.ts
Normal file
49
apps/admin/src/lib/admin-guard.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
17
apps/admin/src/lib/supabase-browser.ts
Normal file
17
apps/admin/src/lib/supabase-browser.ts
Normal 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
|
||||
}
|
||||
30
apps/admin/src/lib/supabase-server.ts
Normal file
30
apps/admin/src/lib/supabase-server.ts
Normal 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은 실패 가능
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
25
apps/admin/tsconfig.json
Normal file
25
apps/admin/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
|
||||
{TABS.map((tab) => {
|
||||
const active = tab.path === '/admin'
|
||||
? pathname === '/admin'
|
||||
: pathname.startsWith(tab.path)
|
||||
return (
|
||||
<Box
|
||||
key={tab.path}
|
||||
onClick={() => 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 },
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="label" sx={{ color: active ? d3roPalette.accent.amber : d3roPalette.text.secondary }}>
|
||||
{tab.label}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4, pb: 8 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">ADMIN</PhosphorText>
|
||||
<Box sx={{ height: 1, flex: 1, bgcolor: d3roPalette.border.subtle }} />
|
||||
</Box>
|
||||
<AdminNav />
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: <PaymentIcon />
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
path: '/admin',
|
||||
label: 'Admin',
|
||||
icon: <AdminPanelSettingsIcon />
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AdminProfile> {
|
||||
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
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
"workspaces": [
|
||||
"apps/desktop",
|
||||
"apps/web",
|
||||
"apps/admin",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue