refactor(admin): CRM을 apps/admin 독립 프로젝트로 분리

- apps/web에서 admin 라우트/가드/sidebar 링크 제거
- apps/admin: 독립 Next.js 앱 (포트 3001)
  - 자체 login/unauthorized/auth callback
  - admin-sidebar: Overview/Users/Subscriptions/Usage
  - requireAdmin() 가드: profile.role='admin' 체크
- monorepo workspace에 apps/admin 등록
This commit is contained in:
윤찬 2026-04-12 20:36:24 +09:00
parent daed9f90d5
commit 46673ee941
23 changed files with 536 additions and 343 deletions

View file

@ -0,0 +1,49 @@
// apps/admin/src/lib/admin-guard.ts
// RSC용 admin 가드 — profile.role='admin' 체크
import { redirect } from 'next/navigation'
import { getSupabaseServerClient } from './supabase-server'
export interface AdminUser {
id: string
email: string | null
name: string | null
}
export async function requireAdmin(): Promise<AdminUser> {
const supabase = await getSupabaseServerClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
// role은 DB 타입에 미정의이므로 raw 캐스팅
const { data: profile } = await supabase
.from('profiles')
.select('id, name')
.eq('id', user.id)
.maybeSingle()
if (!profile) {
redirect('/login')
}
// role 별도 조회 (DB 타입에 role 컬럼 미정의)
const { data: roleData } = await supabase
.from('profiles')
.select('role' as 'id')
.eq('id', user.id)
.maybeSingle()
const role = (roleData as unknown as { role: string } | null)?.role
if (role !== 'admin') {
redirect('/unauthorized')
}
return {
id: user.id,
email: user.email ?? null,
name: (profile as { name: string | null }).name,
}
}

View file

@ -0,0 +1,17 @@
// apps/admin/src/lib/supabase-browser.ts
// 클라이언트 컴포넌트용 Supabase 클라이언트
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
}

View file

@ -0,0 +1,30 @@
// apps/admin/src/lib/supabase-server.ts
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션)
import { cookies } from 'next/headers'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import type { Database } from '@d3ro/api-client'
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
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은 실패 가능
}
}
}
})
}