generated from ttipo/project-base
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
Reference in New Issue
Block a user