diff --git a/apps/admin/src/app/(admin)/layout.tsx b/apps/admin/src/app/(admin)/layout.tsx index 2958c1d..efe53da 100644 --- a/apps/admin/src/app/(admin)/layout.tsx +++ b/apps/admin/src/app/(admin)/layout.tsx @@ -1,5 +1,5 @@ // apps/admin/src/app/(admin)/layout.tsx -// Admin 인증 가드 + Sidebar 레이아웃 +// Admin 레이아웃 — requireAdmin() 가드 + Sidebar import { Box } from '@mui/material' import { requireAdmin } from '@/lib/admin-guard' diff --git a/apps/admin/src/app/auth/callback/route.ts b/apps/admin/src/app/auth/callback/route.ts index 7573902..2b3935a 100644 --- a/apps/admin/src/app/auth/callback/route.ts +++ b/apps/admin/src/app/auth/callback/route.ts @@ -1,42 +1,46 @@ // apps/admin/src/app/auth/callback/route.ts -// OAuth 콜백 핸들러 +// OAuth 콜백 — code를 session으로 교환 import { NextResponse, type NextRequest } from 'next/server' import { createServerClient, type CookieOptions } from '@supabase/ssr' -import type { Database } from '@d3ro/api-client' export async function GET(request: NextRequest): Promise { const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') - if (code) { - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', - { - cookies: { - getAll() { - return request.cookies.getAll() - }, - setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) { - cookiesToSet.forEach(({ name, value, options }) => { - request.cookies.set({ name, value, ...options }) - }) - }, - }, - } - ) - - const { error } = await supabase.auth.exchangeCodeForSession(code) - if (!error) { - const response = NextResponse.redirect(`${origin}/`) - // 세션 쿠키를 응답에 복사 - request.cookies.getAll().forEach((cookie) => { - response.cookies.set(cookie.name, cookie.value) - }) - return response - } + if (!code) { + return NextResponse.redirect(`${origin}/login?error=no_code`) } - return NextResponse.redirect(`${origin}/login`) + // 쿠키를 수집할 배열 + const cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }> = [] + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookies) { + cookiesToSet.push(...cookies) + }, + }, + } + ) + + const { error } = await supabase.auth.exchangeCodeForSession(code) + + if (error) { + return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error.message)}`) + } + + // 세션 교환 성공 후 response 생성 + 수집된 쿠키 적용 + const response = NextResponse.redirect(`${origin}/`) + for (const { name, value, options } of cookiesToSet) { + response.cookies.set({ name, value, ...options }) + } + + return response } diff --git a/apps/admin/src/lib/admin-guard.ts b/apps/admin/src/lib/admin-guard.ts index c813b98..bc88d9d 100644 --- a/apps/admin/src/lib/admin-guard.ts +++ b/apps/admin/src/lib/admin-guard.ts @@ -1,5 +1,5 @@ // apps/admin/src/lib/admin-guard.ts -// RSC용 admin 가드 — profile.role='admin' 체크 +// RSC용 admin 가드 — app_metadata.role='admin' 체크 import { redirect } from 'next/navigation' import { getSupabaseServerClient } from './supabase-server' @@ -18,32 +18,22 @@ export async function requireAdmin(): Promise { 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 + // app_metadata.role 체크 (JWT에 포함, RLS 재귀 없음) + const role = (user.app_metadata as Record)?.role as string | undefined if (role !== 'admin') { redirect('/unauthorized') } + // profile 이름 조회 (자기 자신은 기존 RLS로 접근 가능) + const { data: profile } = await supabase + .from('profiles') + .select('name') + .eq('id', user.id) + .maybeSingle() + return { id: user.id, email: user.email ?? null, - name: (profile as { name: string | null }).name, + name: (profile as { name: string | null } | null)?.name ?? null, } } diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts new file mode 100644 index 0000000..14524da --- /dev/null +++ b/apps/admin/src/middleware.ts @@ -0,0 +1,37 @@ +// apps/admin/src/middleware.ts +// Supabase 세션 갱신 미들웨어 — 모든 요청에서 쿠키 기반 세션을 갱신 + +import { NextResponse, type NextRequest } from 'next/server' +import { createServerClient, type CookieOptions } from '@supabase/ssr' + +export async function middleware(request: NextRequest): Promise { + const response = NextResponse.next({ request: { headers: request.headers } }) + + const url = process.env.NEXT_PUBLIC_SUPABASE_URL + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + + if (!url || !key) return response + + const supabase = createServerClient(url, key, { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) { + cookiesToSet.forEach(({ name, value, options }) => { + request.cookies.set({ name, value, ...options }) + response.cookies.set({ name, value, ...options }) + }) + }, + }, + }) + + // 세션 갱신 (토큰 리프레시) + await supabase.auth.getUser() + + return response +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +} diff --git a/package-lock.json b/package-lock.json index aec576f..9da05c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "workspaces": [ "apps/desktop", "apps/web", + "apps/admin", "packages/*" ], "devDependencies": { @@ -22,6 +23,31 @@ "typescript": "^5.7.0" } }, + "apps/admin": { + "name": "@d3ro/admin", + "version": "1.0.0", + "dependencies": { + "@d3ro/api-client": "*", + "@d3ro/core": "*", + "@d3ro/ui": "*", + "@emotion/cache": "^11.14.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.0.0", + "@mui/material": "^7.0.0", + "@mui/material-nextjs": "^7.0.0", + "@supabase/ssr": "^0.10.0", + "@supabase/supabase-js": "^2.103.0", + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" + } + }, "apps/desktop": { "name": "@d3ro/desktop", "version": "1.0.0", @@ -48,7 +74,6 @@ "electron-log": "^5.2.0", "electron-store": "^10.0.0", "fluent-ffmpeg": "^2.1.3", - "nanoid": "^5.1.7", "node-record-lpcm16": "^1.0.1", "pdf-parse": "^2.4.5", "react": "^19.0.0", @@ -688,6 +713,10 @@ "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", "license": "Apache-2.0" }, + "node_modules/@d3ro/admin": { + "resolved": "apps/admin", + "link": true + }, "node_modules/@d3ro/api-client": { "resolved": "packages/api-client", "link": true diff --git a/server/supabase/config.toml b/server/supabase/config.toml index a14f627..be6a5c1 100644 --- a/server/supabase/config.toml +++ b/server/supabase/config.toml @@ -42,6 +42,8 @@ enabled = true site_url = "http://localhost:5173" additional_redirect_urls = [ "http://localhost:5173", + "http://localhost:3000", + "http://localhost:3001", "https://d3ro.dev", "d3ro-voice://auth-callback" ] diff --git a/server/supabase/migrations/20260413000003_fix_admin_rls.sql b/server/supabase/migrations/20260413000003_fix_admin_rls.sql new file mode 100644 index 0000000..251e000 --- /dev/null +++ b/server/supabase/migrations/20260413000003_fix_admin_rls.sql @@ -0,0 +1,27 @@ +-- 재귀 RLS 정책 제거 + app_metadata 기반으로 교체 +-- profiles 테이블에서 자기 자신을 서브쿼리하면 무한 재귀 발생 + +-- 1. 기존 재귀 정책 삭제 +DROP POLICY IF EXISTS "admin_read_all_profiles" ON public.profiles; +DROP POLICY IF EXISTS "admin_read_all_subscriptions" ON public.subscriptions; +DROP POLICY IF EXISTS "admin_read_all_daily_usage" ON public.daily_usage; + +-- 2. auth.users.raw_app_meta_data 기반 admin 정책 (재귀 없음) +-- 관리자 설정: UPDATE auth.users SET raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' WHERE id = '...' +CREATE POLICY "admin_read_all_profiles" ON public.profiles + FOR SELECT TO authenticated + USING ( + (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' + ); + +CREATE POLICY "admin_read_all_subscriptions" ON public.subscriptions + FOR SELECT TO authenticated + USING ( + (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' + ); + +CREATE POLICY "admin_read_all_daily_usage" ON public.daily_usage + FOR SELECT TO authenticated + USING ( + (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' + );