feat(desktop): SaaS [1+2] 빌드 타임 env + OAuth 강제 로그인 게이트

배경:
사용자가 데스크톱 앱 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만)
This commit is contained in:
윤찬 2026-04-11 09:38:12 +09:00
parent 2c2f87d7d4
commit dd1c3054dd
7 changed files with 295 additions and 11 deletions

View file

@ -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<ThemeMode>('auto')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const systemAudioCleanupRef = useRef<(() => void) | null>(null)
const [authGate, setAuthGate] = useState<AuthGateState>({ 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 (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<CircularProgress size={32} />
</Box>
)
}
if (authGate.status === 'login-required') {
return <LoginScreen />
}
// 'authenticated' or 'legacy' → 메인 UI 렌더
return (
<>
<AppLayout />
<UpgradePromptModal />
</>
)
})()
return (
<I18nProvider storage={electronI18nStorage}>
<ThemeProvider theme={theme}>
<CssBaseline />
<AppLayout />
<UpgradePromptModal />
{gateContent}
</ThemeProvider>
</I18nProvider>
)