Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48c3664136 | |||
| bac3ce1ded |
+15
@@ -9,6 +9,8 @@ import LoginModal from '@/components/LoginModal.vue'
|
|||||||
import SignupModal from '@/components/SignupModal.vue'
|
import SignupModal from '@/components/SignupModal.vue'
|
||||||
import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
|
import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
|
||||||
import AppDialog from '@/components/ui/AppDialog.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 { Capacitor } from '@capacitor/core'
|
||||||
import { reminders } from '@/native/reminders'
|
import { reminders } from '@/native/reminders'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -71,6 +73,12 @@ function retryReload() {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 앱 잠금(PIN) =====
|
||||||
|
// 백그라운드로 가면 잠가서, 복귀 시 잠금 화면이 떠 있도록 한다.
|
||||||
|
function onVisibility() {
|
||||||
|
if (document.hidden) appLock.lock()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
||||||
window.addEventListener('auth:unauthorized', onUnauthorized)
|
window.addEventListener('auth:unauthorized', onUnauthorized)
|
||||||
@@ -78,6 +86,9 @@ onMounted(() => {
|
|||||||
window.addEventListener('online', onOnline)
|
window.addEventListener('online', onOnline)
|
||||||
window.addEventListener('offline', onOffline)
|
window.addEventListener('offline', onOffline)
|
||||||
window.addEventListener('net:error', onNetError)
|
window.addEventListener('net:error', onNetError)
|
||||||
|
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
|
||||||
|
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
||||||
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
@@ -99,6 +110,7 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('online', onOnline)
|
window.removeEventListener('online', onOnline)
|
||||||
window.removeEventListener('offline', onOffline)
|
window.removeEventListener('offline', onOffline)
|
||||||
window.removeEventListener('net:error', onNetError)
|
window.removeEventListener('net:error', onNetError)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
clearTimeout(netTimer)
|
clearTimeout(netTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -115,6 +127,9 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
|||||||
|
|
||||||
<!-- 앱(Capacitor): 전체 기능 -->
|
<!-- 앱(Capacitor): 전체 기능 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- 앱 잠금 화면 (로그인 상태 + 잠금 활성 시) -->
|
||||||
|
<AppLockScreen v-if="appLock.state.locked && auth.isAuthenticated" />
|
||||||
|
|
||||||
<!-- 네트워크 오류/오프라인 안내 배너 -->
|
<!-- 네트워크 오류/오프라인 안내 배너 -->
|
||||||
<Transition name="netbar">
|
<Transition name="netbar">
|
||||||
<div v-if="showNetBanner" class="net-banner" role="alert">
|
<div v-if="showNetBanner" class="net-banner" role="alert">
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { appLock, PIN_LENGTH } from '@/composables/appLock'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import PinPad from '@/components/PinPad.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const pin = ref('')
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
async function onComplete(p) {
|
||||||
|
if (await appLock.verify(p)) {
|
||||||
|
pin.value = ''
|
||||||
|
error.value = false
|
||||||
|
appLock.unlock()
|
||||||
|
} else {
|
||||||
|
error.value = true
|
||||||
|
pin.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onInput(v) {
|
||||||
|
pin.value = v
|
||||||
|
error.value = false
|
||||||
|
}
|
||||||
|
async function forgot() {
|
||||||
|
// PIN 분실 → 잠금 해제 + 로그아웃(재로그인 후 다시 설정). 갇힘 방지.
|
||||||
|
appLock.disable()
|
||||||
|
await auth.logout()
|
||||||
|
appLock.unlock()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="lock-screen">
|
||||||
|
<div class="lock-inner">
|
||||||
|
<div class="lock-logo">🐷</div>
|
||||||
|
<h1 class="lock-title">돈돼지 가계부</h1>
|
||||||
|
<p class="lock-sub" :class="{ err: error }">
|
||||||
|
{{ error ? 'PIN이 일치하지 않아요' : 'PIN을 입력하세요' }}
|
||||||
|
</p>
|
||||||
|
<PinPad :value="pin" :length="PIN_LENGTH" class="lock-pad" @update:value="onInput" @complete="onComplete" />
|
||||||
|
<button type="button" class="lock-forgot" @click="forgot">PIN을 잊으셨나요? 로그아웃</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lock-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: calc(env(safe-area-inset-top, 0) + 1rem) 1rem calc(env(safe-area-inset-bottom, 0) + 1rem);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
.lock-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.lock-logo {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
}
|
||||||
|
.lock-title {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
.lock-sub {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.lock-sub.err {
|
||||||
|
color: #c0392b;
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.lock-forgot {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lock-forgot:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup>
|
||||||
|
// 재사용 PIN 키패드 — 점 표시 + 숫자 키패드. 길이 도달 시 complete 방출.
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: String, default: '' },
|
||||||
|
length: { type: Number, default: 4 },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:value', 'complete'])
|
||||||
|
|
||||||
|
function press(d) {
|
||||||
|
if (props.value.length >= props.length) return
|
||||||
|
const next = props.value + d
|
||||||
|
emit('update:value', next)
|
||||||
|
if (next.length === props.length) emit('complete', next)
|
||||||
|
}
|
||||||
|
function del() {
|
||||||
|
emit('update:value', props.value.slice(0, -1))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pinpad">
|
||||||
|
<div class="dots">
|
||||||
|
<span v-for="i in length" :key="i" class="dot" :class="{ on: value.length >= i }"></span>
|
||||||
|
</div>
|
||||||
|
<div class="keys">
|
||||||
|
<button v-for="n in 9" :key="n" type="button" class="key" @click="press(String(n))">{{ n }}</button>
|
||||||
|
<span class="key empty" aria-hidden="true"></span>
|
||||||
|
<button type="button" class="key" @click="press('0')">0</button>
|
||||||
|
<button type="button" class="key del" aria-label="지우기" @click="del">⌫</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pinpad {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
.dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.dot.on {
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.keys {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 64px);
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.key:active {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
.key.empty {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.key.del {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
// 앱 잠금(PIN). 자체 구현 — 네이티브 플러그인 없이 동작.
|
||||||
|
// PIN 은 평문 저장하지 않고 SHA-256 해시만 보관. 기본 비활성(옵트인).
|
||||||
|
// 갇힘 방지: 잠금 화면에서 'PIN 분실 → 로그아웃' 으로 disable + 로그아웃 가능.
|
||||||
|
const KEY_ENABLED = 'applock:enabled'
|
||||||
|
const KEY_HASH = 'applock:hash'
|
||||||
|
|
||||||
|
export const PIN_LENGTH = 4
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
enabled: localStorage.getItem(KEY_ENABLED) === '1',
|
||||||
|
locked: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function hashPin(pin) {
|
||||||
|
const data = new TextEncoder().encode('dondwaeji-applock:' + pin)
|
||||||
|
const buf = await crypto.subtle.digest('SHA-256', data)
|
||||||
|
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appLock = {
|
||||||
|
state,
|
||||||
|
/** PIN 설정 + 잠금 활성화 */
|
||||||
|
async setPin(pin) {
|
||||||
|
localStorage.setItem(KEY_HASH, await hashPin(pin))
|
||||||
|
localStorage.setItem(KEY_ENABLED, '1')
|
||||||
|
state.enabled = true
|
||||||
|
},
|
||||||
|
/** 입력 PIN 검증 */
|
||||||
|
async verify(pin) {
|
||||||
|
const stored = localStorage.getItem(KEY_HASH)
|
||||||
|
return !!stored && (await hashPin(pin)) === stored
|
||||||
|
},
|
||||||
|
/** 잠금 해제(설정 끄기) */
|
||||||
|
disable() {
|
||||||
|
localStorage.removeItem(KEY_HASH)
|
||||||
|
localStorage.removeItem(KEY_ENABLED)
|
||||||
|
state.enabled = false
|
||||||
|
state.locked = false
|
||||||
|
},
|
||||||
|
/** 잠금 걸기(활성화된 경우에만) */
|
||||||
|
lock() {
|
||||||
|
if (state.enabled) state.locked = true
|
||||||
|
},
|
||||||
|
/** 잠금 풀기(이번 세션) */
|
||||||
|
unlock() {
|
||||||
|
state.locked = false
|
||||||
|
},
|
||||||
|
/** 저장된 세션이 있는지(앱 시작 시 잠금 여부 판단용) */
|
||||||
|
hasSession() {
|
||||||
|
return !!localStorage.getItem('token')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { useUiStore } from '@/stores/ui'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { exportBackup, importBackup } from '@/utils/backup'
|
import { exportBackup, importBackup } from '@/utils/backup'
|
||||||
import { reminders } from '@/native/reminders'
|
import { reminders } from '@/native/reminders'
|
||||||
|
import { appLock, PIN_LENGTH } from '@/composables/appLock'
|
||||||
|
import PinPad from '@/components/PinPad.vue'
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
@@ -61,6 +63,46 @@ async function changeNotifHour(v) {
|
|||||||
if (notifEnabled.value) await reminders.scheduleEntryReminder(false)
|
if (notifEnabled.value) await reminders.scheduleEntryReminder(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 앱 잠금(PIN) =====
|
||||||
|
const lockEnabled = computed(() => appLock.state.enabled)
|
||||||
|
const lockSetupOpen = ref(false)
|
||||||
|
const setupStep = ref(1) // 1: 입력, 2: 확인
|
||||||
|
const setupPin = ref('')
|
||||||
|
const setupFirst = ref('')
|
||||||
|
const setupError = ref('')
|
||||||
|
|
||||||
|
function toggleLock() {
|
||||||
|
if (lockEnabled.value) confirmDisableLock()
|
||||||
|
else openLockSetup()
|
||||||
|
}
|
||||||
|
function openLockSetup() {
|
||||||
|
setupStep.value = 1
|
||||||
|
setupPin.value = ''
|
||||||
|
setupFirst.value = ''
|
||||||
|
setupError.value = ''
|
||||||
|
lockSetupOpen.value = true
|
||||||
|
}
|
||||||
|
async function onSetupComplete(p) {
|
||||||
|
if (setupStep.value === 1) {
|
||||||
|
setupFirst.value = p
|
||||||
|
setupPin.value = ''
|
||||||
|
setupStep.value = 2
|
||||||
|
setupError.value = ''
|
||||||
|
} else if (p === setupFirst.value) {
|
||||||
|
await appLock.setPin(p)
|
||||||
|
lockSetupOpen.value = false
|
||||||
|
} else {
|
||||||
|
setupError.value = 'PIN이 일치하지 않아요. 처음부터 다시 설정해주세요.'
|
||||||
|
setupStep.value = 1
|
||||||
|
setupPin.value = ''
|
||||||
|
setupFirst.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function confirmDisableLock() {
|
||||||
|
const ok = await dialog.confirm('앱 잠금을 끌까요? 다음 실행부터 PIN 없이 열립니다.', { title: '앱 잠금 해제' })
|
||||||
|
if (ok) appLock.disable()
|
||||||
|
}
|
||||||
|
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
async function doExport() {
|
async function doExport() {
|
||||||
if (exporting.value) return
|
if (exporting.value) return
|
||||||
@@ -198,6 +240,21 @@ async function clearAppData() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 앱 잠금 (PIN) -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-main">
|
||||||
|
<span class="row-label">앱 잠금 (PIN)</span>
|
||||||
|
<span class="row-sub">앱 실행·복귀 시 {{ PIN_LENGTH }}자리 PIN 입력</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button" class="switch" :class="{ on: lockEnabled }"
|
||||||
|
role="switch" :aria-checked="lockEnabled"
|
||||||
|
@click="toggleLock"
|
||||||
|
><span class="knob"></span></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- PC(데스크톱) 전용: 창 크기 (가로·세로 각각) -->
|
<!-- PC(데스크톱) 전용: 창 크기 (가로·세로 각각) -->
|
||||||
<section v-if="isDesktop" class="card">
|
<section v-if="isDesktop" class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -295,6 +352,20 @@ async function clearAppData() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p>
|
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p>
|
||||||
|
|
||||||
|
<!-- PIN 설정 모달 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="lockSetupOpen" class="pin-backdrop" @click.self="lockSetupOpen = false">
|
||||||
|
<div class="pin-modal" role="dialog" aria-modal="true" aria-label="PIN 설정">
|
||||||
|
<button type="button" class="pin-close" aria-label="닫기" @click="lockSetupOpen = false">×</button>
|
||||||
|
<h2 class="pin-h">{{ setupStep === 1 ? 'PIN 설정' : 'PIN 다시 입력' }}</h2>
|
||||||
|
<p class="pin-sub" :class="{ err: setupError }">
|
||||||
|
{{ setupError || (setupStep === 1 ? `사용할 ${PIN_LENGTH}자리 PIN을 입력하세요` : '확인을 위해 한 번 더 입력하세요') }}
|
||||||
|
</p>
|
||||||
|
<PinPad :value="setupPin" :length="PIN_LENGTH" @update:value="(v) => { setupPin = v; setupError = '' }" @complete="onSetupComplete" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -303,6 +374,53 @@ async function clearAppData() {
|
|||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
margin: 0 auto;
|
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 {
|
.page-title {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user