fix(desktop+supabase): OAuth 로그인 완주 + RLS 재귀 픽스 (빅뱅 Phase 5 Part 1)

빅뱅 Phase 5 실증 중 발견한 3개 버그 해결:

1. implicit flow 응답 무시:
   - supabase-js가 persistSession:false 일 때 PKCE code_verifier 저장 못해
     implicit flow로 fallback, fragment(#access_token=...&refresh_token=...)로
     토큰 전달. handleDeepLink는 ?code=만 파싱하고 있어 무시됨.
   - handleDeepLink에 fragment parser 추가 (access_token + refresh_token)
   - CloudSyncService.handleAuthTokens() 신규 — setSession() 후
     기존 _onAuthenticated SSOT로 수렴
   - 에러 query/fragment(error, error_description) 상세 로깅
   - 토큰 값은 로그에 안 찍히도록 (query=yes/no, fragment=yes/no 만)

2. Supabase RLS 무한 재귀:
   - team_members_read_same_team 정책이 자기 테이블 재조회 →
     Postgres RLS 엔진 무한 재귀 탐지 에러
   - meetings / meeting_memos / meeting_documents / team_invites 정책이
     team_members 서브쿼리 경유해서 전부 같이 터짐 + Realtime TIMED_OUT
   - migration 20260411000001: SECURITY DEFINER 함수 2개
     (user_team_ids, user_admin_team_ids) 신규 — Supabase 권장 패턴
   - 영향 정책: team_members(4) + meetings(2) + meeting_memos(1) +
     meeting_documents(2) + team_invites(1) 전부 함수 기반으로 재작성
   - supabase db push 완료

3. LoginScreen stale 번들:
   - Phase 1.5에서 import 제거했는데도 vite HMR/cache 어딘가에서
     stale state 유지해서 렌더러에 계속 뜸
   - LoginScreen.tsx 파일 자체 삭제 (vite 컴파일 대상 제거)

로그인 성공 확인: yunchan8804@gmail.com 으로 Google OAuth 완주 →
users/7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93/d3ro.db 생성 → Initial sync 시작.
This commit is contained in:
윤찬 2026-04-11 11:55:42 +09:00
parent 817b4a2580
commit 9498380f7f
5 changed files with 305 additions and 125 deletions

View file

@ -5,6 +5,9 @@ import path from 'path'
import { bootstrap } from './bootstrap'
import { setupLifecycle } from './lifecycle'
import { getMainWindow } from './windows/WindowManager'
import { getLogger } from './services/LoggerService'
const deepLinkLogger = getLogger('deep-link')
// EPIPE 에러 방지: electron-log가 stdout/stderr에 쓸 때 파이프가 끊기면 크래시 방지
process.stdout?.on?.('error', () => { /* ignore EPIPE */ })
@ -27,21 +30,92 @@ if (process.defaultApp) {
/**
* deep link URL을 CloudSyncService로 .
* d3ro-voice://auth-callback?code=...
*
* Supabase OAuth ( ):
* - **Implicit flow** ( ): `d3ro-voice://auth-callback#access_token=...&refresh_token=...&token_type=bearer`
* - **PKCE flow**: `d3ro-voice://auth-callback?code=...`
* - ****: query fragment에 `error=...&error_description=...`
*
* supabase-js `persistSession: false` PKCE가
* . Supabase가 implicit flow로 fragment에
* access_token/refresh_token을 . client.auth.setSession()
* .
*/
async function handleDeepLink(url: string): Promise<void> {
// 토큰/코드가 포함될 수 있으므로 URL 전체 대신 host + path만 로그
let parsed: URL
try {
const parsed = new URL(url)
if (parsed.host === 'auth-callback') {
const code = parsed.searchParams.get('code')
if (code) {
const { getCloudSyncService } = await import('./services/CloudSyncService')
await getCloudSyncService().handleAuthCallback(code)
}
}
} catch {
// 잘못된 URL은 무시
parsed = new URL(url)
} catch (err) {
deepLinkLogger.error(
`Failed to parse deep link URL: ${err instanceof Error ? err.message : String(err)}`
)
return
}
deepLinkLogger.info(
`Deep link received: ${parsed.protocol}//${parsed.host}${parsed.pathname} (query=${parsed.search.length > 0 ? 'yes' : 'no'}, fragment=${parsed.hash.length > 0 ? 'yes' : 'no'})`
)
if (parsed.host !== 'auth-callback') {
deepLinkLogger.warn(`Unknown deep link host: "${parsed.host}" (expected 'auth-callback')`)
return
}
// fragment (#...) 와 query (?...) 양쪽 파싱
const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash
const fragmentParams = new URLSearchParams(fragment)
const queryParams = parsed.searchParams
// 에러 체크 — query/fragment 둘 다
const error = queryParams.get('error') ?? fragmentParams.get('error')
if (error) {
const desc =
queryParams.get('error_description') ??
fragmentParams.get('error_description') ??
'(no description)'
deepLinkLogger.error(`Supabase OAuth returned error: ${error}${desc}`)
return
}
const { getCloudSyncService } = await import('./services/CloudSyncService')
// Implicit flow: fragment에 access_token + refresh_token
const accessToken = fragmentParams.get('access_token')
const refreshToken = fragmentParams.get('refresh_token')
if (accessToken && refreshToken) {
deepLinkLogger.info('Implicit flow tokens detected, calling handleAuthTokens()')
try {
await getCloudSyncService().handleAuthTokens({
accessToken,
refreshToken,
})
deepLinkLogger.info('handleAuthTokens completed successfully')
} catch (err) {
deepLinkLogger.error(
`handleAuthTokens failed: ${err instanceof Error ? err.message : String(err)}`
)
}
return
}
// PKCE flow: query에 code
const code = queryParams.get('code')
if (code) {
deepLinkLogger.info('PKCE flow code detected, calling handleAuthCallback()')
try {
await getCloudSyncService().handleAuthCallback(code)
deepLinkLogger.info('handleAuthCallback completed successfully')
} catch (err) {
deepLinkLogger.error(
`handleAuthCallback failed: ${err instanceof Error ? err.message : String(err)}`
)
}
return
}
deepLinkLogger.warn(
'auth-callback deep link had neither access_token (implicit) nor code (PKCE). Ignoring.'
)
}
// 단일 인스턴스 잠금

View file

@ -447,7 +447,7 @@ class CloudSyncService extends EventEmitter {
}
/**
* Deep link code를 session으로 .
* Deep link PKCE flow code를 session으로 .
* _onAuthenticated( ) .
*/
async handleAuthCallback(code: string): Promise<void> {
@ -469,6 +469,40 @@ class CloudSyncService extends EventEmitter {
await this._onAuthenticated(data.session, { reason: 'signin' })
}
/**
* Deep link Implicit flow로 access/refresh token을
* client.auth.setSession() .
*
* supabase-js가 `persistSession: false` PKCE code_verifier
* PKCE가 . Supabase가 implicit flow로
* fragment(#access_token=...&refresh_token=...) ,
* .
*/
async handleAuthTokens(params: {
accessToken: string
refreshToken: string
}): Promise<void> {
if (!this._client) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Cloud Sync가 설정되지 않았습니다')
}
const { data, error } = await this._client.auth.setSession({
access_token: params.accessToken,
refresh_token: params.refreshToken,
})
if (error || !data.session) {
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`세션 설정 실패: ${error?.message ?? 'unknown'}`
)
}
this._saveRefreshToken(data.session.refresh_token)
logger.info(`Signed in (implicit): ${data.session.user.email ?? data.session.user.id}`)
await this._onAuthenticated(data.session, { reason: 'signin' })
}
/**
* _onSignOut에서 /DB/ .
*/

View file

@ -1,113 +0,0 @@
// 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>
)
}