Files
sb-front/src/components/LoginModal.vue
T

447 lines
13 KiB
Vue
Raw Normal View History

<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'
import { isNativeApple, nativeAppleCredential } from '@/native/appleAuth'
import { ID_LOGIN_ENABLED } from '@/config/features'
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 = '구글 로그인에 실패했습니다.'
}
}
// ===== 애플 로그인 (iOS 전용) =====
const isApple = isNativeApple()
async function handleNativeApple() {
if (loading.value) return
loading.value = true
try {
const { identityToken, name } = await nativeAppleCredential()
loading.value = false
if (!identityToken) return
error.value = null
loading.value = true
await auth.appleLogin(identityToken, name, form.rememberMe)
const redirect = ui.redirectPath
ui.closeLogin()
router.push(redirect || '/')
} catch (e) {
// 사용자가 취소(USER_CANCELLED/1001 등)한 경우는 조용히 무시
const msg = `${e?.code || ''} ${e?.message || ''}`
if (!/cancel|1001/i.test(msg)) {
error.value = e.response?.data?.message || '애플 로그인에 실패했습니다.'
}
} finally {
loading.value = false
}
}
// 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 v-if="ID_LOGIN_ENABLED" 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 v-if="ID_LOGIN_ENABLED" 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>
<!-- 애플 로그인 iOS 전용(App Store 가이드라인 4.8 대응). 서버 설정 불필요 -->
<button
v-if="isApple"
type="button"
class="apple-native"
:disabled="loading"
@click="handleNativeApple"
>
<svg class="a-logo" viewBox="0 0 384 512" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
</svg>
Apple로 계속하기
</button>
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
네이버로 로그인 (준비 )
</button>
<p v-if="ID_LOGIN_ENABLED" 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;
}
/* Apple 로그인 버튼 — HIG 권장(검은 배경 + 흰 로고/텍스트) */
.apple-native {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
max-width: 300px;
margin: 0.6rem auto 0;
padding: 0.6rem 1rem;
border: 1px solid #000;
border-radius: 4px;
background: #000;
color: #fff;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
}
.apple-native:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.apple-native .a-logo {
flex: none;
margin-top: -2px;
}
.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>