feat(frontend): 12단계 계정 보안 화면 — 로그인 코드 단계 · 2단계 인증 · 앱 비밀번호 · 로그인 기록

로그인은 비밀번호가 맞고 2단계를 켠 직원만 코드 단계로 간다. 설정 › 계정에 2단계 인증 · 앱 비밀번호 · 로그인 기록을 더하고,
비밀번호 화면에 2단계를 켠 직원의 코드 칸을 둔다.
비밀(코드 단계의 비밀번호 · 2단계 키 · 앱 비밀번호 값)은 화면 메모리에만 두고, 세션이 바뀌면 그 창을 모두 닫는다 —
12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다. 비밀이 드는 요청은 Pinia 액션 밖에서 보낸다(devtools 기록).
QR 은 uqr 0.1.3(MIT · 의존성 없음 — 2단계 인증 화면 청크에만)으로 SVG 를 그린다(v-html 없음).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:25:53 +09:00
parent f9e3b1c57a
commit c31442e639
55 changed files with 8475 additions and 115 deletions
+302 -1
View File
@@ -1,16 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { TWO_FACTOR_LOGIN_WINDOW_MS } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
@@ -123,3 +127,300 @@ describe('LoginPage', () => {
expect(button.text()).toBe('로그인')
})
})
// ── 코드 단계 (12단계 — 계약 5절 "로그인" · 디자인 원본 LoginTwoFactor) ─────────────
const PASSWORD = 'Ara!2026-mail'
const TWO_FACTOR_REQUIRED = ok({ status: 'twoFactorRequired', email: null, name: null })
const SIGNED_IN = ok({ status: 'signedIn', ...USER })
describe('LoginPage — 2단계 인증 코드 단계', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
clock = createManualClock(Date.UTC(2026, 9, 31, 1, 0, 0))
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
/** 첫 요청(비밀번호만)은 twoFactorRequired, 코드가 실린 요청은 codeReply */
async function mountAtCodeStep(
codeReply: (request: MockRequest) => MockResult | Promise<MockResult> = () => SIGNED_IN,
path = '/login',
) {
requests = installMockApi((request) => {
const body = request.data as { otpCode?: string }
return body.otpCode === undefined ? TWO_FACTOR_REQUIRED : codeReply(request)
})
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(LoginPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
await wrapper.get('#login-email').setValue(` ${USER.email} `)
await wrapper.get('#login-password').setValue(PASSWORD)
await wrapper.get('form').trigger('submit')
await flushPromises()
return { wrapper, router }
}
const loginRequests = () => requests.filter((request) => request.url === '/auth/login')
async function submitCode(wrapper: VueWrapper, code: string) {
await wrapper.get('#login-code').setValue(code)
await wrapper.get('form').trigger('submit')
await flushPromises()
}
it('비밀번호가 맞고 2단계 인증을 켰으면(twoFactorRequired) 코드 단계로 — 로그인하지 않고, 비밀번호 칸은 없어지고, 초점은 코드 칸', async () => {
const { wrapper } = await mountAtCodeStep()
expect(loginRequests().map((request) => request.data)).toEqual([{ email: USER.email, password: PASSWORD }])
expect(useAuthStore().isLoggedIn).toBe(false)
expect(wrapper.get('h1').text()).toBe('2단계 인증')
expect(wrapper.text()).toContain('비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.')
expect(wrapper.get('[data-code-step-email]').text()).toBe(USER.email)
expect(wrapper.find('#login-password').exists()).toBe(false)
expect(wrapper.get('label[for="login-code"]').text()).toBe('인증 앱에 표시된 6자리 코드')
const input = wrapper.get('#login-code')
expect(input.attributes()).toMatchObject({ inputmode: 'numeric', autocomplete: 'one-time-code', placeholder: '000000' })
// 붙여 넣은 "123 456" 을 브라우저가 먼저 자르지 않게 maxlength 를 걸지 않는다
expect(input.attributes('maxlength')).toBeUndefined()
expect(document.activeElement?.id).toBe('login-code')
expect(wrapper.text()).toContain('인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.')
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
})
it('코드 칸은 숫자만 — 붙여 넣은 띄어쓰기 · 하이픈은 지운다', async () => {
const { wrapper } = await mountAtCodeStep()
const input = wrapper.get('#login-code')
await input.setValue('123 456')
expect((input.element as HTMLInputElement).value).toBe('123456')
await input.setValue('12-34ab')
expect((input.element as HTMLInputElement).value).toBe('1234')
})
it('6자리가 아니면 보내지 않고 칸 아래에 사전 문구(AUTH_007)', async () => {
const { wrapper } = await mountAtCodeStep()
await submitCode(wrapper, '1234')
expect(loginRequests()).toHaveLength(1)
expect(wrapper.get('#login-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(wrapper.get('#login-code').attributes('aria-invalid')).toBe('true')
})
it('맞으면 비밀번호와 코드를 함께 싣고 로그인 — 로그인 전에 가려던 주소로', async () => {
const { wrapper, router } = await mountAtCodeStep(() => SIGNED_IN, '/login?redirect=/settings/account/two-factor')
await submitCode(wrapper, '482913')
expect(loginRequests().map((request) => request.data)).toEqual([
{ email: USER.email, password: PASSWORD },
{ email: USER.email, password: PASSWORD, otpCode: '482913' },
])
expect(useAuthStore().user).toEqual(USER)
expect(router.currentRoute.value.fullPath).toBe('/settings/account/two-factor')
})
it('확인하는 동안 — 「확인하는 중…」(aria-disabled · aria-busy — 초점을 지킨다) · 칸은 읽기만 · 다시 눌러도 요청은 하나 · [다른 계정으로 로그인]도 누를 수 없다', async () => {
let release: (result: MockResult) => void = () => undefined
const { wrapper } = await mountAtCodeStep(
() =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
)
const button = wrapper.get('form button')
;(button.element as HTMLButtonElement).focus()
await submitCode(wrapper, '482913')
expect(button.text()).toBe('확인하는 중…')
// disabled 가 아니다 — 꺼진 단추는 키보드 초점을 잃는다
expect(button.attributes('disabled')).toBeUndefined()
expect(button.attributes('aria-disabled')).toBe('true')
expect(button.attributes('aria-busy')).toBe('true')
expect(document.activeElement).toBe(button.element)
expect(wrapper.get('#login-code').attributes('readonly')).toBeDefined()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(loginRequests()).toHaveLength(2)
expect(wrapper.get('[data-code-step-other]').attributes('aria-disabled')).toBe('true')
await wrapper.get('[data-code-step-other]').trigger('click')
expect(wrapper.find('#login-code').exists()).toBe(true)
// 시도 과다 — 위쪽 상자에 알리고, 초점은 [확인]에 그대로 남는다
release(fail(429, 'REQ_001'))
await flushPromises()
expect(button.text()).toBe('확인')
expect(button.attributes('aria-disabled')).toBeUndefined()
expect(button.attributes('aria-busy')).toBe('false')
expect(wrapper.get('#login-code-alert').text()).toBe(ERROR_CODE_MESSAGES.REQ_001)
expect(document.activeElement).toBe(button.element)
})
it('코드가 틀리면(400 AUTH_006) 칸 아래 빨간 글 · 칸을 비우고 초점 · 알림 없음', async () => {
const { wrapper } = await mountAtCodeStep(() => fail(400, 'AUTH_006'))
await submitCode(wrapper, '000111')
expect(wrapper.get('#login-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect((wrapper.get('#login-code').element as HTMLInputElement).value).toBe('')
expect(document.activeElement?.id).toBe('login-code')
expect(useToastStore().toasts).toEqual([])
expect(useAuthStore().isLoggedIn).toBe(false)
// 다시 치면 내린다
await wrapper.get('#login-code').setValue('4')
expect(wrapper.find('#login-code-error').exists()).toBe(false)
})
it('시도가 너무 많으면(429 REQ_001) 위쪽 빨간 상자 — 알림 없이, 칸 · 단추는 그대로', async () => {
const { wrapper } = await mountAtCodeStep(() => fail(429, 'REQ_001'))
await submitCode(wrapper, '482913')
expect(wrapper.get('#login-code-alert').text()).toBe(ERROR_CODE_MESSAGES.REQ_001)
expect(wrapper.get('#login-code-alert').attributes('role')).toBe('alert')
expect((wrapper.get('#login-code').element as HTMLInputElement).value).toBe('482913')
expect(useToastStore().toasts).toEqual([])
})
it('그사이 비밀번호가 바뀌었으면(401 AUTH_004) 첫 단계로 — 주소는 남기고 비밀번호는 비우고 위쪽 상자에 사전 문구', async () => {
const { wrapper } = await mountAtCodeStep(() => fail(401, 'AUTH_004'))
await submitCode(wrapper, '482913')
expect(wrapper.find('#login-code').exists()).toBe(false)
expect(wrapper.get('h1').text()).toBe('로그인')
expect((wrapper.get('#login-email').element as HTMLInputElement).value).toBe(USER.email)
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
expect(wrapper.get('#login-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_004)
expect(wrapper.get('#login-password').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('login-password')
expect(useToastStore().toasts).toEqual([])
})
it('[다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다', async () => {
const { wrapper } = await mountAtCodeStep()
await wrapper.get('[data-code-step-other]').trigger('click')
await flushPromises()
expect(wrapper.get('h1').text()).toBe('로그인')
expect((wrapper.get('#login-email').element as HTMLInputElement).value).toBe('')
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
expect(wrapper.find('#login-error').exists()).toBe(false)
expect(document.activeElement?.id).toBe('login-email')
expect(loginRequests()).toHaveLength(1)
// 10분 타이머도 거뒀다
expect(clock.pendingCount()).toBe(0)
})
it('10분 넘게 머물면 비밀번호를 지운다 — [확인]은 보내지 않고 「다시 로그인해 주세요」와 함께 첫 단계로(주소는 남긴다)', async () => {
const { wrapper } = await mountAtCodeStep()
clock.advance(TWO_FACTOR_LOGIN_WINDOW_MS - 1)
await wrapper.get('#login-code').setValue('482913')
clock.advance(1)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(loginRequests()).toHaveLength(1)
expect(wrapper.get('h1').text()).toBe('로그인')
expect(wrapper.get('#login-error').text()).toBe('코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.')
// 안내일 뿐 — 칸을 빨갛게 하지 않는다
expect(wrapper.get('#login-password').attributes('aria-invalid')).toBeUndefined()
expect((wrapper.get('#login-email').element as HTMLInputElement).value).toBe(USER.email)
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
expect(document.activeElement?.id).toBe('login-password')
})
it('화면을 떠나면 코드 단계의 10분 타이머를 거둔다 (비밀번호도 함께 버린다)', async () => {
const { wrapper } = await mountAtCodeStep()
expect(clock.pendingCount()).toBe(1)
wrapper.unmount()
expect(clock.pendingCount()).toBe(0)
})
it('10분은 타이머 하나에 기대지 않는다 — 절전 · 뒤에 깔린 탭으로 타이머가 돌지 않았어도 [확인] 때 시계로 다시 재 첫 단계로', async () => {
// 타이머는 부르지 않고 시각만 10분 앞으로 간 시계 (절전에서 깨어난 PC · 타이머가 늦춰진 탭)
let slept = 0
const sleepy = {
now: () => clock.now() + slept,
setTimeout: clock.setTimeout,
clearTimeout: clock.clearTimeout,
}
requests = installMockApi((request) =>
(request.data as { otpCode?: string }).otpCode === undefined ? TWO_FACTOR_REQUIRED : SIGNED_IN,
)
const router = createTestRouter()
await router.push('/login')
await router.isReady()
const wrapper = mount(LoginPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: sleepy } },
})
mounted.push(wrapper)
await wrapper.get('#login-email').setValue(USER.email)
await wrapper.get('#login-password').setValue(PASSWORD)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.find('#login-code').exists()).toBe(true)
slept = TWO_FACTOR_LOGIN_WINDOW_MS
await submitCode(wrapper, '482913')
expect(clock.pendingCount()).toBe(0)
expect(loginRequests()).toHaveLength(1)
expect(wrapper.get('#login-error').text()).toBe('코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.')
expect(useAuthStore().isLoggedIn).toBe(false)
})
it('기다리는 사이 화면을 떠났으면 늦게 온 twoFactorRequired 로 코드 단계를 시작하지 않는다 (비밀번호를 붙잡지 않는다)', async () => {
let release: (result: MockResult) => void = () => undefined
requests = installMockApi(
() =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
)
const router = createTestRouter()
await router.push('/login')
await router.isReady()
const wrapper = mount(LoginPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
await wrapper.get('#login-email').setValue(USER.email)
await wrapper.get('#login-password').setValue(PASSWORD)
await wrapper.get('form').trigger('submit')
await flushPromises()
wrapper.unmount()
release(TWO_FACTOR_REQUIRED)
await flushPromises()
expect(clock.pendingCount()).toBe(0)
expect(useAuthStore().isLoggedIn).toBe(false)
})
})
+217 -81
View File
@@ -1,34 +1,87 @@
<script setup lang="ts">
// 로그인 (디자인 원본 Login) — 직원 계정 = 메일 계정
// 로그인 (디자인 원본 Login · LoginTwoFactor) — 직원 계정 = 메일 계정
// 실패 문구는 에러 코드 사전(AUTH_004)에서만 온다. 계정 없음·비밀번호 틀림을 구분하지 않는다.
import { ref } from 'vue'
// 2단계 인증을 켠 직원은 비밀번호가 맞으면 코드 단계로 간다 (12단계 — 계약 5절 "로그인" · 디자인 README "로그인 — 코드 단계"):
// - 확인한 비밀번호는 화면 메모리에만(useLoginChallenge) — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분 지나면 지운다. 새로고침도 첫 단계
// - 코드 틀림(AUTH_006)은 칸 아래 · 칸을 비우고 초점, 시도 과다(REQ_001)는 위쪽 상자, 비밀번호 틀림(AUTH_004 — 그사이 바뀜)은 첫 단계로 돌아가 위쪽 상자
// - 10분 넘게 머문 뒤 [확인]은 보내지 않고 「다시 로그인해 주세요」와 함께 첫 단계로(주소는 남긴다)
import { nextTick, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import logoFull from '@/assets/brand/logo-arasystem-white.png'
import AppIcon from '@/components/AppIcon.vue'
import { isApiError } from '@/composables/useApi'
import LoginCodeStep from '@/components/auth/LoginCodeStep.vue'
import { getErrorMessage, isApiError, toApiError } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import { useClock } from '@/composables/useClock'
import { useLoginChallenge } from '@/composables/useLoginChallenge'
import { LOGIN_TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
import { APP_NAME, COMPANY_MAIL_DOMAIN } from '@/shared/constants/brand'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { isCompleteOtp } from '@/shared/utils/account-security'
import { toSafeRedirect } from '@/shared/utils/redirect'
/** 코드 단계가 직접 보이는 서버 거절 — 알림을 끈다 (그 밖의 장애는 인터셉터의 알림) */
const CODE_STEP_SILENT: readonly ErrorCode[] = ['AUTH_004', 'AUTH_006', 'REQ_001']
const route = useRoute()
const router = useRouter()
const { login } = useAuth()
const clock = useClock()
const {
active: codeStepActive,
email: challengedEmail,
start: startChallenge,
credentials: challengeCredentials,
clear: clearChallenge,
} = useLoginChallenge(clock)
const email = ref<string>('')
const password = ref<string>('')
const submitting = ref<boolean>(false)
/** 자격 증명 불일치(AUTH_004) 안내 — 사전 문구 그대로 */
/** 자격 증명 불일치(AUTH_004) 안내 — 사전 문구 그대로. 칸도 빨갛게 */
const credentialError = ref<string | null>(null)
/** 첫 단계 위쪽 상자의 안내 — 코드 단계 시간 지남 (칸은 빨갛게 하지 않는다) */
const stepNotice = ref<string | null>(null)
const emailRef = ref<HTMLInputElement | null>(null)
const passwordRef = ref<HTMLInputElement | null>(null)
// ── 코드 단계 ─────────────
const codeStepRef = ref<InstanceType<typeof LoginCodeStep> | null>(null)
const code = ref<string>('')
const codeSubmitting = ref<boolean>(false)
const codeError = ref<string | null>(null)
const codeAlert = ref<string | null>(null)
function goToRedirect() {
return router.replace(toSafeRedirect(route.query.redirect) ?? { name: 'mail' })
}
function resetCodeStep() {
code.value = ''
codeError.value = null
codeAlert.value = null
}
async function onSubmit() {
if (submitting.value) return
submitting.value = true
credentialError.value = null
stepNotice.value = null
const address = email.value.trim()
try {
await login({ email: email.value.trim(), password: password.value })
await router.replace(toSafeRedirect(route.query.redirect) ?? { name: 'mail' })
const outcome = await login({ email: address, password: password.value })
if (outcome.status === 'twoFactorRequired') {
// 확인한 비밀번호는 코드 단계의 메모리로만 옮기고 칸은 비운다 — 기다리는 사이 화면을 떠났으면 붙잡지 않는다(start 가 false)
const started = startChallenge(address, password.value)
password.value = ''
if (!started) return
resetCodeStep()
await nextTick()
codeStepRef.value?.focus()
return
}
await goToRedirect()
} catch (error) {
if (isApiError(error) && error.code === 'AUTH_004') {
credentialError.value = error.message
@@ -40,6 +93,73 @@ async function onSubmit() {
submitting.value = false
}
}
/** 코드 단계를 끝내고 첫 단계로 — 비밀번호는 지운다. 주소가 남으면 비밀번호 칸, 없으면 주소 칸에 초점 */
async function returnToPasswordStep(next: { email: string; error?: string; notice?: string }) {
clearChallenge()
resetCodeStep()
email.value = next.email
password.value = ''
credentialError.value = next.error ?? null
stepNotice.value = next.notice ?? null
await nextTick()
if (next.email) passwordRef.value?.focus()
else emailRef.value?.focus()
}
function focusCodeWithError(message: string) {
codeError.value = message
code.value = ''
codeStepRef.value?.focus()
}
/** 코드를 고치면 칸 아래 오류를 내린다 (다른 양식과 같다) */
function setCode(value: string) {
code.value = value
codeError.value = null
}
async function onSubmitCode() {
if (codeSubmitting.value) return
const credentials = challengeCredentials()
if (!credentials) {
await returnToPasswordStep({ email: challengedEmail.value, notice: LOGIN_TWO_FACTOR_TEXT.expired })
return
}
codeAlert.value = null
if (!isCompleteOtp(code.value)) {
// 비었거나 6자리가 아니다 — 보내지 않는다 (서버도 400 VAL_001 로 거절한다)
codeError.value = getErrorMessage('AUTH_007')
codeStepRef.value?.focus()
return
}
codeSubmitting.value = true
codeError.value = null
try {
const outcome = await login({ ...credentials, otpCode: code.value }, { silent: CODE_STEP_SILENT })
if (outcome.status === 'twoFactorRequired') {
// 코드를 실었는데도 코드를 묻는다 — 받아들여지지 않은 코드로 본다
focusCodeWithError(getErrorMessage('AUTH_006'))
return
}
clearChallenge()
await goToRedirect()
} catch (error) {
const apiError = toApiError(error)
if (apiError.code === 'AUTH_006') focusCodeWithError(apiError.message)
else if (apiError.code === 'AUTH_004') await returnToPasswordStep({ email: credentials.email, error: apiError.message })
else if (apiError.code === 'REQ_001') codeAlert.value = apiError.message
// 그 밖의 실패(메일 엔진 장애 등)는 인터셉터가 알렸다 — 칸 · 단추는 그대로
} finally {
codeSubmitting.value = false
}
}
/** [다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다 */
function onOtherAccount() {
if (codeSubmitting.value) return
void returnToPasswordStep({ email: '' })
}
</script>
<template>
@@ -68,85 +188,101 @@ async function onSubmit() {
<main class="login__main">
<div class="login__panel">
<div class="login__heading">
<h1 class="login__title">
로그인
</h1>
<p class="login__subtitle">
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
</p>
</div>
<LoginCodeStep
v-if="codeStepActive"
ref="codeStepRef"
:code="code"
:email="challengedEmail"
:submitting="codeSubmitting"
:code-error="codeError"
:alert="codeAlert"
@update:code="setCode"
@submit="onSubmitCode"
@other-account="onOtherAccount"
/>
<p
v-if="credentialError"
id="login-error"
class="login__error"
role="alert"
>
<AppIcon
name="alert-circle"
class="login__error-icon"
/>
<span>{{ credentialError }}</span>
</p>
<template v-else>
<div class="login__heading">
<h1 class="login__title">
로그인
</h1>
<p class="login__subtitle">
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
</p>
</div>
<form
class="login__form"
@submit.prevent="onSubmit"
>
<div class="login__field">
<label
for="login-email"
class="login__label"
>메일 주소</label>
<input
id="login-email"
v-model="email"
type="email"
name="email"
autocomplete="username"
:placeholder="`name@${COMPANY_MAIL_DOMAIN}`"
required
:class="['login__input', { 'is-invalid': credentialError }]"
:aria-invalid="credentialError ? 'true' : undefined"
:aria-describedby="credentialError ? 'login-error' : undefined"
>
</div>
<div class="login__field">
<label
for="login-password"
class="login__label"
>비밀번호</label>
<input
id="login-password"
ref="passwordRef"
v-model="password"
type="password"
name="password"
autocomplete="current-password"
placeholder="비밀번호 입력"
required
:class="['login__input', { 'is-invalid': credentialError }]"
:aria-invalid="credentialError ? 'true' : undefined"
:aria-describedby="credentialError ? 'login-error' : undefined"
>
</div>
<button
type="submit"
class="login__submit"
:disabled="submitting"
:aria-busy="submitting"
<p
v-if="credentialError || stepNotice"
id="login-error"
class="login__error"
role="alert"
>
{{ submitting ? '로그인하는 중…' : '로그인' }}
</button>
</form>
<AppIcon
name="alert-circle"
class="login__error-icon"
/>
<span>{{ credentialError ?? stepNotice }}</span>
</p>
<p class="login__help">
비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.
</p>
<p class="login__note">
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
</p>
<form
class="login__form"
@submit.prevent="onSubmit"
>
<div class="login__field">
<label
for="login-email"
class="login__label"
>메일 주소</label>
<input
id="login-email"
ref="emailRef"
v-model="email"
type="email"
name="email"
autocomplete="username"
:placeholder="`name@${COMPANY_MAIL_DOMAIN}`"
required
:class="['login__input', { 'is-invalid': credentialError }]"
:aria-invalid="credentialError ? 'true' : undefined"
:aria-describedby="credentialError || stepNotice ? 'login-error' : undefined"
>
</div>
<div class="login__field">
<label
for="login-password"
class="login__label"
>비밀번호</label>
<input
id="login-password"
ref="passwordRef"
v-model="password"
type="password"
name="password"
autocomplete="current-password"
placeholder="비밀번호 입력"
required
:class="['login__input', { 'is-invalid': credentialError }]"
:aria-invalid="credentialError ? 'true' : undefined"
:aria-describedby="credentialError || stepNotice ? 'login-error' : undefined"
>
</div>
<button
type="submit"
class="login__submit"
:disabled="submitting"
:aria-busy="submitting"
>
{{ submitting ? '로그인하는 중…' : '로그인' }}
</button>
</form>
<p class="login__help">
비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.
</p>
<p class="login__note">
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
</p>
</template>
</div>
</main>
</div>
@@ -0,0 +1,427 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppPassword, CreatedAppPassword } from '@/shared/types/account-security'
import { formatBlockedDay, formatRelativeDayTime } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import resultSource from '@/components/settings/AppPasswordResultDialog.vue?raw'
import AccountAppPasswordsPage from './AccountAppPasswordsPage.vue'
// 설정 › 계정 › 앱 비밀번호 (12단계 — 디자인 원본 SettingsAppPasswords · 계약 5절 "앱 비밀번호")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 한국 시간 2026-10-31 (토) 낮 12시 */
const NOW = Date.parse('2026-10-31T03:00:00Z')
const VALUE = 'app_7qm2xk9vtn3wpl1srb3hcy7djf2gaz1euk0n'
const ITEMS: AppPassword[] = [
{ id: 'c', name: '노트북 썬더버드', createdAt: '2026-10-31T00:20:00Z', lastUsed: null },
{
id: 'b',
name: '회사 PC 아웃룩',
createdAt: '2026-10-27T00:00:00Z',
lastUsed: { at: '2026-10-31T00:12:00Z', method: 'imap', ip: '211.186.95.252' },
},
{
id: 'a',
name: '휴대폰 메일 앱',
createdAt: '2026-10-20T00:00:00Z',
lastUsed: { at: '2026-10-30T09:40:00Z', method: 'smtp', ip: '223.38.120.77' },
},
]
const page = (items: AppPassword[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
function created(name: string): CreatedAppPassword {
return { id: 'd', name, createdAt: '2026-10-31T02:59:00Z', lastUsed: null, password: VALUE }
}
interface Server {
list?: () => MockResult
create?: (request: MockRequest) => MockResult | Promise<MockResult>
remove?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('AccountAppPasswordsPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
Reflect.deleteProperty(navigator, 'clipboard')
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
requests = installMockApi((request) => {
if (request.method === 'GET') return server.list ? server.list() : ok(page(ITEMS))
if (request.method === 'POST') {
if (server.create) return server.create(request)
return ok(created((request.data as { name: string }).name), 201)
}
return server.remove ? server.remove(request) : ok(null)
})
const router = createTestRouter()
await router.push('/settings/account/app-passwords')
await router.isReady()
const wrapper = mount(AccountAppPasswordsPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(NOW) } },
})
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const rows = (wrapper: VueWrapper) =>
wrapper.findAll('tbody tr').map((row) => row.findAll('td').slice(0, 3).map((cell) => cell.text()))
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
const dialogOpen = (titleId: string) =>
document.querySelector(`dialog[aria-labelledby="${titleId}"]`)?.hasAttribute('open') === true
async function openCreate(wrapper: VueWrapper) {
await wrapper.get('[data-app-password-create]').trigger('click')
await flushPromises()
}
async function create(wrapper: VueWrapper, name: string) {
await wrapper.get('#app-password-name').setValue(name)
await wrapper.get('[data-app-password-create-submit]').trigger('click')
await flushPromises()
}
it('머리 "앱 비밀번호" · "3 / 5개" · [만들기] → 실제 표(이름 · 만든 날 · 마지막 사용 · [지우기]) → 설정값 상자 → 안내', async () => {
const wrapper = await mountPage()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /app-passwords'])
expect(wrapper.get('#app-passwords-page-title').text()).toBe('앱 비밀번호')
expect(wrapper.get('[data-app-password-count]').text()).toBe('3 / 5개')
expect(wrapper.get('[data-app-password-create]').attributes('aria-disabled')).toBeUndefined()
expect(wrapper.findAll('thead th').map((cell) => cell.text())).toEqual(['이름', '만든 날', '마지막 사용', '지우기'])
expect(wrapper.findAll('thead th').every((cell) => cell.attributes('scope') === 'col')).toBe(true)
const nowDate = new Date(NOW)
expect(rows(wrapper)).toEqual([
['노트북 썬더버드', formatBlockedDay(ITEMS[0]?.createdAt ?? '', nowDate), '아직 쓰지 않음'],
[
'회사 PC 아웃룩',
formatBlockedDay(ITEMS[1]?.createdAt ?? '', nowDate),
`${formatRelativeDayTime('2026-10-31T00:12:00Z', nowDate)} · IMAP · 211.186.95.252`,
],
[
'휴대폰 메일 앱',
formatBlockedDay(ITEMS[2]?.createdAt ?? '', nowDate),
`${formatRelativeDayTime('2026-10-30T09:40:00Z', nowDate)} · SMTP · 223.38.120.77`,
],
])
expect(rows(wrapper)[1]?.[2]).toBe('오늘 오전 9:12 · IMAP · 211.186.95.252')
expect(wrapper.findAll('[data-app-password-used]')[0]?.classes()).toContain('app-password-table__used--never')
expect(wrapper.findAll('[data-app-password-delete]').map((button) => button.attributes('aria-label'))).toEqual([
'‘노트북 썬더버드’ 앱 비밀번호 지우기',
'‘회사 PC 아웃룩’ 앱 비밀번호 지우기',
'‘휴대폰 메일 앱’ 앱 비밀번호 지우기',
])
const settings = wrapper.get('[data-mail-client-settings]').text()
expect(settings).toContain('mx1.arasystem.it.kr · 포트 993 · SSL/TLS')
expect(settings).toContain('mx1.arasystem.it.kr · 포트 465 · SSL/TLS')
expect(settings).toContain('ara.kim@arasystem.it.kr — 메일 주소 전체')
expect(settings).toContain('여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다')
expect(wrapper.get('[data-app-password-notes]').text()).toContain(
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.',
)
})
it('빈 상태 — "0 / 5개" + "만든 앱 비밀번호가 없습니다", 설정값 상자 · 안내는 그대로', async () => {
const wrapper = await mountPage({ list: () => ok(page([])) })
expect(wrapper.get('[data-app-password-count]').text()).toBe('0 / 5개')
expect(wrapper.find('table').exists()).toBe(false)
expect(wrapper.get('[data-app-password-empty]').text()).toContain('만든 앱 비밀번호가 없습니다')
expect(wrapper.find('[data-mail-client-settings]').exists()).toBe(true)
})
it('목록을 받지 못하면 사전 문구 + [다시 시도] — 그동안 [만들기]는 막는다(개수를 모르므로)', async () => {
let reply: MockResult = fail(503, 'MAIL_001')
const wrapper = await mountPage({ list: () => reply })
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(wrapper.find('[data-app-password-count]').exists()).toBe(false)
expect(wrapper.get('[data-app-password-create]').attributes('aria-disabled')).toBe('true')
await openCreate(wrapper)
expect(dialogOpen('app-password-create-title')).toBe(false)
reply = ok(page(ITEMS))
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(wrapper.get('[data-app-password-count]').text()).toBe('3 / 5개')
})
it('5개를 모두 만들었으면 [만들기]는 aria-disabled + 머리 아래 까닭 — 누르면 창을 열지 않고 까닭을 읽어 준다', async () => {
const five = [...ITEMS, { ...ITEMS[0], id: 'e', name: '집 PC 아웃룩' }, { ...ITEMS[0], id: 'f', name: '태블릿' }] as AppPassword[]
const wrapper = await mountPage({ list: () => ok(page(five)) })
const button = wrapper.get('[data-app-password-create]')
expect(wrapper.get('[data-app-password-count]').text()).toBe('5 / 5개')
expect(button.attributes('aria-disabled')).toBe('true')
expect(button.attributes('aria-describedby')).toBe('app-passwords-full')
expect(wrapper.get('#app-passwords-full').text()).toBe('5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.')
await openCreate(wrapper)
expect(dialogOpen('app-password-create-title')).toBe(false)
expect(wrapper.get('[data-app-password-status]').attributes('role')).toBe('status')
expect(wrapper.get('[data-app-password-status]').text()).toBe(
'5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
)
})
describe('만들기', () => {
it('이름 창 — 처음 초점은 이름 칸(40자) · 비었거나 같은 이름(앞뒤 공백을 지운 값)이면 보내지 않고 칸 아래', async () => {
const wrapper = await mountPage()
await openCreate(wrapper)
expect(dialogOpen('app-password-create-title')).toBe(true)
expect(document.activeElement?.id).toBe('app-password-name')
expect(wrapper.get('#app-password-name').attributes('maxlength')).toBe('40')
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
await create(wrapper, ' ')
expect(wrapper.get('#app-password-name-help').text()).toBe('이름을 넣어 주세요.')
await create(wrapper, ' 회사 PC 아웃룩 ')
expect(wrapper.get('#app-password-name-help').text()).toBe('같은 이름의 앱 비밀번호가 있습니다.')
expect(wrapper.get('#app-password-name').attributes('aria-invalid')).toBe('true')
expect(requests.filter((request) => request.method === 'POST')).toEqual([])
})
it('만들면 결과 창(620) — 값은 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다, 처음 초점 [복사] · 닫으면 목록 맨 위에 새 줄 · 값은 화면에서 지운다', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const wrapper = await mountPage()
await openCreate(wrapper)
await create(wrapper, ' 집 노트북 메일 ')
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
{ name: '집 노트북 메일' },
])
expect(dialogOpen('app-password-create-title')).toBe(false)
expect(dialogOpen('app-password-result-title')).toBe(true)
expect(document.querySelector('#app-password-result-message')?.textContent?.trim()).toBe(
'‘집 노트북 메일’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.',
)
expect(wrapper.findAll('.result__group').map((group) => group.text())).toEqual([
'app_',
'7qm2',
'xk9v',
'tn3w',
'pl1s',
'rb3h',
'cy7d',
'jf2g',
'az1e',
'uk0n',
])
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
expect(wrapper.get('.result__length').text()).toBe('40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.')
expect(document.activeElement?.hasAttribute('data-app-password-copy')).toBe(true)
expect(wrapper.get('.result__warning').text()).toContain('이 창을 닫으면 다시 볼 수 없습니다.')
await wrapper.get('[data-app-password-copy]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(VALUE)
expect(toastMessages()).toEqual(['앱 비밀번호를 복사했습니다.'])
await wrapper.get('[data-app-password-result-close]').trigger('click')
await flushPromises()
expect(dialogOpen('app-password-result-title')).toBe(false)
expect(wrapper.find('[data-app-password-value]').text()).toBe('')
expect(document.body.textContent).not.toContain('7qm2')
expect(rows(wrapper)[0]?.[0]).toBe('집 노트북 메일')
expect(wrapper.get('[data-app-password-count]').text()).toBe('4 / 5개')
})
it('값의 묶음은 가운데서 줄이 바뀌지 않는다 — 넓으면 한 줄, 좁으면 묶음 사이에서만 (줄바꿈 없는 inline-block · 글자 사이에 아무것도 넣지 않는다)', async () => {
const css = resultSource.slice(resultSource.indexOf('<style'))
const rule = (selector: string) => new RegExp(`${selector.replace(/[.-]/g, '\\$&')}\\s*\\{([^}]*)\\}`).exec(css)?.[1] ?? ''
expect(rule('.result__group')).toMatch(/display:\s*inline-block/)
expect(rule('.result__group')).toMatch(/white-space:\s*nowrap/)
expect(rule('.result__value')).not.toMatch(/overflow-wrap|word-break/)
const wrapper = await mountPage()
await openCreate(wrapper)
await create(wrapper, '집 노트북 메일')
// 골라 복사해도 값 그대로 — 묶음 사이에 띄어쓰기 · 줄바꿈 글자가 없다
expect(wrapper.get('[data-app-password-value]').element.textContent).toBe(VALUE)
})
it('복사하지 못하면 「값을 직접 골라 복사해 주세요」 알림', async () => {
const wrapper = await mountPage()
await openCreate(wrapper)
await create(wrapper, '집 노트북 메일')
await wrapper.get('[data-app-password-copy]').trigger('click')
await flushPromises()
expect(useToastStore().toasts.at(-1)).toMatchObject({
kind: 'error',
message: '앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.',
})
})
it('세션이 바뀌면(다른 곳에서 이 세션을 끊음 → 세션 만료) 결과 창을 닫고 값을 화면에서 지운다', async () => {
const wrapper = await mountPage()
await openCreate(wrapper)
await create(wrapper, '집 노트북 메일')
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
useAuthStore().expireSession()
await flushPromises()
expect(dialogOpen('app-password-result-title')).toBe(false)
expect(document.body.textContent).not.toContain('7qm2')
})
it('세션이 바뀌면 만들기 창도 닫는다', async () => {
const wrapper = await mountPage()
await openCreate(wrapper)
await wrapper.get('#app-password-name').setValue('집 노트북 메일')
useAuthStore().expireSession()
await flushPromises()
expect(dialogOpen('app-password-create-title')).toBe(false)
})
it('세션이 바뀌면 지우기 확인 창도 닫는다', async () => {
const wrapper = await mountPage()
await wrapper.findAll('[data-app-password-delete]')[0]?.trigger('click')
await flushPromises()
expect(dialogOpen('app-password-delete-title')).toBe(true)
useAuthStore().expireSession()
await flushPromises()
expect(dialogOpen('app-password-delete-title')).toBe(false)
})
it('만드는 사이 세션이 바뀌었으면 늦게 온 값을 그리지도 목록에 넣지도 않는다', async () => {
let release: (result: MockResult) => void = () => undefined
const wrapper = await mountPage({
create: () =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
})
await openCreate(wrapper)
await create(wrapper, '집 노트북 메일')
useAuthStore().startSession(USER)
release(ok(created('집 노트북 메일'), 201))
await flushPromises()
expect(dialogOpen('app-password-result-title')).toBe(false)
expect(document.body.textContent).not.toContain('7qm2')
expect(rows(wrapper).map((row) => row[0])).not.toContain('집 노트북 메일')
})
it('닫았다 다시 연 만들기 창에는 앞 요청의 거절을 넣지 않는다 — [만들기]의 바쁨도 처음으로', async () => {
const replies: ((result: MockResult) => void)[] = []
const wrapper = await mountPage({
create: () =>
new Promise<MockResult>((resolve) => {
replies.push(resolve)
}),
})
await openCreate(wrapper)
await create(wrapper, '집 노트북 메일')
expect(wrapper.get('[data-app-password-create-submit]').attributes('aria-busy')).toBe('true')
await wrapper.get('[data-app-password-create-cancel]').trigger('click')
await flushPromises()
await openCreate(wrapper)
expect(wrapper.get('[data-app-password-create-submit]').attributes('aria-busy')).toBeUndefined()
replies[0]?.(fail(400, 'VAL_001'))
await flushPromises()
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
expect(toastMessages()).toEqual([])
})
it('그사이 다른 창에서 5개를 채웠으면(409 AUTH_009) 칸 아래 사전 문구 · 목록을 다시 받아 개수를 맞춘다', async () => {
let list = ITEMS
const five = [...ITEMS, { ...ITEMS[0], id: 'e', name: '집 PC 아웃룩' }, { ...ITEMS[0], id: 'f', name: '태블릿' }] as AppPassword[]
const wrapper = await mountPage({
list: () => ok(page(list)),
create: () => {
list = five
return fail(409, 'AUTH_009')
},
})
await openCreate(wrapper)
await create(wrapper, '집 노트북 메일')
await flushPromises()
expect(wrapper.get('#app-password-name-help').text()).toBe(ERROR_CODE_MESSAGES.AUTH_009)
expect(toastMessages()).toEqual([])
expect(requests.filter((request) => request.method === 'GET')).toHaveLength(2)
expect(wrapper.get('[data-app-password-count]').text()).toBe('5 / 5개')
expect(dialogOpen('app-password-result-title')).toBe(false)
})
})
describe('지우기', () => {
it('확인 창(alertdialog · 처음 초점 「취소」) → DELETE · 「‘…’ 앱 비밀번호를 지웠습니다.」 — 줄이 빠지고 개수가 준다, 초점은 다음 줄', async () => {
const wrapper = await mountPage()
await wrapper.findAll('[data-app-password-delete]')[1]?.trigger('click')
await flushPromises()
const dialog = document.querySelector('dialog[aria-labelledby="app-password-delete-title"]')
expect(dialog?.hasAttribute('open')).toBe(true)
expect(dialog?.getAttribute('role')).toBe('alertdialog')
expect(document.querySelector('#app-password-delete-title')?.textContent?.trim()).toBe(
'‘회사 PC 아웃룩’ 앱 비밀번호를 지울까요?',
)
expect(document.activeElement?.hasAttribute('data-app-password-delete-cancel')).toBe(true)
await wrapper.get('[data-app-password-delete-confirm]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/app-passwords/b',
])
expect(toastMessages()).toEqual(['‘회사 PC 아웃룩’ 앱 비밀번호를 지웠습니다.'])
expect(rows(wrapper).map((row) => row[0])).toEqual(['노트북 썬더버드', '휴대폰 메일 앱'])
expect(wrapper.get('[data-app-password-count]').text()).toBe('2 / 5개')
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘휴대폰 메일 앱’ 앱 비밀번호 지우기')
})
it('취소하면 지우지 않는다 · 지우지 못하면(장애) 창을 그대로 두고 알림은 인터셉터', async () => {
const wrapper = await mountPage({ remove: () => fail(503, 'MAIL_001') })
await wrapper.findAll('[data-app-password-delete]')[0]?.trigger('click')
await flushPromises()
await wrapper.get('[data-app-password-delete-cancel]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE')).toEqual([])
await wrapper.findAll('[data-app-password-delete]')[0]?.trigger('click')
await flushPromises()
await wrapper.get('[data-app-password-delete-confirm]').trigger('click')
await flushPromises()
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
expect(document.querySelector('dialog[aria-labelledby="app-password-delete-title"]')?.hasAttribute('open')).toBe(true)
expect(rows(wrapper)).toHaveLength(3)
})
})
})
@@ -0,0 +1,430 @@
<script setup lang="ts">
// 설정 › 계정 › 앱 비밀번호 (디자인 원본 SettingsAppPasswords · README "12단계 › 설정 › 계정 › 앱 비밀번호" · 계약 5절 "앱 비밀번호")
// — /settings/account/app-passwords · 탭 제목 "앱 비밀번호"
// 머리 + 설명 · 오른쪽 "3 / 5개" + [앱 비밀번호 만들기] → 표(AppPasswordTable) 또는 빈 상태 → 메일 프로그램 설정값 상자 → 아래 안내 상자
// - 들어올 때마다 새로 받는다(app-passwords.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도" — 그동안 [만들기]는 막는다(개수를 모르므로)
// - 5개를 모두 만들었으면 [만들기]는 aria-disabled + 머리 아래 한 줄(까닭) — 누르면 창을 열지 않고 까닭을 읽어 준다(role=status)
// - [만들기] → 이름 창(AppPasswordCreateDialog) → 결과 창(AppPasswordResultDialog — 값은 이 화면 메모리에만, 닫으면 지운다) → 목록 맨 위에 새 줄.
// 만들기 요청은 이 화면이 보낸다 — 값이 든 응답을 스토어(Pinia 타임라인) · 창의 이벤트로 넘기지 않는다(스토어에는 값을 뺀 줄만)
// - [지우기] → 확인 창(처음 초점 「취소」) → 「‘…’ 앱 비밀번호를 지웠습니다.」 — 줄이 빠지고 개수가 준다(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · [만들기])
// - 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃] · 로그아웃 · 만료) 창을 모두 닫고 값을 지운다 — 세션 만료 창 뒤에 앱 비밀번호가 남지 않게
// - 만들기 창을 닫았다 다시 열면 앞 요청의 거절은 새 창에 넣지 않는다(몇 번째로 연 창인가 — createOpenSeq). 앞 요청이 만들었으면 결과 창은 연다(값은 그때 한 번만 온다)
// 고정 id — #app-passwords-page-title · #app-passwords-full
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import AppPasswordCreateDialog from '@/components/settings/AppPasswordCreateDialog.vue'
import AppPasswordDeleteDialog from '@/components/settings/AppPasswordDeleteDialog.vue'
import AppPasswordResultDialog from '@/components/settings/AppPasswordResultDialog.vue'
import AppPasswordTable from '@/components/settings/AppPasswordTable.vue'
import MailClientSettings from '@/components/settings/MailClientSettings.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { useAccountSecurity } from '@/composables/useAccountSecurity'
import { notifyApiError, toApiError } from '@/composables/useApi'
import { useClock } from '@/composables/useClock'
import { useToast } from '@/composables/useToast'
import { APP_PASSWORD_MAX, APP_PASSWORD_TEXT } from '@/shared/constants/account-security'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { AppPassword } from '@/shared/types/account-security'
import { appPasswordCountText } from '@/shared/utils/account-security'
import { formatCount } from '@/shared/utils/format'
import { useAppPasswordsStore } from '@/stores/app-passwords.store'
import { useAuthStore } from '@/stores/auth.store'
const FULL_REASON_ID = 'app-passwords-full'
/** 만들기 창의 칸 아래에 보이는 서버 거절 — 알림을 끈다 (그사이 5개를 채움 · 형식) */
const CREATE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001', 'AUTH_009']
const api = useAccountSecurity()
const appPasswordsStore = useAppPasswordsStore()
const authStore = useAuthStore()
const toast = useToast()
const clock = useClock()
/** 화면을 연 때 — "오늘 · 어제" 의 기준 */
const now = clock.now()
const createRef = ref<HTMLButtonElement | null>(null)
const tableRef = ref<InstanceType<typeof AppPasswordTable> | null>(null)
const createOpen = ref<boolean>(false)
const creating = ref<boolean>(false)
/** 만들기 창의 서버 거절 — 사전 문구 */
const createError = ref<string | null>(null)
/** 만든 결과 — 값이 들어 있다. 이 화면 메모리에만 두고 창을 닫으면 지운다 */
const result = shallowRef<{ name: string; password: string } | null>(null)
/** 지울지 묻는 줄 */
const deleteTarget = ref<AppPassword | null>(null)
const removing = ref<boolean>(false)
/** 막힌 [만들기]를 눌렀을 때 읽어 줄 까닭 (role=status) */
const announcement = ref<string>('')
/** 만들기 창을 연 차례 — 닫았다 다시 연 창에 앞 요청의 결과를 넣지 않는다 */
let createOpenSeq = 0
let unmounted = false
const ready = computed(() => appPasswordsStore.status === 'ready')
const items = computed(() => appPasswordsStore.items)
const isFull = computed(() => ready.value && appPasswordsStore.isFull)
/** [만들기]를 누를 수 있다 — 목록을 받았고 상한 아래 */
const canCreate = computed(() => ready.value && !appPasswordsStore.isFull)
const countText = computed(() => appPasswordCountText(items.value.length, APP_PASSWORD_MAX))
const fullReason = APP_PASSWORD_TEXT.full(formatCount(APP_PASSWORD_MAX))
const email = computed(() => authStore.user?.email ?? '')
async function onCreateClick() {
if (canCreate.value) {
createOpenSeq += 1
creating.value = false
createError.value = null
createOpen.value = true
return
}
if (!isFull.value) return
// 같은 글이어도 다시 읽게 비웠다가 넣는다
announcement.value = ''
await nextTick()
announcement.value = fullReason
}
/**
* 만들기 — 값(password)은 이 응답에만 온다. 스토어에는 값을 뺀 줄만 넣고 값은 결과 창(화면 메모리)으로.
* 기다리는 사이 세션이 바뀌었으면 아무것도 쓰지 않는다. 창을 닫았거나 다시 열었어도 만들어졌으면 결과 창은 연다(값을 다시 받을 길이 없다)
*/
async function submitCreate(name: string) {
if (creating.value) return
const opened = createOpenSeq
const session = authStore.generation
creating.value = true
createError.value = null
try {
const created = await api.createAppPassword(name, { silent: CREATE_INLINE_CODES })
if (authStore.generation !== session || unmounted) return
appPasswordsStore.add({ id: created.id, name: created.name, createdAt: created.createdAt, lastUsed: created.lastUsed })
createOpen.value = false
result.value = { name: created.name, password: created.password }
} catch (caught) {
const apiError = toApiError(caught)
if (authStore.generation !== session) return
// 그사이 다른 창에서 채웠다 — 개수를 맞춘다 (화면의 [만들기]도 막힌다)
if (apiError.code === 'AUTH_009' && !unmounted) void appPasswordsStore.refresh()
if (!CREATE_INLINE_CODES.includes(apiError.code)) return
if (unmounted || opened !== createOpenSeq || !createOpen.value) {
// 보낸 창이 없다 — 닫힌 채면 알림으로, 다시 연 창에는 앞 요청의 거절을 넣지 않는다
if (opened === createOpenSeq) notifyApiError(apiError)
return
}
createError.value = apiError.message
} finally {
if (opened === createOpenSeq) creating.value = false
}
}
function closeCreate() {
createOpen.value = false
}
function closeResult() {
result.value = null
}
/** 모든 창을 닫고 값을 지운다 — 세션이 바뀌었다 */
function closeAll() {
createOpenSeq += 1
createOpen.value = false
creating.value = false
createError.value = null
result.value = null
deleteTarget.value = null
}
/**
* 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 [만들기].
* 초점이 갈 곳을 잃었을 때만 옮긴다 — 누른 줄의 단추가 사라졌거나, 닫힌 확인 창 안에 남았다(창을 연 단추가 이미 없어 돌려줄 곳이 없었다)
*/
async function focusAfterRemoval(index: number) {
await nextTick()
const active = document.activeElement
const lost = active === null || active === document.body || active.closest('dialog:not([open])') !== null
if (!lost) return
const root: unknown = tableRef.value?.$el
const buttons = root instanceof HTMLElement ? Array.from(root.querySelectorAll<HTMLButtonElement>('[data-app-password-delete]')) : []
const target = buttons[index] ?? buttons[index - 1]
if (target) target.focus()
else createRef.value?.focus()
}
async function confirmDelete() {
const target = deleteTarget.value
if (!target || removing.value) return
const index = items.value.findIndex((item) => item.id === target.id)
removing.value = true
try {
await appPasswordsStore.remove(target.id)
deleteTarget.value = null
toast.success(APP_PASSWORD_TEXT.deleted(target.name))
await focusAfterRemoval(index)
} catch {
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
} finally {
removing.value = false
}
}
onBeforeUnmount(() => {
unmounted = true
result.value = null
})
// 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃]으로 이 세션이 끊김 · 로그아웃 · 만료) 창을 모두 닫고 값을 지운다
watch(
() => authStore.generation,
() => closeAll(),
)
// 처음 그리기 전에 — 다른 탭에서 만들었거나, 비밀번호를 바꿔 모두 지워졌을 수 있다
void appPasswordsStore.load()
</script>
<template>
<SettingsPageFrame
title-id="app-passwords-page-title"
:title="APP_PASSWORD_TEXT.title"
:description="APP_PASSWORD_TEXT.description"
>
<template #actions>
<div class="app-passwords__head-actions">
<span
v-if="ready"
class="app-passwords__count"
data-app-password-count
>{{ countText }}</span>
<button
ref="createRef"
type="button"
class="app-passwords__create"
:aria-disabled="canCreate ? undefined : 'true'"
:aria-describedby="isFull ? FULL_REASON_ID : undefined"
data-app-password-create
@click="onCreateClick"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="2"
/>
<span>{{ APP_PASSWORD_TEXT.create }}</span>
</button>
</div>
</template>
<p
v-if="isFull"
:id="FULL_REASON_ID"
class="app-passwords__full"
data-app-password-full
>
{{ fullReason }}
</p>
<span
class="visually-hidden"
role="status"
data-app-password-status
>{{ announcement }}</span>
<MailLoadError
v-if="appPasswordsStore.status === 'error' && appPasswordsStore.error"
:error="appPasswordsStore.error"
leave-label=""
@retry="appPasswordsStore.load()"
/>
<template v-else-if="ready">
<AppPasswordTable
v-if="items.length > 0"
ref="tableRef"
:items="items"
:now="now"
:removing-id="removing ? (deleteTarget?.id ?? null) : null"
@delete="deleteTarget = $event"
/>
<div
v-else
class="app-passwords__empty"
data-app-password-empty
>
<AppIcon
name="key"
:size="28"
:stroke-width="1.5"
class="app-passwords__empty-icon"
/>
<p class="app-passwords__empty-title">
{{ APP_PASSWORD_TEXT.emptyTitle }}
</p>
<p class="app-passwords__empty-hint">
{{ APP_PASSWORD_TEXT.emptyHint }}
</p>
</div>
</template>
<div
v-else
class="app-passwords__placeholder"
aria-busy="true"
/>
<section
class="app-passwords__settings"
aria-labelledby="app-passwords-settings-title"
>
<h2
id="app-passwords-settings-title"
class="app-passwords__settings-title"
>
{{ APP_PASSWORD_TEXT.settingsTitle }}
</h2>
<MailClientSettings :email="email" />
</section>
<div
class="app-passwords__notes"
data-app-password-notes
>
<p
v-for="note in APP_PASSWORD_TEXT.notes"
:key="note"
class="app-passwords__note"
>
<span aria-hidden="true">·</span>
<span>{{ note }}</span>
</p>
</div>
<AppPasswordCreateDialog
:open="createOpen"
:existing="items"
:pending="creating"
:error="createError"
@submit="submitCreate"
@close="closeCreate"
@clear-error="createError = null"
/>
<AppPasswordResultDialog
:result="result"
:email="email"
@close="closeResult"
/>
<AppPasswordDeleteDialog
:name="deleteTarget?.name ?? null"
:busy="removing"
@confirm="confirmDelete"
@cancel="deleteTarget = null"
/>
</SettingsPageFrame>
</template>
<style scoped>
.app-passwords__head-actions {
display: flex;
align-items: center;
gap: var(--space-12);
}
.app-passwords__count {
font-size: var(--font-size-label);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.app-passwords__create {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.app-passwords__create:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 막힌 [만들기] — 회색 바탕 (디자인 원본 SettingsAppPasswords "5개를 모두 만들었을 때") */
.app-passwords__create[aria-disabled='true'] {
background: var(--color-border-strong);
cursor: not-allowed;
}
/* 머리 아래 한 줄 — 묶음 사이(22)보다 붙여 머리에 딸린 글로 보이게 (위로 10 당긴다) */
.app-passwords__full {
margin: calc(var(--space-10) * -1) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 빈 상태 — 가운데 정렬 상자 (디자인 원본 SettingsAppPasswords "빈 상태") */
.app-passwords__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-6);
padding: var(--space-28) var(--space-16);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
text-align: center;
}
.app-passwords__empty-icon {
color: var(--color-icon-muted);
}
.app-passwords__empty-title {
margin: var(--space-4) 0 0;
font-weight: 600;
}
.app-passwords__empty-hint {
max-width: 30rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.app-passwords__placeholder {
min-height: 10rem;
}
.app-passwords__settings {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.app-passwords__settings-title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
/* 아래 안내 — 바탕 #F3F8F8 */
.app-passwords__notes {
display: flex;
flex-direction: column;
gap: var(--space-6);
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-lg);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.app-passwords__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
</style>
@@ -0,0 +1,506 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AuthSession, LoginEvent } from '@/shared/types/account-security'
import { formatRelativeDayTime } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 한국 시간 2026-10-31 (토) 낮 12시 */
const NOW = Date.parse('2026-10-31T03:00:00Z')
const SESSIONS: AuthSession[] = [
{
id: 'now',
createdAt: '2026-10-31T00:00:00Z',
lastActiveAt: '2026-10-31T02:50:00Z',
ip: '211.186.95.252',
device: { browser: 'Chrome', os: 'Windows' },
isCurrent: true,
},
{
id: 'edge',
createdAt: '2026-10-30T00:00:00Z',
lastActiveAt: '2026-10-31T00:05:00Z',
ip: '211.186.95.252',
device: { browser: 'Edge', os: 'Windows' },
isCurrent: false,
},
{
id: 'phone',
createdAt: '2026-10-29T00:00:00Z',
lastActiveAt: '2026-10-30T13:31:00Z',
ip: null,
device: { browser: 'Safari', os: 'iOS' },
isCurrent: false,
},
]
function loginEvent(id: string, changes: Partial<LoginEvent>): LoginEvent {
return {
id,
occurredAt: '2026-10-31T00:30:00Z',
method: 'webmail',
isSuccess: true,
isTwoFactor: false,
failureReason: null,
ip: '211.186.95.252',
device: { browser: 'Chrome', os: 'Windows' },
appPassword: null,
...changes,
}
}
const EVENTS: LoginEvent[] = [
loginEvent('e1', { occurredAt: '2026-10-31T00:30:00Z', isTwoFactor: true }),
loginEvent('e2', { occurredAt: '2026-10-31T00:29:00Z', isSuccess: false, failureReason: 'wrongCode' }),
loginEvent('e3', {
occurredAt: '2026-10-31T00:12:00Z',
method: 'imap',
device: null,
appPassword: { id: 'b', name: '회사 PC 아웃룩' },
}),
loginEvent('e4', {
occurredAt: '2026-10-30T14:58:00Z',
method: 'smtp',
device: null,
isSuccess: false,
failureReason: 'appPasswordRequired',
ip: '45.133.10.21',
}),
loginEvent('e5', { occurredAt: '2026-10-29T06:21:00Z', device: null, isSuccess: false, failureReason: 'wrongPassword' }),
]
const pageOf = <T>(items: T[], next: string | null = null, total: number | null = items.length) => ({
items,
pageInfo: { hasNext: next !== null, nextCursor: next, total },
})
interface Server {
sessions?: () => MockResult | Promise<MockResult>
events?: (request: MockRequest) => MockResult | Promise<MockResult>
endSession?: (request: MockRequest) => MockResult
endOthers?: () => MockResult
}
describe('AccountLoginHistoryPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url === '/auth/sessions' && request.method === 'GET') {
return server.sessions ? server.sessions() : ok(pageOf(SESSIONS))
}
if (request.url === '/auth/sessions') return server.endOthers ? server.endOthers() : ok({ ended: 2 })
if (request.url.startsWith('/auth/sessions/')) return server.endSession ? server.endSession(request) : ok(null)
if (request.url === '/login-events') return server.events ? server.events(request) : ok(pageOf(EVENTS))
return ok(null)
})
const router = createTestRouter()
await router.push('/settings/account/login-history')
await router.isReady()
const wrapper = mount(AccountLoginHistoryPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(NOW) } },
})
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const sessionRows = (wrapper: VueWrapper) =>
wrapper.findAll('[data-session]').map((row) => row.findAll('td').slice(1, 4).map((cell) => cell.text()))
const eventRows = (wrapper: VueWrapper) =>
wrapper.findAll('[data-login-event]').map((row) => row.findAll('td').map((cell) => cell.text()))
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
const eventRequests = () => requests.filter((request) => request.url === '/login-events')
it('머리 설명의 「비밀번호를 바꾸고」 · 「2단계 인증」은 링크 — 둘을 함께 받는다(세션 100 · 기록 20)', async () => {
const wrapper = await mountPage()
expect(requests.map((request) => [request.method, request.url, request.params])).toEqual([
['GET', '/auth/sessions', { limit: 100 }],
['GET', '/login-events', { limit: 20 }],
])
expect(wrapper.get('#login-history-page-title').text()).toBe('로그인 기록')
const description = wrapper.get('.settings-page__description')
expect(description.text()).toBe(
'최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요.',
)
expect(description.findAll('a').map((link) => [link.text(), link.attributes('href')])).toEqual([
['비밀번호를 바꾸고', '/settings/account/password'],
['2단계 인증', '/settings/account/two-factor'],
])
})
describe('로그인된 브라우저', () => {
it('"웹메일 3곳" · 줄마다 PC · 휴대폰 아이콘 · 브라우저 · 운영체제 / IP / 마지막 활동 — 지금 이 브라우저는 단추 없이 글자만', async () => {
const wrapper = await mountPage()
const nowDate = new Date(NOW)
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳')
expect(wrapper.findAll('thead').at(0)?.findAll('th').map((cell) => cell.text())).toEqual([
'기기',
'브라우저 · 운영체제',
'IP',
'마지막 활동',
'로그아웃',
])
expect(sessionRows(wrapper)).toEqual([
['Chrome · Windows', '211.186.95.252', '지금 이 브라우저'],
['Edge · Windows', '211.186.95.252', formatRelativeDayTime('2026-10-31T00:05:00Z', nowDate)],
['Safari · iOS', '—알 수 없음', formatRelativeDayTime('2026-10-30T13:31:00Z', nowDate)],
])
expect(sessionRows(wrapper)[1]?.[2]).toBe('오늘 오전 9:05')
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual(['PC', 'PC', '휴대폰'])
expect(wrapper.get('[data-session="now"]').find('[data-session-end]').exists()).toBe(false)
expect(wrapper.findAll('[data-session-end]').map((button) => button.attributes('aria-label'))).toEqual([
'‘Edge · Windows’ 로그아웃',
'‘Safari · iOS’ 로그아웃',
])
expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃')
const note = wrapper.get('[data-sessions-note]')
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.')
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
})
it('모르는 브라우저는 "알 수 없는 브라우저"', async () => {
const unknown: AuthSession = { ...SESSIONS[1], id: 'old', ip: null, device: null } as AuthSession
const wrapper = await mountPage({ sessions: () => ok(pageOf([SESSIONS[0] as AuthSession, unknown])) })
expect(wrapper.get('[data-session="old"] [data-session-device]').text()).toBe('알 수 없는 브라우저')
})
it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’의 로그인을 끊었습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => {
const wrapper = await mountPage()
const button = wrapper.findAll('[data-session-end]')[0]
;(button?.element as HTMLButtonElement).focus()
await button?.trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/auth/sessions/edge',
])
expect(toastMessages()).toEqual(['‘Edge · Windows’의 로그인을 끊었습니다.'])
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows', 'Safari · iOS'])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳')
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
})
it('이미 끝난 세션(404 RES_001)도 끊긴 것과 같다 — 오류 알림 없이 줄을 뺀다', async () => {
const wrapper = await mountPage({ endSession: () => fail(404, 'RES_001') })
await wrapper.findAll('[data-session-end]')[1]?.trigger('click')
await flushPromises()
expect(toastMessages()).toEqual(['‘Safari · iOS’의 로그인을 끊었습니다.'])
expect(sessionRows(wrapper)).toHaveLength(2)
})
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남는다', async () => {
const wrapper = await mountPage()
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
const dialog = document.querySelector('dialog[aria-labelledby="end-other-sessions-title"]')
expect(dialog?.hasAttribute('open')).toBe(true)
expect(dialog?.getAttribute('role')).toBe('alertdialog')
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
)
expect(dialog?.textContent).toContain('메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.')
expect(document.activeElement?.hasAttribute('data-end-others-cancel')).toBe(true)
// 데이터를 지우는 일이 아니다 — 청록 주 단추
expect(wrapper.get('[data-end-others-confirm]').classes()).not.toContain('modal-dialog__action--danger')
await wrapper.get('[data-end-others-confirm]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual(['/auth/sessions'])
expect(toastMessages()).toEqual(['다른 곳의 로그인 2곳을 끊었습니다.'])
expect(dialog?.hasAttribute('open')).toBe(false)
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
expect(wrapper.get('[data-sessions-note]').text()).toBe(
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.',
)
expect(document.activeElement?.id).toBe('login-sessions-title')
})
it('지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 확인 창이 이 브라우저도 로그아웃된다고 알리고, 끊은 뒤 다시 받아 세션 만료로 이어진다', async () => {
const unknown = SESSIONS.map((session) => ({ ...session, isCurrent: false }))
const wrapper = await mountPage({ sessions: () => ok(pageOf(unknown)) })
// 끊은 뒤 — 이 브라우저의 세션도 끝났다: 다시 받으면 401, 갱신도 401
restoreApiAdapter()
requests = installMockApi((request) => {
if (request.url === '/auth/sessions' && request.method === 'DELETE') return ok({ ended: 3 })
return fail(401, 'AUTH_001')
})
expect(wrapper.findAll('[data-session-end]')).toHaveLength(3)
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
expect(document.querySelector('#end-other-sessions-title')?.textContent?.trim()).toBe(
'이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?',
)
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
'이 브라우저를 알아보지 못해 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
)
await wrapper.get('[data-end-others-confirm]').trigger('click')
await flushPromises()
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'DELETE /auth/sessions',
'GET /auth/sessions',
'POST /auth/refresh',
])
expect(toastMessages()).toContain('이 브라우저를 포함한 3곳의 로그인을 끊었습니다.')
// 빈 목록을 그려 두지 않는다 — 다시 받은 요청이 세션 만료로 이어졌다
expect(wrapper.find('[data-session]').exists()).toBe(false)
expect(useAuthStore().sessionExpired).toBe(true)
})
it('세션이 바뀌면 [다른 곳 모두 로그아웃] 확인 창을 닫는다', async () => {
const wrapper = await mountPage()
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
expect(document.querySelector('dialog[aria-labelledby="end-other-sessions-title"]')?.hasAttribute('open')).toBe(true)
useAuthStore().startSession(USER)
await flushPromises()
expect(document.querySelector('dialog[aria-labelledby="end-other-sessions-title"]')?.hasAttribute('open')).toBe(false)
})
it('목록을 받지 못하면 그 묶음만 사전 문구 + [다시 시도]', async () => {
let reply: MockResult = fail(503, 'SYS_001')
const wrapper = await mountPage({ sessions: () => reply })
expect(wrapper.get('[data-sessions-error]').text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(wrapper.find('[data-login-event]').exists()).toBe(true)
expect(toastMessages()).toEqual([])
reply = ok(pageOf(SESSIONS))
await wrapper.get('[data-sessions-error] .mail-load-error__action').trigger('click')
await flushPromises()
expect(sessionRows(wrapper)).toHaveLength(3)
})
})
describe('최근 로그인', () => {
it('시각 · 방식(앱 비밀번호 이름) · IP · 기기(메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
const wrapper = await mountPage()
const nowDate = new Date(NOW)
const time = (value: string) => formatRelativeDayTime(value, nowDate)
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
expect(eventRows(wrapper)).toEqual([
[time('2026-10-31T00:30:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
[time('2026-10-31T00:29:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
[time('2026-10-31T00:12:00Z'), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
[time('2026-10-30T14:58:00Z'), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
[time('2026-10-29T06:21:00Z'), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
])
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
false,
true,
false,
true,
true,
])
expect(wrapper.find('[data-events-more]').exists()).toBe(false)
})
it('[실패만] — isSuccess=false 로 처음부터 받는다 · "최근 90일 · 실패 4번"(서버의 total) · 전환은 aria-pressed', async () => {
const failures = EVENTS.filter((event) => !event.isSuccess)
const wrapper = await mountPage({
events: (request) => (request.params.isSuccess === false ? ok(pageOf(failures, null, 4)) : ok(pageOf(EVENTS))),
})
const all = wrapper.get('[data-events-filter="all"]')
const onlyFailures = wrapper.get('[data-events-filter="failures"]')
expect(all.attributes('aria-pressed')).toBe('true')
await onlyFailures.trigger('click')
await flushPromises()
expect(eventRequests().map((request) => request.params)).toEqual([{ limit: 20 }, { limit: 20, isSuccess: false }])
expect(onlyFailures.attributes('aria-pressed')).toBe('true')
expect(all.attributes('aria-pressed')).toBe('false')
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 4번')
expect(eventRows(wrapper).map((row) => row[4])).toEqual([
'실패 — 코드 틀림',
'실패 — 앱 비밀번호 필요',
'실패 — 비밀번호 틀림',
])
// 같은 보기를 다시 누르면 받지 않는다
await onlyFailures.trigger('click')
await flushPromises()
expect(eventRequests()).toHaveLength(2)
})
it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."', async () => {
const wrapper = await mountPage({
events: (request) => (request.params.isSuccess === false ? ok(pageOf([], null, 0)) : ok(pageOf(EVENTS))),
})
await wrapper.get('[data-events-filter="failures"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-login-event]').exists()).toBe(false)
expect(wrapper.get('[data-events-empty]').text()).toContain('최근 90일 동안 실패한 로그인이 없습니다')
expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 봅니다.')
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 0번')
})
it('[더 보기] — 커서로 20개씩 이어 받는다(실패만이면 실패만) · 다 받으면 단추가 사라진다', async () => {
const failures = EVENTS.filter((event) => !event.isSuccess)
const more = [loginEvent('e9', { occurredAt: '2026-10-12T19:02:00Z', isSuccess: false, failureReason: 'wrongPassword' })]
const wrapper = await mountPage({
events: (request) => {
if (request.params.cursor === 'next-failures') return ok(pageOf(more, null, 4))
if (request.params.isSuccess === false) return ok(pageOf(failures, 'next-failures', 4))
return ok(pageOf(EVENTS, 'next-all', 143))
},
})
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
expect(wrapper.text()).toContain('최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.')
await wrapper.get('[data-events-filter="failures"]').trigger('click')
await flushPromises()
await wrapper.get('[data-events-more]').trigger('click')
await flushPromises()
expect(eventRequests().map((request) => request.params)).toEqual([
{ limit: 20 },
{ limit: 20, isSuccess: false },
{ limit: 20, cursor: 'next-failures', isSuccess: false },
])
expect(wrapper.findAll('[data-login-event]').map((row) => row.attributes('data-login-event'))).toEqual([
'e2',
'e4',
'e5',
'e9',
])
expect(wrapper.find('[data-events-more]').exists()).toBe(false)
})
it('보기를 거듭 바꾸면 마지막 보기의 응답만 쓴다 — 받는 동안 지난 목록은 그대로(aria-busy)', async () => {
const gates: ((result: MockResult) => void)[] = []
const wrapper = await mountPage()
restoreApiAdapter()
requests = installMockApi(
() =>
new Promise<MockResult>((resolve) => {
gates.push(resolve)
}),
)
await wrapper.get('[data-events-filter="failures"]').trigger('click')
await wrapper.get('[data-events-filter="all"]').trigger('click')
await flushPromises()
expect(gates).toHaveLength(2)
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBe('true')
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
// 늦게 온 「실패만」 응답은 버린다
gates[1]?.(ok(pageOf([EVENTS[0] as LoginEvent])))
gates[0]?.(ok(pageOf([EVENTS[1] as LoginEvent], null, 1)))
await flushPromises()
expect(wrapper.findAll('[data-login-event]').map((row) => row.attributes('data-login-event'))).toEqual(['e1'])
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBeUndefined()
})
it('다시 들어오면 지난번 목록을 그리지 않고 「전체」부터 새로 받는다', async () => {
const failures = EVENTS.filter((event) => !event.isSuccess)
const first = await mountPage({
events: (request) => (request.params.isSuccess === false ? ok(pageOf(failures, null, 3)) : ok(pageOf(EVENTS))),
})
await first.get('[data-events-filter="failures"]').trigger('click')
await flushPromises()
first.unmount()
let release: (result: MockResult) => void = () => undefined
const again = await mountPage({
events: () =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
})
expect(again.find('[data-login-event]').exists()).toBe(false)
expect(again.findAll('.login-history__placeholder')).toHaveLength(1)
expect(eventRequests().at(-1)?.params).toEqual({ limit: 20 })
release(ok(pageOf(EVENTS)))
await flushPromises()
expect(again.get('[data-events-filter="all"]').attributes('aria-pressed')).toBe('true')
expect(again.findAll('[data-login-event]')).toHaveLength(5)
})
it('기록을 받지 못하면 그 묶음만 사전 문구 + [다시 시도]', async () => {
let reply: MockResult = fail(503, 'SYS_001')
const wrapper = await mountPage({ events: () => reply })
expect(wrapper.get('[data-events-error]').text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(sessionRows(wrapper)).toHaveLength(3)
reply = ok(pageOf(EVENTS))
await wrapper.get('[data-events-error] .mail-load-error__action').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
})
})
it('끊는 사이 세션이 바뀌었으면(로그아웃) 늦게 온 결과를 목록에 쓰지 않는다', async () => {
let release: (result: MockResult) => void = () => undefined
const wrapper = await mountPage()
restoreApiAdapter()
installMockApi(
() =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
)
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
await wrapper.get('[data-end-others-confirm]').trigger('click')
await flushPromises()
useAuthStore().clearAuth()
useAuthStore().startSession({ email: 'seojun.park@arasystem.it.kr', name: '박서준' })
release(ok({ ended: 2 }))
await flushPromises()
// 다음 직원의 목록은 비어 있다(새로 받기 전) — 이전 직원의 끊기 결과가 들어오지 않는다
expect(wrapper.findAll('[data-session]')).toHaveLength(0)
})
})
@@ -0,0 +1,94 @@
<script setup lang="ts">
// 설정 › 계정 › 로그인 기록 (디자인 원본 SettingsLoginHistory · README "12단계 › 설정 › 계정 › 로그인 기록" · 계약 5절 "로그인된 브라우저" · "로그인 기록")
// — /settings/account/login-history · 탭 제목 "로그인 기록"
// 머리 "로그인 기록" + 설명(「비밀번호를 바꾸고」 · 「2단계 인증」 링크) → 로그인된 브라우저(LoginSessionList) → 최근 로그인(LoginEventTable)
// - 들어올 때마다 둘 다 새로 받는다(login-history.store). 묶음마다 받는 동안은 자리만, 못 받으면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도])
// - 끊긴 브라우저는 다음 요청에서 바로 로그인 화면으로 간다(서버가 끊은 세션 id 를 막는다 — 계약 5절)
import { computed } from 'vue'
import LinkedText from '@/components/LinkedText.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import LoginEventTable from '@/components/settings/LoginEventTable.vue'
import LoginSessionList from '@/components/settings/LoginSessionList.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { useClock } from '@/composables/useClock'
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { useLoginHistoryStore } from '@/stores/login-history.store'
const loginHistoryStore = useLoginHistoryStore()
const clock = useClock()
/** 화면을 연 때 — "오늘 · 어제" 의 기준 */
const now = clock.now()
const passwordRoute = { name: SETTINGS_ROUTES.password }
const twoFactorRoute = { name: SETTINGS_ROUTES.twoFactor }
const sessionsReady = computed(() => loginHistoryStore.sessionsStatus === 'ready')
const eventsReady = computed(() => loginHistoryStore.eventsStatus === 'ready')
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게. 보기는 「전체」부터
loginHistoryStore.loadPage()
</script>
<template>
<SettingsPageFrame
title-id="login-history-page-title"
:title="LOGIN_HISTORY_TEXT.title"
:description="LOGIN_HISTORY_TEXT.description"
>
<template #description>
<LinkedText
:before="LOGIN_HISTORY_TEXT.descriptionBefore"
:link="LOGIN_HISTORY_TEXT.descriptionPasswordLink"
:after="LOGIN_HISTORY_TEXT.descriptionMiddle"
:to="passwordRoute"
/><LinkedText
:link="LOGIN_HISTORY_TEXT.descriptionTwoFactorLink"
:after="LOGIN_HISTORY_TEXT.descriptionAfter"
:to="twoFactorRoute"
/>
</template>
<MailLoadError
v-if="loginHistoryStore.sessionsStatus === 'error' && loginHistoryStore.sessionsError"
:error="loginHistoryStore.sessionsError"
leave-label=""
data-sessions-error
@retry="loginHistoryStore.loadSessions()"
/>
<LoginSessionList
v-else-if="sessionsReady"
:sessions="loginHistoryStore.sessions"
:now="now"
/>
<div
v-else
class="login-history__placeholder"
aria-busy="true"
/>
<MailLoadError
v-if="loginHistoryStore.eventsStatus === 'error' && loginHistoryStore.eventsError"
:error="loginHistoryStore.eventsError"
leave-label=""
data-events-error
@retry="loginHistoryStore.loadEvents()"
/>
<LoginEventTable
v-else-if="eventsReady"
:now="now"
/>
<div
v-else
class="login-history__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
.login-history__placeholder {
min-height: 10rem;
}
</style>
@@ -40,8 +40,15 @@ describe('AccountPasswordPage', () => {
document.body.innerHTML = ''
})
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null)) {
requests = installMockApi((request) => (request.url === '/auth/password' ? reply(request) : ok(null)))
async function mountPage(
reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null),
twoFactor: () => MockResult = () => ok({ isEnabled: false, enabledAt: null }),
) {
requests = installMockApi((request) => {
if (request.url === '/auth/password') return reply(request)
if (request.url === '/two-factor') return twoFactor()
return ok(null)
})
const router = createTestRouter()
await router.push('/settings/account/password')
await router.isReady()
@@ -74,7 +81,18 @@ describe('AccountPasswordPage', () => {
expect(wrapper.get('#password-next-hint').text()).toBe(
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
)
expect(wrapper.get('[data-password-note]').text()).toContain('메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.')
// 모든 직원 — 다른 곳 로그인이 끊기고 앱 비밀번호도 모두 지워진다 (12단계)
expect(wrapper.get('[data-password-note]').text()).toContain(
'바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
)
expect(wrapper.get('[data-password-note]').text()).toContain(
'앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
)
// 2단계 인증이 꺼진 직원 — 머리 설명은 그대로, 코드 칸이 없다
expect(wrapper.get('.settings-page__description').text()).toBe(
'웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
)
expect(wrapper.find('#password-code').exists()).toBe(false)
const toggles = wrapper.findAll('.password-field__toggle')
expect(toggles.map((toggle) => toggle.attributes('aria-label'))).toEqual(['지금 비밀번호 보기', '새 비밀번호 보기'])
@@ -195,4 +213,96 @@ describe('AccountPasswordPage', () => {
])
expect(useAuthStore().isLoggedIn).toBe(true)
})
describe('2단계 인증을 켠 직원 (12단계 — 계약 5절 "비밀번호 바꾸기")', () => {
const ENABLED = () => ok({ isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' })
it('머리 설명이 바뀌고 양식 맨 아래에 「인증 앱 코드」 칸(숫자 · one-time-code · 안내)이 생긴다', async () => {
const wrapper = await mountPage(undefined, ENABLED)
expect(wrapper.get('.settings-page__description').text()).toBe(
'웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다.',
)
expect(wrapper.findAll('label').map((label) => label.text())).toEqual([
'지금 비밀번호',
'새 비밀번호',
'새 비밀번호 확인',
'인증 앱 코드',
])
expect(wrapper.get('#password-code').attributes()).toMatchObject({
inputmode: 'numeric',
autocomplete: 'one-time-code',
'aria-describedby': 'password-code-hint',
})
expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.')
})
it('코드는 숫자만 받는다 — 붙여 넣은 "123 456" · "123-456" 은 다듬고, 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007)', async () => {
const wrapper = await mountPage(undefined, ENABLED)
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
await wrapper.get('#password-code').setValue('12 3')
await submit(wrapper)
expect(passwordRequests()).toEqual([])
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('123')
expect(errorOf(wrapper, 'password-code').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(document.activeElement?.id).toBe('password-code')
await wrapper.get('#password-code').setValue('123-456')
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('123456')
expect(errorOf(wrapper, 'password-code').exists()).toBe(false)
await submit(wrapper)
expect(passwordRequests().map((request) => request.data)).toEqual([
{ currentPassword: CURRENT, newPassword: NEXT, otpCode: '123456' },
])
// 바꾸면 코드 칸도 비운다
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('')
})
it('코드가 틀리면(400 AUTH_006) 알림 없이 코드 칸 아래에 사전 문구 · 칸을 비우고 초점', async () => {
const wrapper = await mountPage(() => fail(400, 'AUTH_006'), ENABLED)
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
await wrapper.get('#password-code').setValue('654321')
await submit(wrapper)
expect(errorOf(wrapper, 'password-code').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('')
expect(document.activeElement?.id).toBe('password-code')
expect(useToastStore().toasts).toEqual([])
// 다른 칸은 남긴다
expect((wrapper.get('#password-current').element as HTMLInputElement).value).toBe(CURRENT)
})
it('다른 탭에서 켜서 코드 없이 보냈으면(400 AUTH_007) 2단계 상태를 다시 받고 코드 칸을 보인다 — 알림 없음', async () => {
let enabled = false
const wrapper = await mountPage(
() => fail(400, 'AUTH_007'),
() => ok({ isEnabled: enabled, enabledAt: enabled ? '2026-10-31T00:12:00.000Z' : null }),
)
expect(wrapper.find('#password-code').exists()).toBe(false)
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
enabled = true
await submit(wrapper)
await flushPromises()
expect(passwordRequests().map((request) => request.data)).toEqual([{ currentPassword: CURRENT, newPassword: NEXT }])
expect(requests.filter((request) => request.url === '/two-factor')).toHaveLength(2)
expect(wrapper.find('#password-code').exists()).toBe(true)
expect(errorOf(wrapper, 'password-code').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(document.activeElement?.id).toBe('password-code')
expect(useToastStore().toasts).toEqual([])
expect(wrapper.get('.settings-page__description').text()).toContain('2단계 인증이 켜져 있어')
})
it('2단계 상태를 받지 못해도 비밀번호는 바꿀 수 있다 — 코드 칸 없이 보낸다', async () => {
const wrapper = await mountPage(undefined, () => fail(503, 'MAIL_001'))
expect(useToastStore().toasts).toEqual([])
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(passwordRequests().map((request) => request.data)).toEqual([{ currentPassword: CURRENT, newPassword: NEXT }])
})
})
})
@@ -1,17 +1,32 @@
<script setup lang="ts">
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호") — /settings/account/password · 설정의 첫 화면
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣는 것
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호" · 12단계 SettingsTwoFactor "비밀번호 — 2단계 인증을 켠 직원")
// — /settings/account/password · 설정의 첫 화면
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 앱 비밀번호도 모두 지워지는 것(12단계 — 모든 직원)
// 2단계 인증 상태는 들어올 때마다 새로 받는다(two-factor.store) — 켠 직원이면 머리 설명이 바뀌고 양식 맨 아래에 「인증 앱 코드」 칸이 생긴다.
// 못 받아도 그대로 쓸 수 있다 — 켰는데 코드 없이 보내면 서버가 AUTH_007 로 알려 와 양식이 상태를 다시 받고 칸을 보인다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import PasswordChangeForm from '@/components/settings/PasswordChangeForm.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { PASSWORD_TEXT } from '@/shared/constants/account'
import { useTwoFactorStore } from '@/stores/two-factor.store'
const twoFactorStore = useTwoFactorStore()
const description = computed(() =>
twoFactorStore.isEnabled ? PASSWORD_TEXT.descriptionTwoFactor : PASSWORD_TEXT.description,
)
// 처음 그리기 전에 — 다른 탭에서 켰거나 껐을 수 있다
void twoFactorStore.load()
</script>
<template>
<SettingsPageFrame
title-id="password-page-title"
:title="PASSWORD_TEXT.title"
:description="PASSWORD_TEXT.description"
:description="description"
>
<PasswordChangeForm />
<div
@@ -24,7 +39,7 @@ import { PASSWORD_TEXT } from '@/shared/constants/account'
/>
<div class="password-note__text">
<span>{{ PASSWORD_TEXT.noteSessions }}</span>
<span>{{ PASSWORD_TEXT.noteImap }}</span>
<span>{{ PASSWORD_TEXT.noteAppPasswords }}</span>
</div>
</div>
</SettingsPageFrame>
@@ -0,0 +1,608 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { TWO_FACTOR_CHANGE_TIMEOUT_MS } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import scanSource from '@/components/settings/TwoFactorEnrollmentScan.vue?raw'
import AccountTwoFactorPage from './AccountTwoFactorPage.vue'
// 설정 › 계정 › 2단계 인증 (12단계 — 디자인 원본 SettingsTwoFactor · 계약 5절 "2단계 인증")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
const ON: TwoFactorStatus = { isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' }
const PASSWORD = 'old-pass-1234'
function enrollment(seq: number): TwoFactorEnrollment {
const secret = seq === 1 ? 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS' : 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'
return {
enrollmentId: `enroll-${seq}`,
secret,
otpauthUrl: `otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=${secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
expiresAt: '2026-10-31T00:22:00.000Z',
}
}
interface Server {
status?: () => MockResult
enroll?: (seq: number) => MockResult | Promise<MockResult>
activate?: (request: MockRequest) => MockResult | Promise<MockResult>
deactivate?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('AccountTwoFactorPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
Reflect.deleteProperty(navigator, 'clipboard')
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
let enrollSeq = 0
requests = installMockApi((request) => {
if (request.url === '/two-factor') return server.status ? server.status() : ok(OFF)
if (request.url === '/two-factor/enrollments') {
enrollSeq += 1
return server.enroll ? server.enroll(enrollSeq) : ok(enrollment(enrollSeq), 201)
}
if (request.url === '/two-factor/activation') return server.activate ? server.activate(request) : ok(ON)
if (request.url === '/two-factor/deactivation') return server.deactivate ? server.deactivate(request) : ok(OFF)
return ok(null)
})
const router = createTestRouter()
await router.push('/settings/account/two-factor')
await router.isReady()
const wrapper = mount(AccountTwoFactorPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const calls = (url: string) => requests.filter((request) => request.url === url)
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
const enableDialog = () => document.querySelector<HTMLDialogElement>('dialog[aria-labelledby="two-factor-enable-title"]')
const disableDialog = () => document.querySelector<HTMLDialogElement>('dialog[aria-labelledby="two-factor-disable-title"]')
async function openEnable(wrapper: VueWrapper) {
await wrapper.get('[data-two-factor-enable]').trigger('click')
await flushPromises()
}
async function fillEnable(wrapper: VueWrapper, values: { code?: string; password?: string }) {
if (values.code !== undefined) await wrapper.get('#two-factor-enable-code').setValue(values.code)
if (values.password !== undefined) await wrapper.get('#two-factor-enable-password').setValue(values.password)
}
async function submitEnable(wrapper: VueWrapper) {
await wrapper.get('[data-enable-submit]').trigger('click')
await flushPromises()
}
it('꺼짐 — 「꺼짐」(글자로만) · [2단계 인증 켜기] · 갈색 경고(앱 비밀번호 링크) · 켜는 차례 1 · 2 · 3', async () => {
const wrapper = await mountPage()
expect(calls('/two-factor').map((request) => request.method)).toEqual(['GET'])
expect(wrapper.get('#two-factor-page-title').text()).toBe('2단계 인증')
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
expect(wrapper.get('[data-two-factor-state]').text()).toContain('지금은 로그인할 때 비밀번호만 확인합니다.')
expect(wrapper.get('[data-two-factor-enable]').text()).toBe('2단계 인증 켜기')
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
const warning = wrapper.get('[data-two-factor-warning]')
expect(warning.text()).toContain('켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다')
expect(warning.get('a').text()).toBe('앱 비밀번호')
expect(warning.get('a').attributes('href')).toBe('/settings/account/app-passwords')
// 링크 앞뒤 글과 빈칸 없이 잇는다
expect(warning.text()).toContain('켜기 전에 앱 비밀번호를 만들어 두면 편합니다.')
expect(wrapper.findAll('.two-factor__step').map((step) => step.text().slice(0, 1))).toEqual(['1', '2', '3'])
expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false)
})
it('켜짐 — 「켜짐 — 2026년 10월 31일부터」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
const wrapper = await mountPage({ status: () => ok(ON) })
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
expect(wrapper.get('[data-two-factor-label]').classes()).toContain('two-factor__state-label--on')
expect(wrapper.get('[data-two-factor-disable]').text()).toBe('2단계 인증 끄기')
expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(false)
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('메일 프로그램은 앱 비밀번호로 연결합니다.')
const fromEngine = await mountPage({ status: () => ok({ isEnabled: true, enabledAt: null }) })
expect(fromEngine.get('[data-two-factor-label]').text()).toBe('켜짐')
})
it('상태를 받지 못하면 사전 문구와 [다시 시도] — 누르면 다시 받는다', async () => {
let reply: MockResult = fail(503, 'MAIL_001')
const wrapper = await mountPage({ status: () => reply })
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(toastMessages()).toEqual([])
reply = ok(OFF)
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
})
describe('켜기 창', () => {
it('열 때마다 새 키 — QR(SVG) · 4자씩 띄운 키 · 저장될 이름, 처음 초점은 코드 칸', async () => {
const wrapper = await mountPage()
await openEnable(wrapper)
expect(enableDialog()?.hasAttribute('open')).toBe(true)
expect(calls('/two-factor/enrollments').map((request) => request.method)).toEqual(['POST'])
const qr = wrapper.get('[data-qr-code]')
expect(qr.attributes()).toMatchObject({ role: 'img', 'aria-label': '인증 앱에 등록할 QR 코드' })
expect(qr.findAll('.qr-code__module').length).toBeGreaterThan(100)
expect(wrapper.findAll('.scan__key-group').map((group) => group.text())).toEqual([
'K7QF',
'2M4X',
'HB6R',
'TZ3N',
'PW5C',
'YL7D',
'JQ2V',
'MGAS',
])
// 띄어쓰기는 없다 — 골라 복사해도 · 읽어 줘도 키 그대로
expect(wrapper.get('[data-enable-key]').text()).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
expect(wrapper.get('[data-enable-key]').element.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
// 일부러 두 줄 — 한 줄에 4묶음
expect(
wrapper.findAll('.scan__key-line').map((line) => line.findAll('.scan__key-group').map((group) => group.text())),
).toEqual([
['K7QF', '2M4X', 'HB6R', 'TZ3N'],
['PW5C', 'YL7D', 'JQ2V', 'MGAS'],
])
expect(wrapper.text()).toContain('앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다.')
expect(document.activeElement?.id).toBe('two-factor-enable-code')
expect(wrapper.get('#two-factor-enable-code').attributes()).toMatchObject({
inputmode: 'numeric',
autocomplete: 'one-time-code',
})
})
it('키의 묶음은 가운데서 줄이 바뀌지 않는다 — 묶음 · 줄은 줄바꿈 없는 inline-block (block 이면 골라 복사한 키에 줄바꿈이 낀다)', () => {
const css = scanSource.slice(scanSource.indexOf('<style'))
const rule = (selector: string) => new RegExp(`${selector.replace(/[.-]/g, '\\$&')}\\s*\\{([^}]*)\\}`).exec(css)?.[1] ?? ''
expect(rule('.scan__key-group')).toMatch(/display:\s*inline-block/)
expect(rule('.scan__key-group')).toMatch(/white-space:\s*nowrap/)
expect(rule('.scan__key-line')).toMatch(/display:\s*inline-block/)
expect(rule('.scan__key-line')).toMatch(/width:\s*100%/)
// 글자 아무 데서나 끊는 규칙을 두지 않는다 (묶음 가운데서 "EL / WN" 으로 갈렸다)
expect(rule('.scan__key-value')).not.toMatch(/overflow-wrap|word-break/)
})
it('[복사]는 키를 띄어쓰기 없이 복사하고 「키를 복사했습니다.」 · 못 하면 직접 고르라고 알린다', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const wrapper = await mountPage()
await openEnable(wrapper)
await wrapper.get('[data-enable-copy]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
expect(toastMessages()).toEqual(['키를 복사했습니다.'])
writeText.mockRejectedValueOnce(new Error('막힘'))
await wrapper.get('[data-enable-copy]').trigger('click')
await flushPromises()
expect(useToastStore().toasts.at(-1)).toMatchObject({ kind: 'error', message: '키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요.' })
})
it('보내기 전에 본다 — 코드가 6자리가 아니면 코드 칸(AUTH_007 문구), 지금 비밀번호를 비우면 그 칸(VAL_001 문구)', async () => {
const wrapper = await mountPage()
await openEnable(wrapper)
await fillEnable(wrapper, { code: '12' })
await submitEnable(wrapper)
expect(calls('/two-factor/activation')).toEqual([])
expect(wrapper.get('#two-factor-enable-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(document.activeElement?.id).toBe('two-factor-enable-code')
await fillEnable(wrapper, { code: '482 913' })
await submitEnable(wrapper)
expect(calls('/two-factor/activation')).toEqual([])
expect(wrapper.find('#two-factor-enable-code-error').exists()).toBe(false)
expect(wrapper.get('#two-factor-enable-password-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(document.activeElement?.id).toBe('two-factor-enable-password')
})
it('켜면 POST /two-factor/activation { enrollmentId, otpCode, currentPassword } — 창을 닫고 「켜짐」, 머리 아래 "2단계 인증을 켰습니다" 로 초점', async () => {
const wrapper = await mountPage()
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await submitEnable(wrapper)
expect(calls('/two-factor/activation').map((request) => request.data)).toEqual([
{ enrollmentId: 'enroll-1', otpCode: '482913', currentPassword: PASSWORD },
])
// 서버가 엔진을 여러 번 부른다 — 한도는 보통(10초)보다 긴 30초
expect(calls('/two-factor/activation')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS)
expect(enableDialog()?.hasAttribute('open')).toBe(false)
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
const done = wrapper.get('[data-two-factor-done]')
expect(done.attributes('role')).toBe('status')
expect(done.text()).toContain('2단계 인증을 켰습니다')
expect(done.text()).toContain('메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.')
expect(done.get('a').text()).toBe('앱 비밀번호 만들기')
expect(done.get('a').attributes('href')).toBe('/settings/account/app-passwords')
expect(document.activeElement).toBe(done.element)
// 키는 창에서 지웠다
expect(wrapper.find('[data-enable-key]').text()).toBe('')
})
it('켜는 동안 「켜는 중…」(aria-busy) · [켜기]는 aria-disabled — 다시 눌러도 요청은 하나', async () => {
let release: (result: MockResult) => void = () => undefined
const wrapper = await mountPage({
activate: () =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
})
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await submitEnable(wrapper)
const submit = wrapper.get('[data-enable-submit]')
expect(submit.text()).toBe('켜는 중…')
expect(submit.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
await submitEnable(wrapper)
expect(calls('/two-factor/activation')).toHaveLength(1)
release(ok(ON))
await flushPromises()
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
})
it('코드가 틀리면(AUTH_006) 코드 칸 아래 · 칸을 비우고 초점 — QR · 키는 그대로 (새 키를 받지 않는다)', async () => {
const wrapper = await mountPage({ activate: () => fail(400, 'AUTH_006') })
await openEnable(wrapper)
await fillEnable(wrapper, { code: '000111', password: PASSWORD })
await submitEnable(wrapper)
expect(wrapper.get('#two-factor-enable-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('')
expect(document.activeElement?.id).toBe('two-factor-enable-code')
expect(wrapper.get('[data-enable-key]').text()).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
expect(calls('/two-factor/enrollments')).toHaveLength(1)
expect(toastMessages()).toEqual([])
})
it('지금 비밀번호가 틀리면(AUTH_005) 비밀번호 칸 아래 · 그 칸에 초점 — 코드는 남긴다', async () => {
const wrapper = await mountPage({ activate: () => fail(400, 'AUTH_005') })
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: 'wrong-pass' })
await submitEnable(wrapper)
expect(wrapper.get('#two-factor-enable-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
expect(document.activeElement?.id).toBe('two-factor-enable-password')
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('482913')
expect(toastMessages()).toEqual([])
})
it('AUTH_008(10분 지남 · 더 새 등록) — 처음부터 다시: 창 위에 사전 문구, 새 키로 다시 그리고 코드는 비운다', async () => {
const wrapper = await mountPage({ activate: () => fail(409, 'AUTH_008') })
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await submitEnable(wrapper)
await flushPromises()
expect(calls('/two-factor').length).toBe(2)
expect(calls('/two-factor/enrollments')).toHaveLength(2)
expect(wrapper.get('[data-enable-notice]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
expect(wrapper.get('[data-enable-key]').text()).toBe('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP')
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('')
expect(enableDialog()?.hasAttribute('open')).toBe(true)
expect(toastMessages()).toEqual([])
})
it('AUTH_008 인데 그사이 다른 곳에서 켜졌으면 — 창을 닫고 사전 문구로 알린다, 화면은 「켜짐」', async () => {
let enabledElsewhere = false
const wrapper = await mountPage({
status: () => ok(enabledElsewhere ? ON : OFF),
activate: () => {
enabledElsewhere = true
return fail(409, 'AUTH_008')
},
})
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await submitEnable(wrapper)
await flushPromises()
expect(enableDialog()?.hasAttribute('open')).toBe(false)
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_008])
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
expect(calls('/two-factor/enrollments')).toHaveLength(1)
})
it('키를 받지 못하면 창 안에 사전 문구와 [다시 시도] — [켜기]는 막는다', async () => {
let reply: MockResult = fail(503, 'MAIL_001')
const wrapper = await mountPage({ enroll: () => reply })
await openEnable(wrapper)
expect(wrapper.get('[data-enable-error]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(wrapper.get('[data-enable-submit]').attributes('aria-disabled')).toBe('true')
expect(toastMessages()).toEqual([])
reply = ok(enrollment(1), 201)
await wrapper.get('.scan__retry').trigger('click')
await flushPromises()
expect(wrapper.find('[data-enable-error]').exists()).toBe(false)
expect(wrapper.find('[data-qr-code]').exists()).toBe(true)
expect(wrapper.get('[data-enable-submit]').attributes('aria-disabled')).toBeUndefined()
})
it('코드 · 비밀번호 칸에서 Enter 도 [켜기]와 같다', async () => {
const wrapper = await mountPage()
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await wrapper.get('#two-factor-enable-password').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(calls('/two-factor/activation')).toHaveLength(1)
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
})
it('닫았다 다시 연 창에는 앞 요청의 거절을 넣지 않는다 — 「켜는 중」도 처음으로, 처음부터 다시(AUTH_008)도 하지 않는다', async () => {
const replies: ((result: MockResult) => void)[] = []
const wrapper = await mountPage({
activate: () =>
new Promise<MockResult>((resolve) => {
replies.push(resolve)
}),
})
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await submitEnable(wrapper)
await wrapper.get('[data-enable-cancel]').trigger('click')
await flushPromises()
await openEnable(wrapper)
expect(wrapper.get('[data-enable-submit]').text()).toBe('켜기')
expect(wrapper.get('[data-enable-submit]').attributes('aria-busy')).toBeUndefined()
replies[0]?.(fail(400, 'AUTH_006'))
await flushPromises()
expect(wrapper.find('#two-factor-enable-code-error').exists()).toBe(false)
expect(wrapper.get('[data-enable-submit]').text()).toBe('켜기')
expect(toastMessages()).toEqual([])
// 다시 연 창에서 보낸 요청이 AUTH_008 이어도 — 그 창의 요청이므로 처음부터 다시 한다(앞 요청과 섞이지 않는다)
await fillEnable(wrapper, { code: '135790', password: PASSWORD })
await submitEnable(wrapper)
expect(calls('/two-factor/activation').map((request) => (request.data as { enrollmentId: string }).enrollmentId)).toEqual([
'enroll-1',
'enroll-2',
])
replies[1]?.(fail(409, 'AUTH_008'))
await flushPromises()
await flushPromises()
expect(calls('/two-factor/enrollments')).toHaveLength(3)
expect(wrapper.get('[data-enable-notice]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
})
it('닫힌 뒤(다시 열지 않음) 온 거절은 칸 대신 알림으로 · 서버가 켰으면 창을 닫았어도 화면은 「켜짐」', async () => {
const replies: ((result: MockResult) => void)[] = []
const wrapper = await mountPage({
activate: () =>
new Promise<MockResult>((resolve) => {
replies.push(resolve)
}),
})
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: 'wrong-pass' })
await submitEnable(wrapper)
await wrapper.get('[data-enable-cancel]').trigger('click')
await flushPromises()
replies[0]?.(fail(400, 'AUTH_005'))
await flushPromises()
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_005])
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await submitEnable(wrapper)
await wrapper.get('[data-enable-cancel]').trigger('click')
await flushPromises()
replies[1]?.(ok(ON))
await flushPromises()
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
expect(wrapper.find('[data-two-factor-done]').exists()).toBe(true)
})
it('켜지 않고 닫으면 키를 버린다 — 다시 열면 새 키를 받고, 닫은 뒤 늦게 온 키는 그리지 않는다', async () => {
let releaseFirst: (result: MockResult) => void = () => undefined
const wrapper = await mountPage({
enroll: (seq) =>
seq === 1
? new Promise<MockResult>((resolve) => {
releaseFirst = resolve
})
: ok(enrollment(seq), 201),
})
await openEnable(wrapper)
await wrapper.get('[data-enable-cancel]').trigger('click')
await flushPromises()
expect(enableDialog()?.hasAttribute('open')).toBe(false)
releaseFirst(ok(enrollment(1), 201))
await flushPromises()
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
expect(wrapper.get('[data-enable-key]').text()).toBe('')
await openEnable(wrapper)
expect(calls('/two-factor/enrollments')).toHaveLength(2)
expect(wrapper.get('[data-enable-key]').text()).toBe('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP')
})
})
describe('끄기 창', () => {
async function openDisable(wrapper: VueWrapper) {
await wrapper.get('[data-two-factor-disable]').trigger('click')
await flushPromises()
}
it('alertdialog — "2단계 인증을 끌까요?" · 지금 비밀번호 · 인증 앱 코드, 처음 초점은 「취소」', async () => {
const wrapper = await mountPage({ status: () => ok(ON) })
await openDisable(wrapper)
const dialog = disableDialog()
expect(dialog?.hasAttribute('open')).toBe(true)
expect(dialog?.getAttribute('role')).toBe('alertdialog')
expect(dialog?.textContent).toContain('끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다.')
expect(document.activeElement?.hasAttribute('data-disable-cancel')).toBe(true)
expect(wrapper.get('label[for="two-factor-disable-password"]').text()).toBe('지금 비밀번호')
expect(wrapper.get('label[for="two-factor-disable-code"]').text()).toBe('인증 앱 코드')
})
it('비밀번호부터 본다 — 둘 다 비었으면 비밀번호 칸에 초점 · 틀리면(AUTH_005) 그 칸 아래', async () => {
const wrapper = await mountPage({ status: () => ok(ON), deactivate: () => fail(400, 'AUTH_005') })
await openDisable(wrapper)
await wrapper.get('[data-disable-submit]').trigger('click')
await flushPromises()
expect(calls('/two-factor/deactivation')).toEqual([])
expect(document.activeElement?.id).toBe('two-factor-disable-password')
await wrapper.get('#two-factor-disable-password').setValue('wrong-pass')
await wrapper.get('#two-factor-disable-code').setValue('482913')
await wrapper.get('[data-disable-submit]').trigger('click')
await flushPromises()
expect(wrapper.get('#two-factor-disable-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
expect(toastMessages()).toEqual([])
})
it('코드가 틀리면(AUTH_006) 코드 칸 아래 · 칸을 비운다', async () => {
const wrapper = await mountPage({ status: () => ok(ON), deactivate: () => fail(400, 'AUTH_006') })
await openDisable(wrapper)
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
await wrapper.get('#two-factor-disable-code').setValue('000111')
await wrapper.get('[data-disable-submit]').trigger('click')
await flushPromises()
expect(wrapper.get('#two-factor-disable-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect((wrapper.get('#two-factor-disable-code').element as HTMLInputElement).value).toBe('')
expect(document.activeElement?.id).toBe('two-factor-disable-code')
})
it('두 칸에서 Enter 도 [끄기]와 같다 · 닫았다 다시 연 창에는 앞 요청의 거절을 넣지 않는다', async () => {
const replies: ((result: MockResult) => void)[] = []
const wrapper = await mountPage({
status: () => ok(ON),
deactivate: () =>
new Promise<MockResult>((resolve) => {
replies.push(resolve)
}),
})
await openDisable(wrapper)
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
await wrapper.get('#two-factor-disable-code').setValue('482913')
await wrapper.get('#two-factor-disable-code').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(calls('/two-factor/deactivation')).toHaveLength(1)
await wrapper.get('[data-disable-cancel]').trigger('click')
await flushPromises()
await openDisable(wrapper)
expect(wrapper.get('[data-disable-submit]').text()).toBe('끄기')
replies[0]?.(fail(400, 'AUTH_006'))
await flushPromises()
expect(wrapper.find('#two-factor-disable-code-error').exists()).toBe(false)
expect(toastMessages()).toEqual([])
expect(wrapper.get('[data-disable-submit]').text()).toBe('끄기')
})
it('끄면 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」, 초점은 [2단계 인증 켜기]', async () => {
const wrapper = await mountPage({ status: () => ok(ON) })
await openDisable(wrapper)
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
await wrapper.get('#two-factor-disable-code').setValue('482913')
await wrapper.get('[data-disable-submit]').trigger('click')
await flushPromises()
expect(calls('/two-factor/deactivation').map((request) => request.data)).toEqual([
{ currentPassword: PASSWORD, otpCode: '482913' },
])
expect(disableDialog()?.hasAttribute('open')).toBe(false)
expect(toastMessages()).toEqual(['2단계 인증을 껐습니다.'])
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
expect(document.activeElement?.hasAttribute('data-two-factor-enable')).toBe(true)
})
})
it('세션이 바뀌면(다른 곳에서 이 세션을 끊음 → 세션 만료) 켜기 창을 닫고 QR · 키 · 입력한 비밀번호를 지운다', async () => {
const wrapper = await mountPage()
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
expect(wrapper.find('[data-qr-code]').exists()).toBe(true)
useAuthStore().expireSession()
await flushPromises()
expect(enableDialog()?.hasAttribute('open')).toBe(false)
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
expect(wrapper.get('[data-enable-key]').text()).toBe('')
expect((wrapper.get('#two-factor-enable-password').element as HTMLInputElement).value).toBe('')
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('')
})
it('세션이 바뀌면 끄기 창도 닫고 입력한 비밀번호 · 코드를 지운다', async () => {
const wrapper = await mountPage({ status: () => ok(ON) })
await wrapper.get('[data-two-factor-disable]').trigger('click')
await flushPromises()
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
await wrapper.get('#two-factor-disable-code').setValue('482913')
useAuthStore().expireSession()
await flushPromises()
expect(disableDialog()?.hasAttribute('open')).toBe(false)
expect((wrapper.get('#two-factor-disable-password').element as HTMLInputElement).value).toBe('')
expect((wrapper.get('#two-factor-disable-code').element as HTMLInputElement).value).toBe('')
})
it('켜는 사이 세션이 바뀌었으면(로그아웃) 늦게 온 결과를 쓰지 않는다', async () => {
let release: (result: MockResult) => void = () => undefined
const wrapper = await mountPage({
activate: () =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
})
await openEnable(wrapper)
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
await submitEnable(wrapper)
useAuthStore().clearAuth()
release(ok(ON))
await flushPromises()
expect(wrapper.find('[data-two-factor-done]').exists()).toBe(false)
// 다음 사람의 상태가 되지 않게 스토어에도 쓰지 않는다
expect(useTwoFactorStore().twoFactor).toBeNull()
})
})
@@ -0,0 +1,459 @@
<script setup lang="ts">
// 설정 › 계정 › 2단계 인증 (디자인 원본 SettingsTwoFactor · README "12단계 › 설정 › 계정 › 2단계 인증" · 계약 5절 "2단계 인증")
// — /settings/account/two-factor · 탭 제목 "2단계 인증"
// 머리 + 설명 → 상태 상자(글자로만 — 뱃지 · 스위치 없음): 「꺼짐」(회색 굵게) + [2단계 인증 켜기] / 「켜짐 — 2026년 10월 31일부터」(초록 굵게 — 켠 때를 모르면 「켜짐」) + [2단계 인증 끄기]
// - 꺼져 있을 때: 갈색 경고(켜면 메일 프로그램은 앱 비밀번호로 — 앱 비밀번호 링크) + 「켜는 차례」 1 · 2 · 3(청록 굵은 숫자)
// - 켜져 있을 때: 안내 상자(휴대폰 바꾸기 · 잃어버림 · 메일 프로그램은 앱 비밀번호로)
// - 켜기 창(TwoFactorEnableDialog — 620) · 끄기 창(TwoFactorDisableDialog — 400, 처음 초점 「취소」)
// - 켠 직후: 머리 아래 안내 상자 "2단계 인증을 켰습니다" + [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다(화면 상태). 초점은 그 상자로
// - 끈 뒤: 알림 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」, 초점은 [2단계 인증 켜기]로
// - 들어올 때마다 상태를 새로 받는다(two-factor.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃]으로 이 세션이 끊김 · 로그아웃 · 만료) 창을 모두 닫는다 — 세션 만료 창 뒤에 QR · 키 · 입력한 비밀번호가 남지 않게
// (창은 닫힐 때 키 · 칸을 지운다)
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LinkedText from '@/components/LinkedText.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import TwoFactorDisableDialog from '@/components/settings/TwoFactorDisableDialog.vue'
import TwoFactorEnableDialog from '@/components/settings/TwoFactorEnableDialog.vue'
import { useToast } from '@/composables/useToast'
import { TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { twoFactorStateLabel } from '@/shared/utils/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const toast = useToast()
const enableRef = ref<HTMLButtonElement | null>(null)
const noticeRef = ref<HTMLElement | null>(null)
const enableOpen = ref<boolean>(false)
const disableOpen = ref<boolean>(false)
/** 이 화면에서 방금 켰다 — 머리 아래 안내 상자 (떠나면 사라진다) */
const justEnabled = ref<boolean>(false)
const appPasswordsRoute = { name: SETTINGS_ROUTES.appPasswords }
const ready = computed(() => twoFactorStore.status === 'ready' && twoFactorStore.twoFactor !== null)
const enabled = computed(() => twoFactorStore.isEnabled)
const stateLabel = computed(() => (twoFactorStore.twoFactor ? twoFactorStateLabel(twoFactorStore.twoFactor) : ''))
const email = computed(() => authStore.user?.email ?? '')
async function onEnabled() {
enableOpen.value = false
justEnabled.value = true
await nextTick()
noticeRef.value?.focus()
}
async function onDisabled() {
disableOpen.value = false
justEnabled.value = false
toast.success(TWO_FACTOR_TEXT.disabled)
await nextTick()
enableRef.value?.focus()
}
// 세션이 바뀌면 창을 모두 닫는다 (켠 직후 안내도 내린다)
watch(
() => authStore.generation,
() => {
enableOpen.value = false
disableOpen.value = false
justEnabled.value = false
},
)
// 처음 그리기 전에 — 다른 탭 · 엔진 관리 화면에서 바꿨을 수 있다
void twoFactorStore.load()
</script>
<template>
<SettingsPageFrame
title-id="two-factor-page-title"
:title="TWO_FACTOR_TEXT.title"
:description="TWO_FACTOR_TEXT.description"
>
<MailLoadError
v-if="twoFactorStore.status === 'error' && twoFactorStore.error"
:error="twoFactorStore.error"
leave-label=""
@retry="twoFactorStore.load()"
/>
<template v-else-if="ready">
<div
v-if="justEnabled && enabled"
ref="noticeRef"
class="two-factor__done"
role="status"
tabindex="-1"
data-two-factor-done
>
<AppIcon
name="check"
:stroke-width="2.2"
class="two-factor__done-icon"
/>
<div class="two-factor__done-body">
<div class="two-factor__done-text">
<span class="two-factor__done-title">{{ TWO_FACTOR_TEXT.enabledTitle }}</span>
<span>{{ TWO_FACTOR_TEXT.enabledText }}</span>
</div>
<RouterLink
:to="appPasswordsRoute"
class="two-factor__done-action"
>
{{ TWO_FACTOR_TEXT.enabledAction }}
</RouterLink>
</div>
</div>
<div
class="two-factor__state"
data-two-factor-state
>
<div class="two-factor__state-text">
<span
:class="['two-factor__state-label', { 'two-factor__state-label--on': enabled }]"
data-two-factor-label
>{{ stateLabel }}</span>
<span class="two-factor__state-hint">{{ enabled ? TWO_FACTOR_TEXT.onHint : TWO_FACTOR_TEXT.offHint }}</span>
</div>
<button
v-if="!enabled"
ref="enableRef"
type="button"
class="two-factor__enable"
data-two-factor-enable
@click="enableOpen = true"
>
<AppIcon
name="shield-check"
:size="15"
/>
<span>{{ TWO_FACTOR_TEXT.enable }}</span>
</button>
<button
v-else
type="button"
class="two-factor__disable"
data-two-factor-disable
@click="disableOpen = true"
>
{{ TWO_FACTOR_TEXT.disable }}
</button>
</div>
<template v-if="!enabled">
<div
class="two-factor__warning"
data-two-factor-warning
>
<AppIcon
name="alert-circle"
:stroke-width="1.9"
class="two-factor__warning-icon"
/>
<div class="two-factor__warning-text">
<span class="two-factor__warning-title">{{ TWO_FACTOR_TEXT.warningTitle }}</span>
<LinkedText
:before="TWO_FACTOR_TEXT.warningBefore"
:link="TWO_FACTOR_TEXT.warningLink"
:after="TWO_FACTOR_TEXT.warningAfter"
:to="appPasswordsRoute"
/>
</div>
</div>
<section
class="two-factor__steps"
aria-labelledby="two-factor-steps-title"
>
<h2
id="two-factor-steps-title"
class="two-factor__steps-title"
>
{{ TWO_FACTOR_TEXT.stepsTitle }}
</h2>
<ol class="two-factor__step-list">
<li
v-for="(step, index) in TWO_FACTOR_TEXT.steps"
:key="step"
class="two-factor__step"
>
<span
class="two-factor__step-number"
aria-hidden="true"
>{{ index + 1 }}</span>
<span>{{ step }}</span>
</li>
</ol>
</section>
</template>
<div
v-else
class="two-factor__notes"
data-two-factor-notes
>
<p class="two-factor__note">
<span aria-hidden="true">·</span>
<span>{{ TWO_FACTOR_TEXT.noteChangePhone }}</span>
</p>
<p class="two-factor__note">
<span aria-hidden="true">·</span>
<span>{{ TWO_FACTOR_TEXT.noteLostPhone }}</span>
</p>
<p class="two-factor__note">
<span aria-hidden="true">·</span>
<LinkedText
:before="TWO_FACTOR_TEXT.noteMailClientBefore"
:link="TWO_FACTOR_TEXT.noteMailClientLink"
:after="TWO_FACTOR_TEXT.noteMailClientAfter"
:to="appPasswordsRoute"
/>
</p>
</div>
</template>
<div
v-else
class="two-factor__placeholder"
aria-busy="true"
/>
<!-- 창은 상태 묶음 밖에 둔다 — 창 안에서 상태를 다시 받는 동안에도 사라지지 않게 -->
<TwoFactorEnableDialog
:open="enableOpen"
:email="email"
@close="enableOpen = false"
@enabled="onEnabled"
/>
<TwoFactorDisableDialog
:open="disableOpen"
@close="disableOpen = false"
@disabled="onDisabled"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* 켠 직후 안내 상자 — 바탕 #F3F8F8 · 초록 체크 · 제목 초록 굵게 · [앱 비밀번호 만들기] 36 (최대 700) */
.two-factor__done {
display: flex;
align-items: flex-start;
gap: var(--space-10);
max-width: 43.75rem;
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.two-factor__done:focus {
outline: none;
}
.two-factor__done:focus-visible {
outline: var(--focus-ring-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
}
.two-factor__done-icon {
margin-top: var(--space-2);
color: var(--color-success-text);
}
.two-factor__done-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.two-factor__done-text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.two-factor__done-title {
font-weight: 600;
color: var(--color-success-text);
}
/* 36 — 밀도 예외 (--size-notice-action, 차단 풀기와 같다) */
.two-factor__done-action {
align-self: flex-start;
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
text-decoration: none;
}
.two-factor__done-action:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 상태 상자 — 테두리 · 안쪽 16 18 (최대 700) */
.two-factor__state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12) var(--space-20);
max-width: 43.75rem;
padding: var(--space-16) var(--space-18);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
}
.two-factor__state-text {
flex: 1 1 18.75rem;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.two-factor__state-label {
font-size: var(--font-size-body);
font-weight: 700;
color: var(--color-text-secondary);
}
.two-factor__state-label--on {
color: var(--color-success-text);
}
.two-factor__state-hint {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.two-factor__enable {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-18);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.two-factor__enable:hover {
background: var(--color-brand-strong);
}
/* 끄기 — 흰 바탕 · 빨간 글 · 테두리 */
.two-factor__disable {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-danger);
font-weight: 600;
}
.two-factor__disable:hover {
background: var(--color-danger-surface);
}
/* 꺼져 있을 때의 갈색 경고 (최대 700) */
.two-factor__warning {
display: flex;
align-items: flex-start;
gap: var(--space-10);
max-width: 43.75rem;
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
}
.two-factor__warning-icon {
margin-top: var(--space-2);
}
.two-factor__warning-text {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.two-factor__warning-title {
font-weight: 600;
}
.two-factor__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.two-factor__steps-title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.two-factor__step-list {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 43.75rem;
margin: 0;
padding: 0;
list-style: none;
}
/* 청록 굵은 숫자 — 동그라미 없음 (숫자 칸 20) */
.two-factor__step {
display: grid;
grid-template-columns: var(--space-20) minmax(0, 1fr);
gap: var(--space-6);
}
.two-factor__step-number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
/* 켜져 있을 때의 안내 상자 — 바탕 #F3F8F8 (최대 700) */
.two-factor__notes {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 43.75rem;
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-lg);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.two-factor__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
.two-factor__placeholder {
min-height: 16rem;
}
</style>
@@ -9,8 +9,8 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부")
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -35,7 +35,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -43,7 +43,12 @@ describe('SettingsLayout', () => {
const lists = menu.findAll('ul')
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
[['비밀번호', '/settings/account/password']],
[
['비밀번호', '/settings/account/password'],
['2단계 인증', '/settings/account/two-factor'],
['앱 비밀번호', '/settings/account/app-passwords'],
['로그인 기록', '/settings/account/login-history'],
],
[
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
@@ -106,6 +111,39 @@ describe('SettingsLayout', () => {
expect(router.currentRoute.value.meta).toMatchObject({ title: '대용량 첨부', requiresAuth: true })
})
it('설정 › 계정 › 2단계 인증 · 앱 비밀번호 · 로그인 기록 (12단계) — 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/account/two-factor')
expect(router.currentRoute.value.name).toBe('settings-account-two-factor')
expect(router.currentRoute.value.meta).toMatchObject({ title: '2단계 인증', requiresAuth: true })
await router.push('/settings/account/app-passwords')
expect(router.currentRoute.value.name).toBe('settings-account-app-passwords')
expect(router.currentRoute.value.meta).toMatchObject({ title: '앱 비밀번호', requiresAuth: true })
await router.push('/settings/account/login-history')
expect(router.currentRoute.value.name).toBe('settings-account-login-history')
expect(router.currentRoute.value.meta).toMatchObject({ title: '로그인 기록', requiresAuth: true })
})
it('계정 보안 메뉴의 아이콘 — 방패(체크) · 열쇠 · 시계(되감기), 지금 화면이면 현재로 표시한다', async () => {
const twoFactor = await mountAt('/settings/account/two-factor')
const shield = twoFactor.wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(shield.text()).toBe('2단계 인증')
expect(shield.find('polyline').attributes('points')).toBe('9 12 11 14 15 10')
const appPasswords = await mountAt('/settings/account/app-passwords')
const key = appPasswords.wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(key.text()).toBe('앱 비밀번호')
expect(key.findAll('path')).toHaveLength(1)
const history = await mountAt('/settings/account/login-history')
const clock = history.wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(clock.text()).toBe('로그인 기록')
expect(clock.findAll('path').map((shape) => shape.attributes('d'))).toContain('M3 3v5h5')
})
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/large-files')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')