v0.1.45 - 로그인 유지 옵션과 랜딩 문구 정리

This commit is contained in:
2026-04-23 13:42:53 +09:00
parent 1f2d9ddc54
commit e847ddd227
5 changed files with 57 additions and 17 deletions

View File

@@ -225,6 +225,8 @@
- 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다. - 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다.
- 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다. - 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다.
- 비로그인 랜딩은 모바일에서 `카드 안 카드`처럼 보이지 않도록 기능 설명 카드를 얇은 리스트로 단순화했고, `LOGIN` / `SIGN UP` 버튼은 같은 너비와 높이로 맞췄다. 로그인/회원가입 모달도 하단 전환 영역을 별도 카드 대신 구분선 형태로 정리했다. - 비로그인 랜딩은 모바일에서 `카드 안 카드`처럼 보이지 않도록 기능 설명 카드를 얇은 리스트로 단순화했고, `LOGIN` / `SIGN UP` 버튼은 같은 너비와 높이로 맞췄다. 로그인/회원가입 모달도 하단 전환 영역을 별도 카드 대신 구분선 형태로 정리했다.
- 로그인 모달에 `로그인 유지` 체크박스를 추가했다. 기본값은 OFF이며, OFF 상태에서는 인증 토큰을 `sessionStorage`에 저장해 브라우저 세션이 끝나면 사라지고, ON 상태에서만 `localStorage`에 저장한다.
- 현재 로그아웃은 프론트 저장 토큰을 지우는 수준이다. 개인 기록 서비스 성격을 고려하면 다음 단계에서 서버 세션 폐기 API와 미사용 자동 로그아웃 옵션을 추가하는 편이 좋다.
## 갱신 규칙 ## 갱신 규칙

View File

