Merge branch 'dev'
This commit is contained in:
@@ -12,6 +12,7 @@ dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capawesome-capacitor-google-sign-in')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,6 @@ project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/c
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capawesome-capacitor-google-sign-in'
|
||||
project(':capawesome-capacitor-google-sign-in').projectDir = new File('../node_modules/@capawesome/capacitor-google-sign-in/android')
|
||||
|
||||
Generated
+20
@@ -14,6 +14,7 @@
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capawesome/capacitor-google-sign-in": "^0.1.2",
|
||||
"@toast-ui/editor": "^3.2.2",
|
||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||
"axios": "^1.16.1",
|
||||
@@ -1195,6 +1196,25 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capawesome/capacitor-google-sign-in": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.2.tgz",
|
||||
"integrity": "sha512-41Z5LBVZJQ/20lEbzQJUtjfm7rCUhiq0EsfKmytTfKjqUjQB4zy0zlDMowe/z5CtetL8u7WGvERtyRYil2exww==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/capawesome-team/"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/capawesome"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@capacitor/cli": "^8.3.4",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capawesome/capacitor-google-sign-in": "^0.1.2",
|
||||
"@toast-ui/editor": "^3.2.2",
|
||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -11,6 +11,13 @@ export const authApi = {
|
||||
login(payload) {
|
||||
return http.post('/auth/login', payload)
|
||||
},
|
||||
// 구글 로그인/가입 — GIS에서 받은 ID 토큰 전달
|
||||
googleLogin(payload) {
|
||||
return http.post('/auth/google', payload)
|
||||
},
|
||||
googleClientId() {
|
||||
return http.get('/auth/google-client-id')
|
||||
},
|
||||
logout() {
|
||||
return http.post('/auth/logout')
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 네이티브(안드로이드) 구글 로그인 래퍼 — Capacitor Credential Manager 기반.
|
||||
// 웹/Electron 에서는 GIS(웹) 방식을 쓰므로 여기선 네이티브 전용. 플러그인은 지연 import.
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
// 네이티브 플랫폼(안드로이드 APK)에서만 네이티브 구글 로그인을 사용한다.
|
||||
export function isNativeGoogle() {
|
||||
return Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
let initializedClientId = null
|
||||
|
||||
// 웹 클라이언트 ID(서버에서 받은 값)로 네이티브 구글 로그인을 수행하고 ID 토큰을 반환한다.
|
||||
// 반환된 ID 토큰의 aud 는 웹 클라이언트 ID 라서 백엔드 /api/auth/google 가 그대로 검증한다.
|
||||
export async function nativeGoogleIdToken(clientId) {
|
||||
const { GoogleSignIn } = await import('@capawesome/capacitor-google-sign-in')
|
||||
// initialize 는 한 번만(같은 clientId 면 재호출 생략)
|
||||
if (initializedClientId !== clientId) {
|
||||
await GoogleSignIn.initialize({ clientId })
|
||||
initializedClientId = clientId
|
||||
}
|
||||
const res = await GoogleSignIn.signIn()
|
||||
return res?.idToken || null
|
||||
}
|
||||
+10
-1
@@ -63,6 +63,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return res
|
||||
}
|
||||
|
||||
// 구글 로그인 — GIS ID 토큰으로 세션 발급
|
||||
async function googleLogin(idToken, rememberMe = true) {
|
||||
const res = await authApi.googleLogin({ idToken, rememberMe })
|
||||
token.value = res.token
|
||||
user.value = res.member
|
||||
await persist()
|
||||
return res
|
||||
}
|
||||
|
||||
async function signup(payload) {
|
||||
return authApi.signup(payload)
|
||||
}
|
||||
@@ -96,7 +105,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await persist()
|
||||
}
|
||||
|
||||
return { token, user, ready, isAuthenticated, restore, login, signup, fetchMe, applyUser, logout, clear }
|
||||
return { token, user, ready, isAuthenticated, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear }
|
||||
})
|
||||
|
||||
function safeParse(value) {
|
||||
|
||||
Reference in New Issue
Block a user