feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,162 +1,116 @@
|
|||
// 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 { requireAdmin, requireManager } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { writeAuditLog } from '../_shared/audit.ts'
|
||||
import { verifyCurrentAdminActor } from '../_shared/admin-current-role.ts'
|
||||
import {
|
||||
AdminPublicError,
|
||||
actorEmail,
|
||||
adminErrorResponse,
|
||||
adminJsonResponse,
|
||||
adminRpcError,
|
||||
managedRoleFilter,
|
||||
parsePagination,
|
||||
parseRoleChangeRequest,
|
||||
parseSearch,
|
||||
readAdminJson,
|
||||
requireIdempotencyKey,
|
||||
requireUuid,
|
||||
validateQueryKeys,
|
||||
validateRoleRpcResult,
|
||||
} from '../_shared/admin-contract.ts'
|
||||
|
||||
// @ts-expect-error — Deno 런타임 import
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||
async function readUsers(req: Request): Promise<Response> {
|
||||
const admin = await requireManager(req)
|
||||
const url = new URL(req.url)
|
||||
validateQueryKeys(url, ['userId', 'page', 'limit', 'search', 'role'])
|
||||
const serviceClient = createServiceRoleClient()
|
||||
await verifyCurrentAdminActor(serviceClient, admin)
|
||||
const userIdValue = url.searchParams.get('userId')
|
||||
|
||||
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' },
|
||||
if (userIdValue) {
|
||||
const userId = requireUuid(userIdValue, 'invalid_userId')
|
||||
const [{ data: profile, error: profileError }, { data: subscription, error: subscriptionError }, accountResult] =
|
||||
await Promise.all([
|
||||
serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, locale, tier, role, created_at, updated_at')
|
||||
.eq('id', userId)
|
||||
.maybeSingle(),
|
||||
serviceClient
|
||||
.from('subscriptions')
|
||||
.select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle(),
|
||||
serviceClient.auth.admin.getUserById(userId),
|
||||
])
|
||||
|
||||
if (profileError || subscriptionError || accountResult.error) {
|
||||
throw new AdminPublicError(500, 'admin_user_read_failed')
|
||||
}
|
||||
if (!profile || !accountResult.data.user) throw new AdminPublicError(404, 'user_not_found')
|
||||
|
||||
const account = accountResult.data.user
|
||||
return adminJsonResponse({
|
||||
profile,
|
||||
subscription: subscription ?? null,
|
||||
account: {
|
||||
id: account.id,
|
||||
email: account.email ?? null,
|
||||
createdAt: account.created_at,
|
||||
lastSignInAt: account.last_sign_in_at ?? null,
|
||||
},
|
||||
}, corsHeaders)
|
||||
}
|
||||
|
||||
const { page, limit, from, to } = parsePagination(url)
|
||||
const search = parseSearch(url.searchParams.get('search'))
|
||||
const role = managedRoleFilter(url.searchParams.get('role'))
|
||||
let query = serviceClient
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, tier, role, created_at', { count: 'exact' })
|
||||
|
||||
if (search) {
|
||||
const literalPattern = search.replace(/[\\%_]/g, '\\$&')
|
||||
query = query.ilike('name', `%${literalPattern}%`)
|
||||
}
|
||||
if (role) query = query.eq('role', role)
|
||||
|
||||
const { data, count, error } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
if (error) throw new AdminPublicError(500, 'admin_user_read_failed')
|
||||
|
||||
return adminJsonResponse({ profiles: data ?? [], total: count ?? 0, page, limit }, corsHeaders)
|
||||
}
|
||||
|
||||
async function changeRole(req: Request): Promise<Response> {
|
||||
const admin = await requireAdmin(req)
|
||||
const body = await readAdminJson(req)
|
||||
const mutation = parseRoleChangeRequest(body, requireIdempotencyKey(req))
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const { data, error } = await serviceClient.rpc('admin_change_user_role_v1', {
|
||||
p_actor_email: actorEmail(admin),
|
||||
p_idempotency_key: mutation.idempotencyKey,
|
||||
p_target_user_id: mutation.userId,
|
||||
p_new_role: mutation.newRole,
|
||||
p_memo: mutation.memo,
|
||||
})
|
||||
if (error) throw adminRpcError(error)
|
||||
return adminJsonResponse(validateRoleRpcResult(data, mutation), corsHeaders)
|
||||
}
|
||||
|
||||
interface RoleChangeBody {
|
||||
userId: string
|
||||
newRole: 'user' | 'manager' | 'admin' | 'super_admin'
|
||||
memo: string
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
Deno.serve(async (req: Request) => {
|
||||
export async function handleAdminUsers(req: Request): Promise<Response> {
|
||||
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)
|
||||
if (req.method === 'GET') return await readUsers(req)
|
||||
if (req.method === 'PATCH') return await changeRole(req)
|
||||
return adminJsonResponse({ error: 'method_not_allowed' }, corsHeaders, 405)
|
||||
} catch (error) {
|
||||
console.error('admin-users request failed', error)
|
||||
return adminErrorResponse(error, corsHeaders)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.main) Deno.serve(handleAdminUsers)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue