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
45
server/supabase/functions/_shared/admin-auth.ts
Normal file
45
server/supabase/functions/_shared/admin-auth.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// server/supabase/functions/_shared/admin-auth.ts
|
||||
// Admin/Super-admin 권한 검증 — requireUser 확장
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import type { User } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
import { requireUser, type AuthError } from './auth.ts'
|
||||
|
||||
export type AdminRole = 'admin' | 'super_admin'
|
||||
|
||||
/**
|
||||
* admin 이상 권한 필요 (admin, super_admin).
|
||||
* 실패 시 AuthError throw.
|
||||
*/
|
||||
export async function requireAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'admin' && role !== 'super_admin') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Admin access required' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* super_admin 전용 권한 필요.
|
||||
* 실패 시 AuthError throw.
|
||||
*/
|
||||
export async function requireSuperAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'super_admin') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Super admin access required' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 유저의 admin role 반환. admin이 아니면 null.
|
||||
*/
|
||||
export function getAdminRole(user: User): AdminRole | null {
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role === 'admin' || role === 'super_admin') return role
|
||||
return null
|
||||
}
|
||||
37
server/supabase/functions/_shared/audit.ts
Normal file
37
server/supabase/functions/_shared/audit.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// server/supabase/functions/_shared/audit.ts
|
||||
// 감사로그 기록 유틸리티
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
|
||||
export interface AuditLogEntry {
|
||||
adminId: string
|
||||
action: string // 'subscription.create', 'subscription.update', 'subscription.delete', 'user.role_change'
|
||||
targetType: string // 'subscription', 'profile'
|
||||
targetId: string
|
||||
beforeData: Record<string, unknown> | null
|
||||
afterData: Record<string, unknown> | null
|
||||
memo: string
|
||||
}
|
||||
|
||||
/**
|
||||
* audit_log 테이블에 감사 기록을 삽입한다.
|
||||
* service_role 클라이언트를 사용해야 RLS를 우회한다.
|
||||
*/
|
||||
export async function writeAuditLog(
|
||||
supabase: SupabaseClient,
|
||||
entry: AuditLogEntry
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('audit_log').insert({
|
||||
admin_id: entry.adminId,
|
||||
action: entry.action,
|
||||
target_type: entry.targetType,
|
||||
target_id: entry.targetId,
|
||||
before_data: entry.beforeData,
|
||||
after_data: entry.afterData,
|
||||
memo: entry.memo,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(`Failed to write audit log: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ export const corsHeaders: Record<string, string> = {
|
|||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS'
|
||||
}
|
||||
|
||||
export function handleCorsPreflightRequest(req: Request): Response | null {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue