From bac3ce1ded0bccce2a9cd95ed8b6cbb0e4e8442a Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Mon, 29 Jun 2026 08:45:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=B1=20=EC=9E=A0=EA=B8=88(PIN)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EA=B0=80=EA=B3=84=EB=B6=80=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자체 구현(네이티브 플러그인 없음): PIN은 SHA-256 해시만 저장, 기본 OFF 옵트인 - 앱 시작(저장 세션 재진입)·백그라운드 복귀 시 잠금 화면 노출 - 설정에 앱 잠금 토글 + PIN 설정(입력→확인 2단계) 모달 - 갇힘 방지: 잠금 화면에서 'PIN 분실 → 로그아웃'(잠금 해제 후 로그아웃) - 신규/민감정보 앱 신뢰도 향상 Co-Authored-By: Claude Opus 4.8 --- src/App.vue | 15 ++++ src/components/AppLockScreen.vue | 96 ++++++++++++++++++++++ src/components/PinPad.vue | 85 ++++++++++++++++++++ src/composables/appLock.js | 54 +++++++++++++ src/views/settings/SettingsView.vue | 118 ++++++++++++++++++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 src/components/AppLockScreen.vue create mode 100644 src/components/PinPad.vue create mode 100644 src/composables/appLock.js diff --git a/src/App.vue b/src/App.vue index 5d57b0e..2a7c525 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,8 @@ import LoginModal from '@/components/LoginModal.vue' import SignupModal from '@/components/SignupModal.vue' import WebOnlyNotice from '@/components/WebOnlyNotice.vue' import AppDialog from '@/components/ui/AppDialog.vue' +import AppLockScreen from '@/components/AppLockScreen.vue' +import { appLock } from '@/composables/appLock' import { Capacitor } from '@capacitor/core' import { reminders } from '@/native/reminders' import { useAuthStore } from '@/stores/auth' @@ -71,6 +73,12 @@ function retryReload() { window.location.reload() } +// ===== 앱 잠금(PIN) ===== +// 백그라운드로 가면 잠가서, 복귀 시 잠금 화면이 떠 있도록 한다. +function onVisibility() { + if (document.hidden) appLock.lock() +} + onMounted(() => { if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략 window.addEventListener('auth:unauthorized', onUnauthorized) @@ -78,6 +86,9 @@ onMounted(() => { window.addEventListener('online', onOnline) window.addEventListener('offline', onOffline) window.addEventListener('net:error', onNetError) + // 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음) + if (appLock.state.enabled && appLock.hasSession()) appLock.lock() + document.addEventListener('visibilitychange', onVisibility) ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영) // 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영 if (auth.isAuthenticated) { @@ -99,6 +110,7 @@ onUnmounted(() => { window.removeEventListener('online', onOnline) window.removeEventListener('offline', onOffline) window.removeEventListener('net:error', onNetError) + document.removeEventListener('visibilitychange', onVisibility) clearTimeout(netTimer) }) @@ -115,6 +127,9 @@ watch(() => route.fullPath, () => ui.closeSidebar()) @@ -303,6 +374,53 @@ async function clearAppData() { max-width: 560px; margin: 0 auto; } +/* PIN 설정 모달 */ +.pin-backdrop { + position: fixed; + inset: 0; + z-index: 2500; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.5); +} +.pin-modal { + position: relative; + width: 100%; + max-width: 340px; + padding: 1.6rem 1.2rem 1.8rem; + border-radius: 14px; + background: var(--color-background); + border: 1px solid var(--color-border); + text-align: center; +} +.pin-close { + position: absolute; + top: 0.5rem; + right: 0.6rem; + width: 30px; + height: 30px; + border: 0; + background: transparent; + color: var(--color-text); + font-size: 1.4rem; + cursor: pointer; +} +.pin-h { + font-size: 1.1rem; + color: var(--color-heading); +} +.pin-sub { + margin: 0.4rem 0 1.4rem; + font-size: 0.85rem; + opacity: 0.7; +} +.pin-sub.err { + color: #c0392b; + opacity: 1; + font-weight: 600; +} .page-title { font-size: 1.4rem; margin-bottom: 1rem;