- LoginModal: 서버에서 구글 클라이언트 ID 수신 시에만 버튼 노출 - 웹/PC: Google Identity Services 렌더 버튼 - 앱(안드로이드): @capawesome/capacitor-google-sign-in 네이티브 버튼 - 네이티브/웹 모두 ID 토큰을 /api/auth/google 로 전달(aud=웹 클라이언트 ID) - auth 스토어/authApi 에 googleLogin·googleClientId 추가 - src/native/googleAuth.js 네이티브 래퍼 신규 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { reactive, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { authApi } from '@/api/authApi'
|
||||
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
@@ -20,6 +22,103 @@ const form = reactive({ loginId: '', password: '', rememberMe: true })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// ===== 구글 로그인 =====
|
||||
// 웹/Electron: GIS(웹) 렌더 버튼. 앱(안드로이드): 네이티브 Credential Manager 버튼.
|
||||
const isNative = isNativeGoogle()
|
||||
const GIS_SRC = 'https://accounts.google.com/gsi/client'
|
||||
const googleClientId = ref('') // 백엔드에서 받은 OAuth 웹 클라이언트 ID (없으면 버튼 숨김)
|
||||
const googleBtn = ref(null) // 구글 버튼이 그려질 컨테이너(웹 GIS)
|
||||
let gisScriptPromise = null // GIS 스크립트는 한 번만 로드
|
||||
|
||||
// ID 토큰으로 세션 발급 → 닫기/이동 (웹·네이티브 공통)
|
||||
async function finishGoogleLogin(idToken) {
|
||||
if (!idToken) return
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.googleLogin(idToken, form.rememberMe)
|
||||
const redirect = ui.redirectPath
|
||||
ui.closeLogin()
|
||||
router.push(redirect || '/')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '구글 로그인에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 앱(안드로이드) 네이티브 구글 로그인 버튼 클릭
|
||||
async function handleNativeGoogle() {
|
||||
if (loading.value || !googleClientId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const idToken = await nativeGoogleIdToken(googleClientId.value)
|
||||
loading.value = false
|
||||
await finishGoogleLogin(idToken)
|
||||
} catch (e) {
|
||||
loading.value = false
|
||||
// 사용자가 취소한 경우(메시지 없음)는 조용히 무시
|
||||
const msg = e?.message || ''
|
||||
if (msg && !/cancel/i.test(msg)) error.value = '구글 로그인에 실패했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
// GIS 스크립트 로드 (한 번만). 실패하면 reject.
|
||||
function loadGisScript() {
|
||||
if (window.google?.accounts?.id) return Promise.resolve()
|
||||
if (gisScriptPromise) return gisScriptPromise
|
||||
gisScriptPromise = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = GIS_SRC
|
||||
s.async = true
|
||||
s.defer = true
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => {
|
||||
gisScriptPromise = null
|
||||
reject(new Error('GIS load failed'))
|
||||
}
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return gisScriptPromise
|
||||
}
|
||||
|
||||
// 웹 GIS 자격증명(ID 토큰) 수신 → 공통 처리
|
||||
function onGoogleCredential(response) {
|
||||
finishGoogleLogin(response?.credential)
|
||||
}
|
||||
|
||||
// 모달 열릴 때 구글 버튼 준비. 클라이언트 ID 미설정/스크립트 실패 시 조용히 숨김.
|
||||
async function setupGoogle() {
|
||||
try {
|
||||
if (!googleClientId.value) {
|
||||
const { clientId } = await authApi.googleClientId()
|
||||
googleClientId.value = clientId || ''
|
||||
}
|
||||
if (!googleClientId.value) return // 서버에 미설정 → 구글 버튼 미노출
|
||||
if (isNative) return // 앱: 네이티브 버튼(템플릿)으로 처리 — GIS 스크립트 불필요
|
||||
await loadGisScript()
|
||||
await nextTick()
|
||||
if (!ui.loginOpen || !googleBtn.value) return
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: googleClientId.value,
|
||||
callback: onGoogleCredential,
|
||||
})
|
||||
googleBtn.value.innerHTML = ''
|
||||
window.google.accounts.id.renderButton(googleBtn.value, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
shape: 'rectangular',
|
||||
text: 'continue_with',
|
||||
logo_alignment: 'center',
|
||||
width: 300,
|
||||
})
|
||||
} catch {
|
||||
// 구글 로그인 준비 실패 — 일반 로그인은 그대로 사용 가능하므로 버튼만 숨김
|
||||
googleClientId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 팝업이 열릴 때마다 입력값 초기화 (로그인 상태 유지는 기본 켜둠)
|
||||
watch(
|
||||
() => ui.loginOpen,
|
||||
@@ -29,6 +128,7 @@ watch(
|
||||
form.password = ''
|
||||
form.rememberMe = true
|
||||
error.value = null
|
||||
setupGoogle()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -85,6 +185,28 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
<p v-if="error" class="msg error">{{ error }}</p>
|
||||
|
||||
<!-- 구글 로그인 — 서버에 클라이언트 ID 가 설정된 경우에만 노출 -->
|
||||
<div v-show="googleClientId" class="google-wrap">
|
||||
<div class="divider"><span>또는</span></div>
|
||||
<!-- 앱(안드로이드): 네이티브 버튼 / 웹·PC: GIS 렌더 버튼 -->
|
||||
<button
|
||||
v-if="isNative"
|
||||
type="button"
|
||||
class="google-native"
|
||||
:disabled="loading"
|
||||
@click="handleNativeGoogle"
|
||||
>
|
||||
<svg class="g-logo" viewBox="0 0 18 18" width="18" height="18" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
|
||||
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
|
||||
<path fill="#FBBC05" d="M3.97 10.72a5.41 5.41 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
|
||||
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
|
||||
</svg>
|
||||
Google 계정으로 계속하기
|
||||
</button>
|
||||
<div v-else ref="googleBtn" class="google-btn"></div>
|
||||
</div>
|
||||
|
||||
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||
네이버로 로그인 (준비 중)
|
||||
</button>
|
||||
@@ -185,6 +307,53 @@ h2 {
|
||||
border-color: #03c75a;
|
||||
color: #03c75a;
|
||||
}
|
||||
.google-wrap {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.google-btn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.google-native {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #747775;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #1f1f1f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.google-native:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.google-native .g-logo {
|
||||
flex: none;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.75rem 0;
|
||||
color: var(--color-text);
|
||||
opacity: 0.55;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.links {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user