@@ -94,6 +94,10 @@
- [ ] 이메일 인증 플로우를 설계하고 구현한다. - [ ] 이메일 인증 플로우를 설계하고 구현한다.
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다. - [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다. - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
- [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다.
- [ ] 일정 시간 미사용 시 자동 로그아웃 옵션을 추가한다.
- [ ] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
- [ ] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다. - [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다.
- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다. - [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다.
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다. - [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.

View File

@@ -59,6 +59,7 @@ const authForm = reactive({
nickname: '', nickname: '',
email: '', email: '',
password: '', password: '',
rememberSession: false,
}) })
const goalForm = reactive({ const goalForm = reactive({
title: '', title: '',
@@ -1062,6 +1063,7 @@ function resetAuthForm() {
authForm.nickname = '' authForm.nickname = ''
authForm.email = '' authForm.email = ''
authForm.password = '' authForm.password = ''
authForm.rememberSession = false
} }
function resetGoalForm() { function resetGoalForm() {
@@ -1099,13 +1101,14 @@ function updateAuthField({ field, value }) {
authForm[field] = value authForm[field] = value
} }
async function applyAuthSuccess(data) { async function applyAuthSuccess(data, persist = false) {
authToken.value = data.token authToken.value = data.token
currentUser.value = data.user currentUser.value = data.user
setSyncFeedback('cloud', '클라우드 동기화 연결됨') setSyncFeedback('cloud', '클라우드 동기화 연결됨')
persistAuthState({ persistAuthState({
token: data.token, token: data.token,
user: data.user, user: data.user,
persist,
}) })
await loadGoals() await loadGoals()
await hydratePlannerRecordsFromApi() await hydratePlannerRecordsFromApi()
@@ -1131,7 +1134,7 @@ async function submitAuthForm() {
password: authForm.password, password: authForm.password,
}) })
await applyAuthSuccess(result) await applyAuthSuccess(result, authMode.value === 'login' && authForm.rememberSession)
} catch (error) { } catch (error) {
authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.') authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.')
} finally { } finally {
@@ -1156,6 +1159,7 @@ async function restoreAuthSession() {
persistAuthState({ persistAuthState({
token: savedAuth.token, token: savedAuth.token,
user: result.user, user: result.user,
persist: savedAuth.persist,
}) })
await loadGoals() await loadGoals()
await hydratePlannerRecordsFromApi() await hydratePlannerRecordsFromApi()
@@ -2015,12 +2019,13 @@ onBeforeUnmount(() => {
> >
<div class="mx-auto flex max-w-2xl flex-col gap-6 text-center"> <div class="mx-auto flex max-w-2xl flex-col gap-6 text-center">
<div> <div>
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">회원 전용 플래너</p> <p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">MEMBERS ONLY</p>
<h2 class="mt-3 text-3xl font-semibold tracking-[-0.05em] text-stone-900 sm:text-4xl"> <h2 class="mt-3 text-3xl font-semibold tracking-[-0.05em] text-stone-900 sm:text-4xl">
10 Minute Planner 오늘을 흘려보내지 않는<br>
10분의 기록
</h2> </h2>
<p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-stone-600 sm:text-base"> <p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-stone-600 sm:text-base">
로그인하면 날짜별 플래너, 통계, 목표를 계정 기준으로 이어 사용할 있습니다. , 집중 시간, 짧은 코멘트를 장의 이어리로 남기고 내일의 작업까지 이어가세요.
</p> </p>
</div> </div>

View File

@@ -32,7 +32,7 @@ const emit = defineEmits([
function updateField(field, event) { function updateField(field, event) {
emit('update:field', { emit('update:field', {
field, field,
value: event.target.value, value: event.target.type === 'checkbox' ? event.target.checked : event.target.value,
}) })
} }
</script> </script>
@@ -98,6 +98,24 @@ function updateField(field, event) {
/> />
</div> </div>
<label
v-if="mode === 'login'"
class="flex items-start gap-3 rounded-2xl border border-stone-300/70 bg-white/55 px-4 py-3 text-left"
>
<input
:checked="form.rememberSession"
type="checkbox"
class="mt-1 h-4 w-4 shrink-0 accent-stone-900"
@change="updateField('rememberSession', $event)"
/>
<span>
<span class="block text-xs font-bold tracking-[0.12em] text-stone-800">로그인 유지</span>
<span class="mt-1 block text-[11px] font-semibold leading-5 tracking-[0.02em] text-stone-500">
체크하지 않으면 브라우저를 닫을 로그인 정보가 사라집니다.
</span>
</span>
</label>
<p <p
v-if="message" v-if="message"
class="rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700" class="rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700"

View File

@@ -28,29 +28,39 @@ async function request(path, { method = 'GET', token, body } = {}) {
export function readAuthState() { export function readAuthState() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { token: '', user: null } return { token: '', user: null, persist: false }
} }
try { try {
return JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? '{"token":"","user":null}') const localState = JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? 'null')
if (localState?.token) {
return { ...localState, persist: true }
}
const sessionState = JSON.parse(window.sessionStorage.getItem(AUTH_STORAGE_KEY) ?? 'null')
if (sessionState?.token) {
return { ...sessionState, persist: false }
}
return { token: '', user: null, persist: false }
} catch (error) { } catch (error) {
console.warn('저장된 인증 상태를 불러오지 못했습니다.', error) console.warn('저장된 인증 상태를 불러오지 못했습니다.', error)
return { token: '', user: null } return { token: '', user: null, persist: false }
} }
} }
export function persistAuthState({ token, user }) { export function persistAuthState({ token, user, persist = false }) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
} }
window.localStorage.setItem( const targetStorage = persist ? window.localStorage : window.sessionStorage
AUTH_STORAGE_KEY, const unusedStorage = persist ? window.sessionStorage : window.localStorage
JSON.stringify({
token, unusedStorage.removeItem(AUTH_STORAGE_KEY)
user, targetStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, user }))
}),
)
} }
export function clearAuthState() { export function clearAuthState() {
@@ -59,6 +69,7 @@ export function clearAuthState() {
} }
window.localStorage.removeItem(AUTH_STORAGE_KEY) window.localStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
} }
export async function signup({ email, password, nickname }) { export async function signup({ email, password, nickname }) {