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:
윤찬 2026-04-13 01:28:10 +09:00
parent d0e854c255
commit 8af75a0a1e
50 changed files with 2185 additions and 950 deletions

View file

@ -14,10 +14,16 @@ export async function callAdminApi<T = Record<string, unknown>>(
options: AdminApiOptions = {}
): Promise<T> {
const supabase = getSupabaseBrowserClient()
const { data: { session } } = await supabase.auth.getSession()
// getUser()로 토큰 갱신을 트리거한 뒤 세션에서 access_token 획득
const { data: { user }, error: userError } = await supabase.auth.getUser()
if (userError || !user) {
throw new Error('Not authenticated — please re-login')
}
const { data: { session } } = await supabase.auth.getSession()
if (!session?.access_token) {
throw new Error('Not authenticated')
throw new Error('Not authenticated — session expired')
}
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {

View file

@ -1,10 +1,17 @@
// apps/admin/src/lib/admin-guard.ts
// RSC용 admin 가드 — app_metadata.role = 'admin' | 'super_admin'
// RSC용 3단계 권한 가드 — manager < admin < super_admin
import { redirect } from 'next/navigation'
import { getSupabaseServerClient } from './supabase-server'
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,
}
export interface AdminUser {
id: string
@ -13,8 +20,8 @@ export interface AdminUser {
role: AdminRole
}
/** admin 이상 (admin, super_admin) */
export async function requireAdmin(): Promise<AdminUser> {
/** manager 이상 (manager, admin, super_admin) — CRM 접근 최소 권한 */
export async function requireManager(): Promise<AdminUser> {
const supabase = await getSupabaseServerClient()
const { data: { user } } = await supabase.auth.getUser()
@ -23,7 +30,7 @@ export async function requireAdmin(): Promise<AdminUser> {
}
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
if (role !== 'admin' && role !== 'super_admin') {
if ((ROLE_LEVEL[role ?? ''] ?? 0) < ROLE_LEVEL.manager) {
redirect('/unauthorized')
}
@ -41,16 +48,35 @@ export async function requireAdmin(): Promise<AdminUser> {
}
}
/** super_admin 전용 */
export async function requireSuperAdmin(): Promise<AdminUser> {
const adminUser = await requireAdmin()
if (adminUser.role !== 'super_admin') {
/** admin 이상 (admin, super_admin) */
export async function requireAdmin(): Promise<AdminUser> {
const adminUser = await requireManager()
if ((ROLE_LEVEL[adminUser.role] ?? 0) < ROLE_LEVEL.admin) {
redirect('/unauthorized')
}
return adminUser
}
/** super_admin 전용 */
export async function requireSuperAdmin(): Promise<AdminUser> {
const adminUser = await requireManager()
if ((ROLE_LEVEL[adminUser.role] ?? 0) < ROLE_LEVEL.super_admin) {
redirect('/unauthorized')
}
return adminUser
}
/** 최소 role 레벨 체크 */
export function hasMinRole(user: AdminUser, minRole: AdminRole): boolean {
return (ROLE_LEVEL[user.role] ?? 0) >= (ROLE_LEVEL[minRole] ?? 0)
}
/** role이 super_admin인지 체크 */
export function isSuperAdmin(user: AdminUser): boolean {
return user.role === 'super_admin'
}
/** role이 admin 이상인지 체크 */
export function isAdmin(user: AdminUser): boolean {
return (ROLE_LEVEL[user.role] ?? 0) >= ROLE_LEVEL.admin
}

View file

@ -0,0 +1,114 @@
// apps/admin/src/lib/console-theme.ts
// D3RO Console 디자인 토큰 — 터미널/콘솔 스타일
export const C = {
// backgrounds
base: '#000000',
panel: '#09090b',
panelHover: '#121214',
// borders
border: '#1f1f22',
borderHl: '#27272a',
// text
dim: '#71717a',
text: '#a1a1aa',
bright: '#ffffff',
// accent
accent: '#ff5c28',
// semantic
green: '#22c55e',
green400: '#4ade80',
orange: '#f97316',
orange400: '#fb923c',
red: '#ef4444',
red400: '#f87171',
blue: '#3b82f6',
blue400: '#60a5fa',
purple: '#a855f7',
purple400: '#c084fc',
} as const
export const FONT = '"JetBrains Mono", ui-monospace, monospace'
/** 공통 패널 스타일 */
export const panelSx = {
bgcolor: C.panel,
border: `1px solid ${C.border}`,
borderRadius: '16px',
position: 'relative' as const,
overflow: 'hidden',
'&:hover': { borderColor: C.borderHl },
transition: 'border-color 0.2s',
}
/** 테이블 공통 스타일 */
export const tableSx = {
width: '100%',
borderCollapse: 'collapse' as const,
fontFamily: FONT,
fontSize: '12px',
'& th': {
pb: 1.5,
fontWeight: 400,
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
color: C.dim,
textAlign: 'left' as const,
borderBottom: `1px solid ${C.borderHl}`,
},
'& td': {
py: 1.5,
textAlign: 'left' as const,
color: C.text,
borderBottom: `1px solid ${C.border}50`,
},
'& tr:hover td': {
bgcolor: `${C.borderHl}33`,
},
}
/** 필터 버튼 스타일 */
export const filterBtnSx = (active: boolean) => ({
px: 1.5,
py: 0.5,
borderRadius: '4px',
fontFamily: FONT,
fontSize: '10px',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase' as const,
cursor: 'pointer',
border: `1px solid ${active ? C.borderHl : 'transparent'}`,
bgcolor: active ? C.borderHl : 'transparent',
color: active ? C.bright : C.text,
'&:hover': {
bgcolor: C.panelHover,
borderColor: C.border,
},
transition: 'all 0.15s',
})
/** 상태 뱃지 스타일 */
export function statusBadgeSx(variant: 'green' | 'red' | 'orange' | 'blue' | 'purple') {
const colorMap = {
green: { bg: 'rgba(34, 197, 94, 0.1)', fg: C.green400, border: 'rgba(34, 197, 94, 0.2)' },
red: { bg: 'rgba(239, 68, 68, 0.1)', fg: C.red400, border: 'rgba(239, 68, 68, 0.2)' },
orange: { bg: 'rgba(249, 115, 22, 0.1)', fg: C.orange400, border: 'rgba(249, 115, 22, 0.2)' },
blue: { bg: 'rgba(59, 130, 246, 0.1)', fg: C.blue400, border: 'rgba(59, 130, 246, 0.2)' },
purple: { bg: 'rgba(168, 85, 247, 0.1)', fg: C.purple400, border: 'rgba(168, 85, 247, 0.2)' },
}
const c = colorMap[variant]
return {
display: 'inline-block',
px: 1,
py: 0.25,
borderRadius: '4px',
fontSize: '10px',
fontFamily: FONT,
fontWeight: 500,
letterSpacing: '0.05em',
bgcolor: c.bg,
color: c.fg,
border: `1px solid ${c.border}`,
}
}

View file

@ -1,17 +1,4 @@
// apps/admin/src/lib/supabase-browser.ts
// 클라이언트 컴포넌트용 Supabase 클라이언트
// re-export from shared package
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@d3ro/api-client'
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
if (cachedClient) return cachedClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
cachedClient = createBrowserClient<Database>(url, key)
return cachedClient
}
export { getSupabaseBrowserClient, isSupabaseConfigured } from '@d3ro/api-client/supabase-browser'

View file

@ -1,30 +1,10 @@
// apps/admin/src/lib/supabase-server.ts
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션)
// Next.js cookies() 주입 래퍼 — 실제 로직은 @d3ro/api-client
import { cookies } from 'next/headers'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import type { Database } from '@d3ro/api-client'
import { createSupabaseServerClient } from '@d3ro/api-client/supabase-server'
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
export async function getSupabaseServerClient() {
const cookieStore = await cookies()
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
return createServerClient<Database>(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch {
// RSC에서 set은 실패 가능
}
}
}
})
return createSupabaseServerClient(cookieStore)
}