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
|
|
@ -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}`, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
114
apps/admin/src/lib/console-theme.ts
Normal file
114
apps/admin/src/lib/console-theme.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue