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:
parent
2c2f87d7d4
commit
dd1c3054dd
7 changed files with 295 additions and 11 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T extends Record<string, unknown>> {
|
||||
get<K extends keyof T>(key: K): T[K]
|
||||
|
|
@ -92,6 +114,14 @@ export function getConfigService(): ElectronStore<AppConfig> | null {
|
|||
}
|
||||
|
||||
export function configGet<K extends keyof AppConfig>(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<K extends keyof AppConfig>(key: K): AppConfig[K] {
|
|||
}
|
||||
|
||||
export function configSet<K extends keyof AppConfig>(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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue