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() 선행 토큰 갱신)
This commit is contained in:
parent
d0e854c255
commit
8af75a0a1e
50 changed files with 2185 additions and 950 deletions
|
|
@ -1,20 +1,43 @@
|
|||
// server/supabase/functions/_shared/admin-auth.ts
|
||||
// Admin/Super-admin 권한 검증 — requireUser 확장
|
||||
// 3단계 권한 검증: manager < admin < super_admin
|
||||
|
||||
// @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'
|
||||
export type AdminRole = 'manager' | 'admin' | 'super_admin'
|
||||
|
||||
const ROLE_LEVEL: Record<string, number> = {
|
||||
user: 0,
|
||||
manager: 1,
|
||||
admin: 2,
|
||||
super_admin: 3,
|
||||
}
|
||||
|
||||
function getUserRole(user: User): string {
|
||||
return ((user.app_metadata as Record<string, unknown>)?.role as string) ?? 'user'
|
||||
}
|
||||
|
||||
/**
|
||||
* manager 이상 권한 필요 (manager, admin, super_admin).
|
||||
*/
|
||||
export async function requireManager(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = getUserRole(user)
|
||||
if ((ROLE_LEVEL[role] ?? 0) < ROLE_LEVEL.manager) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Manager access required' } as AuthError
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
const role = getUserRole(user)
|
||||
if ((ROLE_LEVEL[role] ?? 0) < ROLE_LEVEL.admin) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Admin access required' } as AuthError
|
||||
}
|
||||
|
|
@ -23,12 +46,11 @@ export async function requireAdmin(req: Request): Promise<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') {
|
||||
const role = getUserRole(user)
|
||||
if ((ROLE_LEVEL[role] ?? 0) < ROLE_LEVEL.super_admin) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw { status: 403, message: 'Super admin access required' } as AuthError
|
||||
}
|
||||
|
|
@ -36,10 +58,18 @@ export async function requireSuperAdmin(req: Request): Promise<User> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 현재 유저의 admin role 반환. admin이 아니면 null.
|
||||
* 현재 유저의 admin role 반환. manager 미만이면 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
|
||||
const role = getUserRole(user)
|
||||
if (role === 'manager' || role === 'admin' || role === 'super_admin') return role as AdminRole
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저가 특정 최소 레벨 이상인지 체크.
|
||||
*/
|
||||
export function hasMinRole(user: User, minRole: AdminRole): boolean {
|
||||
const role = getUserRole(user)
|
||||
return (ROLE_LEVEL[role] ?? 0) >= (ROLE_LEVEL[minRole] ?? 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// server/supabase/functions/admin-audit-log/index.ts
|
||||
// 감사로그 조회 — admin 이상
|
||||
// 감사로그 조회 — manager 이상
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin } from '../_shared/admin-auth.ts'
|
||||
import { requireManager } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
|
||||
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], status = 200): Response {
|
||||
|
|
@ -23,7 +23,7 @@ Deno.serve(async (req: Request) => {
|
|||
}
|
||||
|
||||
try {
|
||||
await requireAdmin(req)
|
||||
await requireManager(req)
|
||||
const url = new URL(req.url)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin } from '../_shared/admin-auth.ts'
|
||||
import { requireManager } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { getPaypleConfig, paypleAuth } from '../_shared/payple.ts'
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ Deno.serve(async (req: Request) => {
|
|||
}
|
||||
|
||||
try {
|
||||
await requireAdmin(req)
|
||||
await requireManager(req)
|
||||
const url = new URL(req.url)
|
||||
const userId = url.searchParams.get('userId')
|
||||
const source = url.searchParams.get('source') // 'db' | 'payple' | null(=db)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// server/supabase/functions/admin-subscriptions/index.ts
|
||||
// 구독 CRUD — admin: 조회 / super_admin: 생성/수정/삭제
|
||||
// 구독 CRUD — manager: 조회+수정 / admin+: 생성/수정/삭제
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
|
||||
import { requireManager, requireAdmin } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { writeAuditLog } from '../_shared/audit.ts'
|
||||
|
||||
|
|
@ -45,9 +45,9 @@ Deno.serve(async (req: Request) => {
|
|||
const url = new URL(req.url)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
// ── GET: 목록/상세 ──
|
||||
// ── GET: 목록/상세 (manager 이상) ──
|
||||
if (req.method === 'GET') {
|
||||
await requireAdmin(req)
|
||||
await requireManager(req)
|
||||
|
||||
const userId = url.searchParams.get('userId')
|
||||
|
||||
|
|
@ -95,9 +95,9 @@ Deno.serve(async (req: Request) => {
|
|||
return jsonResponse({ subscriptions: data ?? [], total: count ?? 0, page, limit })
|
||||
}
|
||||
|
||||
// ── POST: 생성 (super_admin) ──
|
||||
// ── POST: 생성 (admin 이상) ──
|
||||
if (req.method === 'POST') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const admin = await requireAdmin(req)
|
||||
const body = (await req.json()) as CreateBody
|
||||
|
||||
if (!body.userId || !body.tier || !body.memo) {
|
||||
|
|
@ -155,9 +155,9 @@ Deno.serve(async (req: Request) => {
|
|||
return jsonResponse({ success: true, subscription: created as unknown as Record<string, unknown> }, 201)
|
||||
}
|
||||
|
||||
// ── PATCH: 수정 (super_admin) ──
|
||||
// ── PATCH: 수정 (manager 이상) ──
|
||||
if (req.method === 'PATCH') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const admin = await requireManager(req)
|
||||
const userId = url.searchParams.get('userId')
|
||||
if (!userId) return jsonResponse({ error: 'userId query param required' }, 400)
|
||||
|
||||
|
|
@ -211,9 +211,9 @@ Deno.serve(async (req: Request) => {
|
|||
return jsonResponse({ success: true, subscription: after as unknown as Record<string, unknown> })
|
||||
}
|
||||
|
||||
// ── DELETE: 소프트 삭제 (super_admin) ──
|
||||
// ── DELETE: 소프트 삭제 (admin 이상) ──
|
||||
if (req.method === 'DELETE') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const admin = await requireAdmin(req)
|
||||
const userId = url.searchParams.get('userId')
|
||||
if (!userId) return jsonResponse({ error: 'userId query param required' }, 400)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
|
||||
import { requireManager, requireAdmin, requireSuperAdmin, hasMinRole } from '../_shared/admin-auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import { writeAuditLog } from '../_shared/audit.ts'
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[],
|
|||
|
||||
interface RoleChangeBody {
|
||||
userId: string
|
||||
newRole: 'user' | 'admin' | 'super_admin'
|
||||
newRole: 'user' | 'manager' | 'admin' | 'super_admin'
|
||||
memo: string
|
||||
}
|
||||
|
||||
|
|
@ -31,9 +31,9 @@ Deno.serve(async (req: Request) => {
|
|||
try {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// ── GET: 유저 목록/상세 ──
|
||||
// ── GET: 유저 목록/상세 (manager 이상) ──
|
||||
if (req.method === 'GET') {
|
||||
const admin = await requireAdmin(req)
|
||||
const admin = await requireManager(req)
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
const userId = url.searchParams.get('userId')
|
||||
|
|
@ -86,9 +86,11 @@ Deno.serve(async (req: Request) => {
|
|||
return jsonResponse({ profiles: profiles ?? [], total: count ?? 0, page, limit })
|
||||
}
|
||||
|
||||
// ── PATCH: role 변경 (super_admin 전용) ──
|
||||
// ── PATCH: role 변경 ──
|
||||
// admin: user↔manager 변경 가능
|
||||
// super_admin: 모든 role 변경 가능 (→admin 포함)
|
||||
if (req.method === 'PATCH') {
|
||||
const admin = await requireSuperAdmin(req)
|
||||
const admin = await requireAdmin(req)
|
||||
const body = (await req.json()) as RoleChangeBody
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
|
|
@ -96,11 +98,16 @@ Deno.serve(async (req: Request) => {
|
|||
return jsonResponse({ error: 'userId, newRole, memo are required' }, 400)
|
||||
}
|
||||
|
||||
const validRoles = ['user', 'admin', 'super_admin']
|
||||
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')
|
||||
|
|
@ -110,6 +117,12 @@ Deno.serve(async (req: Request) => {
|
|||
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
-- Phase V2-6.1: 3단계 권한 체계 — manager 역할 추가
|
||||
-- manager: CS 업무 (조회 + 구독 수정/메모)
|
||||
-- admin: 운영 전권 (구독 CRUD, 삭제, manager 관리)
|
||||
-- super_admin: admin 계정 관리 (승격/강등)
|
||||
|
||||
-- 1. profiles.role CHECK 확장
|
||||
ALTER TABLE public.profiles DROP CONSTRAINT profiles_role_check;
|
||||
ALTER TABLE public.profiles
|
||||
ADD CONSTRAINT profiles_role_check
|
||||
CHECK (role IN ('user', 'manager', 'admin', 'super_admin'));
|
||||
|
||||
-- 2. RLS: manager도 읽기 허용
|
||||
DROP POLICY IF EXISTS admin_read_all_profiles ON public.profiles;
|
||||
CREATE POLICY admin_read_all_profiles ON public.profiles FOR SELECT
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
|
||||
|
||||
DROP POLICY IF EXISTS admin_read_all_subscriptions ON public.subscriptions;
|
||||
CREATE POLICY admin_read_all_subscriptions ON public.subscriptions FOR SELECT
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
|
||||
|
||||
DROP POLICY IF EXISTS admin_read_all_daily_usage ON public.daily_usage;
|
||||
CREATE POLICY admin_read_all_daily_usage ON public.daily_usage FOR SELECT
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
|
||||
|
||||
DROP POLICY IF EXISTS admin_read_audit_log ON public.audit_log;
|
||||
CREATE POLICY admin_read_audit_log ON public.audit_log FOR SELECT
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
|
||||
|
||||
-- 3. RLS: admin도 subscriptions/profiles 쓰기 허용 (기존 super_admin 전용 → admin 이상)
|
||||
DROP POLICY IF EXISTS super_admin_write_subscriptions ON public.subscriptions;
|
||||
CREATE POLICY admin_write_subscriptions ON public.subscriptions
|
||||
FOR ALL
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'))
|
||||
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
|
||||
|
||||
DROP POLICY IF EXISTS super_admin_update_profiles ON public.profiles;
|
||||
CREATE POLICY admin_update_profiles ON public.profiles
|
||||
FOR UPDATE
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'))
|
||||
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
|
||||
|
||||
-- 4. RLS: manager는 subscriptions 수정만 허용 (생성/삭제 불가)
|
||||
-- manager가 UPDATE를 수행할 수 있도록 별도 정책 (위의 admin_write_subscriptions은 admin 이상만)
|
||||
-- 주의: 위 정책이 FOR ALL이므로 admin/super_admin은 이미 커버. manager만 UPDATE 추가.
|
||||
CREATE POLICY manager_update_subscriptions ON public.subscriptions
|
||||
FOR UPDATE
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'manager')
|
||||
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') = 'manager');
|
||||
Loading…
Add table
Add a link
Reference in a new issue