feat(admin): Phase V2-6 Admin CRM 고도화 — CRUD + 차트 + 결제 + 감사

- DB: audit_log 테이블(diff 포함) + subscriptions.admin_note + super_admin role
- Edge Functions 4개: admin-users, admin-subscriptions, admin-payments, admin-audit-log
- 공유 유틸: admin-auth.ts(권한 검증), audit.ts(감사로그 기록)
- Swagger UI: 독립 정적 페이지 + OpenAPI 3.0 spec
- CRUD 페이지: 구독 생성/수정/삭제, role 변경, 감사로그 목록/상세
- recharts: feature별 StackedBar + DAU Line + Top Users HorizontalBar
- 결제 이력: DB + Payple API 병행 조회
- 권한: super_admin만 위험 작업, admin은 조회 전용
- RLS: admin/super_admin IN 정책 + super_admin 쓰기 정책
- SQL RPC: admin_usage_by_feature, admin_top_users, admin_dau
This commit is contained in:
윤찬 2026-04-12 21:32:47 +09:00
parent f7c50eb2ed
commit dca1b90faa
34 changed files with 3142 additions and 45 deletions

View file

@ -23,7 +23,8 @@
"@supabase/supabase-js": "^2.103.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"recharts": "^2.15.0"
},
"devDependencies": {
"@types/node": "^22.13.0",

View file

@ -0,0 +1,96 @@
// apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
// 감사로그 상세 — before/after diff 뷰
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 { requireAdmin } from '@/lib/admin-guard'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
interface PageProps {
params: Promise<{ id: string }>
}
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
await requireAdmin()
const { id } = await params
const supabase = await getSupabaseServerClient()
const { data: log } = await supabase
.from('audit_log')
.select('*')
.eq('id', parseInt(id, 10))
.maybeSingle()
if (!log) notFound()
const typedLog = log as Record<string, unknown>
// Admin profile
const { data: adminProfile } = await supabase
.from('profiles')
.select('id, name')
.eq('id', typedLog.admin_id as string)
.maybeSingle()
const adminName = (adminProfile as { name: string | null } | null)?.name ?? 'Unknown'
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">AUDIT LOG #{id}</PhosphorText>
<Link href="/audit-log" style={{ textDecoration: 'none' }}>
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
</Link>
</Box>
<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' }}>DETAILS</PhosphorText>
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Row label="ACTION" value={typedLog.action as string} />
<Row label="ADMIN" value={adminName} />
<Row label="TARGET TYPE" value={typedLog.target_type as string} />
<Row label="TARGET ID" value={typedLog.target_id as string} />
<Row label="DATE" value={new Date(typedLog.created_at as string).toLocaleString()} />
</Box>
</Box>
</MetalCard>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>MEMO</PhosphorText>
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
{typedLog.memo as string}
</PhosphorText>
</Box>
</MetalCard>
</Grid>
</Grid>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CHANGES (DIFF)</PhosphorText>
<AuditDiffViewer
beforeData={typedLog.before_data as Record<string, unknown> | null}
afterData={typedLog.after_data as Record<string, unknown> | null}
/>
</Box>
</MetalCard>
</Box>
)
}
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
</Box>
)
}

View file

