generated from ttipo/project-base
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 까지만 — 넘으면 일은 그대로 두고 먼저 돌아온다 */
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user