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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:37:14 +09:00
parent 452d9fef1d
commit 1881ec7a42
62 changed files with 4773 additions and 100 deletions
@@ -0,0 +1,619 @@
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 { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { RESTRICTED_SECRET_WINDOW_MS } from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
import type { SessionRestriction } from '@/shared/types/auth'
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'
import LoginPage from './LoginPage.vue'
// 로그인 — 제한된 세션의 단계 (13단계 — 계약 6절 "제한된 세션" · 디자인 원본 LoginPasswordChange · LoginTwoFactorSetup · README "13단계")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const TEMPORARY = 'Temp-2026!x'
const NEW_PASSWORD = 'Ara!2026-new'
const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
const ON: TwoFactorStatus = { isEnabled: true, enabledAt: '2026-11-02T01:00:00.000Z' }
function session(restriction: SessionRestriction | null) {
return { ...USER, restriction, twoFactorDeadline: null }
}
function enrollment(seq: number): TwoFactorEnrollment {
const secret = seq === 1 ? 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS' : 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'
return {
enrollmentId: `enroll-${seq}`,
secret,
otpauthUrl: `otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.test?secret=${secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
expiresAt: '2026-11-02T01:10:00.000Z',
}
}
interface Server {
/** 첫 로그인 응답 — 기본은 새 비밀번호 단계가 붙은 로그인 */
login?: (request: MockRequest) => MockResult | Promise<MockResult>
password?: (request: MockRequest) => MockResult | Promise<MockResult>
/** n 번째 갱신 — 기본은 제한 없음 */
refresh?: (count: number) => MockResult | Promise<MockResult>
twoFactor?: () => MockResult
enroll?: (seq: number) => MockResult | Promise<MockResult>
activate?: (request: MockRequest) => MockResult | Promise<MockResult>
supportContact?: () => MockResult
/** n 번째 me — 기본은 제한 없음 */
me?: (count: number) => MockResult | Promise<MockResult>
}
describe('LoginPage — 제한된 세션 (13단계)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
clock = createManualClock(Date.UTC(2026, 10, 2, 1, 0, 0))
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function install(server: Server = {}) {
let refreshCount = 0
let meCount = 0
let enrollSeq = 0
requests = installMockApi((request) => {
switch (request.url) {
case '/support-contact':
return server.supportContact ? server.supportContact() : ok({ text: null })
case '/auth/login':
return server.login ? server.login(request) : ok({ status: 'signedIn', ...session('passwordChange') })
case '/auth/password':
return server.password ? server.password(request) : ok(null)
case '/auth/refresh':
refreshCount += 1
return server.refresh ? server.refresh(refreshCount) : ok(session(null))
case '/auth/logout':
return ok(null)
case '/auth/me':
meCount += 1
return server.me ? server.me(meCount) : ok(session(null))
case '/two-factor':
return server.twoFactor ? server.twoFactor() : ok(OFF)
case '/two-factor/enrollments':
enrollSeq += 1
return server.enroll ? server.enroll(enrollSeq) : ok(enrollment(enrollSeq), 201)
case '/two-factor/activation':
return server.activate ? server.activate(request) : ok(ON)
default:
return ok(null)
}
})
}
async function mountLogin(server: Server = {}, path = '/login?redirect=/mail/inbox') {
install(server)
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 flushPromises()
return { wrapper, router }
}
async function signIn(wrapper: VueWrapper, password = TEMPORARY) {
await wrapper.get('#login-email').setValue(USER.email)
await wrapper.get('#login-password').setValue(password)
await wrapper.get('form').trigger('submit')
await flushPromises()
}
async function submitStep(wrapper: VueWrapper) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
const urls = () => requests.map((request) => `${request.method} ${request.url}`)
const bodyOf = (url: string) => requests.filter((request) => request.url === url).map((request) => request.data)
const inputValue = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).value
describe('새 비밀번호 정하기 (restriction: passwordChange)', () => {
it('로그인이 제한과 함께 오면 같은 틀에서 새 비밀번호 단계를 그린다 — 임시 비밀번호는 묻지 않고(화면 메모리) 초점은 새 비밀번호', async () => {
const { wrapper, router } = await mountLogin()
await signIn(wrapper)
const authStore = useAuthStore()
expect(authStore.isLoggedIn).toBe(true)
expect(authStore.restriction).toBe('passwordChange')
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.get('h1').text()).toBe('새 비밀번호를 정해 주세요')
expect(wrapper.text()).toContain('관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.')
expect(wrapper.get('[data-account-email]').text()).toBe(USER.email)
expect(wrapper.find('#restricted-password-temporary').exists()).toBe(false)
expect(wrapper.find('#restricted-password-code').exists()).toBe(false)
expect(wrapper.get('label[for="restricted-password-next"]').text()).toBe('새 비밀번호')
expect(wrapper.text()).toContain('8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.')
expect(wrapper.get('[data-restricted-submit]').text()).toBe('비밀번호 바꾸고 계속')
expect(wrapper.text()).toContain('메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.')
expect(document.activeElement?.id).toBe('restricted-password-next')
// 비밀번호는 칸 · 반응형 상태에 남기지 않는다 — 로그인 칸은 사라졌다
expect(wrapper.find('#login-password').exists()).toBe(false)
})
it('규칙(8자 이상 · 임시 비밀번호와 다름)에 맞지 않으면 보내지 않고 안내 글이 빨간 오류 · 확인 칸이 다르면 확인 칸 아래', async () => {
const { wrapper } = await mountLogin()
await signIn(wrapper)
await wrapper.get('#restricted-password-next').setValue(TEMPORARY)
await wrapper.get('#restricted-password-confirm').setValue(TEMPORARY)
await submitStep(wrapper)
expect(wrapper.get('#restricted-password-next').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#restricted-password-next-hint').classes()).toContain('form-message--error')
expect(document.activeElement?.id).toBe('restricted-password-next')
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue('different-1')
await submitStep(wrapper)
expect(wrapper.get('#restricted-password-confirm-error').text()).toBe('새 비밀번호가 서로 다릅니다.')
expect(document.activeElement?.id).toBe('restricted-password-confirm')
expect(bodyOf('/auth/password')).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('바꾸면 로그인 때 넣은 비밀번호를 지금 비밀번호로 보내고(30초 한도) 갱신한 뒤 가려던 주소로 — 알림 「새 비밀번호를 정했습니다.」', async () => {
const { wrapper, router } = await mountLogin()
await signIn(wrapper)
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
expect(bodyOf('/auth/password')).toEqual([{ currentPassword: TEMPORARY, newPassword: NEW_PASSWORD }])
expect(requests.find((request) => request.url === '/auth/password')?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS)
expect(urls().slice(-2)).toEqual(['PUT /auth/password', 'POST /auth/refresh'])
expect(useAuthStore().restriction).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['새 비밀번호를 정했습니다.'])
})
it('바꾸는 동안 「바꾸는 중…」(aria-busy · aria-disabled) · 칸은 읽기만 · [다른 계정으로 로그인]도 막힌다 — 다시 눌러도 요청은 하나', async () => {
let release: (result: MockResult) => void = () => undefined
const { wrapper } = await mountLogin({
password: () =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
})
await signIn(wrapper)
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
const button = wrapper.get('[data-restricted-submit]')
expect(button.text()).toBe('바꾸는 중…')
expect(button.attributes('aria-busy')).toBe('true')
expect(button.attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#restricted-password-next').attributes('readonly')).toBeDefined()
expect(wrapper.get('[data-account-other]').attributes('aria-disabled')).toBe('true')
await submitStep(wrapper)
expect(bodyOf('/auth/password')).toHaveLength(1)
release(fail(429, 'REQ_001'))
await flushPromises()
// 시도 과다 — 위쪽 빨간 상자, 칸 · 단추는 그대로 (알림 없음)
expect(wrapper.get('#restricted-password-alert').text()).toBe(ERROR_CODE_MESSAGES.REQ_001)
expect(button.text()).toBe('비밀번호 바꾸고 계속')
expect(inputValue(wrapper, '#restricted-password-next')).toBe(NEW_PASSWORD)
expect(useToastStore().toasts).toEqual([])
})
it('엔진 규칙으로 거절되면(VAL_001) 새 비밀번호 칸 아래에 사전 문구 — 알림 없음', async () => {
const { wrapper } = await mountLogin({ password: () => fail(400, 'VAL_001') })
await signIn(wrapper)
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
expect(wrapper.get('#restricted-password-next-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
})
it('메모리의 임시 비밀번호가 틀렸다면(AUTH_005) 지우고 「임시 비밀번호」 칸을 그 오류와 함께 보인다', async () => {
const { wrapper } = await mountLogin({ password: () => fail(400, 'AUTH_005') })
await signIn(wrapper)
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
expect(wrapper.get('label[for="restricted-password-temporary"]').text()).toBe('임시 비밀번호')
expect(wrapper.get('#restricted-password-temporary-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
expect(document.activeElement?.id).toBe('restricted-password-temporary')
expect(useToastStore().toasts).toEqual([])
})
it('10분이 지나면 메모리의 임시 비밀번호를 지우고 「임시 비밀번호」 칸을 보인다 — 넣은 값으로 보낸다', async () => {
const { wrapper } = await mountLogin()
await signIn(wrapper)
clock.advance(RESTRICTED_SECRET_WINDOW_MS)
await flushPromises()
expect(wrapper.text()).toContain('관리자에게 받은 임시 비밀번호를 다시 넣어 주세요.')
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
// 비우고 보내면 안내 글이 빨간 오류 · 초점은 그 칸
expect(wrapper.get('#restricted-password-temporary').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('restricted-password-temporary')
expect(bodyOf('/auth/password')).toEqual([])
await wrapper.get('#restricted-password-temporary').setValue('typed-again-1')
await submitStep(wrapper)
expect(bodyOf('/auth/password')).toEqual([{ currentPassword: 'typed-again-1', newPassword: NEW_PASSWORD }])
})
it('새로고침 · 다른 탭(메모리 없음) — 「임시 비밀번호」 칸이 맨 위, 2단계 인증 상태를 물어 켰으면 코드 칸', async () => {
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
const { wrapper } = await mountLogin({ twoFactor: () => ok(ON) })
expect(wrapper.get('h1').text()).toBe('새 비밀번호를 정해 주세요')
expect(wrapper.find('#restricted-password-temporary').exists()).toBe(true)
expect(wrapper.get('label[for="restricted-password-code"]').text()).toBe('인증 앱 코드')
expect(wrapper.text()).toContain('로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.')
expect(urls()).toContain('GET /two-factor')
})
it('코드 단계를 거쳐 왔으면(2단계를 켠 직원) 코드 칸 — 다음 코드를 함께 보내고, 틀리면(AUTH_006) 칸을 비우고 칸 아래에', async () => {
let passwordReply: MockResult = fail(400, 'AUTH_006')
const { wrapper } = await mountLogin({
login: (request) =>
(request.data as { otpCode?: string }).otpCode === undefined
? ok({ status: 'twoFactorRequired', email: null, name: null })
: ok({ status: 'signedIn', ...session('passwordChange') }),
password: () => passwordReply,
})
await signIn(wrapper)
await wrapper.get('#login-code').setValue('111222')
await submitStep(wrapper)
expect(wrapper.get('h1').text()).toBe('새 비밀번호를 정해 주세요')
expect(urls()).not.toContain('GET /two-factor')
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
// 6자리가 아니면 보내지 않는다
await submitStep(wrapper)
expect(wrapper.get('#restricted-password-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(bodyOf('/auth/password')).toEqual([])
await wrapper.get('#restricted-password-code').setValue('333444')
await submitStep(wrapper)
expect(bodyOf('/auth/password')).toEqual([{ currentPassword: TEMPORARY, newPassword: NEW_PASSWORD, otpCode: '333444' }])
expect(wrapper.get('#restricted-password-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect(inputValue(wrapper, '#restricted-password-code')).toBe('')
expect(useToastStore().toasts).toEqual([])
passwordReply = ok(null)
await wrapper.get('#restricted-password-code').setValue('555666')
await submitStep(wrapper)
expect(bodyOf('/auth/password').at(-1)).toEqual({ currentPassword: TEMPORARY, newPassword: NEW_PASSWORD, otpCode: '555666' })
})
it('서버가 코드를 요구하면(AUTH_007 — 그사이 켬) 코드 칸을 보인다', async () => {
const { wrapper } = await mountLogin({ password: () => fail(400, 'AUTH_007') })
await signIn(wrapper)
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
expect(wrapper.get('#restricted-password-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(document.activeElement?.id).toBe('restricted-password-code')
})
it('[다른 계정으로 로그인] — 로그아웃하고 처음 화면으로(주소 칸은 비움), 10분 타이머도 거둔다', async () => {
const { wrapper } = await mountLogin()
await signIn(wrapper)
await wrapper.get('[data-account-other]').trigger('click')
await flushPromises()
expect(urls()).toContain('POST /auth/logout')
expect(useAuthStore().isLoggedIn).toBe(false)
expect(useAuthStore().restriction).toBeNull()
expect(wrapper.get('h1').text()).toBe('로그인')
expect(inputValue(wrapper, '#login-email')).toBe('')
expect(document.activeElement?.id).toBe('login-email')
expect(clock.pendingCount()).toBe(0)
})
it('화면을 떠나면 메모리의 비밀번호와 10분 타이머를 거둔다', async () => {
const { wrapper } = await mountLogin()
await signIn(wrapper)
expect(clock.pendingCount()).toBe(1)
wrapper.unmount()
expect(clock.pendingCount()).toBe(0)
})
})
describe('2단계 인증 켜기 (restriction: twoFactorSetup)', () => {
async function atSetupStep(server: Server = {}) {
const result = await mountLogin({ login: () => ok({ status: 'signedIn', ...session('twoFactorSetup') }), ...server })
await signIn(result.wrapper, 'Ara!2026-current')
return result
}
it('새 키를 받아 QR · 키를 그린다 — 지금 비밀번호는 묻지 않고(메모리) 문의 줄 · 아래 안내', async () => {
const { wrapper } = await atSetupStep({ supportContact: () => ok({ text: CONTACT }) })
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
expect(urls()).toContain('POST /two-factor/enrollments')
expect(wrapper.find('[data-qr-code]').exists()).toBe(true)
expect(wrapper.get('[data-setup-key]').text().replace(/\s/g, '')).toBe(enrollment(1).secret)
expect(wrapper.text()).toContain(`앱에는 「ARA SYSTEM (${USER.email})」로 저장됩니다.`)
expect(wrapper.get('label[for="restricted-setup-code"]').text()).toBe('앱에 표시된 6자리 코드')
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
expect(wrapper.get('[data-support-line]').text()).toBe(`인증 앱을 쓸 수 없나요?${CONTACT}`)
expect(wrapper.get('[data-support-contact]').text()).toBe(CONTACT)
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
expect(document.activeElement?.id).toBe('restricted-setup-code')
})
it('켜면 로그인 때 넣은 비밀번호로 보내고 갱신한 뒤 가려던 주소로 — 알림 + [앱 비밀번호 만들기]', async () => {
const { wrapper, router } = await atSetupStep()
await wrapper.get('#restricted-setup-code').setValue('482913')
await submitStep(wrapper)
expect(bodyOf('/two-factor/activation')).toEqual([
{ enrollmentId: 'enroll-1', otpCode: '482913', currentPassword: 'Ara!2026-current' },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
const toasts = useToastStore().toasts
expect(toasts.map((toast) => toast.message)).toEqual(['2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.'])
expect(toasts[0]?.action).toEqual({ label: '앱 비밀번호 만들기', to: { name: 'settings-account-app-passwords' } })
})
it('코드가 틀리면(AUTH_006) 칸 아래 · 칸을 비우고 초점 — QR · 키는 그대로', async () => {
const { wrapper } = await atSetupStep({ activate: () => fail(400, 'AUTH_006') })
await wrapper.get('#restricted-setup-code').setValue('000111')
await submitStep(wrapper)
expect(wrapper.get('#restricted-setup-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect(inputValue(wrapper, '#restricted-setup-code')).toBe('')
expect(document.activeElement?.id).toBe('restricted-setup-code')
expect(requests.filter((request) => request.url === '/two-factor/enrollments')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
})
it('시간이 지났으면(AUTH_008) 위쪽 상자에 사전 문구 + 새 키로 다시 그린다(코드 칸은 비움)', async () => {
const { wrapper } = await atSetupStep({ activate: () => fail(409, 'AUTH_008') })
await wrapper.get('#restricted-setup-code').setValue('482913')
await submitStep(wrapper)
expect(wrapper.get('#restricted-setup-alert').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
expect(requests.filter((request) => request.url === '/two-factor/enrollments')).toHaveLength(2)
expect(wrapper.get('[data-setup-key]').text().replace(/\s/g, '')).toBe(enrollment(2).secret)
expect(inputValue(wrapper, '#restricted-setup-code')).toBe('')
})
it('다른 곳에서 이미 켰으면 이 단계를 건너뛴다 — 갱신하고 가려던 주소로', async () => {
const { router } = await atSetupStep({ enroll: () => fail(409, 'AUTH_008'), twoFactor: () => ok(ON) })
expect(urls()).toContain('POST /auth/refresh')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('메모리에 비밀번호가 없으면(새로고침) ③ 지금 비밀번호 — 틀리면(AUTH_005) 그 칸 아래', async () => {
useAuthStore().markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
const { wrapper } = await mountLogin({ activate: () => fail(400, 'AUTH_005') })
expect(wrapper.get('label[for="restricted-setup-password"]').text()).toBe('지금 비밀번호')
expect(wrapper.text()).toContain('새로고침해서 다시 넣어야 합니다.')
await wrapper.get('#restricted-setup-code').setValue('482913')
await submitStep(wrapper)
expect(bodyOf('/two-factor/activation')).toEqual([])
expect(document.activeElement?.id).toBe('restricted-setup-password')
// 비우고 보내면 그 칸의 안내 글이 빨간 오류 (임시 비밀번호 칸과 같다)
expect(wrapper.get('#restricted-setup-password').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#restricted-setup-password-hint').classes()).toContain('form-message--error')
await wrapper.get('#restricted-setup-password').setValue('typed-1234')
await submitStep(wrapper)
expect(bodyOf('/two-factor/activation')).toEqual([{ enrollmentId: 'enroll-1', otpCode: '482913', currentPassword: 'typed-1234' }])
expect(wrapper.get('#restricted-setup-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
})
})
describe('새 비밀번호 → 2단계 켜기 (입사 — 둘 다 걸림)', () => {
it('새 비밀번호를 정한 뒤 갱신이 다음 제한을 주면 켜기 단계로 — 지금 비밀번호는 방금 정한 새 비밀번호', async () => {
const { wrapper, router } = await mountLogin({ refresh: () => ok(session('twoFactorSetup')) })
await signIn(wrapper)
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['새 비밀번호를 정했습니다.'])
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
expect(document.activeElement?.id).toBe('restricted-setup-code')
await wrapper.get('#restricted-setup-code').setValue('482913')
await submitStep(wrapper)
expect(bodyOf('/two-factor/activation')).toEqual([
{ enrollmentId: 'enroll-1', otpCode: '482913', currentPassword: NEW_PASSWORD },
])
})
async function changePasswordWith(server: Server) {
const result = await mountLogin(server)
await signIn(result.wrapper)
await result.wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await result.wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(result.wrapper)
return result
}
it('갱신이 장애면 me(서버 세션 표의 지금 값)를 묻는다 — 제한이 없으면 비밀번호를 지우고 가려던 주소로', async () => {
const { router } = await changePasswordWith({ refresh: () => fail(503, 'MAIL_001') })
expect(urls().slice(-2)).toEqual(['POST /auth/refresh', 'GET /auth/me'])
expect(useAuthStore().restriction).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['새 비밀번호를 정했습니다.'])
expect(clock.pendingCount()).toBe(0)
})
it('갱신이 장애여도 me 가 다음 제한을 알려 주면 그 단계로 — 비밀번호 기억(방금 정한 것)은 둔다', async () => {
const { wrapper, router } = await changePasswordWith({
refresh: () => fail(503, 'MAIL_001'),
me: () => ok(session('twoFactorSetup')),
})
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
await wrapper.get('#restricted-setup-code').setValue('482913')
await submitStep(wrapper)
expect(bodyOf('/two-factor/activation')).toEqual([
{ enrollmentId: 'enroll-1', otpCode: '482913', currentPassword: NEW_PASSWORD },
])
})
it('me 도 받지 못하면 그 단계에 머물며 사전 문구 + [다시 시도] — 칸 · 단추는 막고, 다시 시도가 넘기면 알림', async () => {
let refreshReply: MockResult = fail(503, 'MAIL_001')
const { wrapper, router } = await changePasswordWith({
refresh: () => refreshReply,
me: () => fail(503, 'SYS_001'),
})
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.get('h1').text()).toBe('새 비밀번호를 정해 주세요')
const box = wrapper.get('[data-restricted-finish-error]')
expect(box.attributes('role')).toBe('alert')
expect(box.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(box.get('[data-restricted-retry]').text()).toBe('다시 시도')
expect(wrapper.get('[data-restricted-submit]').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#restricted-password-next').attributes('readonly')).toBeDefined()
// 다시 보내지 않는다 — 이미 바꿨다
await submitStep(wrapper)
expect(bodyOf('/auth/password')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
// [다른 계정으로 로그인]은 누를 수 있다
expect(wrapper.get('[data-account-other]').attributes('aria-disabled')).toBeUndefined()
refreshReply = ok(session(null))
await box.get('[data-restricted-retry]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['새 비밀번호를 정했습니다.'])
})
it('2단계 켜기 — 다른 곳에서 켜 건너뛸 때 갱신 · me 가 모두 장애여도 키를 되풀이해 만들지 않는다', async () => {
const { wrapper, router } = await mountLogin({
login: () => ok({ status: 'signedIn', ...session('twoFactorSetup') }),
enroll: () => fail(409, 'AUTH_008'),
twoFactor: () => ok(ON),
refresh: () => fail(503, 'MAIL_001'),
me: () => fail(503, 'SYS_001'),
})
await signIn(wrapper, 'Ara!2026-current')
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.get('[data-restricted-finish-error]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(requests.filter((request) => request.url === '/two-factor/enrollments')).toHaveLength(1)
})
})
describe('세션 · 모르는 제한', () => {
it('세션이 끝나면(다른 탭의 로그아웃 · 만료) 메모리의 비밀번호와 10분 타이머를 거두고 첫 화면으로', async () => {
const { wrapper } = await mountLogin()
await signIn(wrapper)
expect(clock.pendingCount()).toBe(1)
useAuthStore().clearAuth()
await flushPromises()
expect(clock.pendingCount()).toBe(0)
expect(wrapper.get('h1').text()).toBe('로그인')
})
it('다시 로그인해도 앞 세션의 비밀번호를 쓰지 않는다 — 세션이 만료된 뒤 다시 로그인하면 그때 넣은 비밀번호', async () => {
const { wrapper } = await mountLogin()
await signIn(wrapper)
useAuthStore().expireSession()
await flushPromises()
await signIn(wrapper, 'second-temp-1')
await wrapper.get('#restricted-password-next').setValue(NEW_PASSWORD)
await wrapper.get('#restricted-password-confirm').setValue(NEW_PASSWORD)
await submitStep(wrapper)
expect(bodyOf('/auth/password')).toEqual([{ currentPassword: 'second-temp-1', newPassword: NEW_PASSWORD }])
})
it('모르는 제한(새 서버)이면 AUTH_010 사전 문구와 [다른 계정으로 로그인]만 — 비밀번호는 기억하지 않는다', async () => {
const { wrapper } = await mountLogin({ login: () => ok({ status: 'signedIn', ...USER, restriction: 'brandNew', twoFactorDeadline: null }) })
await signIn(wrapper)
expect(useAuthStore().restriction).toBe('unknown')
expect(wrapper.get('[data-restriction-unknown]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_010)
expect(wrapper.get('[data-account-email]').text()).toBe(USER.email)
expect(wrapper.find('form').exists()).toBe(false)
expect(clock.pendingCount()).toBe(0)
await wrapper.get('[data-account-other]').trigger('click')
await flushPromises()
expect(urls()).toContain('POST /auth/logout')
expect(wrapper.get('h1').text()).toBe('로그인')
})
})
describe('문의 줄 (GET /support-contact — 로그인 없음)', () => {
it('연락처가 있으면 「비밀번호를 잊으셨나요?」 다음 줄에 연락처(글자로만)', async () => {
const { wrapper } = await mountLogin({ supportContact: () => ok({ text: '<b>IT</b> 담당 · 02-000-0000' }) })
expect(urls()).toContain('GET /support-contact')
expect(wrapper.get('[data-support-line]').text()).toBe('비밀번호를 잊으셨나요?<b>IT</b> 담당 · 02-000-0000')
expect(wrapper.find('[data-support-line] b').exists()).toBe(false)
})
it('정하지 않았거나 받지 못하면 지금 글 그대로 — 알리지 않는다', async () => {
const { wrapper } = await mountLogin({ supportContact: () => fail(503, 'SYS_001') })
expect(wrapper.get('[data-support-line]').text()).toBe('비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.')
expect(useToastStore().toasts).toEqual([])
})
})
it('제한이 이 화면 밖에서 풀리면(다른 탭에서 마침 → 갱신) 가려던 주소로 간다', async () => {
const { wrapper, router } = await mountLogin()
await signIn(wrapper)
useAuthStore().markAuthenticated(USER, { restriction: null, twoFactorDeadline: null })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
})
+14 -3
View File
@@ -159,8 +159,11 @@ describe('LoginPage — 2단계 인증 코드 단계', () => {
async function mountAtCodeStep(
codeReply: (request: MockRequest) => MockResult | Promise<MockResult> = () => SIGNED_IN,
path = '/login',
contact: string | null = null,
) {
requests = installMockApi((request) => {
// 문의 줄의 연락처 (13단계 — 로그인 없음)
if (request.url === '/support-contact') return ok({ text: contact })
const body = request.data as { otpCode?: string }
return body.otpCode === undefined ? TWO_FACTOR_REQUIRED : codeReply(request)
})
@@ -206,6 +209,13 @@ describe('LoginPage — 2단계 인증 코드 단계', () => {
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
})
it('문의 연락처가 있으면(13단계) 「인증 앱을 쓸 수 없나요?」 다음 줄에 연락처', async () => {
const { wrapper } = await mountAtCodeStep(() => SIGNED_IN, '/login', 'IT 담당 홍길동 · 02-000-0000')
expect(wrapper.get('[data-support-line]').text()).toBe('인증 앱을 쓸 수 없나요?IT 담당 홍길동 · 02-000-0000')
expect(wrapper.get('[data-support-contact]').text()).toBe('IT 담당 홍길동 · 02-000-0000')
})
it('코드 칸은 숫자만 — 붙여 넣은 띄어쓰기 · 하이픈은 지운다', async () => {
const { wrapper } = await mountAtCodeStep()
const input = wrapper.get('#login-code')
@@ -369,9 +379,10 @@ describe('LoginPage — 2단계 인증 코드 단계', () => {
setTimeout: clock.setTimeout,
clearTimeout: clock.clearTimeout,
}
requests = installMockApi((request) =>
(request.data as { otpCode?: string }).otpCode === undefined ? TWO_FACTOR_REQUIRED : SIGNED_IN,
)
requests = installMockApi((request) => {
if (request.url === '/support-contact') return ok({ text: null })
return (request.data as { otpCode?: string }).otpCode === undefined ? TWO_FACTOR_REQUIRED : SIGNED_IN
})
const router = createTestRouter()
await router.push('/login')
await router.isReady()
+253 -15
View File
@@ -5,29 +5,57 @@
// - 확인한 비밀번호는 화면 메모리에만(useLoginChallenge) — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분 지나면 지운다. 새로고침도 첫 단계
// - 코드 틀림(AUTH_006)은 칸 아래 · 칸을 비우고 초점, 시도 과다(REQ_001)는 위쪽 상자, 비밀번호 틀림(AUTH_004 — 그사이 바뀜)은 첫 단계로 돌아가 위쪽 상자
// - 10분 넘게 머문 뒤 [확인]은 보내지 않고 「다시 로그인해 주세요」와 함께 첫 단계로(주소는 남긴다)
import { nextTick, ref } from 'vue'
// 세션에 제한이 붙으면 같은 틀이 그 단계를 그린다 (13단계 — 계약 6절 "제한된 세션" · 디자인 README "13단계"):
// - 새 비밀번호(LoginPasswordChangeStep) → 2단계 인증 켜기(LoginTwoFactorSetupStep). 새로고침 · 다른 탭 · 주소를 바꿔 들어와도 me 의 restriction 으로 여기 온다(라우터 가드)
// - 로그인 때 넣은 비밀번호는 화면 메모리에(useLoginSecret — 반응형이 아닌 클로저 · 10분) — 새 비밀번호 단계의 임시 비밀번호 · 2단계 켜기의 지금 비밀번호로 다시 쓴다
// - 단계를 마치면 /auth/refresh 로 새 토큰을 받는다 — 다음 제한이 있으면 그 단계, 없으면 가려던 주소(없으면 받은편지함)와 알림.
// 갱신이 장애면 me(서버 세션 표의 지금 값)로 정하고, 그것도 못 받으면 그 단계에 머물며 사전 문구 + [다시 시도]
// - 모르는 제한(새 서버)은 AUTH_010 사전 문구와 [다른 계정으로 로그인]만 보인다
// - [다른 계정으로 로그인]은 로그아웃하고 처음 화면으로(주소 칸은 비움). 세션이 끝나면(로그아웃 · 만료 · 다른 탭) 메모리의 비밀번호를 지운다
// 문의 줄의 연락처는 GET /support-contact (로그인 없음 — 받지 못하면 지금 글 그대로)
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import logoFull from '@/assets/brand/logo-arasystem-white.png'
import AppIcon from '@/components/AppIcon.vue'
import LoginAccountRow from '@/components/auth/LoginAccountRow.vue'
import LoginCodeStep from '@/components/auth/LoginCodeStep.vue'
import { getErrorMessage, isApiError, toApiError } from '@/composables/useApi'
import LoginPasswordChangeStep from '@/components/auth/LoginPasswordChangeStep.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import LoginTwoFactorSetupStep from '@/components/auth/LoginTwoFactorSetupStep.vue'
import { fetchCurrentSession, getErrorMessage, isApiError, isSessionEnd, refreshSession, toApiError } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import { useClock } from '@/composables/useClock'
import { useLoginChallenge } from '@/composables/useLoginChallenge'
import { useLoginSecret } from '@/composables/useLoginSecret'
import { useToast } from '@/composables/useToast'
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 {
LOGIN_PASSWORD_CHANGE_TEXT,
LOGIN_TWO_FACTOR_SETUP_TEXT,
SUPPORT_CONTACT_TEXT,
} from '@/shared/constants/session-restriction'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { isCompleteOtp } from '@/shared/utils/account-security'
import { sessionStateOf, sessionUserOf } from '@/shared/utils/auth-session'
import { toSafeRedirect } from '@/shared/utils/redirect'
import { useAuthStore } from '@/stores/auth.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
/** 코드 단계가 직접 보이는 서버 거절 — 알림을 끈다 (그 밖의 장애는 인터셉터의 알림) */
const CODE_STEP_SILENT: readonly ErrorCode[] = ['AUTH_004', 'AUTH_006', 'REQ_001']
const route = useRoute()
const router = useRouter()
const { login } = useAuth()
const { login, logout } = useAuth()
const authStore = useAuthStore()
const supportContactStore = useSupportContactStore()
const toast = useToast()
const clock = useClock()
/** 제한된 단계에서 다시 쓸 비밀번호 — 화면 메모리에만 (13단계) */
const secret = useLoginSecret(clock)
const {
active: codeStepActive,
email: challengedEmail,
@@ -53,10 +81,140 @@ const codeSubmitting = ref<boolean>(false)
const codeError = ref<string | null>(null)
const codeAlert = ref<string | null>(null)
// ── 제한된 단계 (13단계) ─────────────
/** 처음 초점을 줄 단계 — 새 비밀번호 · 2단계 켜기 */
const restrictedStepRef = ref<{ focus: () => void } | null>(null)
/** 코드 단계를 거쳐 들어왔다 — 2단계 인증을 켠 직원 (새 비밀번호 단계가 코드 칸을 보인다) */
const passedCodeStep = ref<boolean>(false)
/** 단계를 마치고 갱신 · 이동하는 중 */
const finishing = ref<boolean>(false)
/** 단계를 마쳤지만 다음으로 넘기지 못했다(갱신 · me 모두 장애) — 그 단계의 위쪽 상자에 사전 문구 + [다시 시도] */
const finishError = ref<string | null>(null)
/** [다시 시도]로 넘긴 뒤에 띄울 알림 — 마친 일의 알림은 넘긴 뒤에 띄운다 */
let pendingAnnounce: (() => void) | null = null
const restriction = computed(() => authStore.restriction)
const restrictedEmail = computed(() => authStore.user?.email ?? '')
function goToRedirect() {
return router.replace(toSafeRedirect(route.query.redirect) ?? { name: 'mail' })
}
/**
* 로그인 요청 직전 — 보내는 비밀번호를 화면 메모리에 둔다. 응답과 함께 제한된 단계가 그려지면(스토어의 제한) 그 단계가 처음부터
* 이 비밀번호를 지금 비밀번호로 쓴다(칸이 잠깐 보였다 사라지지 않게). 제한이 없거나 · 코드 단계로 가거나 · 실패하면 바로 지운다
*/
function holdPasswordForRestriction(value: string) {
secret.remember(value)
}
/** 로그인했다 — 제한이 붙었으면 그 단계의 첫 칸으로, 아니면 비밀번호를 지우고 가려던 주소로 (모르는 제한이면 비밀번호도 쓸 일이 없다) */
async function onSignedIn() {
if (authStore.restriction === null) {
secret.forget()
await goToRedirect()
return
}
if (authStore.restriction === 'unknown') secret.forget()
await nextTick()
restrictedStepRef.value?.focus()
}
/** 갱신이 장애일 때 — me 로 서버 세션 표의 지금 값(제한 · 유예 마감)을 받아 둔다. 받았으면 true (그사이 세션이 바뀌었으면 false) */
async function adoptCurrentSession(session: number): Promise<boolean> {
try {
const response = await fetchCurrentSession()
if (authStore.generation !== session) return false
authStore.markAuthenticated(sessionUserOf(response), sessionStateOf(response))
return true
} catch {
return false
}
}
/**
* 단계를 마쳤다(비밀번호를 바꿈 · 2단계를 켬 · 다른 곳에서 이미 켬) — /auth/refresh 로 새 토큰을 받는다 (계약 6절).
* 다음 제한이 붙어 오면 그 단계로(알림은 그대로 — 바꾼 것은 바뀌었다, 비밀번호 기억도 둔다), 없으면 비밀번호를 지우고 가려던 주소로.
* - 갱신이 장애면 me 로 지금 값을 묻는다 — 받으면 위와 같이 정한다(옛 토큰이 AUTH_010 을 받으면 useApi 가 me → 갱신으로 맞춘다).
* me 도 못 받으면 그 단계에 머물며 사전 문구 + [다시 시도] — 짐작으로 넘기지 않는다(넘긴 뒤 다시 막혀 이 단계로 되돌아오는 되풀이가 없게)
* - 갱신이 세션 끝(401)이면 세션 만료 대화상자가 맡는다 — 메모리의 비밀번호만 지운다
*/
async function finishRestrictedStep(announce: (() => void) | null) {
if (finishing.value) return
finishing.value = true
finishError.value = null
pendingAnnounce = null
const session = authStore.generation
try {
const outcome = await refreshSession()
if (authStore.generation !== session) {
secret.forget()
return
}
if (!outcome.ok) {
if (isSessionEnd(outcome.error.code)) {
secret.forget()
return
}
if (!(await adoptCurrentSession(session))) {
if (authStore.generation !== session) {
secret.forget()
return
}
pendingAnnounce = announce
finishError.value = outcome.error.message
return
}
}
announce?.()
if (authStore.restriction !== null) {
await nextTick()
restrictedStepRef.value?.focus()
return
}
secret.forget()
await goToRedirect()
} finally {
finishing.value = false
}
}
/** [다시 시도] — 마친 단계를 다시 넘겨 본다 (갱신부터) */
function onRetryFinish() {
void finishRestrictedStep(pendingAnnounce)
}
function onPasswordChanged() {
void finishRestrictedStep(() => toast.success(LOGIN_PASSWORD_CHANGE_TEXT.done))
}
/** 2단계 인증을 켰다 — 받은편지함(가려던 주소)으로 가며 알림 + [앱 비밀번호 만들기](단추가 있는 알림 10초) */
function onTwoFactorEnabled() {
void finishRestrictedStep(() =>
toast.success(LOGIN_TWO_FACTOR_SETUP_TEXT.done, {
label: LOGIN_TWO_FACTOR_SETUP_TEXT.doneAction,
to: { name: SETTINGS_ROUTES.appPasswords },
}),
)
}
/** 다른 곳에서 이미 켰다 — 이 단계를 건너뛴다 */
function onTwoFactorEnabledElsewhere() {
void finishRestrictedStep(null)
}
/** 제한된 단계의 [다른 계정으로 로그인] — 로그아웃하고 처음 화면으로(주소 칸은 비움) */
async function onRestrictedOtherAccount() {
if (finishing.value) return
secret.forget()
finishError.value = null
pendingAnnounce = null
passedCodeStep.value = false
// 화면의 세션은 바로 정리된다(clearAuth) — 서버 응답을 기다리지 않고 첫 단계를 그린다
const signingOut = logout()
await returnToPasswordStep({ email: '' })
await signingOut
}
function resetCodeStep() {
code.value = ''
codeError.value = null
@@ -70,8 +228,12 @@ async function onSubmit() {
stepNotice.value = null
const address = email.value.trim()
try {
// 제한된 단계가 그려지기 전에 정해 둔다 — 응답과 함께 단계가 그려진다(스토어의 제한)
passedCodeStep.value = false
holdPasswordForRestriction(password.value)
const outcome = await login({ email: address, password: password.value })
if (outcome.status === 'twoFactorRequired') {
secret.forget()
// 확인한 비밀번호는 코드 단계의 메모리로만 옮기고 칸은 비운다 — 기다리는 사이 화면을 떠났으면 붙잡지 않는다(start 가 false)
const started = startChallenge(address, password.value)
password.value = ''
@@ -81,8 +243,11 @@ async function onSubmit() {
codeStepRef.value?.focus()
return
}
await goToRedirect()
// 확인한 비밀번호는 제한된 단계의 메모리에만 남고(제한이 없으면 지운다) 칸은 비운다
password.value = ''
await onSignedIn()
} catch (error) {
secret.forget()
if (isApiError(error) && error.code === 'AUTH_004') {
credentialError.value = error.message
password.value = ''
@@ -136,15 +301,22 @@ async function onSubmitCode() {
codeSubmitting.value = true
codeError.value = null
try {
// 코드 단계를 거쳤다 — 새 비밀번호 단계가 그려질 때 코드 칸을 보이게 응답 전에 정해 둔다
passedCodeStep.value = true
holdPasswordForRestriction(credentials.password)
const outcome = await login({ ...credentials, otpCode: code.value }, { silent: CODE_STEP_SILENT })
if (outcome.status === 'twoFactorRequired') {
// 코드를 실었는데도 코드를 묻는다 — 받아들여지지 않은 코드로 본다
secret.forget()
focusCodeWithError(getErrorMessage('AUTH_006'))
return
}
clearChallenge()
await goToRedirect()
// 쓴 코드를 반응형 칸에 남기지 않는다
resetCodeStep()
await onSignedIn()
} catch (error) {
secret.forget()
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 })
@@ -160,6 +332,29 @@ function onOtherAccount() {
if (codeSubmitting.value) return
void returnToPasswordStep({ email: '' })
}
// 제한이 이 화면 밖에서 풀렸다(다른 탭에서 마침 · 회사가 정책을 끔 → 갱신 때 지워짐) — 가려던 주소로.
// 이 화면이 단계를 마치는 중이면 그쪽이 이동한다 (finishing)
watch(restriction, (next, previous) => {
if (previous === null || next !== null || finishing.value || !authStore.isLoggedIn) return
secret.forget()
void goToRedirect()
})
// 세션이 끝나면(로그아웃 · 만료 · 다른 탭의 로그아웃 · 다른 기기의 [다른 곳 모두 로그아웃]) 메모리의 비밀번호를 지운다.
// 로그인 직전에 기억한 비밀번호는 지우지 않는다 — 그때는 로그인 전(false)에서 로그인(true)으로 바뀐다
watch(
() => authStore.isLoggedIn,
(loggedIn) => {
if (loggedIn) return
secret.forget()
finishError.value = null
pendingAnnounce = null
},
)
// 문의 줄의 연락처 — 받지 못하면 지금 글 그대로 (알리지 않는다)
void supportContactStore.ensureLoaded()
</script>
<template>
@@ -188,14 +383,61 @@ function onOtherAccount() {
<main class="login__main">
<div class="login__panel">
<LoginPasswordChangeStep
v-if="restriction === 'passwordChange'"
ref="restrictedStepRef"
:email="restrictedEmail"
:secret="secret"
:known-two-factor="passedCodeStep"
:finishing="finishing"
:finish-error="finishError"
@done="onPasswordChanged"
@retry-finish="onRetryFinish"
@other-account="onRestrictedOtherAccount"
/>
<LoginTwoFactorSetupStep
v-else-if="restriction === 'twoFactorSetup'"
ref="restrictedStepRef"
:email="restrictedEmail"
:secret="secret"
:support-contact="supportContactStore.text"
:finishing="finishing"
:finish-error="finishError"
@done="onTwoFactorEnabled"
@enabled-elsewhere="onTwoFactorEnabledElsewhere"
@retry-finish="onRetryFinish"
@other-account="onRestrictedOtherAccount"
/>
<!-- 모르는 제한(새 서버) — 사전 문구와 [다른 계정으로 로그인]만 -->
<template v-else-if="restriction === 'unknown'">
<LoginAccountRow
:email="restrictedEmail"
@other-account="onRestrictedOtherAccount"
/>
<p
class="login__error"
role="alert"
data-restriction-unknown
>
<AppIcon
name="alert-circle"
class="login__error-icon"
/>
<span>{{ getErrorMessage('AUTH_010') }}</span>
</p>
</template>
<LoginCodeStep
v-if="codeStepActive"
v-else-if="codeStepActive"
ref="codeStepRef"
:code="code"
:email="challengedEmail"
:submitting="codeSubmitting"
:code-error="codeError"
:alert="codeAlert"
:support-contact="supportContactStore.text"
@update:code="setCode"
@submit="onSubmitCode"
@other-account="onOtherAccount"
@@ -276,9 +518,11 @@ function onOtherAccount() {
</button>
</form>
<p class="login__help">
비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.
</p>
<LoginSupportLine
:question="SUPPORT_CONTACT_TEXT.passwordQuestion"
:fallback="SUPPORT_CONTACT_TEXT.passwordFallback"
:contact="supportContactStore.text"
/>
<p class="login__note">
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
</p>
@@ -443,12 +687,6 @@ function onOtherAccount() {
cursor: progress;
}
.login__help {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.login__note {
margin: 0;
padding-top: var(--space-16);
@@ -1685,6 +1685,20 @@ describe('ComposePage', () => {
expect(dialogOpen(wrapper)).toBe(false)
})
it('제한이 붙으면(13단계 — 새 비밀번호 · 2단계 켜기 전) 묻지 않는다 — 세션이 끝난 때처럼, 로그인 틀로 가는 이동이 이긴다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
await writeSubject(wrapper, '쓰던 메일')
expect(blocksUnload()).toBe(true)
useAuthStore().markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push({ name: 'login' })
expect(router.currentRoute.value.name).toBe('login')
expect(dialogOpen(wrapper)).toBe(false)
})
it('다른 탭이 로그아웃하면 묻지 않고 로그인 화면으로 간다', async () => {
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
+5 -5
View File
@@ -529,7 +529,7 @@ function commitRecipientInputs() {
/**
* 떠나기 전 — 칸에 쓰다 만 주소를 확정하고 남은 변경을 저장한다. 그 뒤에도 잃을 것이 남았을 때만 묻는다
* (저장 실패 · 올리는 중인 첨부). 세션이 끝났으면(로그아웃 · 만료 · 다른 탭의 로그아웃) 묻지도 저장하지도 않는다 —
* (저장 실패 · 올리는 중인 첨부). 세션이 끝났으면(로그아웃 · 만료 · 다른 탭의 로그아웃) · 제한이 붙었으면(13단계 — 저장은 서버가 막는다) 묻지도 저장하지도 않는다 —
* 그 흐름이 로그인 화면으로 보낸다. 묻는 중에 세션이 끝나면 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않는다.
* 같은 쓰기 화면 안의 이동도 쓰기 종류나 원본이 바뀌면 양식을 새로 채우므로 같다 (답장 쓰다 "쓰기" 등).
* 보내는 중에는 앱 안 이동을 묻지 않는다 — 이미 보내기로 했고, 요청은 이어지며 결과는 토스트로 알린다 (formGeneration).
@@ -541,8 +541,8 @@ const {
stay: stayOnCompose,
leave: leaveCompose,
} = useLeaveConfirm({
active: () => hasUnsavedChanges.value && authStore.isLoggedIn && !sending.value && !discarding.value,
unloadActive: () => (hasUnsavedChanges.value || sending.value) && authStore.isLoggedIn,
active: () => hasUnsavedChanges.value && authStore.canUseApp && !sending.value && !discarding.value,
unloadActive: () => (hasUnsavedChanges.value || sending.value) && authStore.canUseApp,
// 이 화면이 저장 뒤 바꿔 끼운 주소는 떠나는 이동이 아니다
leavesOnUpdate: (next) => !draftLink.ownsKey(draftKeyOf(next.query)),
// 떠나기 전 저장은 5초까지만 기다린다 — 넘으면 묻는다 (저장은 뒤에서 이어지고, 늦게 끝난 결과는 양식 세대 규칙대로)
@@ -551,8 +551,8 @@ const {
await nextTick()
await waitAtMost(drafts.saveBeforeLeave(), DRAFT_LEAVE_SAVE_WAIT_MS)
},
// 묻는 동안 그 저장이 끝나 잃을 것이 없어지면 그대로 떠난다 — 세션이 끝났으면 취소한다 (로그인 화면으로 가는 흐름)
leaveWhenSettled: () => authStore.isLoggedIn,
// 묻는 동안 그 저장이 끝나 잃을 것이 없어지면 그대로 떠난다 — 세션이 끝났거나 제한이 붙었으면 취소한다 (로그인 화면으로 가는 흐름)
leaveWhenSettled: () => authStore.canUseApp,
})
/** 일을 기다리되 ms 까지만 — 넘으면 일은 그대로 두고 먼저 돌아온다 */
+5
View File
@@ -9,12 +9,14 @@
// 예약한 메일 수는 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 다시 묻는다 (계약 2절 "보내기 취소 · 예약 발송").
// 엔진이 붙잡은 발송을 풀 때(보낼 시각)는 새 메일 확인이 알아채지 못한다 — 그 시각이 지나면 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다)
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
import { useClock } from '@/composables/useClock'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
@@ -68,6 +70,9 @@ usePendingSendRefresh({ clock })
<template #search>
<MailSearchBox />
</template>
<template #notice>
<TwoFactorGraceNotice />
</template>
<MailboxPanel />
<RouterView />
</AppShell>
@@ -148,6 +148,15 @@ describe('AccountAppPasswordsPage', () => {
)
})
it('설정값 상자 바로 아래 메일 프로그램 연결 화면으로 가는 한 줄 (13단계)', async () => {
const wrapper = await mountPage()
const line = wrapper.get('[data-app-password-clients-link]')
expect(line.text()).toBe('프로그램별 연결 방법은 메일 프로그램 연결에서 볼 수 있습니다.')
expect(line.get('a').text()).toBe('메일 프로그램 연결')
expect(line.get('a').attributes('href')).toBe('/settings/account/mail-clients')
})
it('빈 상태 — "0 / 5개" + "만든 앱 비밀번호가 없습니다", 설정값 상자 · 안내는 그대로', async () => {
const wrapper = await mountPage({ list: () => ok(page([])) })
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 설정 › 계정 › 앱 비밀번호 (디자인 원본 SettingsAppPasswords · README "12단계 › 설정 › 계정 › 앱 비밀번호" · 계약 5절 "앱 비밀번호")
// — /settings/account/app-passwords · 탭 제목 "앱 비밀번호"
// 머리 + 설명 · 오른쪽 "3 / 5개" + [앱 비밀번호 만들기] → 표(AppPasswordTable) 또는 빈 상태 → 메일 프로그램 설정값 상자 → 아래 안내 상자
// 머리 + 설명 · 오른쪽 "3 / 5개" + [앱 비밀번호 만들기] → 표(AppPasswordTable) 또는 빈 상태 → 메일 프로그램 설정값 상자
// (+ 바로 아래 "프로그램별 연결 방법은 메일 프로그램 연결에서 볼 수 있습니다." — 13단계) → 아래 안내 상자
// - 들어올 때마다 새로 받는다(app-passwords.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도" — 그동안 [만들기]는 막는다(개수를 모르므로)
// - 5개를 모두 만들었으면 [만들기]는 aria-disabled + 머리 아래 한 줄(까닭) — 누르면 창을 열지 않고 까닭을 읽어 준다(role=status)
// - [만들기] → 이름 창(AppPasswordCreateDialog) → 결과 창(AppPasswordResultDialog — 값은 이 화면 메모리에만, 닫으면 지운다) → 목록 맨 위에 새 줄.
@@ -13,6 +14,7 @@
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LinkedText from '@/components/LinkedText.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import AppPasswordCreateDialog from '@/components/settings/AppPasswordCreateDialog.vue'
import AppPasswordDeleteDialog from '@/components/settings/AppPasswordDeleteDialog.vue'
@@ -25,6 +27,8 @@ 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 { MAIL_CLIENTS_TEXT } from '@/shared/constants/mail-clients'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { AppPassword } from '@/shared/types/account-security'
import { appPasswordCountText } from '@/shared/utils/account-security'
@@ -284,6 +288,17 @@ void appPasswordsStore.load()
{{ APP_PASSWORD_TEXT.settingsTitle }}
</h2>
<MailClientSettings :email="email" />
<p
class="app-passwords__shortcut"
data-app-password-clients-link
>
<LinkedText
:before="MAIL_CLIENTS_TEXT.shortcutBefore"
:link="MAIL_CLIENTS_TEXT.shortcutLink"
:after="MAIL_CLIENTS_TEXT.shortcutAfter"
:to="{ name: SETTINGS_ROUTES.mailClients }"
/>
</p>
</section>
<div
@@ -411,6 +426,13 @@ void appPasswordsStore.load()
font-weight: 700;
}
/* 설정값 상자 바로 아래 한 줄 — 메일 프로그램 연결 화면으로 (13단계 · 12.5px · #4A5363) */
.app-passwords__shortcut {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 아래 안내 — 바탕 #F3F8F8 */
.app-passwords__notes {
display: flex;
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AccountMailClientsPage from './AccountMailClientsPage.vue'
// 설정 › 계정 › 메일 프로그램 연결 (13단계 — 디자인 원본 SettingsMailClients · README "13단계 › 설정 › 계정 › 메일 프로그램 연결")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const ON: TwoFactorStatus = { isEnabled: true, enabledAt: null }
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
describe('AccountMailClientsPage', () => {
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(twoFactor: () => MockResult = () => ok(ON)) {
requests = installMockApi((request) => (request.url === '/two-factor' ? twoFactor() : ok(null)))
const router = createTestRouter()
await router.push('/settings/account/mail-clients')
await router.isReady()
const wrapper = mount(AccountMailClientsPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const settingsRows = (wrapper: VueWrapper) =>
wrapper.get('[data-mail-client-settings]').findAll('dd').map((value) => value.text())
it('머리 · 설정값 상자(앱 비밀번호 화면과 같은 값) — API 없이 화면 상수로 그린다 (2단계 인증 상태만 묻는다)', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('메일 프로그램 연결')
expect(wrapper.text()).toContain('아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.')
expect(settingsRows(wrapper)).toEqual([
'mx1.arasystem.it.kr · 포트 993 · SSL/TLS',
'mx1.arasystem.it.kr · 포트 465 · SSL/TLS',
`${USER.email} — 메일 주소 전체`,
'앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
])
expect(requests.map((request) => request.url)).toEqual(['/two-factor'])
})
it('2단계 인증을 켠 직원 — 갈색 상자 「비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요」 + [앱 비밀번호 만들기]', async () => {
const wrapper = await mountPage()
const notice = wrapper.get('[data-mail-clients-notice]')
expect(notice.attributes('data-mail-clients-notice')).toBe('required')
expect(notice.text()).toContain('비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요')
expect(wrapper.get('[data-mail-clients-create]').attributes('href')).toBe('/settings/account/app-passwords')
})
it('켜지 않은 직원 — 비밀번호 줄은 「앱 비밀번호(권장) …」, 청록 상자 「앱 비밀번호로 연결하기를 권합니다」', async () => {
const wrapper = await mountPage(() => ok(OFF))
expect(settingsRows(wrapper).at(-1)).toBe('앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다')
const notice = wrapper.get('[data-mail-clients-notice]')
expect(notice.attributes('data-mail-clients-notice')).toBe('recommend')
expect(notice.text()).toContain('앱 비밀번호로 연결하기를 권합니다')
})
it('2단계 인증 상태를 받지 못하면 켠 직원의 안내(더 안전한 쪽) — 알리지 않는다', async () => {
const wrapper = await mountPage(() => fail(503, 'MAIL_001'))
expect(wrapper.get('[data-mail-clients-notice]').attributes('data-mail-clients-notice')).toBe('required')
expect(useToastStore().toasts).toEqual([])
})
it('프로그램별 차례 — 새 Outlook 주의 줄 · 다섯 프로그램, 처음에는 모두 접힘', async () => {
const wrapper = await mountPage()
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(
'새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
)
const buttons = wrapper.findAll('[data-mail-client] h3 button')
expect(buttons.map((button) => button.text())).toEqual([
expect.stringContaining('아웃룩 (Windows)'),
expect.stringContaining('새 Outlook'),
expect.stringContaining('썬더버드'),
expect.stringContaining('iPhone · iPad'),
expect.stringContaining('안드로이드'),
])
expect(buttons.map((button) => button.attributes('aria-expanded'))).toEqual(['false', 'false', 'false', 'false', 'false'])
expect(buttons.every((button) => button.attributes('aria-controls') === undefined)).toBe(true)
expect(wrapper.findAll('[role="region"]')).toHaveLength(0)
})
it('펼치면 그 차례만 region(이름 = 그 버튼)으로 — 하나를 펼치면 다른 것은 접힌다, 다시 누르면 접힌다', async () => {
const wrapper = await mountPage()
const outlook = wrapper.get('#mail-client-outlook-button')
await outlook.trigger('click')
expect(outlook.attributes('aria-expanded')).toBe('true')
expect(outlook.attributes('aria-controls')).toBe('mail-client-outlook-panel')
const panel = wrapper.get('#mail-client-outlook-panel')
expect(panel.attributes('role')).toBe('region')
expect(panel.attributes('aria-labelledby')).toBe('mail-client-outlook-button')
expect(panel.findAll('li')).toHaveLength(5)
expect(panel.text()).toContain('Outlook 에서 [파일] → [계정 추가]를 누릅니다.')
// 호스트 이름은 굵게
expect(panel.get('strong').text()).toBe('mx1.arasystem.it.kr')
await wrapper.get('#mail-client-android-button').trigger('click')
expect(outlook.attributes('aria-expanded')).toBe('false')
expect(wrapper.find('#mail-client-outlook-panel').exists()).toBe(false)
expect(wrapper.get('#mail-client-android-panel').text()).toContain('보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다.')
await wrapper.get('#mail-client-android-button').trigger('click')
expect(wrapper.findAll('[role="region"]')).toHaveLength(0)
})
it('아래 안내 상자 — 네 줄', async () => {
const wrapper = await mountPage()
const notes = wrapper.get('[data-mail-clients-notes]').findAll('p').map((note) => note.text())
expect(notes).toHaveLength(4)
expect(notes[1]).toContain('POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.')
})
})
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 설정 › 계정 › 메일 프로그램 연결 (13단계 — 디자인 원본 SettingsMailClients · README "13단계 › 설정 › 계정 › 메일 프로그램 연결" · 계약 6절)
// — /settings/account/mail-clients · 탭 제목 "메일 프로그램 연결"
// 머리 + 설명 → 「메일 프로그램 설정값」 상자(앱 비밀번호 화면과 같은 MAIL_CLIENT_SETTINGS) → 2단계 인증 상태에 따른 안내 상자 →
// 「프로그램별 연결 차례」(주의 줄 + 펼침 목록 — MailClientGuideList) → 아래 안내 상자. API 는 없다 — 화면 상수로만 그린다.
// - 2단계 인증 상태만 이미 있는 GET /two-factor(two-factor.store)로 본다. 받는 중 · 받지 못함은 켠 직원의 안내(앱 비밀번호 꼭 — 더 안전한 쪽)
// · 켠 직원: 비밀번호 줄 "앱 비밀번호 — 웹메일 비밀번호가 아닙니다" + 갈색 상자(열쇠) [앱 비밀번호 만들기](36)
// · 켜지 않은 직원: 비밀번호 줄 "앱 비밀번호(권장) — …" + 청록 상자(열쇠) [앱 비밀번호 만들기](36)
// - 새 Outlook · 휴대폰 Outlook 앱 주의 줄은 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 상수와 함께 지운다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailClientGuideList from '@/components/settings/MailClientGuideList.vue'
import MailClientSettings from '@/components/settings/MailClientSettings.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { MAIL_CLIENTS_TEXT } from '@/shared/constants/mail-clients'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const appPasswordsRoute = { name: SETTINGS_ROUTES.appPasswords }
const email = computed(() => authStore.user?.email ?? '')
/** 2단계 인증을 켜지 않은 직원이라고 안다 — 모르면(처음 받는 중 · 못 받음) 켠 직원의 안내. 다시 받는 동안은 앞서 받은 값을 쓴다 */
const knownOff = computed(() => twoFactorStore.status !== 'error' && twoFactorStore.twoFactor?.isEnabled === false)
const passwordNote = computed(() => (knownOff.value ? MAIL_CLIENTS_TEXT.passwordOff : MAIL_CLIENTS_TEXT.passwordOn))
// 처음 그리기 전에 — 다른 탭 · 엔진 관리 화면에서 바꿨을 수 있다 (못 받아도 알리지 않는다 — 켠 직원의 안내로 둔다)
void twoFactorStore.load()
</script>
<template>
<SettingsPageFrame
title-id="mail-clients-page-title"
:title="MAIL_CLIENTS_TEXT.title"
:description="MAIL_CLIENTS_TEXT.description"
>
<section
class="mail-clients__section"
aria-labelledby="mail-clients-settings-title"
>
<h2
id="mail-clients-settings-title"
class="mail-clients__section-title"
>
{{ MAIL_CLIENTS_TEXT.settingsTitle }}
</h2>
<MailClientSettings
:email="email"
:password-note="passwordNote"
/>
</section>
<div
:class="['mail-clients__notice', knownOff ? 'mail-clients__notice--recommend' : 'mail-clients__notice--required']"
:data-mail-clients-notice="knownOff ? 'recommend' : 'required'"
>
<AppIcon
name="key"
:stroke-width="knownOff ? 1.75 : 1.9"
class="mail-clients__notice-icon"
/>
<div class="mail-clients__notice-body">
<div class="mail-clients__notice-text">
<span class="mail-clients__notice-title">
{{ knownOff ? MAIL_CLIENTS_TEXT.recommendTitle : MAIL_CLIENTS_TEXT.requiredTitle }}
</span>
<span>{{ knownOff ? MAIL_CLIENTS_TEXT.recommendText : MAIL_CLIENTS_TEXT.requiredText }}</span>
</div>
<RouterLink
:to="appPasswordsRoute"
class="mail-clients__notice-action"
data-mail-clients-create
>
{{ MAIL_CLIENTS_TEXT.createAppPassword }}
</RouterLink>
</div>
</div>
<section
class="mail-clients__steps"
aria-labelledby="mail-clients-steps-title"
>
<div class="mail-clients__steps-head">
<h2
id="mail-clients-steps-title"
class="mail-clients__section-title"
>
{{ MAIL_CLIENTS_TEXT.stepsTitle }}
</h2>
<p class="mail-clients__steps-hint">
{{ MAIL_CLIENTS_TEXT.stepsHint }}
</p>
</div>
<div
class="mail-clients__warning"
data-mail-clients-cloud-warning
>
<AppIcon
name="alert-triangle"
:stroke-width="1.9"
class="mail-clients__notice-icon"
/>
<span>{{ MAIL_CLIENTS_TEXT.cloudWarning }}</span>
</div>
<MailClientGuideList />
</section>
<div
class="mail-clients__notes"
data-mail-clients-notes
>
<p
v-for="note in MAIL_CLIENTS_TEXT.notes"
:key="note"
class="mail-clients__note"
>
<span aria-hidden="true">·</span>
<span>{{ note }}</span>
</p>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.mail-clients__section {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.mail-clients__section-title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
/* 설정값 바로 아래 상자 — 켠 직원 갈색 · 켜지 않은 직원 청록 (열쇠 아이콘 · 단추 36) */
.mail-clients__notice {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
}
.mail-clients__notice--required {
background: var(--color-warning-surface);
color: var(--color-warning-text);
--mail-clients-action-color: var(--color-warning-text);
--mail-clients-action-border: var(--color-warning-border);
--mail-clients-action-hover: var(--color-warning-hover);
}
.mail-clients__notice--recommend {
background: var(--color-brand-tint);
color: var(--color-text-strong);
--mail-clients-action-color: var(--color-text-strong);
--mail-clients-action-border: var(--color-border-strong);
--mail-clients-action-hover: var(--color-surface-muted);
}
.mail-clients__notice--recommend .mail-clients__notice-icon,
.mail-clients__notice--recommend .mail-clients__notice-title {
color: var(--color-brand-strong);
}
.mail-clients__notice-icon {
flex: 0 0 auto;
margin-top: var(--space-2);
}
.mail-clients__notice-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.mail-clients__notice-text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mail-clients__notice-title {
font-weight: 600;
}
/* [앱 비밀번호 만들기] 36 — 밀도 예외 (--size-notice-action) */
.mail-clients__notice-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(--mail-clients-action-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--mail-clients-action-color);
font-weight: 600;
text-decoration: none;
}
.mail-clients__notice-action:hover {
background: var(--mail-clients-action-hover);
color: var(--mail-clients-action-color);
}
.mail-clients__steps {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.mail-clients__steps-head {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mail-clients__steps-hint {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 새 Outlook 주의 줄 — 갈색 (경고 삼각형) */
.mail-clients__warning {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
}
/* 아래 안내 — 바탕 #F3F8F8 · 가운뎃점 줄 */
.mail-clients__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);
}
.mail-clients__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
</style>
@@ -2,12 +2,14 @@ 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 { 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 { 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 scanSource from '@/components/settings/TwoFactorEnrollmentScan.vue?raw'
@@ -606,3 +608,67 @@ describe('AccountTwoFactorPage', () => {
expect(useTwoFactorStore().twoFactor).toBeNull()
})
})
// 회사가 2단계 인증을 필수로 정한 직원 (13단계 — 계약 6절 "2단계 인증 필수" · 디자인 README "13단계 › 설정 › 2단계 인증 — 필수인 직원")
describe('AccountTwoFactorPage — 필수인 직원 (13단계)', () => {
let pinia: Pinia
const mounted: VueWrapper[] = []
// 시작일 2026-12-01 0시(KST)
const DEADLINE = '2026-11-30T15:00:00.000Z'
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 mountWith(status: TwoFactorStatus, now = Date.UTC(2026, 10, 2, 1, 0, 0)) {
installMockApi((request) => (request.url === '/two-factor' ? ok(status) : 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], provide: { [CLOCK as symbol]: createManualClock(now) } },
})
mounted.push(wrapper)
await flushPromises()
return wrapper
}
it('유예 중 · 꺼짐 — 머리 아래 갈색 상자에 시작일 전날까지 켜 달라는 안내, 상태 상자 · 켜는 차례는 그대로', async () => {
const wrapper = await mountWith({ ...OFF, isRequired: true, deadline: DEADLINE })
const grace = wrapper.get('[data-two-factor-grace]')
expect(grace.text()).toContain('11월 30일까지 2단계 인증을 켜 주세요')
expect(grace.text()).toContain('회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다.')
expect(wrapper.get('[data-two-factor-enable]').text()).toBe('2단계 인증 켜기')
expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(true)
})
it('필수가 아니거나 마감이 없으면 유예 안내가 없다', async () => {
const wrapper = await mountWith({ ...OFF, isRequired: false, deadline: null })
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
})
it('켜짐 — [2단계 인증 끄기] 대신 「회사 정책에 따라 끌 수 없습니다」, 휴대폰 바꾸기 안내는 관리자에게 해제 요청', async () => {
const wrapper = await mountWith({ ...ON, isRequired: true, deadline: null })
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
expect(wrapper.get('[data-two-factor-locked]').text()).toBe('회사 정책에 따라 끌 수 없습니다')
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
const notes = wrapper.get('[data-two-factor-notes]').text()
expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.')
expect(notes).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
})
})
@@ -10,6 +10,9 @@
// - 들어올 때마다 상태를 새로 받는다(two-factor.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃]으로 이 세션이 끊김 · 로그아웃 · 만료) 창을 모두 닫는다 — 세션 만료 창 뒤에 QR · 키 · 입력한 비밀번호가 남지 않게
// (창은 닫힐 때 키 · 칸을 지운다)
// 회사가 2단계 인증을 필수로 정한 직원(13단계 — GET /two-factor 의 isRequired · deadline, 디자인 README "13단계 › 설정 › 2단계 인증 — 필수인 직원"):
// - 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패) "11월 30일까지 2단계 인증을 켜 주세요" (날짜는 시작일 전날 — 올해가 아니면 해를 붙인다)
// - 켜짐 — [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 VAL_001), 아래 안내의 첫 줄은 관리자에게 해제를 요청하는 글
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -18,16 +21,20 @@ 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 { useClock } from '@/composables/useClock'
import { useToast } from '@/composables/useToast'
import { TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
import { TWO_FACTOR_GRACE_TEXT } from '@/shared/constants/session-restriction'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { twoFactorStateLabel } from '@/shared/utils/account-security'
import { isTwoFactorGraceActive, twoFactorDeadlineDayLabel } from '@/shared/utils/auth-session'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const toast = useToast()
const clock = useClock()
const enableRef = ref<HTMLButtonElement | null>(null)
const noticeRef = ref<HTMLElement | null>(null)
@@ -41,6 +48,15 @@ const ready = computed(() => twoFactorStore.status === 'ready' && twoFactorStore
const enabled = computed(() => twoFactorStore.isEnabled)
const stateLabel = computed(() => (twoFactorStore.twoFactor ? twoFactorStateLabel(twoFactorStore.twoFactor) : ''))
const email = computed(() => authStore.user?.email ?? '')
/** 회사 정책으로 이 직원에게 필수다 (13단계) */
const required = computed(() => twoFactorStore.twoFactor?.isRequired === true)
/** 유예 중 · 꺼짐 — 머리 아래 안내의 날짜 ("11월 30일"). 아니면 null */
const graceDay = computed(() => {
const deadline = twoFactorStore.twoFactor?.deadline ?? null
if (!required.value || enabled.value || deadline === null) return null
const now = clock.now()
return isTwoFactorGraceActive(deadline, now) ? twoFactorDeadlineDayLabel(deadline, now) : null
})
async function onEnabled() {
enableOpen.value = false
@@ -112,6 +128,22 @@ void twoFactorStore.load()
</div>
</div>
<div
v-if="graceDay"
class="two-factor__grace"
data-two-factor-grace
>
<AppIcon
name="shield"
:stroke-width="1.9"
class="two-factor__warning-icon"
/>
<div class="two-factor__warning-text">
<span class="two-factor__warning-title">{{ TWO_FACTOR_GRACE_TEXT.deadline(graceDay) }}</span>
<span>{{ TWO_FACTOR_GRACE_TEXT.settingsText }}</span>
</div>
</div>
<div
class="two-factor__state"
data-two-factor-state
@@ -137,6 +169,11 @@ void twoFactorStore.load()
/>
<span>{{ TWO_FACTOR_TEXT.enable }}</span>
</button>
<span
v-else-if="required"
class="two-factor__locked"
data-two-factor-locked
>{{ TWO_FACTOR_GRACE_TEXT.cannotDisable }}</span>
<button
v-else
type="button"
@@ -202,7 +239,7 @@ void twoFactorStore.load()
>
<p class="two-factor__note">
<span aria-hidden="true">·</span>
<span>{{ TWO_FACTOR_TEXT.noteChangePhone }}</span>
<span>{{ required ? TWO_FACTOR_GRACE_TEXT.noteChangePhone : TWO_FACTOR_TEXT.noteChangePhone }}</span>
</p>
<p class="two-factor__note">
<span aria-hidden="true">·</span>
@@ -373,7 +410,14 @@ void twoFactorStore.load()
background: var(--color-danger-surface);
}
/* 꺼져 있을 때의 갈색 경고 (최대 700) */
/* 필수인 직원이 켠 뒤 — 끄기 단추 대신 회색 글 (13단계) */
.two-factor__locked {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 꺼져 있을 때의 갈색 경고 · 필수인 직원의 유예 안내(13단계 — 방패) (최대 700) */
.two-factor__grace,
.two-factor__warning {
display: flex;
align-items: flex-start;
@@ -428,6 +428,21 @@ describe('MailSignaturePage', () => {
expect(useMailSignatureStore().signature?.body).toBe('김아라 · 인프라팀\nARA SYSTEM')
})
it('제한이 붙으면(13단계) 묻지 않는다 — 세션이 끝난 때처럼 창 닫기 확인도 뗀다', async () => {
const { wrapper, router } = await mountPage(server())
await typeLine(wrapper, 'ARA SYSTEM')
expect(blocksUnload()).toBe(true)
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push('/login')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/login')
})
it('세션이 끝나면 묻지 않는다 — 서명 칸을 내리고 창 닫기 확인도 뗀다', async () => {
const { wrapper, router } = await mountPage(server())
await typeLine(wrapper, 'ARA SYSTEM')
@@ -35,7 +35,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -47,6 +47,7 @@ describe('SettingsLayout', () => {
['비밀번호', '/settings/account/password'],
['2단계 인증', '/settings/account/two-factor'],
['앱 비밀번호', '/settings/account/app-passwords'],
['메일 프로그램 연결', '/settings/account/mail-clients'],
['로그인 기록', '/settings/account/login-history'],
],
[