- 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
149 lines
5.2 KiB
TypeScript
149 lines
5.2 KiB
TypeScript
// server/supabase/functions/admin-users/index.ts
|
|
// Admin: 유저 목록/상세 조회 + super_admin: role 변경
|
|
|
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
import { writeAuditLog } from '../_shared/audit.ts'
|
|
|
|
// @ts-expect-error — Deno 런타임 import
|
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
|
|
|
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
interface RoleChangeBody {
|
|
userId: string
|
|
newRole: 'user' | 'admin' | 'super_admin'
|
|
memo: string
|
|
}
|
|
|
|
// @ts-expect-error — Deno 런타임 전역
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
|
|
try {
|
|
const url = new URL(req.url)
|
|
|
|
// ── GET: 유저 목록/상세 ──
|
|
if (req.method === 'GET') {
|
|
const admin = await requireAdmin(req)
|
|
const serviceClient = createServiceRoleClient()
|
|
|
|
const userId = url.searchParams.get('userId')
|
|
|
|
if (userId) {
|
|
// 유저 상세
|
|
const { data: profile, error } = await serviceClient
|
|
.from('profiles')
|
|
.select('id, name, avatar_url, locale, tier, role, created_at, updated_at')
|
|
.eq('id', userId)
|
|
.maybeSingle()
|
|
|
|
if (error) return jsonResponse({ error: error.message }, 500)
|
|
if (!profile) return jsonResponse({ error: 'User not found' }, 404)
|
|
|
|
const { data: sub } = await serviceClient
|
|
.from('subscriptions')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.maybeSingle()
|
|
|
|
return jsonResponse({ profile, subscription: sub })
|
|
}
|
|
|
|
// 유저 목록
|
|
const page = parseInt(url.searchParams.get('page') ?? '1', 10)
|
|
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
|
|
const search = url.searchParams.get('search') ?? ''
|
|
const roleFilter = url.searchParams.get('role') ?? ''
|
|
const from = (page - 1) * limit
|
|
const to = from + limit - 1
|
|
|
|
let query = serviceClient
|
|
.from('profiles')
|
|
.select('id, name, avatar_url, tier, role, created_at', { count: 'exact' })
|
|
|
|
if (search) {
|
|
query = query.ilike('name', `%${search}%`)
|
|
}
|
|
if (roleFilter) {
|
|
query = query.eq('role', roleFilter)
|
|
}
|
|
|
|
const { data: profiles, count, error } = await query
|
|
.order('created_at', { ascending: false })
|
|
.range(from, to)
|
|
|
|
if (error) return jsonResponse({ error: error.message }, 500)
|
|
|
|
return jsonResponse({ profiles: profiles ?? [], total: count ?? 0, page, limit })
|
|
}
|
|
|
|
// ── PATCH: role 변경 (super_admin 전용) ──
|
|
if (req.method === 'PATCH') {
|
|
const admin = await requireSuperAdmin(req)
|
|
const body = (await req.json()) as RoleChangeBody
|
|
const serviceClient = createServiceRoleClient()
|
|
|
|
if (!body.userId || !body.newRole || !body.memo) {
|
|
return jsonResponse({ error: 'userId, newRole, memo are required' }, 400)
|
|
}
|
|
|
|
const validRoles = ['user', 'admin', 'super_admin']
|
|
if (!validRoles.includes(body.newRole)) {
|
|
return jsonResponse({ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, 400)
|
|
}
|
|
|
|
// 현재 프로필 조회 (before 스냅샷)
|
|
const { data: before } = await serviceClient
|
|
.from('profiles')
|
|
.select('id, name, role')
|
|
.eq('id', body.userId)
|
|
.maybeSingle()
|
|
|
|
if (!before) return jsonResponse({ error: 'User not found' }, 404)
|
|
|
|
// 1. auth.users.raw_app_meta_data.role 변경
|
|
const { error: authError } = await serviceClient.auth.admin.updateUserById(body.userId, {
|
|
app_metadata: { role: body.newRole },
|
|
})
|
|
if (authError) return jsonResponse({ error: `Auth update failed: ${authError.message}` }, 500)
|
|
|
|
// 2. profiles.role 동기화
|
|
const { error: profileError } = await serviceClient
|
|
.from('profiles')
|
|
.update({ role: body.newRole, updated_at: new Date().toISOString() })
|
|
.eq('id', body.userId)
|
|
|
|
if (profileError) return jsonResponse({ error: `Profile update failed: ${profileError.message}` }, 500)
|
|
|
|
// 3. 감사로그
|
|
await writeAuditLog(serviceClient, {
|
|
adminId: admin.id,
|
|
action: 'user.role_change',
|
|
targetType: 'profile',
|
|
targetId: body.userId,
|
|
beforeData: { role: before.role },
|
|
afterData: { role: body.newRole },
|
|
memo: body.memo,
|
|
})
|
|
|
|
return jsonResponse({ success: true, userId: body.userId, newRole: body.newRole })
|
|
}
|
|
|
|
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
} catch (err) {
|
|
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
|
return authErrorResponse(err as AuthError, corsHeaders)
|
|
}
|
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
return jsonResponse({ error: message }, 500)
|
|
}
|
|
})
|