d3ro-voice/server/supabase/functions/admin-users/index.ts
윤찬 8af75a0a1e feat: V2-7 Admin 콘솔 리디자인 + 3단계 권한 + SaaS 전환 + 코드 정리
- Admin CRM: D3RO Console 스타일 전체 적용 (panelSx/tableSx/filterBtnSx)
- 3단계 권한: manager/admin/super_admin (DB + Edge Functions + Frontend)
- 랜딩 페이지: 1회 결제 → 월간/연간 구독 SaaS 모델 (10개 언어)
- SSE 스트리밍: VoiceConversation Premium LLM 라우팅 + fallback
- Supabase 클라이언트: packages/api-client 공통 추출 (browser+server)
- RPC 함수 타입: 9개 정의 (admin_usage_by_feature 등)
- callAdminApi 401 버그 수정 (getUser() 선행 토큰 갱신)
2026-04-13 01:28:10 +09:00

162 lines
6 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 { requireManager, requireAdmin, requireSuperAdmin, hasMinRole } 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' | 'manager' | '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: 유저 목록/상세 (manager 이상) ──
if (req.method === 'GET') {
const admin = await requireManager(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 변경 ──
// admin: user↔manager 변경 가능
// super_admin: 모든 role 변경 가능 (→admin 포함)
if (req.method === 'PATCH') {
const admin = await requireAdmin(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', 'manager', 'admin', 'super_admin']
if (!validRoles.includes(body.newRole)) {
return jsonResponse({ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, 400)
}
// admin은 user↔manager만 변경 가능, admin/super_admin 변경은 super_admin만
if (!hasMinRole(admin, 'super_admin') && (body.newRole === 'admin' || body.newRole === 'super_admin')) {
return jsonResponse({ error: 'Only super_admin can assign admin or super_admin roles' }, 403)
}
// 현재 프로필 조회 (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)
// admin이 admin/super_admin 유저의 role을 변경하려는 시도 차단
const targetRole = (before as Record<string, unknown>).role as string
if (!hasMinRole(admin, 'super_admin') && (targetRole === 'admin' || targetRole === 'super_admin')) {
return jsonResponse({ error: 'Only super_admin can modify admin or super_admin users' }, 403)
}
// 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)
}
})