feat(frontend): 13단계 전환 준비 화면 — 새 비밀번호 · 2단계 켜기 단계 · 유예 줄 · 메일 프로그램 연결 · 30일 안내 · 문의 줄

- 제한된 세션: restriction 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로, AUTH_010 은 알림 없이 me 를 다시 받아 옮긴다.
  제한된 동안은 나가기 확인을 묻지 않고(확인에서 이동을 취소하면 메일 화면에 갇혔다 — 리뷰) 실시간 알림을 열지 않는다.
- 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분 · 세션이 끝나면 지운다. 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다.
- 유예 노란 줄(시작일 전날까지), 설정 › 2단계 인증의 필수 안내, 설정 › 메일 프로그램 연결(펼침 줄 · 새 Outlook 은 권하지 않음), 휴지통 · 스팸메일함 30일 줄, 로그인 · 코드 단계의 문의 줄.
검증: lint · type-check · 단위 2,354 · 실사용 시험 89/89.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:37:14 +09:00
parent 452d9fef1d
commit 1881ec7a42
62 changed files with 4773 additions and 100 deletions
+25 -1
View File
@@ -1,13 +1,15 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { fail, installMockApi, restoreApiAdapter } from '@/test-utils/mock-api'
import { useAuthStore } from '@/stores/auth.store'
import { fail, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import router from './index'
// 라우터 가드 — 공개 화면(11단계 내려받기 /s/:token)은 세션을 묻지 않는다 (계약 4절 "화면 /s/{token}")
// 받는 쪽은 우리 직원이 아니다 — 쿠키 없는 바깥 브라우저에서 /auth/me · /auth/refresh 가 오가지 않게
const TOKEN = 'Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
describe('router 가드', () => {
beforeEach(() => {
@@ -41,4 +43,26 @@ describe('router 가드', () => {
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/settings/mail/large-files')
})
// 제한된 세션 (13단계 — 계약 6절) — 로그인은 됐지만 새 비밀번호 · 2단계 켜기를 마칠 때까지 보호 화면 대신 로그인 틀의 그 단계
it('제한된 세션이면 보호 화면 대신 로그인으로 — 돌아올 곳은 가려던 주소', async () => {
installMockApi(() => ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
await router.push('/contacts')
expect(useAuthStore().isLoggedIn).toBe(true)
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/contacts')
})
it('제한된 세션은 로그인 화면에 머문다 (그 단계를 그린다) — 제한이 풀리면 가려던 주소로 보낸다', async () => {
installMockApi(() => ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }))
await router.push('/login?redirect=/mail/inbox')
expect(router.currentRoute.value.name).toBe('login')
useAuthStore().markAuthenticated(USER, { restriction: null, twoFactorDeadline: null })
await router.push('/login?redirect=/settings/account/two-factor')
expect(router.currentRoute.value.fullPath).toBe('/settings/account/two-factor')
})
})
+4 -3
View File
@@ -35,11 +35,12 @@ router.beforeEach(async (to) => {
await useAuth().restoreSession()
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
// 로그인 후 원래 가려던 화면으로 돌아온다
// 로그인 전 · 제한된 세션(13단계 — 새 비밀번호 · 2단계 켜기를 마치기 전, 계약 6절)은 보호 화면 대신 로그인 틀로.
// 로그인 화면이 제한의 단계를 그리고, 끝나면 원래 가려던 화면으로 돌아온다
if (to.meta.requiresAuth && (!authStore.isLoggedIn || authStore.restriction !== null)) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.guestOnly && authStore.isLoggedIn) {
if (to.meta.guestOnly && authStore.isLoggedIn && authStore.restriction === null) {
return toSafeRedirect(to.query.redirect) ?? { name: 'mail' }
}
return true
+8 -1
View File
@@ -96,7 +96,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -122,6 +122,13 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/AccountAppPasswordsPage.vue'),
meta: { title: '앱 비밀번호' },
},
{
// 메일 프로그램 연결 (13단계 — 디자인 README "13단계 › 설정 › 계정 › 메일 프로그램 연결") — 화면 상수로만 그린다
path: 'account/mail-clients',
name: SETTINGS_ROUTES.mailClients,
component: () => import('@/pages/settings/AccountMailClientsPage.vue'),
meta: { title: '메일 프로그램 연결' },
},
{
path: 'account/login-history',
name: SETTINGS_ROUTES.loginHistory,