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

@ -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'
}