@ -0,0 +1,137 @@
// apps/admin/src/app/(admin)/audit-log/page.tsx
// 감사로그 목록
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 { requireAdmin } from '@/lib/admin-guard'
import Link from 'next/link'
interface PageProps {
searchParams: Promise<{ target_type?: string; page?: string }>
}
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
await requireAdmin()
const params = await searchParams
const targetTypeFilter = params.target_type ?? 'all'
const page = parseInt(params.page ?? '1', 10)
const limit = 20
const from = (page - 1) * limit
const to = from + limit - 1
const supabase = await getSupabaseServerClient()
let query = supabase
.from('audit_log')
.select('*', { count: 'exact' })
if (targetTypeFilter !== 'all') {
query = query.eq('target_type', targetTypeFilter)
}
const { data: rawLogs, count } = await query
.order('created_at', { ascending: false })
.range(from, to)
const logs = (rawLogs ?? []) as Array<Record<string, unknown>>
const totalPages = Math.ceil((count ?? 0) / limit)
// Admin names
const adminIds = [...new Set(logs.map(l => l.admin_id as string))]
let adminMap: Record<string, string> = {}
if (adminIds.length > 0) {
const { data: admins } = await supabase
.from('profiles')
.select('id, name')
.in('id', adminIds)
if (admins) {
adminMap = Object.fromEntries(
(admins as Array<{ id: string; name: string | null }>).map(a => [a.id, a.name ?? 'Unknown'])
)
}
}
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>AUDIT LOG</PhosphorText>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{['all', 'subscription', 'profile'].map((t) => (
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: targetTypeFilter === t ? d3roPalette.bg.inset : 'transparent',
color: targetTypeFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>
{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: 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>ADMIN</th><th>ACTION</th><th>TARGET</th><th>MEMO</th><th>DETAIL</th></tr></thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No audit logs</td></tr>
) : (
logs.map((log) => (
<tr key={log.id as number}>
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
{new Date(log.created_at as string).toLocaleString()}
</td>
<td>{adminMap[log.admin_id as string] ?? (log.admin_id as string).substring(0, 8)}</td>
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
<td>
<Link
href={
(log.target_type as string) === 'subscription'
? `/subscriptions/${log.target_id as string}`
: `/users/${log.target_id as string}`
}
style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}
>
{(log.target_id as string).substring(0, 8)}...
</Link>
</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{log.memo as string}
</td>
<td>
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
View
</Link>
</td>
</tr>
))
)}
</tbody>
</Box>
</MetalCard>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
{Array.from({ length: totalPages }, (_, i) => i + 1).slice(0, 10).map((p) => (
<Link key={p} href={`/audit-log?target_type=${targetTypeFilter}&page=${p}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1, py: 0.25, borderRadius: 0.5,
bgcolor: p === page ? d3roPalette.bg.inset : 'transparent',
color: p === page ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>
{p}
</PhosphorText>
</Link>
))}
</Box>
)}
</Box>
)
}

View file

@ -0,0 +1,95 @@
'use client'
// apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
// 구독 수정/삭제 클라이언트 컴포넌트
import { useState } from 'react'
import { Box, Button } from '@mui/material'
import { d3roFontMono } from '@d3ro/ui/theme'
import { SubscriptionForm } from '@/components/subscription-form'
import { MemoDialog } from '@/components/memo-dialog'
import { callAdminApi } from '@/lib/admin-api'
import { useRouter } from 'next/navigation'
type Tier = 'free' | 'pro' | 'pro_plus'
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
interface SubscriptionDetailClientProps {
userId: string
hasSub: boolean
isSuperAdmin: boolean
initialSub?: {
tier: Tier
status: SubStatus
currentPeriodEnd: string | null
overageCredits: number
adminNote: string | null
}
}
export function SubscriptionDetailClient({
userId, hasSub, isSuperAdmin, initialSub,
}: SubscriptionDetailClientProps): React.ReactElement {
const router = useRouter()
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteLoading, setDeleteLoading] = useState(false)
const handleDelete = async (memo: string): Promise<void> => {
setDeleteLoading(true)
try {
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
method: 'DELETE',
body: JSON.stringify({ memo }),
})
setDeleteOpen(false)
router.refresh()
} catch {
// error handled in dialog
} finally {
setDeleteLoading(false)
}
}
if (!isSuperAdmin) {
return <Box />
}
return (
<Box>
{hasSub && initialSub ? (
<>
<SubscriptionForm
mode="edit"
userId={userId}
initial={initialSub}
onSuccess={() => router.refresh()}
/>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="outlined"
color="error"
onClick={() => setDeleteOpen(true)}
sx={{ fontFamily: d3roFontMono }}
>
Delete Subscription
</Button>
</Box>
<MemoDialog
open={deleteOpen}
title="DELETE SUBSCRIPTION"
description={`This will soft-delete the subscription for user ${userId}. The subscription will be set to expired/free.`}
onConfirm={(memo) => void handleDelete(memo)}
onCancel={() => setDeleteOpen(false)}
loading={deleteLoading}
/>
</>
) : (
<SubscriptionForm
mode="create"
userId={userId}
onSuccess={() => router.refresh()}
/>
)}
</Box>
)
}

View file

@ -0,0 +1,140 @@
// apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
// 구독 상세 + 수정/삭제 (super_admin) — [id]는 user_id
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 { requireAdmin } from '@/lib/admin-guard'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { SubscriptionDetailClient } from './client'
interface PageProps {
params: Promise<{ id: string }>
}
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
const { id: userId } = await params
const admin = await requireAdmin()
const supabase = await getSupabaseServerClient()
const [subRes, profileRes, auditRes] = await Promise.all([
supabase.from('subscriptions').select('*').eq('user_id', userId).maybeSingle(),
supabase.from('profiles').select('id, name, tier, role').eq('id', userId).maybeSingle(),
supabase.from('audit_log').select('*')
.eq('target_id', userId)
.eq('target_type', 'subscription')
.order('created_at', { ascending: false })
.limit(20),
])
const sub = subRes.data as Record<string, unknown> | null
const profile = profileRes.data as Record<string, unknown> | null
if (!profile) notFound()
const auditLogs = (auditRes.data ?? []) as Array<Record<string, unknown>>
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">SUBSCRIPTION DETAIL</PhosphorText>
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
</Link>
</Box>
<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' }}>USER</PhosphorText>
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Row label="NAME" value={(profile.name as string) ?? '-'} />
<Row label="ID" value={userId} />
<Row label="ROLE" value={((profile.role as string) ?? 'user').toUpperCase()} />
</Box>
</Box>
</MetalCard>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CURRENT SUBSCRIPTION</PhosphorText>
{sub ? (
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Row label="TIER" value={((sub.tier as string) ?? 'free').toUpperCase()} />
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} />
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
<Row label="OVERAGE" value={String(sub.overage_credits ?? 0)} />
<Row label="NOTE" value={(sub.admin_note as string) ?? '-'} />
</Box>
) : (
<PhosphorText variant="dim">No subscription record</PhosphorText>
)}
</Box>
</MetalCard>
</Grid>
</Grid>
{/* Client component for CRUD actions */}
<SubscriptionDetailClient
userId={userId}
hasSub={!!sub}
isSuperAdmin={admin.role === 'super_admin'}
initialSub={sub ? ({
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
overageCredits: (sub.overage_credits as number) ?? 0,
adminNote: (sub.admin_note as string | null) ?? null,
}) : undefined}
/>
{/* Audit trail */}
<Box sx={{ mt: 3 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>AUDIT TRAIL</PhosphorText>
{auditLogs.length === 0 ? (
<PhosphorText variant="dim">No audit records</PhosphorText>
) : (
<MetalCard sx={{ overflow: 'auto' }}>
<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 },
}}>
<thead><tr><th>DATE</th><th>ACTION</th><th>MEMO</th><th>DETAIL</th></tr></thead>
<tbody>
{auditLogs.map((log) => (
<tr key={log.id as number}>
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
{new Date(log.created_at as string).toLocaleString()}
</td>
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
<td>{(log.memo as string).substring(0, 50)}</td>
<td>
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
View
</Link>
</td>
</tr>
))}
</tbody>
</Box>
</MetalCard>
)}
</Box>
</Box>
)
}
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
</Box>
)
}

View file

@ -0,0 +1,48 @@
'use client'
// apps/admin/src/app/(admin)/subscriptions/new/client.tsx
import { useState } from 'react'
import { Box, TextField } from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { SubscriptionForm } from '@/components/subscription-form'
import { useRouter } from 'next/navigation'
interface NewSubscriptionClientProps {
initialUserId: string
}
export function NewSubscriptionClient({ initialUserId }: NewSubscriptionClientProps): React.ReactElement {
const [userId, setUserId] = useState(initialUserId)
const router = useRouter()
return (
<Box>
<Box sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ display: 'block', mb: 1 }}>TARGET USER ID</PhosphorText>
<TextField
fullWidth
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="UUID of the user..."
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.inset,
},
}}
/>
</Box>
{userId && (
<SubscriptionForm
mode="create"
userId={userId}
onSuccess={() => router.push(`/subscriptions/${userId}`)}
/>
)}
</Box>
)
}

View file

@ -0,0 +1,30 @@
// apps/admin/src/app/(admin)/subscriptions/new/page.tsx
// 새 구독 생성 (VIP 부여) — super_admin 전용
import { Box } from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { requireSuperAdmin } from '@/lib/admin-guard'
import Link from 'next/link'
import { NewSubscriptionClient } from './client'
interface PageProps {
searchParams: Promise<{ userId?: string }>
}
export default async function NewSubscriptionPage({ searchParams }: PageProps): Promise<React.ReactElement> {
await requireSuperAdmin()
const params = await searchParams
const userId = params.userId ?? ''
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">NEW SUBSCRIPTION</PhosphorText>
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
</Link>
</Box>
<NewSubscriptionClient initialUserId={userId} />
</Box>
)
}

View file

@ -63,7 +63,18 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>SUBSCRIPTIONS</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<PhosphorText variant="title">SUBSCRIPTIONS</PhosphorText>
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: d3roPalette.accent.amber,
color: d3roPalette.bg.app,
}}>
+ NEW
</PhosphorText>
</Link>
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
@ -86,7 +97,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
'& 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>
<tr><th>USER</th><th>TIER</th><th>STATUS</th><th>PROVIDER</th><th>EXPIRES</th><th>CANCEL</th><th>FAILS</th><th>EDIT</th></tr>
</thead>
<tbody>
{subs.map((s) => (
@ -106,6 +117,11 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
<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>
<Link href={`/subscriptions/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
Edit
</Link>
</td>
</tr>
))}
</tbody>

View file

@ -1,10 +1,14 @@
// apps/admin/src/app/(admin)/usage/page.tsx
// 사용량 집계 — feature별, 날짜 범위
// 사용량 — feature별 차트 + DAU + Top users + 테이블
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 Link from 'next/link'
import { FeatureUsageChart } from '@/components/charts/feature-usage-chart'
import { DauChart } from '@/components/charts/dau-chart'
import { TopUsersChart } from '@/components/charts/top-users-chart'
interface PageProps {
searchParams: Promise<{ days?: string }>
@ -14,16 +18,32 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
const params = await searchParams
const days = parseInt(params.days ?? '7', 10)
const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]
const today = new Date().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 }>
// Fetch all data in parallel
// RPC functions are defined in migration but not in Database types — cast via unknown
const rpcClient = supabase as unknown as {
rpc: (fn: string, params: Record<string, unknown>) => Promise<{ data: unknown[]; error: unknown }>
}
const [featureRes, dauRes, topUsersRes, rawDataRes] = await Promise.all([
rpcClient.rpc('admin_usage_by_feature', { p_from: since, p_to: today }),
rpcClient.rpc('admin_dau', { p_from: since, p_to: today }),
rpcClient.rpc('admin_top_users', { p_from: since, p_to: today, p_limit: 20 }),
supabase.from('daily_usage')
.select('date, feature, count, user_id')
.gte('date', since)
.order('date', { ascending: false }),
])
const featureData = (featureRes.data ?? []) as Array<{ date: string; feature: string; total_count: number; unique_users: number }>
const dauData = (dauRes.data ?? []) as Array<{ date: string; active_users: number }>
const topUsersData = (topUsersRes.data ?? []) as Array<{ user_id: string; name: string | null; total_count: number; feature_count: number }>
// Summary cards from raw data
const rows = (rawDataRes.data ?? []) 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>() }
@ -31,41 +51,27 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
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' }}>
<Link 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>
</Link>
))}
</Box>
{/* Summary cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
{summaries.map((s) => (
<Grid size={{ xs: 6, md: 3 }} key={s.feature}>
@ -80,6 +86,49 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
))}
</Grid>
{/* Feature usage stacked bar chart */}
<MetalCard sx={{ mb: 3 }}>
<Box sx={{ p: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>FEATURE USAGE (DAILY)</PhosphorText>
{featureData.length > 0 ? (
<FeatureUsageChart data={featureData} />
) : (
<PhosphorText variant="dim">No data</PhosphorText>
)}
</Box>
</MetalCard>
<Grid container spacing={2} sx={{ mb: 3 }}>
{/* DAU chart */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY ACTIVE USERS</PhosphorText>
{dauData.length > 0 ? (
<DauChart data={dauData} />
) : (
<PhosphorText variant="dim">No data</PhosphorText>
)}
</Box>
</MetalCard>
</Grid>
{/* Top users chart */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>TOP USERS</PhosphorText>
{topUsersData.length > 0 ? (
<TopUsersChart data={topUsersData} />
) : (
<PhosphorText variant="dim">No data</PhosphorText>
)}
</Box>
</MetalCard>
</Grid>
</Grid>
{/* Daily breakdown table */}
<MetalCard sx={{ overflow: 'auto' }}>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY BREAKDOWN</PhosphorText>
@ -88,17 +137,19 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
'& 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>
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th><th>UNIQUE USERS</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>
{featureData.length > 0 ? (
[...featureData].reverse().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_count.toLocaleString()}</td>
<td style={{ color: d3roPalette.text.secondary }}>{r.unique_users}</td>
</tr>
))
) : (
<tr><td colSpan={4} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No usage data</td></tr>
)}
</tbody>
</Box>

View file

@ -5,7 +5,11 @@ 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 { requireAdmin } from '@/lib/admin-guard'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { RoleChangeButton } from './role-change-button'
import { PaymentHistory } from '@/components/payment-history'
interface PageProps {
params: Promise<{ id: string }>
@ -13,6 +17,7 @@ interface PageProps {
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
const { id } = await params
const admin = await requireAdmin()
const supabase = await getSupabaseServerClient()
const [profileRes, subRes, usageRes] = await Promise.all([
@ -32,9 +37,19 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
const tier = (profile.tier as string) ?? 'free'
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'admin' | 'super_admin'
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>USER DETAIL</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">USER DETAIL</PhosphorText>
<RoleChangeButton
userId={id}
userName={(profile.name as string) ?? null}
currentRole={userRole}
isSuperAdmin={admin.role === 'super_admin'}
/>
</Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 12, md: 6 }}>
@ -45,6 +60,7 @@ 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={userRole.toUpperCase()} valueColor={userRole === 'super_admin' ? d3roPalette.tag.purple : userRole === 'admin' ? d3roPalette.tag.green : d3roPalette.text.secondary} />
<Row label="LOCALE" value={(profile.locale as string) ?? '-'} />
<Row label="JOINED" value={new Date(profile.created_at as string).toLocaleDateString()} />
</Box>
@ -61,6 +77,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
<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 sx={{ mt: 0.5 }}>
<Link href={`/subscriptions/${id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none', fontSize: 11 }}>
Edit Subscription {'->'}
</Link>
</Box>
</Box>
) : (
<PhosphorText variant="dim">No subscription</PhosphorText>
@ -95,6 +116,12 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
)}
</Box>
</MetalCard>
{/* Payment History */}
<Box sx={{ mt: 3 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT HISTORY</PhosphorText>
<PaymentHistory userId={id} />
</Box>
</Box>
)
}

View file

@ -0,0 +1,50 @@
'use client'
// apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
// super_admin만 볼 수 있는 role 변경 버튼
import { useState } from 'react'
import { Button } from '@mui/material'
import { d3roFontMono } from '@d3ro/ui/theme'
import { RoleChangeDialog } from '@/components/role-change-dialog'
import { useRouter } from 'next/navigation'
interface RoleChangeButtonProps {
userId: string
userName: string | null
currentRole: 'user' | 'admin' | 'super_admin'
isSuperAdmin: boolean
}
export function RoleChangeButton({
userId, userName, currentRole, isSuperAdmin,
}: RoleChangeButtonProps): React.ReactElement | null {
const [open, setOpen] = useState(false)
const router = useRouter()
if (!isSuperAdmin) return null
return (
<>
<Button
variant="outlined"
size="small"
onClick={() => setOpen(true)}
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
>
Change Role
</Button>
<RoleChangeDialog
open={open}
userId={userId}
userName={userName}
currentRole={currentRole}
onClose={() => setOpen(false)}
onSuccess={() => {
setOpen(false)
router.refresh()
}}
/>
</>
)
}

View file

@ -9,6 +9,8 @@ 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 HistoryIcon from '@mui/icons-material/History'
import ApiIcon from '@mui/icons-material/Api'
import LogoutIcon from '@mui/icons-material/Logout'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
@ -19,6 +21,11 @@ const NAV_ITEMS = [
{ key: 'users', path: '/users', label: 'Users', icon: <PeopleIcon /> },
{ key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions', icon: <SubscriptionsIcon /> },
{ key: 'usage', path: '/usage', label: 'Usage', icon: <BarChartIcon /> },
{ key: 'audit-log', path: '/audit-log', label: 'Audit Log', icon: <HistoryIcon /> },
]
const EXTERNAL_LINKS = [
{ key: 'swagger', href: '/admin-swagger/', label: 'API Docs', icon: <ApiIcon /> },
]
export function AdminSidebar(): React.ReactElement {
@ -78,6 +85,41 @@ export function AdminSidebar(): React.ReactElement {
</ListItem>
)
})}
<ListItem disablePadding sx={{ mt: 1 }}>
<ListItemText
primary="EXTERNAL"
primaryTypographyProps={{
fontFamily: d3roFontMono,
fontSize: 10,
color: d3roPalette.text.label,
px: 2,
pt: 1,
}}
/>
</ListItem>
{EXTERNAL_LINKS.map((item) => (
<ListItem key={item.key} disablePadding>
<ListItemButton
component="a"
href={item.href}
target="_blank"
rel="noopener noreferrer"
sx={{ fontFamily: d3roFontMono }}
>
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.inactive }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.secondary,
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ p: 2, borderTop: `1px solid ${d3roPalette.border.default}` }}>

View file

@ -0,0 +1,128 @@
'use client'
// apps/admin/src/components/audit-diff-viewer.tsx
// before/after JSON diff 뷰어
import { Box } from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
interface AuditDiffViewerProps {
beforeData: Record<string, unknown> | null
afterData: Record<string, unknown> | null
}
interface DiffEntry {
key: string
before: unknown
after: unknown
type: 'added' | 'removed' | 'changed' | 'unchanged'
}
function computeDiff(
before: Record<string, unknown> | null,
after: Record<string, unknown> | null
): DiffEntry[] {
const allKeys = new Set<string>([
...Object.keys(before ?? {}),
...Object.keys(after ?? {}),
])
const entries: DiffEntry[] = []
for (const key of allKeys) {
const bVal = before?.[key]
const aVal = after?.[key]
const bStr = JSON.stringify(bVal)
const aStr = JSON.stringify(aVal)
if (bVal === undefined) {
entries.push({ key, before: undefined, after: aVal, type: 'added' })
} else if (aVal === undefined) {
entries.push({ key, before: bVal, after: undefined, type: 'removed' })
} else if (bStr !== aStr) {
entries.push({ key, before: bVal, after: aVal, type: 'changed' })
} else {
entries.push({ key, before: bVal, after: aVal, type: 'unchanged' })
}
}
// changed/added/removed first
return entries.sort((a, b) => {
const order = { changed: 0, added: 1, removed: 2, unchanged: 3 }
return order[a.type] - order[b.type]
})
}
const typeColors: Record<DiffEntry['type'], string> = {
added: d3roPalette.tag.green,
removed: d3roPalette.tag.red,
changed: d3roPalette.accent.amber,
unchanged: d3roPalette.text.muted,
}
const typeLabels: Record<DiffEntry['type'], string> = {
added: '+',
removed: '-',
changed: '~',
unchanged: ' ',
}
function formatValue(val: unknown): string {
if (val === undefined) return '(none)'
if (val === null) return 'null'
if (typeof val === 'string') return `"${val}"`
return JSON.stringify(val)
}
export function AuditDiffViewer({ beforeData, afterData }: AuditDiffViewerProps): React.ReactElement {
if (!beforeData && !afterData) {
return <PhosphorText variant="dim">No diff data</PhosphorText>
}
const diff = computeDiff(beforeData, afterData)
return (
<Box sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1,
p: 1.5,
overflow: 'auto',
}}>
{diff.map((entry) => (
<Box
key={entry.key}
sx={{
display: 'flex',
gap: 1,
py: 0.25,
opacity: entry.type === 'unchanged' ? 0.5 : 1,
}}
>
<Box sx={{ color: typeColors[entry.type], width: 12, flexShrink: 0, textAlign: 'center' }}>
{typeLabels[entry.type]}
</Box>
<Box sx={{ color: d3roPalette.text.label, minWidth: 140, flexShrink: 0 }}>
{entry.key}
</Box>
{entry.type === 'changed' ? (
<Box>
<Box component="span" sx={{ color: d3roPalette.tag.red, textDecoration: 'line-through' }}>
{formatValue(entry.before)}
</Box>
<Box component="span" sx={{ mx: 0.5, color: d3roPalette.text.muted }}>{'->'}</Box>
<Box component="span" sx={{ color: d3roPalette.tag.green }}>
{formatValue(entry.after)}
</Box>
</Box>
) : (
<Box sx={{ color: typeColors[entry.type] }}>
{entry.type === 'removed' ? formatValue(entry.before) : formatValue(entry.after)}
</Box>
)}
</Box>
))}
</Box>
)
}

View file

@ -0,0 +1,60 @@
'use client'
// apps/admin/src/components/charts/dau-chart.tsx
// DAU/활성 유저 추이 — Line chart
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from 'recharts'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
interface DauRow {
date: string
active_users: number
}
interface DauChartProps {
data: DauRow[]
}
export function DauChart({ data }: DauChartProps): React.ReactElement {
return (
<Box sx={{ width: '100%', height: 250 }}>
<ResponsiveContainer>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
<XAxis
dataKey="date"
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
tickFormatter={(val: string) => val.slice(5)}
/>
<YAxis tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }} />
<Tooltip
contentStyle={{
background: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.primary,
}}
/>
<Line
type="monotone"
dataKey="active_users"
stroke={d3roPalette.accent.amber}
strokeWidth={2}
dot={{ fill: d3roPalette.accent.amber, r: 3 }}
name="Active Users"
/>
</LineChart>
</ResponsiveContainer>
</Box>
)
}

View file

@ -0,0 +1,88 @@
'use client'
// apps/admin/src/components/charts/feature-usage-chart.tsx
// feature별 일간 API 호출 — StackedBar
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
interface FeatureUsageRow {
date: string
feature: string
total_count: number
unique_users: number
}
interface FeatureUsageChartProps {
data: FeatureUsageRow[]
}
const FEATURE_COLORS: Record<string, string> = {
llm_haiku: d3roPalette.accent.amber,
llm_sonnet: d3roPalette.tag.green,
llm_opus: d3roPalette.tag.purple,
stt_transcribe: d3roPalette.tag.blue,
translate: d3roPalette.tag.blue,
}
const DEFAULT_COLOR = d3roPalette.text.secondary
export function FeatureUsageChart({ data }: FeatureUsageChartProps): React.ReactElement {
// Pivot: group by date, features as columns
const features = [...new Set(data.map(d => d.feature))]
const dateMap = new Map<string, Record<string, string | number>>()
for (const row of data) {
const existing = dateMap.get(row.date) ?? { date: row.date }
existing[row.feature] = row.total_count
dateMap.set(row.date, existing)
}
const chartData = [...dateMap.values()].sort((a, b) =>
String(a.date).localeCompare(String(b.date))
)
return (
<Box sx={{ width: '100%', height: 300 }}>
<ResponsiveContainer>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
<XAxis
dataKey="date"
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
tickFormatter={(val: string) => val.slice(5)}
/>
<YAxis tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }} />
<Tooltip
contentStyle={{
background: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.primary,
}}
/>
<Legend wrapperStyle={{ fontFamily: d3roFontMono, fontSize: 11 }} />
{features.map((feature) => (
<Bar
key={feature}
dataKey={feature}
stackId="a"
fill={FEATURE_COLORS[feature] ?? DEFAULT_COLOR}
/>
))}
</BarChart>
</ResponsiveContainer>
</Box>
)
}

View file

@ -0,0 +1,65 @@
'use client'
// apps/admin/src/components/charts/top-users-chart.tsx
// 유저별 사용량 Top 20 — Horizontal bar
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from 'recharts'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
interface TopUserRow {
user_id: string
name: string | null
total_count: number
feature_count: number
}
interface TopUsersChartProps {
data: TopUserRow[]
}
export function TopUsersChart({ data }: TopUsersChartProps): React.ReactElement {
const chartData = data.map(row => ({
name: row.name ?? row.user_id.substring(0, 8),
total: row.total_count,
features: row.feature_count,
}))
return (
<Box sx={{ width: '100%', height: Math.max(250, data.length * 28) }}>
<ResponsiveContainer>
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
<XAxis
type="number"
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
/>
<YAxis
type="category"
dataKey="name"
width={100}
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
/>
<Tooltip
contentStyle={{
background: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.primary,
}}
/>
<Bar dataKey="total" fill={d3roPalette.accent.amber} name="Total Calls" />
</BarChart>
</ResponsiveContainer>
</Box>
)
}

View file

@ -0,0 +1,93 @@
'use client'
// apps/admin/src/components/memo-dialog.tsx
// 메모 입력 다이얼로그 — 감사로그 기록 시 필수
import { useState } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
} from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
interface MemoDialogProps {
open: boolean
title: string
description?: string
onConfirm: (memo: string) => void
onCancel: () => void
loading?: boolean
}
export function MemoDialog({ open, title, description, onConfirm, onCancel, loading }: MemoDialogProps): React.ReactElement {
const [memo, setMemo] = useState('')
const handleConfirm = (): void => {
if (memo.trim()) {
onConfirm(memo.trim())
setMemo('')
}
}
const handleCancel = (): void => {
setMemo('')
onCancel()
}
return (
<Dialog
open={open}
onClose={handleCancel}
maxWidth="sm"
fullWidth
PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, color: d3roPalette.text.primary } }}
>
<DialogTitle sx={{ fontFamily: d3roFontMono }}>
<PhosphorText variant="heading">{title}</PhosphorText>
</DialogTitle>
<DialogContent>
{description && (
<PhosphorText variant="dim" sx={{ display: 'block', mb: 2 }}>
{description}
</PhosphorText>
)}
<TextField
autoFocus
fullWidth
multiline
rows={3}
placeholder="Reason for this action (required)..."
value={memo}
onChange={(e) => setMemo(e.target.value)}
sx={{
mt: 1,
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.inset,
},
}}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleCancel} sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.secondary }}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!memo.trim() || loading}
variant="contained"
sx={{ fontFamily: d3roFontMono }}
>
{loading ? 'Processing...' : 'Confirm'}
</Button>
</DialogActions>
</Dialog>
)
}

View file

@ -0,0 +1,169 @@
'use client'
// apps/admin/src/components/payment-history.tsx
// 결제 이력 패널 — DB + Payple 조회
import { useState, useEffect } from 'react'
import { Box, Button, CircularProgress } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
interface PaymentHistoryProps {
userId: string
}
interface AuditLogEntry {
id: number
action: string
memo: string
created_at: string
before_data: Record<string, unknown> | null
after_data: Record<string, unknown> | null
}
interface PaymentData {
subscription: Record<string, unknown> | null
auditLogs: AuditLogEntry[]
paypleHistory?: Record<string, unknown>
paypleError?: string
}
export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
const [data, setData] = useState<PaymentData | null>(null)
const [loading, setLoading] = useState(true)
const [paypleLoading, setPaypleLoading] = useState(false)
useEffect(() => {
const load = async (): Promise<void> => {
try {
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}`)
setData(result)
} catch {
// ignore
} finally {
setLoading(false)
}
}
void load()
}, [userId])
const loadPayple = async (): Promise<void> => {
setPaypleLoading(true)
try {
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}&source=payple`)
setData(result)
} catch {
// ignore
} finally {
setPaypleLoading(false)
}
}
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={24} sx={{ color: d3roPalette.accent.amber }} />
</Box>
)
}
if (!data) {
return <PhosphorText variant="dim">Failed to load payment data</PhosphorText>
}
const sub = data.subscription
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Subscription summary */}
{sub && (
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT INFO</PhosphorText>
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
<Row label="PAYPLE PAYER ID" value={(sub.payple_payer_id as string) ?? '-'} />
<Row label="PAYPLE OID" value={(sub.payple_pay_oid as string) ?? '-'} />
<Row label="RENEWAL FAILURES" value={String(sub.renewal_failures ?? 0)} />
</Box>
</Box>
</MetalCard>
)}
{/* Payple direct query */}
<MetalCard>
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<PhosphorText variant="label">PAYPLE HISTORY</PhosphorText>
<Button
size="small"
onClick={() => void loadPayple()}
disabled={paypleLoading}
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
>
{paypleLoading ? 'Loading...' : 'Fetch from Payple'}
</Button>
</Box>
{data.paypleHistory ? (
<Box sx={{
fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
bgcolor: d3roPalette.bg.inset, borderRadius: 1, p: 1, maxHeight: 300, overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: d3roPalette.text.primary,
}}>
{JSON.stringify(data.paypleHistory, null, 2)}
</Box>
) : data.paypleError ? (
<PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{data.paypleError}</PhosphorText>
) : (
<PhosphorText variant="dim">Click &quot;Fetch from Payple&quot; to query payment history</PhosphorText>
)}
</Box>
</MetalCard>
{/* Audit log timeline */}
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>SUBSCRIPTION TIMELINE</PhosphorText>
{data.auditLogs.length === 0 ? (
<PhosphorText variant="dim">No subscription changes recorded</PhosphorText>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{data.auditLogs.map((log) => (
<Box
key={log.id}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
borderLeft: `2px solid ${d3roPalette.accent.amber}`,
pl: 1.5,
py: 0.5,
}}
>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'baseline' }}>
<Box component="span" sx={{ color: d3roPalette.text.muted, fontSize: 10 }}>
{new Date(log.created_at).toLocaleString()}
</Box>
<Box component="span" sx={{ color: d3roPalette.accent.amber }}>
{log.action}
</Box>
</Box>
<Box sx={{ color: d3roPalette.text.secondary, mt: 0.25 }}>{log.memo}</Box>
</Box>
))}
</Box>
)}
</Box>
</MetalCard>
</Box>
)
}
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
</Box>
)
}

View file

@ -0,0 +1,130 @@
'use client'
// apps/admin/src/components/role-change-dialog.tsx
// role 변경 확인 다이얼로그 — super_admin 전용
import { useState } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
} from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
type Role = 'user' | 'admin' | 'super_admin'
interface RoleChangeDialogProps {
open: boolean
userId: string
userName: string | null
currentRole: Role
onClose: () => void
onSuccess: () => void
}
export function RoleChangeDialog({
open, userId, userName, currentRole, onClose, onSuccess,
}: RoleChangeDialogProps): React.ReactElement {
const [newRole, setNewRole] = useState<Role>(currentRole)
const [memo, setMemo] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleConfirm = async (): Promise<void> => {
if (!memo.trim() || newRole === currentRole) return
setLoading(true)
setError(null)
try {
await callAdminApi('admin-users', {
method: 'PATCH',
body: JSON.stringify({ userId, newRole, memo: memo.trim() }),
})
setMemo('')
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change role')
} finally {
setLoading(false)
}
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, color: d3roPalette.text.primary } }}
>
<DialogTitle sx={{ fontFamily: d3roFontMono }}>
<PhosphorText variant="heading">CHANGE USER ROLE</PhosphorText>
</DialogTitle>
<DialogContent>
<PhosphorText variant="dim" sx={{ display: 'block', mb: 2 }}>
{userName ?? userId.substring(0, 8)} : {currentRole}
</PhosphorText>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
<Select
value={newRole}
onChange={(e) => setNewRole(e.target.value as Role)}
label="New Role"
sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.primary }}
>
<MenuItem value="user">user</MenuItem>
<MenuItem value="admin">admin</MenuItem>
<MenuItem value="super_admin">super_admin</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
multiline
rows={3}
placeholder="Reason for role change (required)..."
value={memo}
onChange={(e) => setMemo(e.target.value)}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.inset,
},
}}
/>
{error && (
<PhosphorText variant="dim" sx={{ display: 'block', mt: 1, color: d3roPalette.tag.red }}>
{error}
</PhosphorText>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.secondary }}>
Cancel
</Button>
<Button
onClick={() => void handleConfirm()}
disabled={!memo.trim() || newRole === currentRole || loading}
variant="contained"
color={newRole === 'super_admin' ? 'error' : 'primary'}
sx={{ fontFamily: d3roFontMono }}
>
{loading ? 'Changing...' : 'Change Role'}
</Button>
</DialogActions>
</Dialog>
)
}

View file

@ -0,0 +1,187 @@
'use client'
// apps/admin/src/components/subscription-form.tsx
// 구독 생성/수정 공용 폼
import { useState } from 'react'
import {
Box,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
} from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
type Tier = 'free' | 'pro' | 'pro_plus'
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
interface SubscriptionFormProps {
mode: 'create' | 'edit'
userId: string
initial?: {
tier: Tier
status: SubStatus
currentPeriodEnd: string | null
overageCredits: number
adminNote: string | null
}
onSuccess: () => void
}
export function SubscriptionForm({ mode, userId, initial, onSuccess }: SubscriptionFormProps): React.ReactElement {
const [tier, setTier] = useState<Tier>(initial?.tier ?? 'free')
const [status, setStatus] = useState<SubStatus>(initial?.status ?? 'active')
const [periodEnd, setPeriodEnd] = useState(initial?.currentPeriodEnd?.split('T')[0] ?? '')
const [overageCredits, setOverageCredits] = useState(initial?.overageCredits ?? 0)
const [adminNote, setAdminNote] = useState(initial?.adminNote ?? '')
const [memo, setMemo] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const handleSubmit = async (): Promise<void> => {
if (!memo.trim()) return
setLoading(true)
setError(null)
setSuccess(false)
try {
if (mode === 'create') {
await callAdminApi('admin-subscriptions', {
method: 'POST',
body: JSON.stringify({
userId,
tier,
status,
currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
adminNote: adminNote || undefined,
memo: memo.trim(),
}),
})
} else {
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
method: 'PATCH',
body: JSON.stringify({
tier,
status,
currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
overageCredits,
adminNote: adminNote || undefined,
memo: memo.trim(),
}),
})
}
setSuccess(true)
setMemo('')
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : 'Operation failed')
} finally {
setLoading(false)
}
}
const inputSx = {
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
},
}
return (
<MetalCard>
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<PhosphorText variant="label">
{mode === 'create' ? 'CREATE SUBSCRIPTION' : 'EDIT SUBSCRIPTION'}
</PhosphorText>
<FormControl fullWidth sx={inputSx}>
<InputLabel sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
<Select value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
<MenuItem value="free">FREE</MenuItem>
<MenuItem value="pro">PRO</MenuItem>
<MenuItem value="pro_plus">PRO+</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth sx={inputSx}>
<InputLabel sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
<Select value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
<MenuItem value="active">ACTIVE</MenuItem>
<MenuItem value="canceled">CANCELED</MenuItem>
<MenuItem value="past_due">PAST DUE</MenuItem>
<MenuItem value="expired">EXPIRED</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Period End"
type="date"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={inputSx}
/>
{mode === 'edit' && (
<TextField
fullWidth
label="Overage Credits"
type="number"
value={overageCredits}
onChange={(e) => setOverageCredits(parseInt(e.target.value, 10) || 0)}
sx={inputSx}
/>
)}
<TextField
fullWidth
label="Admin Note"
multiline
rows={2}
value={adminNote}
onChange={(e) => setAdminNote(e.target.value)}
sx={inputSx}
/>
<TextField
fullWidth
label="Memo (required for audit log)"
multiline
rows={2}
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="Reason for this action..."
sx={{
...inputSx,
'& .MuiInputBase-root': {
...inputSx['& .MuiInputBase-root'],
bgcolor: d3roPalette.bg.inset,
},
}}
/>
{error && <Alert severity="error" sx={{ fontFamily: d3roFontMono }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ fontFamily: d3roFontMono }}>Operation successful</Alert>}
<Button
variant="contained"
onClick={() => void handleSubmit()}
disabled={!memo.trim() || loading}
sx={{ fontFamily: d3roFontMono, alignSelf: 'flex-end' }}
>
{loading ? 'Processing...' : mode === 'create' ? 'Create' : 'Update'}
</Button>
</Box>
</MetalCard>
)
}

View file

@ -0,0 +1,39 @@
// apps/admin/src/lib/admin-api.ts
// Edge Function 호출 헬퍼 — 클라이언트 컴포넌트용
import { getSupabaseBrowserClient } from './supabase-browser'
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
interface AdminApiOptions extends Omit<RequestInit, 'headers'> {
headers?: Record<string, string>
}
export async function callAdminApi<T = Record<string, unknown>>(
path: string,
options: AdminApiOptions = {}
): Promise<T> {
const supabase = getSupabaseBrowserClient()
const { data: { session } } = await supabase.auth.getSession()
if (!session?.access_token) {
throw new Error('Not authenticated')
}
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
...options,
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
...options.headers,
},
})
const data = await response.json() as T & { error?: string }
if (!response.ok) {
throw new Error(data.error ?? `API error: ${response.status}`)
}
return data
}

View file

@ -1,15 +1,19 @@
// apps/admin/src/lib/admin-guard.ts
// RSC용 admin 가드 — app_metadata.role='admin' 체크
// RSC용 admin 가드 — app_metadata.role = 'admin' | 'super_admin'
import { redirect } from 'next/navigation'
import { getSupabaseServerClient } from './supabase-server'
export type AdminRole = 'admin' | 'super_admin'
export interface AdminUser {
id: string
email: string | null
name: string | null
role: AdminRole
}
/** admin 이상 (admin, super_admin) */
export async function requireAdmin(): Promise<AdminUser> {
const supabase = await getSupabaseServerClient()
const { data: { user } } = await supabase.auth.getUser()
@ -18,13 +22,11 @@ export async function requireAdmin(): Promise<AdminUser> {
redirect('/login')
}
// app_metadata.role 체크 (JWT에 포함, RLS 재귀 없음)
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
if (role !== 'admin') {
if (role !== 'admin' && role !== 'super_admin') {
redirect('/unauthorized')
}
// profile 이름 조회 (자기 자신은 기존 RLS로 접근 가능)
const { data: profile } = await supabase
.from('profiles')
.select('name')
@ -35,5 +37,20 @@ export async function requireAdmin(): Promise<AdminUser> {
id: user.id,
email: user.email ?? null,
name: (profile as { name: string | null } | null)?.name ?? null,
role: role as AdminRole,
}
}
/** super_admin 전용 */
export async function requireSuperAdmin(): Promise<AdminUser> {
const adminUser = await requireAdmin()
if (adminUser.role !== 'super_admin') {
redirect('/unauthorized')
}
return adminUser
}
/** role이 super_admin인지 체크 */
export function isSuperAdmin(user: AdminUser): boolean {
return user.role === 'super_admin'
}