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
|
|
@ -1,5 +1,5 @@
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
import { defineConfig, externalizeDepsPlugin, loadEnv } from 'electron-vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
const sharedAlias = {
|
const sharedAlias = {
|
||||||
|
|
@ -10,14 +10,34 @@ const sharedAlias = {
|
||||||
|
|
||||||
const workspaceExclude = ['@d3ro/core', '@d3ro/ui', '@d3ro/i18n']
|
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: {
|
main: {
|
||||||
plugins: [
|
plugins: [
|
||||||
externalizeDepsPlugin({
|
externalizeDepsPlugin({
|
||||||
exclude: ['nanoid', 'electron-store', ...workspaceExclude]
|
exclude: ['nanoid', 'electron-store', ...workspaceExclude]
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
resolve: { alias: sharedAlias }
|
resolve: { alias: sharedAlias },
|
||||||
|
define: supabaseDefine
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
plugins: [externalizeDepsPlugin({ exclude: workspaceExclude })],
|
plugins: [externalizeDepsPlugin({ exclude: workspaceExclude })],
|
||||||
|
|
@ -62,4 +82,5 @@ export default defineConfig({
|
||||||
resolve: { alias: sharedAlias },
|
resolve: { alias: sharedAlias },
|
||||||
plugins: [react()]
|
plugins: [react()]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
9
apps/desktop/env.example
Normal file
9
apps/desktop/env.example
Normal file
|
|
@ -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
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
} from '@supabase/supabase-js'
|
} from '@supabase/supabase-js'
|
||||||
import { eq, gt } from 'drizzle-orm'
|
import { eq, gt } from 'drizzle-orm'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet, isSupabaseBuildTimeConfigured } from './ConfigService'
|
||||||
import { getDatabase } from '../db'
|
import { getDatabase } from '../db'
|
||||||
import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
@ -40,6 +40,11 @@ interface CloudSyncState {
|
||||||
userEmail: string | null
|
userEmail: string | null
|
||||||
lastSyncAt: number | null
|
lastSyncAt: number | null
|
||||||
syncing: boolean
|
syncing: boolean
|
||||||
|
/**
|
||||||
|
* SaaS 빌드 타임 모드: Supabase URL/Key가 빌드 시점에 박혀있는지 여부.
|
||||||
|
* true이면 사용자는 OAuth 로그인만 하면 됨 (URL/Key 입력 화면 노출 금지).
|
||||||
|
*/
|
||||||
|
saasMode: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CloudSyncEvents {
|
interface CloudSyncEvents {
|
||||||
|
|
@ -135,7 +140,8 @@ class CloudSyncService extends EventEmitter {
|
||||||
authenticated: this.isAuthenticated(),
|
authenticated: this.isAuthenticated(),
|
||||||
userEmail: this._session?.user?.email ?? null,
|
userEmail: this._session?.user?.email ?? null,
|
||||||
lastSyncAt: this._lastSyncAt,
|
lastSyncAt: this._lastSyncAt,
|
||||||
syncing: this._syncing
|
syncing: this._syncing,
|
||||||
|
saasMode: isSupabaseBuildTimeConfigured()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,28 @@ import { getLogger } from './LoggerService'
|
||||||
|
|
||||||
const logger = getLogger('ConfigService')
|
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 필요
|
// electron-store v10은 ESM 전용이므로 동적 import 필요
|
||||||
interface ElectronStore<T extends Record<string, unknown>> {
|
interface ElectronStore<T extends Record<string, unknown>> {
|
||||||
get<K extends keyof T>(key: K): T[K]
|
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] {
|
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) {
|
if (!store) {
|
||||||
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
|
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
|
||||||
return CONFIG_DEFAULTS[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 {
|
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) {
|
if (!store) {
|
||||||
logger.warn(`ConfigService not initialized, cannot set "${key}"`)
|
logger.warn(`ConfigService not initialized, cannot set "${key}"`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,22 @@
|
||||||
// i18n: I18nProvider가 전체 트리를 감쌈. ConfigService에서 언어 로드.
|
// i18n: I18nProvider가 전체 트리를 감쌈. ConfigService에서 언어 로드.
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
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 { getTheme } from '@d3ro/ui/theme'
|
||||||
import { I18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n'
|
import { I18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n'
|
||||||
import { AppLayout } from './components/AppLayout'
|
import { AppLayout } from './components/AppLayout'
|
||||||
import { UpgradePromptModal } from './components/UpgradePromptModal'
|
import { UpgradePromptModal } from './components/UpgradePromptModal'
|
||||||
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture'
|
import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture'
|
||||||
import type { ThemeMode, ConfigChangedEvent } from '@d3ro/core/types'
|
import type { ThemeMode, ConfigChangedEvent } from '@d3ro/core/types'
|
||||||
|
|
||||||
|
// CloudSync 게이트 상태
|
||||||
|
type AuthGateState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'authenticated' }
|
||||||
|
| { status: 'login-required' }
|
||||||
|
| { status: 'legacy' } // 빌드 타임 SaaS 모드 아님 → 기존 동작 (선택적 클라우드)
|
||||||
|
|
||||||
// Electron ConfigService에 바인딩된 i18n 영속화 어댑터
|
// Electron ConfigService에 바인딩된 i18n 영속화 어댑터
|
||||||
const electronI18nStorage: I18nStorage = {
|
const electronI18nStorage: I18nStorage = {
|
||||||
load: async () => {
|
load: async () => {
|
||||||
|
|
@ -26,6 +34,7 @@ export function App(): React.ReactElement {
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>('auto')
|
const [themeMode, setThemeMode] = useState<ThemeMode>('auto')
|
||||||
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
|
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||||
const systemAudioCleanupRef = useRef<(() => void) | null>(null)
|
const systemAudioCleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
const [authGate, setAuthGate] = useState<AuthGateState>({ status: 'loading' })
|
||||||
|
|
||||||
// 설정에서 테마 로드 + 변경 감지
|
// 설정에서 테마 로드 + 변경 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -43,6 +52,38 @@ export function App(): React.ReactElement {
|
||||||
return unsub
|
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(() => {
|
useEffect(() => {
|
||||||
const unsubStart = window.electronAPI.caption.onStartSystemAudio(() => {
|
const unsubStart = window.electronAPI.caption.onStartSystemAudio(() => {
|
||||||
|
|
@ -70,12 +111,39 @@ export function App(): React.ReactElement {
|
||||||
|
|
||||||
const theme = useMemo(() => getTheme(themeMode, prefersDark), [themeMode, prefersDark])
|
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 (
|
return (
|
||||||
<I18nProvider storage={electronI18nStorage}>
|
<I18nProvider storage={electronI18nStorage}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<AppLayout />
|
{gateContent}
|
||||||
<UpgradePromptModal />
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ interface CloudSyncState {
|
||||||
userEmail: string | null
|
userEmail: string | null
|
||||||
lastSyncAt: number | null
|
lastSyncAt: number | null
|
||||||
syncing: boolean
|
syncing: boolean
|
||||||
|
saasMode: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncProgress {
|
interface SyncProgress {
|
||||||
|
|
@ -30,7 +31,8 @@ export function CloudSyncSection(): React.ReactElement {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userEmail: null,
|
userEmail: null,
|
||||||
lastSyncAt: null,
|
lastSyncAt: null,
|
||||||
syncing: false
|
syncing: false,
|
||||||
|
saasMode: false
|
||||||
})
|
})
|
||||||
const [supabaseUrl, setSupabaseUrl] = useState('')
|
const [supabaseUrl, setSupabaseUrl] = useState('')
|
||||||
const [anonKey, setAnonKey] = useState('')
|
const [anonKey, setAnonKey] = useState('')
|
||||||
|
|
@ -159,10 +161,36 @@ export function CloudSyncSection(): React.ReactElement {
|
||||||
<Box sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>Cloud Sync</Box>
|
<Box sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>Cloud Sync</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{!state.authenticated && (
|
{!state.authenticated && state.saasMode && (
|
||||||
|
// SaaS 빌드 타임 모드: URL/Key는 빌드에 박혀있으므로 OAuth 버튼만 노출.
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}>OAuth 로그인</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<GoogleIcon />}
|
||||||
|
onClick={() => void handleSignIn('google')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<GitHubIcon />}
|
||||||
|
onClick={() => void handleSignIn('github')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!state.authenticated && !state.saasMode && (
|
||||||
|
// Legacy 개발자 모드: 빌드 타임 env 미설정 → 사용자가 직접 입력 가능.
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>
|
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>
|
||||||
SUPABASE 설정 (env 미설정 시 직접 입력)
|
SUPABASE 설정 (개발자 모드)
|
||||||
</Box>
|
</Box>
|
||||||
<TextField
|
<TextField
|
||||||
label="Supabase URL"
|
label="Supabase URL"
|
||||||
|
|
|
||||||
113
apps/desktop/src/renderer/components/LoginScreen.tsx
Normal file
113
apps/desktop/src/renderer/components/LoginScreen.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// src/renderer/components/LoginScreen.tsx
|
||||||
|
// SaaS 첫 실행 게이트 — OAuth 로그인 안 됐으면 메인 UI 진입 차단.
|
||||||
|
// 사용자는 Google/GitHub 버튼만 클릭하면 됨. URL/Key 입력 필드 노출 금지.
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
||||||
|
import GoogleIcon from '@mui/icons-material/Google'
|
||||||
|
import GitHubIcon from '@mui/icons-material/GitHub'
|
||||||
|
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||||
|
import { d3roPalette, d3roFontMono, typoSx } from '@d3ro/ui/theme'
|
||||||
|
import { useI18n } from '@d3ro/i18n'
|
||||||
|
|
||||||
|
export function LoginScreen(): React.ReactElement {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [info, setInfo] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSignIn(provider: 'google' | 'github'): Promise<void> {
|
||||||
|
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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '100vh',
|
||||||
|
width: '100vw',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: d3roPalette.bg.app,
|
||||||
|
p: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MetalCard sx={{ p: 5, maxWidth: 440, width: '100%' }}>
|
||||||
|
<Stack spacing={3} alignItems="center">
|
||||||
|
<PhosphorText variant="title">D3RO VOICE</PhosphorText>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
...typoSx('label'),
|
||||||
|
color: d3roPalette.text.label,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: d3roFontMono
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('login.subtitle') ?? 'AI 음성 어시스턴트'}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ width: '100%', borderTop: `1px solid ${d3roPalette.border.subtle}`, pt: 3 }}>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
startIcon={busy ? <CircularProgress size={16} /> : <GoogleIcon />}
|
||||||
|
onClick={() => void handleSignIn('google')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t('login.google') ?? 'Google로 계속하기'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<GitHubIcon />}
|
||||||
|
onClick={() => void handleSignIn('github')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t('login.github') ?? 'GitHub로 계속하기'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" variant="outlined" sx={{ width: '100%' }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{info && !error && (
|
||||||
|
<Alert severity="info" variant="outlined" sx={{ width: '100%' }}>
|
||||||
|
{info}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
...typoSx('label'),
|
||||||
|
color: d3roPalette.text.muted,
|
||||||
|
fontSize: 11,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 320
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('login.terms') ?? '계속 진행하면 이용약관 및 개인정보 처리방침에 동의합니다.'}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</MetalCard>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue