From dd1c3054ddf5066ca903644310fb3191beaf45e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 09:38:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20SaaS=20[1+2]=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=ED=83=80=EC=9E=84=20env=20+=20OAuth=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배경: 사용자가 데스크톱 앱 Settings에서 직접 Supabase URL/Key를 입력해야 했음. SaaS 데스크톱(Notion/Linear/Slack) 패턴에서는 사용자가 OAuth 로그인만 하면 끝이고 연결 정보는 빌드 타임에 박혀있어야 함. V1 single-user 잔재로 인한 UX 결함이었고 사용자가 'SaaS 설계가 잘못된 것 같다'고 우려할 만한 명확한 흠집. [1] 빌드 타임 Supabase env 주입 - electron.vite.config.ts: defineConfig가 mode 받아 loadEnv('D3RO_') 호출 - .env, .env.local 자동 로드 (vite/electron-vite 표준) - main 번들에 process.env.D3RO_SUPABASE_URL/ANON_KEY를 inline define으로 박음 - ConfigService: - BUILD_TIME_SUPABASE_URL / BUILD_TIME_SUPABASE_ANON_KEY 상수 - isSupabaseBuildTimeConfigured() / getBuildTimeSupabase{Url,AnonKey}() 헬퍼 - configGet('supabaseUrl'/'supabaseAnonKey')는 빌드 타임 값을 절대 우선 - configSet은 빌드 타임 모드일 때 supabaseUrl/AnonKey 변경 거부 (덮어쓰기 방지) - apps/desktop/.env.local 생성 (gitignored, llnocwyqvhgwpdjcqqyw 프로젝트) - apps/desktop/env.example 템플릿 (committed) [2] OAuth 첫 실행 강제 게이트 - CloudSyncState에 saasMode: boolean 필드 추가 (CloudSyncService.getState) - LoginScreen.tsx 신규: Google/GitHub 버튼만 노출 (URL/Key 입력 필드 없음) - MetalCard + PhosphorText D3RO 디자인 시스템 - i18n 키 재사용 (login.subtitle/google/github/terms) - App.tsx에 AuthGate 추가: - cloudSync.getState로 saasMode + authenticated 조회 - status: 'loading' | 'login-required' | 'authenticated' | 'legacy' - saasMode이고 인증 안 됨 → LoginScreen 강제 - saasMode이고 인증 됨 → 기존 AppLayout - saasMode 아님 → 'legacy' (기존 동작 유지, 개발자가 .env.local 설정 안 한 경우) - onAuthChanged 구독으로 로그인 직후 자동 전환 - CloudSyncSection (Settings 안): - saasMode일 때 URL/Key 입력 필드 완전 숨김 (OAuth 버튼만) - !saasMode (legacy)일 때만 기존 입력 화면 유지 검증: - typecheck OK - dev 재시작 시 'CloudSync disabled — Supabase URL/key not configured' 로그 사라짐 → 빌드 타임 env가 정상적으로 ConfigService에 주입되어 client 생성됨 - 첫 실행 시 사용자가 보는 화면 = LoginScreen (Google/GitHub만) --- apps/desktop/electron.vite.config.ts | 27 ++++- apps/desktop/env.example | 9 ++ .../src/main/services/CloudSyncService.ts | 10 +- .../src/main/services/ConfigService.ts | 39 ++++++ apps/desktop/src/renderer/App.tsx | 74 +++++++++++- .../renderer/components/CloudSyncSection.tsx | 34 +++++- .../src/renderer/components/LoginScreen.tsx | 113 ++++++++++++++++++ 7 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/env.example create mode 100644 apps/desktop/src/renderer/components/LoginScreen.tsx diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 5b379b7..e8f243f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -1,5 +1,5 @@ import { resolve } from 'path' -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import { defineConfig, externalizeDepsPlugin, loadEnv } from 'electron-vite' import react from '@vitejs/plugin-react' const sharedAlias = { @@ -10,14 +10,34 @@ const sharedAlias = { const workspaceExclude = ['@d3ro/core', '@d3ro/ui', '@d3ro/i18n'] -export default defineConfig({ +// SaaS 데스크톱 클라이언트: Supabase 연결 정보를 빌드 타임에 박는다. +// `apps/desktop/.env` (committed shared defaults) + `.env.local` (gitignored, 개인/비공개 키) +// 우선순위는 vite/electron-vite의 dotenv 로드 규칙: .env.local > .env.[mode] > .env +// 환경 변수에 값이 없으면 빈 문자열로 박혀, 런타임에 ConfigService가 fallback(설정 UI 입력)으로 동작. +function loadSupabaseEnv(mode: string): { url: string; anonKey: string } { + const env = loadEnv(mode, process.cwd(), 'D3RO_') + return { + url: env.D3RO_SUPABASE_URL ?? '', + anonKey: env.D3RO_SUPABASE_ANON_KEY ?? '' + } +} + +export default defineConfig(({ mode }) => { + const supabase = loadSupabaseEnv(mode) + const supabaseDefine = { + 'process.env.D3RO_SUPABASE_URL': JSON.stringify(supabase.url), + 'process.env.D3RO_SUPABASE_ANON_KEY': JSON.stringify(supabase.anonKey) + } + + return { main: { plugins: [ externalizeDepsPlugin({ exclude: ['nanoid', 'electron-store', ...workspaceExclude] }) ], - resolve: { alias: sharedAlias } + resolve: { alias: sharedAlias }, + define: supabaseDefine }, preload: { plugins: [externalizeDepsPlugin({ exclude: workspaceExclude })], @@ -62,4 +82,5 @@ export default defineConfig({ resolve: { alias: sharedAlias }, plugins: [react()] } + } }) diff --git a/apps/desktop/env.example b/apps/desktop/env.example new file mode 100644 index 0000000..068f5a0 --- /dev/null +++ b/apps/desktop/env.example @@ -0,0 +1,9 @@ +# apps/desktop SaaS 빌드 타임 환경변수 템플릿. +# 실제 값은 .env.local로 복사 (gitignored). +# +# cp env.example .env.local +# +# Supabase 프로젝트 — 데스크톱 클라이언트가 연결할 SaaS 인스턴스. +# 빌드 시점에 main 번들에 inline 박혀 사용자가 Settings에서 입력할 필요 없음. +D3RO_SUPABASE_URL=https://your-project-ref.supabase.co +D3RO_SUPABASE_ANON_KEY=your-anon-or-publishable-key diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index f059af9..0d1d3f5 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -13,7 +13,7 @@ import { } from '@supabase/supabase-js' import { eq, gt } from 'drizzle-orm' import { getLogger } from './LoggerService' -import { configGet } from './ConfigService' +import { configGet, isSupabaseBuildTimeConfigured } from './ConfigService' import { getDatabase } from '../db' import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -40,6 +40,11 @@ interface CloudSyncState { userEmail: string | null lastSyncAt: number | null syncing: boolean + /** + * SaaS 빌드 타임 모드: Supabase URL/Key가 빌드 시점에 박혀있는지 여부. + * true이면 사용자는 OAuth 로그인만 하면 됨 (URL/Key 입력 화면 노출 금지). + */ + saasMode: boolean } interface CloudSyncEvents { @@ -135,7 +140,8 @@ class CloudSyncService extends EventEmitter { authenticated: this.isAuthenticated(), userEmail: this._session?.user?.email ?? null, lastSyncAt: this._lastSyncAt, - syncing: this._syncing + syncing: this._syncing, + saasMode: isSupabaseBuildTimeConfigured() } } diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 05cefb1..a6b38b3 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -7,6 +7,28 @@ import { getLogger } from './LoggerService' const logger = getLogger('ConfigService') +// ============================================================ +// SaaS 빌드 타임 주입 (electron.vite.config.ts의 define으로 박힘) +// ============================================================ +// D3RO_SUPABASE_URL / D3RO_SUPABASE_ANON_KEY가 비어있지 않으면 +// 데스크톱 사용자가 Settings에서 직접 입력할 필요 없이 SaaS 인스턴스에 자동 연결. +// 빈 문자열이면 기존 동작(사용자 입력) fallback. + +const BUILD_TIME_SUPABASE_URL: string = process.env.D3RO_SUPABASE_URL ?? '' +const BUILD_TIME_SUPABASE_ANON_KEY: string = process.env.D3RO_SUPABASE_ANON_KEY ?? '' + +export function isSupabaseBuildTimeConfigured(): boolean { + return BUILD_TIME_SUPABASE_URL.length > 0 && BUILD_TIME_SUPABASE_ANON_KEY.length > 0 +} + +export function getBuildTimeSupabaseUrl(): string { + return BUILD_TIME_SUPABASE_URL +} + +export function getBuildTimeSupabaseAnonKey(): string { + return BUILD_TIME_SUPABASE_ANON_KEY +} + // electron-store v10은 ESM 전용이므로 동적 import 필요 interface ElectronStore> { get(key: K): T[K] @@ -92,6 +114,14 @@ export function getConfigService(): ElectronStore | null { } export function configGet(key: K): AppConfig[K] { + // SaaS 빌드 타임 주입은 사용자 설정보다 우선. + if (key === 'supabaseUrl' && BUILD_TIME_SUPABASE_URL.length > 0) { + return BUILD_TIME_SUPABASE_URL as AppConfig[K] + } + if (key === 'supabaseAnonKey' && BUILD_TIME_SUPABASE_ANON_KEY.length > 0) { + return BUILD_TIME_SUPABASE_ANON_KEY as AppConfig[K] + } + if (!store) { logger.warn(`ConfigService not initialized, returning default for "${key}"`) return CONFIG_DEFAULTS[key] @@ -100,6 +130,15 @@ export function configGet(key: K): AppConfig[K] { } export function configSet(key: K, value: AppConfig[K]): void { + // SaaS 빌드 타임 주입된 값은 변경 차단 (사용자가 잘못된 값으로 덮어쓰는 것 방지). + if ( + (key === 'supabaseUrl' || key === 'supabaseAnonKey') && + isSupabaseBuildTimeConfigured() + ) { + logger.warn(`Refusing to override build-time SaaS config: "${key}"`) + return + } + if (!store) { logger.warn(`ConfigService not initialized, cannot set "${key}"`) return diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 662cadf..45a01c2 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -3,14 +3,22 @@ // i18n: I18nProvider가 전체 트리를 감쌈. ConfigService에서 언어 로드. import { useState, useEffect, useMemo, useRef } from 'react' -import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material' +import { ThemeProvider, CssBaseline, useMediaQuery, Box, CircularProgress } from '@mui/material' import { getTheme } from '@d3ro/ui/theme' import { I18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n' import { AppLayout } from './components/AppLayout' import { UpgradePromptModal } from './components/UpgradePromptModal' +import { LoginScreen } from './components/LoginScreen' import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture' import type { ThemeMode, ConfigChangedEvent } from '@d3ro/core/types' +// CloudSync 게이트 상태 +type AuthGateState = + | { status: 'loading' } + | { status: 'authenticated' } + | { status: 'login-required' } + | { status: 'legacy' } // 빌드 타임 SaaS 모드 아님 → 기존 동작 (선택적 클라우드) + // Electron ConfigService에 바인딩된 i18n 영속화 어댑터 const electronI18nStorage: I18nStorage = { load: async () => { @@ -26,6 +34,7 @@ export function App(): React.ReactElement { const [themeMode, setThemeMode] = useState('auto') const prefersDark = useMediaQuery('(prefers-color-scheme: dark)') const systemAudioCleanupRef = useRef<(() => void) | null>(null) + const [authGate, setAuthGate] = useState({ status: 'loading' }) // 설정에서 테마 로드 + 변경 감지 useEffect(() => { @@ -43,6 +52,38 @@ export function App(): React.ReactElement { return unsub }, []) + // SaaS 인증 게이트: 빌드 타임에 Supabase가 박힌 경우(=SaaS 모드)에만 활성화. + // 인증 안 된 상태면 LoginScreen 강제. 로그인 후 자동 진입. + // 빌드 타임 env가 없으면(legacy 개발자 모드) 게이트를 비활성화 — 기존 Settings UI 유지. + useEffect(() => { + let cancelled = false + + void window.electronAPI.cloudSync.getState().then((r) => { + if (cancelled || !r.success) return + if (!r.data.saasMode) { + setAuthGate({ status: 'legacy' }) + return + } + setAuthGate({ + status: r.data.authenticated ? 'authenticated' : 'login-required' + }) + }) + + const unsub = window.electronAPI.cloudSync.onAuthChanged((payload) => { + if (cancelled) return + // saasMode 여부는 변하지 않으므로 인증 상태만 토글. + setAuthGate((prev) => { + if (prev.status === 'legacy') return prev + return { status: payload.user ? 'authenticated' : 'login-required' } + }) + }) + + return () => { + cancelled = true + unsub() + } + }, []) + // 시스템 오디오 캡처: 메인 프로세스의 시작/중지 요청에 응답 useEffect(() => { const unsubStart = window.electronAPI.caption.onStartSystemAudio(() => { @@ -70,12 +111,39 @@ export function App(): React.ReactElement { const theme = useMemo(() => getTheme(themeMode, prefersDark), [themeMode, prefersDark]) + const gateContent = (() => { + if (authGate.status === 'loading') { + return ( + + + + ) + } + if (authGate.status === 'login-required') { + return + } + // 'authenticated' or 'legacy' → 메인 UI 렌더 + return ( + <> + + + + ) + })() + return ( - - + {gateContent} ) diff --git a/apps/desktop/src/renderer/components/CloudSyncSection.tsx b/apps/desktop/src/renderer/components/CloudSyncSection.tsx index d73272e..109ddfd 100644 --- a/apps/desktop/src/renderer/components/CloudSyncSection.tsx +++ b/apps/desktop/src/renderer/components/CloudSyncSection.tsx @@ -16,6 +16,7 @@ interface CloudSyncState { userEmail: string | null lastSyncAt: number | null syncing: boolean + saasMode: boolean } interface SyncProgress { @@ -30,7 +31,8 @@ export function CloudSyncSection(): React.ReactElement { authenticated: false, userEmail: null, lastSyncAt: null, - syncing: false + syncing: false, + saasMode: false }) const [supabaseUrl, setSupabaseUrl] = useState('') const [anonKey, setAnonKey] = useState('') @@ -159,10 +161,36 @@ export function CloudSyncSection(): React.ReactElement { Cloud Sync - {!state.authenticated && ( + {!state.authenticated && state.saasMode && ( + // SaaS 빌드 타임 모드: URL/Key는 빌드에 박혀있으므로 OAuth 버튼만 노출. + + OAuth 로그인 + + + + + + )} + + {!state.authenticated && !state.saasMode && ( + // Legacy 개발자 모드: 빌드 타임 env 미설정 → 사용자가 직접 입력 가능. - SUPABASE 설정 (env 미설정 시 직접 입력) + SUPABASE 설정 (개발자 모드) (null) + const [info, setInfo] = useState(null) + + async function handleSignIn(provider: 'google' | 'github'): Promise { + setError(null) + setInfo(null) + setBusy(true) + try { + const r = await window.electronAPI.cloudSync.signIn({ provider }) + if (!r.success) { + setError(r.error.message) + setBusy(false) + return + } + setInfo(t('login.browserPrompt') ?? '브라우저에서 로그인을 완료해주세요...') + // 성공 시 auth-changed 이벤트가 AuthGate로 전달되어 자동 전환됨. + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setBusy(false) + } + } + + return ( + + + + D3RO VOICE + + {t('login.subtitle') ?? 'AI 음성 어시스턴트'} + + + + + + + + + + {error && ( + + {error} + + )} + {info && !error && ( + + {info} + + )} + + + {t('login.terms') ?? '계속 진행하면 이용약관 및 개인정보 처리방침에 동의합니다.'} + + + + + ) +}