Files
sb-front/src/components/LoginModal.vue
T
ByungCheol a310c7a471
CI / build (push) Failing after 13m30s
feat: 구글 로그인/가입 — 웹(GIS)·앱(네이티브 Credential Manager)
- 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>
2026-06-27 18:02:19 +09:00

378 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
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()
const router = useRouter()
// 로그인 닫고 회원가입 팝업 열기
function goSignup() {
ui.openSignup()
}
// 소셜 로그인(네이버)은 준비 중 — 잠시 숨김. 구현 완료 시 true 로 전환.
const SOCIAL_LOGIN_ENABLED = false
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,
(open) => {
if (open) {
form.loginId = ''
form.password = ''
form.rememberMe = true
error.value = null
setupGoogle()
}
},
)
async function handleLogin() {
error.value = null
if (!form.loginId || !form.password) {
error.value = '아이디와 비밀번호를 입력하세요.'
return
}
loading.value = true
try {
await auth.login(form.loginId, form.password, form.rememberMe)
const redirect = ui.redirectPath
ui.closeLogin()
// 가드가 보존한 목적지가 있으면 그곳으로, 없으면 대시보드(홈)로 이동
router.push(redirect || '/')
} catch (e) {
error.value = e.response?.data?.message || '로그인에 실패했습니다.'
} finally {
loading.value = false
}
}
function handleNaverLogin() {
alert('네이버 로그인은 준비 중입니다.')
}
// ESC 로 닫기
function onKeydown(e) {
if (e.key === 'Escape' && ui.loginOpen) ui.closeLogin()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="ui.loginOpen" class="modal-backdrop" @click.self="ui.closeLogin()">
<div class="modal" role="dialog" aria-modal="true" aria-label="로그인">
<button class="close" type="button" aria-label="닫기" @click="ui.closeLogin()">×</button>
<h2>로그인</h2>
<form class="form" @submit.prevent="handleLogin">
<input v-model="form.loginId" type="text" placeholder="아이디" autocomplete="username" :disabled="loading" />
<input v-model="form.password" type="password" placeholder="비밀번호" autocomplete="current-password" :disabled="loading" />
<label class="remember">
<input v-model="form.rememberMe" type="checkbox" :disabled="loading" />
로그인 상태 유지
</label>
<button class="submit" type="submit" :disabled="loading">{{ loading ? '로그인 중...' : '로그인' }}</button>
</form>
<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>
<p class="links">
<template v-if="ui.signupEnabled">
계정이 없으신가요?
<a href="#" @click.prevent="goSignup">회원가입</a>
</template>
<span v-else class="signup-off">회원가입이 제한되어 있습니다.</span>
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
h2 {
margin-bottom: 1.25rem;
font-size: 1.3rem;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form input {
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.remember {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
cursor: pointer;
}
.remember input {
width: auto;
padding: 0;
cursor: pointer;
}
.submit,
.naver {
padding: 0.6rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.submit:disabled,
.naver:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.naver {
width: 100%;
margin-top: 0.75rem;
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;
font-size: 0.9rem;
}
.msg {
margin: 0.75rem 0 0;
}
.msg.error {
color: #c0392b;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>