2026-05-31 15:42:52 +09:00
|
|
|
|
<script setup>
|
2026-06-27 18:02:19 +09:00
|
|
|
|
import { reactive, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
|
|
import { useUiStore } from '@/stores/ui'
|
2026-06-27 18:02:19 +09:00
|
|
|
|
import { authApi } from '@/api/authApi'
|
|
|
|
|
|
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
|
2026-06-27 19:41:04 +09:00
|
|
|
|
import { ID_LOGIN_ENABLED } from '@/config/features'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
const ui = useUiStore()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
|
|
|
|
|
|
// 로그인 닫고 회원가입 팝업 열기
|
|
|
|
|
|
function goSignup() {
|
|
|
|
|
|
ui.openSignup()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 14:08:01 +09:00
|
|
|
|
// 소셜 로그인(네이버)은 준비 중 — 잠시 숨김. 구현 완료 시 true 로 전환.
|
|
|
|
|
|
const SOCIAL_LOGIN_ENABLED = false
|
|
|
|
|
|
|
2026-05-31 21:47:03 +09:00
|
|
|
|
const form = reactive({ loginId: '', password: '', rememberMe: true })
|
2026-05-31 15:42:52 +09:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const error = ref(null)
|
|
|
|
|
|
|
2026-06-27 18:02:19 +09:00
|
|
|
|
// ===== 구글 로그인 =====
|
|
|
|
|
|
// 웹/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 = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 21:47:03 +09:00
|
|
|
|
// 팝업이 열릴 때마다 입력값 초기화 (로그인 상태 유지는 기본 켜둠)
|
2026-05-31 15:42:52 +09:00
|
|
|
|
watch(
|
|
|
|
|
|
() => ui.loginOpen,
|
|
|
|
|
|
(open) => {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
form.loginId = ''
|
|
|
|
|
|
form.password = ''
|
2026-05-31 21:47:03 +09:00
|
|
|
|
form.rememberMe = true
|
2026-05-31 15:42:52 +09:00
|
|
|
|
error.value = null
|
2026-06-27 18:02:19 +09:00
|
|
|
|
setupGoogle()
|
2026-05-31 15:42:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async function handleLogin() {
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
if (!form.loginId || !form.password) {
|
|
|
|
|
|
error.value = '아이디와 비밀번호를 입력하세요.'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
2026-05-31 21:47:03 +09:00
|
|
|
|
await auth.login(form.loginId, form.password, form.rememberMe)
|
2026-05-31 15:42:52 +09:00
|
|
|
|
const redirect = ui.redirectPath
|
|
|
|
|
|
ui.closeLogin()
|
2026-06-06 14:08:01 +09:00
|
|
|
|
// 가드가 보존한 목적지가 있으면 그곳으로, 없으면 대시보드(홈)로 이동
|
|
|
|
|
|
router.push(redirect || '/')
|
2026-05-31 15:42:52 +09:00
|
|
|
|
} 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>
|
|
|
|
|
|
|
2026-06-27 19:41:04 +09:00
|
|
|
|
<form v-if="ID_LOGIN_ENABLED" class="form" @submit.prevent="handleLogin">
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<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" />
|
2026-05-31 21:47:03 +09:00
|
|
|
|
<label class="remember">
|
|
|
|
|
|
<input v-model="form.rememberMe" type="checkbox" :disabled="loading" />
|
|
|
|
|
|
로그인 상태 유지
|
|
|
|
|
|
</label>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
<button class="submit" type="submit" :disabled="loading">{{ loading ? '로그인 중...' : '로그인' }}</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-if="error" class="msg error">{{ error }}</p>
|
|
|
|
|
|
|
2026-06-27 18:02:19 +09:00
|
|
|
|
<!-- 구글 로그인 — 서버에 클라이언트 ID 가 설정된 경우에만 노출 -->
|
|
|
|
|
|
<div v-show="googleClientId" class="google-wrap">
|
2026-06-27 19:41:04 +09:00
|
|
|
|
<div v-if="ID_LOGIN_ENABLED" class="divider"><span>또는</span></div>
|
2026-06-27 18:02:19 +09:00
|
|
|
|
<!-- 앱(안드로이드): 네이티브 버튼 / 웹·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>
|
|
|
|
|
|
|
2026-06-06 14:08:01 +09:00
|
|
|
|
<button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
2026-05-31 15:42:52 +09:00
|
|
|
|
네이버로 로그인 (준비 중)
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-06-27 19:41:04 +09:00
|
|
|
|
<p v-if="ID_LOGIN_ENABLED" class="links">
|
2026-06-06 00:16:50 +09:00
|
|
|
|
<template v-if="ui.signupEnabled">
|
|
|
|
|
|
계정이 없으신가요?
|
|
|
|
|
|
<a href="#" @click.prevent="goSignup">회원가입</a>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<span v-else class="signup-off">회원가입이 제한되어 있습니다.</span>
|
2026-05-31 15:42:52 +09:00
|
|
|
|
</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);
|
|
|
|
|
|
}
|
2026-05-31 21:47:03 +09:00
|
|
|
|
.remember {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.remember input {
|
|
|
|
|
|
width: auto;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-06-27 18:02:19 +09:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
.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>
|