feat(auth): iOS 애플 로그인 버튼 — Sign in with Apple(App Store 4.8 대응)
- @capacitor-community/apple-sign-in + LoginModal Apple 버튼(iOS 전용, HIG 검은 버튼) - appleAuth 네이티브 래퍼 + authApi/auth store appleLogin - SPM capacitor-swift-pm 8.x 로 패치(patch-package) — @capawesome 구글 플러그인과 iOS 공존 - App.entitlements(applesignin) 준비 — Xcode 'Sign in with Apple' Capability 추가 시 사용 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,10 @@ export const authApi = {
|
||||
googleClientId() {
|
||||
return http.get('/auth/google-client-id')
|
||||
},
|
||||
// 애플 로그인/가입 — Sign in with Apple 에서 받은 identity token 전달
|
||||
appleLogin(payload) {
|
||||
return http.post('/auth/apple', payload)
|
||||
},
|
||||
logout() {
|
||||
return http.post('/auth/logout')
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ 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()
|
||||
@@ -64,6 +65,33 @@ async function handleNativeGoogle() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 애플 로그인 (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()
|
||||
@@ -208,6 +236,20 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<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>
|
||||
@@ -339,6 +381,32 @@ h2 {
|
||||
.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;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// 네이티브 Sign in with Apple 래퍼 (iOS 전용). 플러그인은 지연 import.
|
||||
// 반환된 identity token(JWT)의 aud 는 앱 번들 ID → 백엔드 /api/auth/apple 가 JWKS 로 검증한다.
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
// Apple 로그인은 iOS 네이티브에서만 노출한다(웹/안드로이드는 별도 흐름 필요 — 현재 미지원).
|
||||
export function isNativeApple() {
|
||||
return Capacitor.getPlatform() === 'ios'
|
||||
}
|
||||
|
||||
// Sign in with Apple 수행 → { identityToken, name }.
|
||||
// 이름(givenName/familyName)은 애플이 '최초 인증 시에만' 주므로 신규 가입 채움용으로 함께 반환.
|
||||
export async function nativeAppleCredential() {
|
||||
const { SignInWithApple } = await import('@capacitor-community/apple-sign-in')
|
||||
const result = await SignInWithApple.authorize({
|
||||
clientId: 'kr.sblog.slimbudget',
|
||||
redirectURI: 'https://app.sblog.kr/auth/apple/callback',
|
||||
scopes: 'email name',
|
||||
})
|
||||
const r = result?.response || {}
|
||||
const name = [r.givenName, r.familyName].filter(Boolean).join(' ').trim() || null
|
||||
return { identityToken: r.identityToken || null, name }
|
||||
}
|
||||
+9
-1
@@ -77,6 +77,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await persist()
|
||||
return res
|
||||
}
|
||||
async function appleLogin(identityToken, name, rememberMe = true) {
|
||||
const res = await authApi.appleLogin({ identityToken, name, rememberMe })
|
||||
token.value = res.token
|
||||
user.value = res.member
|
||||
exitDemo()
|
||||
await persist()
|
||||
return res
|
||||
}
|
||||
|
||||
async function signup(payload) {
|
||||
return authApi.signup(payload)
|
||||
@@ -123,7 +131,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await persist()
|
||||
}
|
||||
|
||||
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
|
||||
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, appleLogin, signup, fetchMe, applyUser, refreshProfile, logout, clear }
|
||||
})
|
||||
|
||||
function safeParse(value) {
|
||||
|
||||
Reference in New Issue
Block a user