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
|
|
@ -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 {
|
|||
<Box sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>Cloud Sync</Box>
|
||||
</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}>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label }}>
|
||||
SUPABASE 설정 (env 미설정 시 직접 입력)
|
||||
SUPABASE 설정 (개발자 모드)
|
||||
</Box>
|
||||
<TextField
|
||||
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