Merge branch 'dev'
This commit is contained in:
@@ -12,6 +12,7 @@ dependencies {
|
|||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
implementation project(':capacitor-camera')
|
implementation project(':capacitor-camera')
|
||||||
implementation project(':capacitor-preferences')
|
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'
|
include ':capacitor-preferences'
|
||||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
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/cli": "^8.3.4",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
|
"@capawesome/capacitor-google-sign-in": "^0.1.2",
|
||||||
"@toast-ui/editor": "^3.2.2",
|
"@toast-ui/editor": "^3.2.2",
|
||||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
@@ -1195,6 +1196,25 @@
|
|||||||
"@capacitor/core": ">=8.0.0"
|
"@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": {
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
"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/cli": "^8.3.4",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
|
"@capawesome/capacitor-google-sign-in": "^0.1.2",
|
||||||
"@toast-ui/editor": "^3.2.2",
|
"@toast-ui/editor": "^3.2.2",
|
||||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export const authApi = {
|
|||||||
login(payload) {
|
login(payload) {
|
||||||
return http.post('/auth/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() {
|
logout() {
|
||||||
return http.post('/auth/logout')
|
return http.post('/auth/logout')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup>
|
<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 { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import { authApi } from '@/api/authApi'
|
||||||
|
import { isNativeGoogle, nativeGoogleIdToken } from '@/native/googleAuth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
@@ -20,6 +22,103 @@ const form = reactive({ loginId: '', password: '', rememberMe: true })
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
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(
|
watch(
|
||||||
() => ui.loginOpen,
|
() => ui.loginOpen,
|
||||||
@@ -29,6 +128,7 @@ watch(
|
|||||||
form.password = ''
|
form.password = ''
|
||||||
form.rememberMe = true
|
form.rememberMe = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
setupGoogle()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -85,6 +185,28 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
|||||||
|
|
||||||
<p v-if="error" class="msg error">{{ error }}</p>
|
<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 v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
|
||||||
네이버로 로그인 (준비 중)
|
네이버로 로그인 (준비 중)
|
||||||
</button>
|
</button>
|
||||||
@@ -185,6 +307,53 @@ h2 {
|
|||||||
border-color: #03c75a;
|
border-color: #03c75a;
|
||||||
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 {
|
.links {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
text-align: center;
|
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
|
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) {
|
async function signup(payload) {
|
||||||
return authApi.signup(payload)
|
return authApi.signup(payload)
|
||||||
}
|
}
|
||||||
@@ -96,7 +105,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
await persist()
|
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) {
|
function safeParse(value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user