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:
parent
f7c50eb2ed
commit
dca1b90faa
34 changed files with 3142 additions and 45 deletions
39
apps/admin/src/lib/admin-api.ts
Normal file
39
apps/admin/src/lib/admin-api.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue