docs: 13단계 전환 준비 — 계약 6절 · 디자인 보드 넷 · README #10

Merged
ttipo merged 8 commits from feat/mail-cutover-prep into main 2026-11-02 19:18:08 +09:00
62 changed files with 4773 additions and 100 deletions
Showing only changes of commit 1881ec7a42 - Show all commits
+3
View File
@@ -10,9 +10,12 @@ import { useLargeFileAlerts } from '@/composables/useLargeFileAlerts'
import { useLogoutSync } from '@/composables/useLogoutBroadcast'
import { useMailRealtime } from '@/composables/useMailRealtime'
import { useNewMailNotifier } from '@/composables/useNewMailNotifier'
import { useSessionRestriction } from '@/composables/useSessionRestriction'
// 다른 탭에서 로그아웃하면 이 탭도 로그인 화면으로 간다 (계약 1절 "클라이언트 처리 규칙")
useLogoutSync()
// 세션에 제한(새 비밀번호 · 2단계 켜기 — 13단계)이 생기면 보호 화면을 떠나 로그인 틀의 그 단계로 간다
useSessionRestriction()
// 실시간 새 메일 — 로그인한 동안 앱 전체에서 돈다(설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게).
// 메일 화면은 이 소식을 받아 목록 · 개수를 맞춘다 (계약 2절 "실시간 알림" — MailLayout 의 useMailStatePolling).
+12 -2
View File
@@ -12,9 +12,13 @@ interface Props {
value: string
/** 그림의 이름 (스크린리더) */
label: string
/** 148 — 로그인 틀의 2단계 인증 켜기(양식 칸 360 안에 키를 옆에 둔다 — 13단계). 기본은 160 */
compact?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
compact: false,
})
/** 바깥 여백 칸 수 */
const QUIET_ZONE = 2
@@ -26,7 +30,7 @@ const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`)
<template>
<svg
class="qr-code"
:class="['qr-code', { 'qr-code--compact': compact }]"
role="img"
:aria-label="label"
:viewBox="viewBox"
@@ -58,6 +62,12 @@ const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`)
height: 10rem;
}
/* 148 — 로그인 틀 (디자인 원본 LoginTwoFactorSetup) */
.qr-code--compact {
width: 9.25rem;
height: 9.25rem;
}
.qr-code__background {
fill: var(--color-surface);
}
@@ -0,0 +1,97 @@
<script setup lang="ts">
// 로그인 틀의 주소 줄 (디자인 원본 LoginTwoFactor · LoginPasswordChange · LoginTwoFactorSetup)
// 48 — 바탕 #FBFBFC · 테두리, 사람 아이콘 + 주소(넘치면 줄임) + 오른쪽 끝 [다른 계정으로 로그인](44).
// 보내는 동안은 누를 수 없다 — disabled 가 아니라 aria-disabled(초점을 잃지 않게), 누름은 무시한다
import AppIcon from '@/components/AppIcon.vue'
import { LOGIN_TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
interface Props {
email: string
busy?: boolean
}
interface Emits {
(e: 'other-account'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
function onOtherAccount() {
if (!props.busy) emit('other-account')
}
</script>
<template>
<div class="account-row">
<AppIcon
name="user"
class="account-row__icon"
/>
<span
class="account-row__email"
data-account-email
>{{ email }}</span>
<button
type="button"
class="account-row__other"
:aria-disabled="busy ? 'true' : undefined"
data-account-other
@click="onOtherAccount"
>
{{ LOGIN_TWO_FACTOR_TEXT.otherAccount }}
</button>
</div>
</template>
<style scoped>
.account-row {
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--space-48);
padding: var(--space-1) var(--space-1) var(--space-1) var(--space-12);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-sunken);
}
.account-row__icon {
color: var(--color-text-muted);
}
.account-row__email {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.account-row__other {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-brand);
font-size: var(--font-size-label);
font-weight: 600;
}
.account-row__other:hover:not([aria-disabled='true']) {
color: var(--color-brand-strong);
background: var(--color-brand-subtle);
}
.account-row__other[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
+13 -10
View File
@@ -6,11 +6,14 @@
// - 코드 틀림(AUTH_006)은 칸 아래 빨간 글(부모가 칸을 비우고 초점을 돌려준다), 시도 과다(REQ_001)는 위쪽 상자 — 로그인 오류와 같은 자리 · 같은 모양
// - 확인 중: 「확인하는 중…」(로그인 단추와 같은 모양 · aria-busy), 칸은 읽기만, [다른 계정으로 로그인]도 누를 수 없다(aria-disabled).
// [확인]은 disabled 가 아니라 aria-disabled — 키보드 초점을 잃지 않게(시도 과다 · 장애 뒤에도 그 자리에 남는다). 바쁜 동안의 누름은 부모가 무시한다
// - 도움말은 문의 줄(13단계 — LoginSupportLine): 연락처가 있으면 "인증 앱을 쓸 수 없나요?" 다음 줄에 연락처, 없으면 지금 글
// 상태는 부모(LoginPage)가 가진다 — 비밀번호를 다시 실어 보내는 일이 부모 몫이라서. 고정 id: #login-code · #login-code-error · #login-code-alert
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import { LOGIN_TWO_FACTOR_TEXT, OTP_TEXT } from '@/shared/constants/account-security'
import { SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction'
import { normalizeOtpInput } from '@/shared/utils/account-security'
interface Props {
@@ -23,6 +26,8 @@ interface Props {
codeError: string | null
/** 위쪽 상자 — 사전 문구(REQ_001) */
alert: string | null
/** 문의 연락처 — 없으면 지금 글 (13단계) */
supportContact?: string | null
}
interface Emits {
@@ -31,7 +36,9 @@ interface Emits {
(e: 'other-account'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
supportContact: null,
})
const emit = defineEmits<Emits>()
const inputRef = ref<HTMLInputElement | null>(null)
@@ -147,9 +154,11 @@ defineExpose({
</button>
</form>
<p class="code-step__help">
{{ LOGIN_TWO_FACTOR_TEXT.help }}
</p>
<LoginSupportLine
:question="SUPPORT_CONTACT_TEXT.authenticatorQuestion"
:fallback="SUPPORT_CONTACT_TEXT.authenticatorFallback"
:contact="supportContact"
/>
<p class="code-step__note">
{{ LOGIN_TWO_FACTOR_TEXT.note }}
</p>
@@ -327,12 +336,6 @@ defineExpose({
cursor: progress;
}
.code-step__help {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.code-step__note {
margin: 0;
padding-top: var(--space-16);
@@ -0,0 +1,488 @@
<script setup lang="ts">
// 로그인 — 새 비밀번호 정하기 (13단계 — 디자인 원본 LoginPasswordChange · README "13단계 › 로그인 — 새 비밀번호 정하기" · 계약 6절 "비밀번호 바꾸기 강제")
// 세션에 restriction: passwordChange 가 붙었을 때 로그인 틀이 이 단계를 그린다. 틀은 로그인 화면과 같다:
// 제목 · 설명 → 주소 + [다른 계정으로 로그인] → (위쪽 빨간 상자 — 시도 과다) → 칸들 → [비밀번호 바꾸고 계속] → 맨 아래 안내(위쪽 선 · 12px).
// - 「임시 비밀번호」는 화면 메모리(LoginSecret — 로그인 때 넣은 것, 10분)에 없을 때만 맨 위에 보인다(새로고침 · 다른 탭 · 10분 지남)
// - 칸 규칙은 설정 › 비밀번호(PasswordChangeForm)와 같다: 8자 이상 · 임시 비밀번호와 다름(어기면 새 비밀번호 안내 글이 빨간 오류) · 확인 칸 일치 ·
// 임시 비밀번호를 비움(그 칸의 안내 글이 빨간 오류) · 128자 초과(VAL_001). 보내기 전에 보고, 처음 걸린 칸에 초점을 둔다
// - 2단계 인증을 켠 직원(코드 단계를 거쳐 왔거나 GET /two-factor 가 켜짐, 서버가 AUTH_007 로 코드를 요구함)은 맨 아래 「인증 앱 코드」 —
// 로그인 때 쓴 코드는 2분 동안 다시 받지 않아 앱의 다음 코드를 넣는다
// - 서버 거절은 칸 아래에(알림 없이): 임시 비밀번호 틀림 AUTH_005(메모리의 비밀번호였으면 지우고 칸을 보인다) · 엔진 규칙 VAL_001(새 비밀번호 칸) ·
// 코드 틀림 AUTH_006(칸을 비운다) · 코드 없음 AUTH_007(칸을 보인다). 시도 과다 REQ_001 은 위쪽 빨간 상자 — 칸 · 단추는 그대로
// - 보내는 동안 「바꾸는 중…」(aria-busy) · 칸은 읽기만 · [다른 계정으로 로그인]도 누를 수 없다. 요청 한도 30초(useAuth — 설정 › 비밀번호와 같다)
// - 바뀌면 새 비밀번호를 화면 메모리에 남기고(다음 단계 2단계 켜기의 지금 비밀번호) done — 갱신 · 다음 단계 · 이동은 부모(LoginPage)가 한다.
// 부모가 넘기지 못하면(finishError — 갱신 · me 모두 장애) 위쪽 상자에 사전 문구 + [다시 시도](retry-finish), 그동안 칸 · 단추는 막는다(이미 바꿨다)
// - 비밀번호 바꾸기 요청은 이 단계가 보낸다(useAuth) — 비밀번호 · 코드가 Pinia 액션의 인자로 개발 도구에 남지 않게
// 고정 id — #restricted-password-temporary · #restricted-password-next · #restricted-password-confirm · #restricted-password-code · #restricted-password-alert
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginAccountRow from '@/components/auth/LoginAccountRow.vue'
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
import PasswordField from '@/components/settings/PasswordField.vue'
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import type { LoginSecret } from '@/composables/useLoginSecret'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { LOGIN_PASSWORD_CHANGE_TEXT, RESTRICTED_STEP_TEXT } from '@/shared/constants/session-restriction'
import { isCompleteOtp } from '@/shared/utils/account-security'
import {
hasPasswordChangeProblem,
passwordChangeProblems,
type PasswordChangeProblems,
} from '@/shared/utils/password-change'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
interface Props {
email: string
/** 로그인 때 넣은 비밀번호(임시 비밀번호)의 화면 메모리 */
secret: LoginSecret
/** 코드 단계를 거쳐 왔다 — 2단계 인증을 켠 직원 */
knownTwoFactor?: boolean
/** 부모가 갱신 · 다음 단계로 넘기는 중 — 단추는 바쁨, 칸은 읽기만 */
finishing?: boolean
/** 바꿨지만 부모가 다음으로 넘기지 못했다 — 사전 문구 (위쪽 상자 + [다시 시도]) */
finishError?: string | null
}
interface Emits {
/** 바꿨다 — 새 비밀번호는 secret 에 남겼다 */
(e: 'done'): void
/** [다시 시도] — 부모가 다시 넘겨 본다 */
(e: 'retry-finish'): void
(e: 'other-account'): void
}
const props = withDefaults(defineProps<Props>(), {
knownTwoFactor: false,
finishing: false,
finishError: null,
})
const emit = defineEmits<Emits>()
/** 이 단계가 직접 보이는 서버 거절 — 알림을 끈다 (그 밖의 장애는 인터셉터의 알림) */
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001', 'AUTH_006', 'AUTH_007', 'REQ_001']
const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null }
const { changePassword } = useAuth()
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const temporaryRef = ref<InstanceType<typeof PasswordField> | null>(null)
const nextRef = ref<InstanceType<typeof PasswordField> | null>(null)
const confirmRef = ref<InstanceType<typeof PasswordField> | null>(null)
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
const temporary = ref<string>('')
const next = ref<string>('')
const confirm = ref<string>('')
const code = ref<string>('')
const showTemporary = ref<boolean>(false)
const showNext = ref<boolean>(false)
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
const codeError = ref<string | null>(null)
/** 서버가 코드를 요구했다(AUTH_007) — 상태를 다시 받지 못해도 칸을 보인다 */
const codeRequired = ref<boolean>(false)
/** 위쪽 빨간 상자 — 시도 과다(REQ_001) */
const alert = ref<string | null>(null)
const submitting = ref<boolean>(false)
let unmounted = false
const busy = computed(() => submitting.value || props.finishing)
/** 칸 · 단추를 막는다 — 바쁘거나, 이미 바꿨는데 넘기지 못했다(다시 보내면 안 된다) */
const blocked = computed(() => busy.value || props.finishError !== null)
/** 「임시 비밀번호」 칸 — 화면 메모리에 없을 때만 */
const askTemporary = computed(() => !props.secret.available.value)
/** 「인증 앱 코드」 칸 — 2단계 인증을 켠 직원 */
const showCode = computed(() => props.knownTwoFactor || twoFactorStore.isEnabled || codeRequired.value)
const temporaryError = computed(() =>
serverError.value?.field === 'current' ? serverError.value.message : null,
)
const nextError = computed(() => {
if (problems.value.next === 'tooLong') return getErrorMessage('VAL_001')
return serverError.value?.field === 'next' ? serverError.value.message : null
})
const confirmError = computed(() => (problems.value.confirm === 'mismatch' ? LOGIN_PASSWORD_CHANGE_TEXT.mismatch : null))
// 칸을 고치면 그 칸에 걸린 오류를 내린다 (임시 비밀번호를 고치면 "임시 비밀번호와 같음"도 다시 본다)
function setTemporary(value: string) {
temporary.value = value
problems.value = { ...problems.value, current: null, next: problems.value.next === 'rule' ? null : problems.value.next }
if (serverError.value?.field === 'current') serverError.value = null
}
function setNext(value: string) {
next.value = value
problems.value = { ...problems.value, next: null, confirm: null }
if (serverError.value?.field === 'next') serverError.value = null
}
function setConfirm(value: string) {
confirm.value = value
problems.value = { ...problems.value, confirm: null }
}
function setCode(value: string) {
code.value = value
codeError.value = null
}
function focusFirstProblem(found: PasswordChangeProblems) {
if (found.current) temporaryRef.value?.focus()
else if (found.next) nextRef.value?.focus()
else if (found.confirm) confirmRef.value?.focus()
}
function clearFields() {
temporary.value = ''
next.value = ''
confirm.value = ''
code.value = ''
showTemporary.value = false
showNext.value = false
}
async function focusCodeWithError(message: string) {
codeError.value = message
code.value = ''
await nextTick()
codeRef.value?.focus()
}
async function submit() {
if (blocked.value) return
alert.value = null
serverError.value = null
const remembered = props.secret.peek()
const current = remembered ?? temporary.value
const found = passwordChangeProblems({ current, next: next.value, confirm: confirm.value })
problems.value = found
const codeMissing = showCode.value && !isCompleteOtp(code.value)
codeError.value = codeMissing ? getErrorMessage('AUTH_007') : null
if (hasPasswordChangeProblem(found)) {
// 10분이 막 지나 「임시 비밀번호」 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점
await nextTick()
focusFirstProblem(found)
return
}
if (codeMissing) {
codeRef.value?.focus()
return
}
const session = authStore.generation
const newPassword = next.value
submitting.value = true
try {
await changePassword(
{ currentPassword: current, newPassword, ...(showCode.value ? { otpCode: code.value } : {}) },
{ silent: INLINE_CODES },
)
// 기다리는 사이 로그아웃 · 다른 계정 — 이 세션의 일이 아니다
if (authStore.generation !== session) return
clearFields()
problems.value = NO_PROBLEMS
// 바뀐 비밀번호가 다음 단계(2단계 켜기)의 지금 비밀번호다 — 화면이 사라졌으면 남기지 않는다
if (unmounted) return
props.secret.remember(newPassword)
emit('done')
} catch (error) {
const apiError = toApiError(error)
if (authStore.generation !== session) return
if (unmounted) {
if (INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
return
}
if (apiError.code === 'AUTH_005') {
// 메모리의 비밀번호였으면 지우고 칸을 보인다 — 그 칸 아래에 알린다
props.secret.forget()
serverError.value = { field: 'current', message: apiError.message }
await nextTick()
temporaryRef.value?.focus()
} else if (apiError.code === 'VAL_001') {
serverError.value = { field: 'next', message: apiError.message }
nextRef.value?.focus()
} else if (apiError.code === 'AUTH_006') {
await focusCodeWithError(apiError.message)
} else if (apiError.code === 'AUTH_007') {
// 그사이 다른 곳에서 2단계 인증을 켰다 — 칸을 보이고 상태를 다시 받는다
codeRequired.value = true
await focusCodeWithError(apiError.message)
void twoFactorStore.refresh()
} else if (apiError.code === 'REQ_001') {
alert.value = apiError.message
}
// 그 밖의 실패(메일 서버 장애 등)는 인터셉터가 알렸다 — 칸 · 단추는 그대로
} finally {
submitting.value = false
}
}
/** 처음 초점 — 보이는 첫 칸 */
function focus() {
if (askTemporary.value) temporaryRef.value?.focus()
else nextRef.value?.focus()
}
onBeforeUnmount(() => {
unmounted = true
clearFields()
})
// 코드 단계를 거치지 않았으면(새로고침 · 다른 탭) 2단계 인증 상태를 묻는다 — 제한된 세션도 부를 수 있다(계약 6절). 못 받아도 AUTH_007 이 칸을 부른다
if (!props.knownTwoFactor) void twoFactorStore.load()
defineExpose({ focus })
</script>
<template>
<div class="restricted-step">
<div class="restricted-step__heading">
<h1 class="restricted-step__title">
{{ LOGIN_PASSWORD_CHANGE_TEXT.title }}
</h1>
<p class="restricted-step__subtitle">
{{ LOGIN_PASSWORD_CHANGE_TEXT.subtitle }}
</p>
</div>
<LoginAccountRow
:email="email"
:busy="busy"
@other-account="emit('other-account')"
/>
<div
v-if="finishError"
id="restricted-password-finish-error"
class="restricted-step__alert restricted-step__alert--retry"
role="alert"
data-restricted-finish-error
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span class="restricted-step__alert-text">{{ finishError }}</span>
<button
type="button"
class="restricted-step__retry"
data-restricted-retry
@click="emit('retry-finish')"
>
{{ RESTRICTED_STEP_TEXT.retry }}
</button>
</div>
<p
v-else-if="alert"
id="restricted-password-alert"
class="restricted-step__alert"
role="alert"
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span>{{ alert }}</span>
</p>
<form
class="restricted-step__form"
novalidate
@submit.prevent="submit"
>
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
<input
type="text"
name="username"
autocomplete="username"
:value="email"
hidden
readonly
>
<PasswordField
v-if="askTemporary"
id="restricted-password-temporary"
ref="temporaryRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.temporary"
:model-value="temporary"
autocomplete="current-password"
:shown="showTemporary"
:toggle-label="LOGIN_PASSWORD_CHANGE_TEXT.showTemporary"
:hint="LOGIN_PASSWORD_CHANGE_TEXT.temporaryHint"
:hint-is-error="problems.current === 'missing'"
:error="temporaryError"
:readonly="blocked"
@update:model-value="setTemporary"
@toggle="showTemporary = !showTemporary"
/>
<PasswordField
id="restricted-password-next"
ref="nextRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.next"
:model-value="next"
autocomplete="new-password"
:shown="showNext"
:toggle-label="LOGIN_PASSWORD_CHANGE_TEXT.showNext"
:hint="LOGIN_PASSWORD_CHANGE_TEXT.nextHint"
:hint-is-error="problems.next === 'rule'"
:error="nextError"
:readonly="blocked"
@update:model-value="setNext"
@toggle="showNext = !showNext"
/>
<PasswordField
id="restricted-password-confirm"
ref="confirmRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.confirm"
:model-value="confirm"
autocomplete="new-password"
:shown="showNext"
:error="confirmError"
:readonly="blocked"
@update:model-value="setConfirm"
/>
<OtpCodeField
v-if="showCode"
id="restricted-password-code"
ref="codeRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.code"
:model-value="code"
:hint="LOGIN_PASSWORD_CHANGE_TEXT.codeHint"
:error="codeError"
:readonly="blocked"
@update:model-value="setCode"
/>
<button
type="submit"
class="restricted-step__submit"
:aria-disabled="blocked ? 'true' : undefined"
:aria-busy="busy ? 'true' : 'false'"
data-restricted-submit
>
{{ busy ? LOGIN_PASSWORD_CHANGE_TEXT.submitting : LOGIN_PASSWORD_CHANGE_TEXT.submit }}
</button>
</form>
<p class="restricted-step__note">
{{ LOGIN_PASSWORD_CHANGE_TEXT.note }}
</p>
</div>
</template>
<style scoped>
/* 로그인 화면의 패널과 같은 간격 — 묶음 사이 22 */
.restricted-step {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.restricted-step__heading {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.restricted-step__title {
margin: 0;
font-size: var(--font-size-heading-lg);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
}
.restricted-step__subtitle {
margin: 0;
color: var(--color-text-secondary);
}
/* 위쪽 빨간 상자 — 로그인 오류와 같은 모양 */
.restricted-step__alert {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
.restricted-step__alert-icon {
margin-top: var(--space-1);
}
/* 마쳤지만 넘기지 못함 — 사전 문구 + [다시 시도](36 — 밀도 예외 --size-notice-action) */
.restricted-step__alert--retry {
flex-wrap: wrap;
align-items: center;
}
.restricted-step__alert-text {
flex: 1 1 12rem;
min-width: 0;
}
.restricted-step__retry {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.restricted-step__retry:hover {
background: var(--color-surface-muted);
}
.restricted-step__form {
display: flex;
flex-direction: column;
gap: var(--space-14);
}
.restricted-step__submit {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--size-touch);
margin-top: var(--space-6);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-body);
font-weight: 600;
}
.restricted-step__submit:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 바꾸는 동안 — 흐리게 (disabled 가 아니라 aria-disabled — 초점을 잃지 않게, 누름은 무시한다) */
.restricted-step__submit[aria-disabled='true'] {
opacity: 0.7;
cursor: progress;
}
.restricted-step__note {
margin: 0;
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-small);
line-height: 1.6;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
// 로그인 틀의 문의 줄 (13단계 — 디자인 README "13단계 › 문의 줄" · 계약 6절 "문의 연락처")
// 연락처가 있으면 물음(12.5px 회색) 다음 줄에 연락처(#344054 · 500), 없거나 받지 못했으면 지금 글 그대로(fallback).
// 연락처는 운영 도구로 넣는 설정값이다 — 글자로만 그린다(텍스트 보간 — 링크 · HTML 없음). 길어도 낱말 가운데서 끊지 않는다
interface Props {
/** 물음 — "비밀번호를 잊으셨나요?" · "인증 앱을 쓸 수 없나요?" */
question: string
/** 연락처가 없을 때의 글 */
fallback: string
/** 연락처 한 줄 — 없으면 null */
contact: string | null
}
defineProps<Props>()
</script>
<template>
<p
class="support-line"
data-support-line
>
<template v-if="contact">
{{ question }}<br><span
class="support-line__contact"
data-support-contact
>{{ contact }}</span>
</template>
<template v-else>
{{ fallback }}
</template>
</p>
</template>
<style scoped>
.support-line {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
word-break: keep-all;
overflow-wrap: anywhere;
}
.support-line__contact {
color: var(--color-text-strong);
font-weight: 500;
}
</style>
@@ -0,0 +1,274 @@
<script setup lang="ts">
// 로그인 틀 · 2단계 인증 켜기의 ① — QR · 키 · [복사] (13단계 — 디자인 원본 LoginTwoFactorSetup · README "13단계 › 로그인 — 2단계 인증 켜기")
// 12단계 켜기 창의 ①(TwoFactorEnrollmentScan)을 양식 칸 360 에 맞춘 꼴이다: 제목 → 안내(12.5px) → QR(148) 오른쪽에 키 이름(12px 굵게) ·
// 32자 키(4자씩 · 두 줄 · 고정폭 12px — 띄어쓰기 없이 조각만, 골라 복사해도 키 그대로) · [복사](36) → 그 아래 "앱에는 「ARA SYSTEM (주소)」로 저장됩니다."
// - 키를 받는 동안은 QR 자리에 회색 상자(aria-busy), 못 받으면 사전 문구 + [다시 시도](부모가 다시 받는다)
// - 키는 부모(켜기 단계)의 화면 메모리에서만 받는다 — 여기서도 남기지 않는다. [복사]는 키를 띄어쓰기 없이
// 고정 id — #login-two-factor-key-label
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import QrCodeImage from '@/components/QrCodeImage.vue'
import type { ApiError } from '@/composables/useApi'
import { useToast } from '@/composables/useToast'
import { TWO_FACTOR_ENABLE_TEXT, TWO_FACTOR_KEY_GROUPS_PER_LINE } from '@/shared/constants/account-security'
import { LOGIN_TWO_FACTOR_SETUP_TEXT } from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
import { chunkItems, groupCharacters } from '@/shared/utils/account-security'
import { copyText } from '@/shared/utils/clipboard'
interface Props {
/** 등록 — 키 · otpauth 주소. 받는 중이거나 못 받았으면 null */
enrollment: TwoFactorEnrollment | null
status: 'loading' | 'ready' | 'error'
/** 못 받은 까닭 — 사전 문구 */
error: ApiError | null
/** 직원 주소 — "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." */
email: string
}
interface Emits {
(e: 'retry'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const toast = useToast()
/** 키의 줄 — 줄마다 4자 묶음 넷 */
const keyLines = computed(() =>
props.enrollment ? chunkItems(groupCharacters(props.enrollment.secret), TWO_FACTOR_KEY_GROUPS_PER_LINE) : [],
)
async function copyKey() {
const secret = props.enrollment?.secret
if (!secret) return
if (await copyText(secret)) toast.success(TWO_FACTOR_ENABLE_TEXT.copied)
else toast.error(TWO_FACTOR_ENABLE_TEXT.copyFailed)
}
</script>
<template>
<div class="login-scan">
<span class="login-scan__title">{{ LOGIN_TWO_FACTOR_SETUP_TEXT.scanTitle }}</span>
<p class="login-scan__hint">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.scanHint }}
</p>
<div
v-if="status === 'error' && error"
class="login-scan__load-error"
role="alert"
data-setup-load-error
>
<span>{{ error.message }}</span>
<button
type="button"
class="login-scan__retry"
@click="emit('retry')"
>
{{ TWO_FACTOR_ENABLE_TEXT.retry }}
</button>
</div>
<template v-else>
<div class="login-scan__row">
<div
class="login-scan__qr-frame"
:aria-busy="status === 'loading' ? 'true' : undefined"
>
<QrCodeImage
v-if="enrollment"
:value="enrollment.otpauthUrl"
:label="TWO_FACTOR_ENABLE_TEXT.qrLabel"
compact
/>
<span
v-else
class="login-scan__qr-placeholder"
>
<span class="visually-hidden">{{ TWO_FACTOR_ENABLE_TEXT.loading }}</span>
</span>
</div>
<div class="login-scan__key">
<span
id="login-two-factor-key-label"
class="login-scan__key-label"
>{{ TWO_FACTOR_ENABLE_TEXT.keyLabel }}</span>
<code
class="login-scan__key-value"
aria-labelledby="login-two-factor-key-label"
data-setup-key
>
<span
v-for="(line, lineIndex) in keyLines"
:key="lineIndex"
class="login-scan__key-line"
>
<span
v-for="(group, index) in line"
:key="index"
class="login-scan__key-group"
>{{ group }}</span>
</span>
</code>
<button
type="button"
class="login-scan__copy"
:aria-label="TWO_FACTOR_ENABLE_TEXT.copyLabel"
aria-describedby="login-two-factor-key-label"
:aria-disabled="enrollment ? undefined : 'true'"
data-setup-copy
@click="copyKey"
>
<AppIcon
name="copy"
:size="14"
/>
<span>{{ TWO_FACTOR_ENABLE_TEXT.copy }}</span>
</button>
</div>
</div>
<span class="login-scan__saved">{{ TWO_FACTOR_ENABLE_TEXT.savedAs(email) }}</span>
</template>
</div>
</template>
<style scoped>
.login-scan {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.login-scan__title {
font-weight: 600;
}
.login-scan__hint {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.login-scan__row {
display: flex;
align-items: flex-start;
gap: var(--space-12);
}
/* QR 상자 — 흰 바탕 · 테두리 · 안쪽 6 */
.login-scan__qr-frame {
flex: 0 0 auto;
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.login-scan__qr-placeholder {
display: block;
width: 9.25rem;
height: 9.25rem;
border-radius: var(--radius-sm);
background: var(--color-skeleton);
}
.login-scan__key {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-6);
}
.login-scan__key-label {
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 키 — 고정폭 12px, 4자 조각 사이를 여백으로(띄어쓰기 없이). 누르면 한 번에 고른다 */
.login-scan__key-value {
align-self: stretch;
display: block;
padding: var(--space-6) var(--space-8);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-panel);
font-family: Consolas, 'SFMono-Regular', Menlo, monospace;
font-size: var(--font-size-small);
line-height: 1.6;
color: var(--color-text);
user-select: all;
}
/* 한 줄 — 상자 폭을 다 차지해 두 줄로 쌓인다(골라 복사할 때 줄바꿈이 끼지 않게 block 이 아니라 inline-block) */
.login-scan__key-line {
display: inline-block;
width: 100%;
}
.login-scan__key-group {
display: inline-block;
white-space: nowrap;
}
.login-scan__key-group:not(:last-child) {
margin-right: 0.5em;
}
/* [복사] 36 — 밀도 예외 (--size-notice-action) */
.login-scan__copy {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.login-scan__copy:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.login-scan__copy[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.login-scan__saved {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* 키를 받지 못했을 때 — 사전 문구 + [다시 시도] */
.login-scan__load-error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-12);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
color: var(--color-danger);
}
.login-scan__retry {
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.login-scan__retry:hover {
background: var(--color-surface-muted);
}
</style>
@@ -0,0 +1,530 @@
<script setup lang="ts">
// 로그인 — 2단계 인증 켜기(필수) (13단계 — 디자인 원본 LoginTwoFactorSetup · README "13단계 › 로그인 — 2단계 인증 켜기(필수)" · 계약 6절 "2단계 인증 필수")
// 세션에 restriction: twoFactorSetup 이 붙었을 때 로그인 틀이 이 단계를 그린다. 틀은 로그인 화면과 같다:
// 제목 · 설명 → 주소 + [다른 계정으로 로그인] → (위쪽 빨간 상자) → 차례 ① QR · 키 ② 코드 (③ 지금 비밀번호) → [켜고 계속] → 문의 줄 → 맨 아래 안내.
// - 12단계 API 그대로 — 열 때마다 새 키(POST /two-factor/enrollments — 10분 안에 켠다), 켜기 POST /two-factor/activation.
// 키 · 주소는 이 단계의 화면 메모리에만 두고 떠나면(다른 계정 · 새로고침) 버린다
// - 지금 비밀번호는 화면 메모리(LoginSecret — 로그인 때 넣은 것, 방금 새 비밀번호를 정했으면 그것 · 10분)에서 쓴다. 없을 때만 ③ 칸
// - 코드 틀림 AUTH_006 — 칸 아래 · 칸을 비우고 초점(QR · 키는 그대로). 지금 비밀번호 틀림 AUTH_005 — 메모리의 비밀번호였으면 지우고 ③ 칸을 보인다.
// 둘 다 틀렸으면 코드부터(서버가 코드를 먼저 본다)
// - 시간 지남 · 그사이 바뀜 AUTH_008 — 다른 곳에서 이미 켰으면 이 단계를 건너뛴다(enabled-elsewhere), 아니면 위쪽 상자에 사전 문구 + 새 키(코드 칸은 비움)
// - 시도 과다 REQ_001 — 위쪽 상자. 켜는 동안 「켜는 중…」(aria-busy) · 칸은 읽기만 · [다른 계정으로 로그인]도 누를 수 없다. 요청 한도 30초(TWO_FACTOR_CHANGE_TIMEOUT_MS)
// - 켜기 요청은 이 단계가 보낸다(useAccountSecurity) — 비밀번호 · 코드가 Pinia 액션의 인자로 남지 않게. 스토어에는 비밀 없는 결과만(apply)
// - 켰으면 done — 갱신 · 이동 · 알림은 부모(LoginPage)가 한다. 부모가 넘기지 못하면(finishError) 위쪽 상자에 사전 문구 + [다시 시도](retry-finish),
// 그동안 칸 · 단추는 막는다(이미 켰다)
// - ③ 지금 비밀번호를 비우고 보내면 그 칸의 안내 글이 빨간 오류가 된다 (새 비밀번호 단계의 임시 비밀번호와 같다)
// 고정 id — #restricted-setup-code · #restricted-setup-password · #restricted-setup-alert
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginAccountRow from '@/components/auth/LoginAccountRow.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import LoginTwoFactorScan from '@/components/auth/LoginTwoFactorScan.vue'
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
import PasswordField from '@/components/settings/PasswordField.vue'
import { useAccountSecurity } from '@/composables/useAccountSecurity'
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
import type { LoginSecret } from '@/composables/useLoginSecret'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import {
LOGIN_TWO_FACTOR_SETUP_TEXT,
RESTRICTED_STEP_TEXT,
SUPPORT_CONTACT_TEXT,
} from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
import { isCompleteOtp } from '@/shared/utils/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
interface Props {
email: string
/** 지금 비밀번호의 화면 메모리 */
secret: LoginSecret
/** 문의 연락처 — 없으면 지금 글 */
supportContact: string | null
/** 부모가 갱신 · 이동하는 중 — 단추는 바쁨, 칸은 읽기만 */
finishing?: boolean
/** 켰지만 부모가 다음으로 넘기지 못했다 — 사전 문구 (위쪽 상자 + [다시 시도]) */
finishError?: string | null
}
interface Emits {
/** 켰다 — 상태는 스토어에 넣었다 */
(e: 'done'): void
/** 다른 곳에서 이미 켰다 — 이 단계를 건너뛴다 */
(e: 'enabled-elsewhere'): void
/** [다시 시도] — 부모가 다시 넘겨 본다 */
(e: 'retry-finish'): void
(e: 'other-account'): void
}
const props = withDefaults(defineProps<Props>(), {
finishing: false,
finishError: null,
})
const emit = defineEmits<Emits>()
/** 이 단계가 직접 보이는 서버 거절 — 알림을 끈다 */
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'AUTH_006', 'AUTH_008', 'REQ_001']
const api = useAccountSecurity()
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
const passwordRef = ref<InstanceType<typeof PasswordField> | null>(null)
/** 등록 — 비밀이 들어 있다. 반응형 깊이 없이 둔다 */
const enrollment = shallowRef<TwoFactorEnrollment | null>(null)
const enrollStatus = ref<'loading' | 'ready' | 'error'>('loading')
const enrollError = ref<ApiError | null>(null)
/** 위쪽 빨간 상자 — 처음부터 다시(AUTH_008) · 시도 과다(REQ_001) */
const alert = ref<string | null>(null)
const code = ref<string>('')
const password = ref<string>('')
const showPassword = ref<boolean>(false)
const codeError = ref<string | null>(null)
const passwordError = ref<string | null>(null)
/** ③ 지금 비밀번호를 비우고 보냈다 — 그 칸의 안내 글이 빨간 오류가 된다 */
const passwordMissing = ref<boolean>(false)
const submitting = ref<boolean>(false)
/** 등록을 받는 차례 — 다시 받으면 앞의 응답은 버린다 */
let enrollSeq = 0
let unmounted = false
const busy = computed(() => submitting.value || props.finishing)
/** 칸 · 단추를 막는다 — 바쁘거나, 이미 켰는데 넘기지 못했다(다시 보내면 안 된다) */
const blocked = computed(() => busy.value || props.finishError !== null)
/** ③ 지금 비밀번호 — 화면 메모리에 없을 때만 */
const askPassword = computed(() => !props.secret.available.value)
/** [켜고 계속]을 누를 수 없다 — 키가 아직 없거나 켜는 중 · 이미 켰다 */
const unavailable = computed(() => enrollStatus.value !== 'ready' || blocked.value)
function clearSecrets() {
enrollSeq += 1
enrollment.value = null
code.value = ''
password.value = ''
showPassword.value = false
}
/** 이 단계가 아직 있고 세션도 그대로인가 — 늦게 온 응답을 쓸지 */
function stillHere(session: number): boolean {
return !unmounted && authStore.generation === session
}
/** 그사이 다른 곳에서 켰는지 — 켰으면 이 단계를 건너뛴다(true) */
async function skipIfEnabledElsewhere(session: number): Promise<boolean> {
const status = await twoFactorStore.refresh()
if (!status?.isEnabled) return false
if (stillHere(session)) emit('enabled-elsewhere')
return true
}
async function startEnrollment() {
enrollSeq += 1
const current = enrollSeq
const session = authStore.generation
enrollment.value = null
enrollStatus.value = 'loading'
enrollError.value = null
try {
const result = await api.startTwoFactorEnrollment({ silent: true })
if (current !== enrollSeq || !stillHere(session)) return
enrollment.value = result
enrollStatus.value = 'ready'
} catch (caught) {
if (current !== enrollSeq || !stillHere(session)) return
const apiError = toApiError(caught)
// 이미 켜져 있다 — 다른 곳에서 켰다
if (apiError.code === 'AUTH_008' && (await skipIfEnabledElsewhere(session))) return
if (current !== enrollSeq || !stillHere(session)) return
enrollStatus.value = 'error'
enrollError.value = apiError
}
}
/** 처음부터 다시(AUTH_008) — 켜졌으면 건너뛰고, 아니면 위쪽 상자에 알리고 새 키로 다시 그린다 */
async function restart(error: ApiError, session: number) {
if (await skipIfEnabledElsewhere(session)) return
if (!stillHere(session)) return
alert.value = error.message
code.value = ''
codeError.value = null
passwordError.value = null
await startEnrollment()
await nextTick()
if (stillHere(session)) codeRef.value?.focus()
}
function setCode(value: string) {
code.value = value
codeError.value = null
}
function setPassword(value: string) {
password.value = value
passwordError.value = null
passwordMissing.value = false
}
async function submit() {
const current = enrollment.value
if (unavailable.value || !current) return
alert.value = null
const remembered = props.secret.peek()
const currentPassword = remembered ?? password.value
const codeProblem = isCompleteOtp(code.value) ? null : getErrorMessage('AUTH_007')
codeError.value = codeProblem
passwordError.value = null
passwordMissing.value = currentPassword === ''
if (codeProblem) {
codeRef.value?.focus()
return
}
if (passwordMissing.value) {
// 10분이 막 지나 ③ 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점
await nextTick()
passwordRef.value?.focus()
return
}
const session = authStore.generation
submitting.value = true
try {
const status = await api.activateTwoFactor(
{ enrollmentId: current.enrollmentId, otpCode: code.value, currentPassword },
{ silent: INLINE_CODES },
)
if (authStore.generation !== session) return
// 서버가 켰다 — 화면이 사라졌어도 상태는 바꾼다
twoFactorStore.apply(status)
if (unmounted) return
clearSecrets()
props.secret.forget()
emit('done')
} catch (caught) {
const apiError = toApiError(caught)
if (!stillHere(session)) return
if (apiError.code === 'AUTH_006') {
codeError.value = apiError.message
code.value = ''
codeRef.value?.focus()
} else if (apiError.code === 'AUTH_005') {
// 메모리의 비밀번호였으면 지우고 ③ 칸을 보인다 — 그 칸 아래에 알린다
props.secret.forget()
passwordError.value = apiError.message
await nextTick()
passwordRef.value?.focus()
} else if (apiError.code === 'AUTH_008') {
await restart(apiError, session)
} else if (apiError.code === 'REQ_001') {
alert.value = apiError.message
}
// 그 밖의 실패(메일 서버 장애 등)는 인터셉터가 알렸다
} finally {
submitting.value = false
}
}
/** 처음 초점 — 코드 칸 */
function focus() {
codeRef.value?.focus()
}
onMounted(() => {
void startEnrollment()
})
onBeforeUnmount(() => {
unmounted = true
clearSecrets()
})
defineExpose({ focus })
</script>
<template>
<div class="restricted-step">
<div class="restricted-step__heading">
<h1 class="restricted-step__title">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.title }}
</h1>
<p class="restricted-step__subtitle">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.subtitle }}
</p>
</div>
<LoginAccountRow
:email="email"
:busy="busy"
@other-account="emit('other-account')"
/>
<div
v-if="finishError"
id="restricted-setup-finish-error"
class="restricted-step__alert restricted-step__alert--retry"
role="alert"
data-restricted-finish-error
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span class="restricted-step__alert-text">{{ finishError }}</span>
<button
type="button"
class="restricted-step__retry"
data-restricted-retry
@click="emit('retry-finish')"
>
{{ RESTRICTED_STEP_TEXT.retry }}
</button>
</div>
<p
v-else-if="alert"
id="restricted-setup-alert"
class="restricted-step__alert"
role="alert"
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span>{{ alert }}</span>
</p>
<form
class="restricted-step__form"
novalidate
@submit.prevent="submit"
>
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
<input
type="text"
name="username"
autocomplete="username"
:value="email"
hidden
readonly
>
<ol class="restricted-step__steps">
<li class="restricted-step__step">
<span
class="restricted-step__number"
aria-hidden="true"
>1</span>
<div class="restricted-step__step-body">
<LoginTwoFactorScan
:enrollment="enrollment"
:status="enrollStatus"
:error="enrollError"
:email="email"
@retry="startEnrollment"
/>
</div>
</li>
<li class="restricted-step__step">
<span
class="restricted-step__number"
aria-hidden="true"
>2</span>
<div class="restricted-step__step-body">
<OtpCodeField
id="restricted-setup-code"
ref="codeRef"
variant="login"
:label="LOGIN_TWO_FACTOR_SETUP_TEXT.codeLabel"
:model-value="code"
:error="codeError"
:readonly="blocked"
@update:model-value="setCode"
/>
</div>
</li>
<li
v-if="askPassword"
class="restricted-step__step"
>
<span
class="restricted-step__number"
aria-hidden="true"
>3</span>
<div class="restricted-step__step-body">
<PasswordField
id="restricted-setup-password"
ref="passwordRef"
variant="login"
:label="LOGIN_TWO_FACTOR_SETUP_TEXT.passwordLabel"
:model-value="password"
autocomplete="current-password"
:shown="showPassword"
:toggle-label="LOGIN_TWO_FACTOR_SETUP_TEXT.showPassword"
:hint="LOGIN_TWO_FACTOR_SETUP_TEXT.passwordHint"
:hint-is-error="passwordMissing"
:error="passwordError"
:readonly="blocked"
@update:model-value="setPassword"
@toggle="showPassword = !showPassword"
/>
</div>
</li>
</ol>
<button
type="submit"
class="restricted-step__submit"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-busy="busy ? 'true' : 'false'"
data-restricted-submit
>
{{ busy ? LOGIN_TWO_FACTOR_SETUP_TEXT.submitting : LOGIN_TWO_FACTOR_SETUP_TEXT.submit }}
</button>
</form>
<LoginSupportLine
:question="SUPPORT_CONTACT_TEXT.authenticatorQuestion"
:fallback="SUPPORT_CONTACT_TEXT.authenticatorFallback"
:contact="supportContact"
/>
<p class="restricted-step__note">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.note }}
</p>
</div>
</template>
<style scoped>
/* 로그인 화면의 패널과 같은 간격 — 묶음 사이 22 */
.restricted-step {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.restricted-step__heading {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.restricted-step__title {
margin: 0;
font-size: var(--font-size-heading-lg);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
}
.restricted-step__subtitle {
margin: 0;
color: var(--color-text-secondary);
}
/* 위쪽 빨간 상자 — 로그인 오류와 같은 모양 */
.restricted-step__alert {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
.restricted-step__alert-icon {
margin-top: var(--space-1);
}
/* 마쳤지만 넘기지 못함 — 사전 문구 + [다시 시도](36 — 밀도 예외 --size-notice-action) */
.restricted-step__alert--retry {
flex-wrap: wrap;
align-items: center;
}
.restricted-step__alert-text {
flex: 1 1 12rem;
min-width: 0;
}
.restricted-step__retry {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.restricted-step__retry:hover {
background: var(--color-surface-muted);
}
.restricted-step__form {
display: flex;
flex-direction: column;
gap: var(--space-14);
}
/* 차례 1 · 2 · 3 — 청록 굵은 숫자(동그라미 없음) · 숫자 칸 22 · 사이 18 (12단계 켜기 창과 같다) */
.restricted-step__steps {
display: flex;
flex-direction: column;
gap: var(--space-18);
margin: 0;
padding: 0;
list-style: none;
}
.restricted-step__step {
display: grid;
grid-template-columns: var(--space-22) minmax(0, 1fr);
gap: var(--space-6);
}
.restricted-step__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.restricted-step__step-body {
min-width: 0;
}
.restricted-step__submit {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--size-touch);
margin-top: var(--space-6);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-body);
font-weight: 600;
}
.restricted-step__submit:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 키를 받는 중 · 켜는 동안 — 흐리게 (disabled 가 아니라 aria-disabled — 초점을 잃지 않게, 누름은 무시한다) */
.restricted-step__submit[aria-disabled='true'] {
opacity: 0.7;
cursor: progress;
}
.restricted-step__note {
margin: 0;
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-small);
line-height: 1.6;
color: var(--color-text-muted);
}
</style>
@@ -182,3 +182,75 @@ describe('MailListFrame (목록 칸의 틀)', () => {
expect(document.activeElement).toBe(section)
})
})
// 휴지통 · 스팸메일함 — 30일 줄 (13단계 — 계약 6절 "휴지통 · 스팸 30일" · 디자인 원본 MailNoticesCutover)
describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계)', () => {
let pinia: Pinia
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 2 }
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 1 }
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountBox(mailbox: Mailbox, messages: MessageSummary[], component: 'list' | 'table' = 'list') {
const store = useMailStore()
store.mailboxes = [INBOX, TRASH, JUNK]
store.currentMailboxId = mailbox.id
store.messages = messages
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${mailbox.id}`)
await router.isReady()
const page = component === 'list' ? MessageList : (await import('./MessageTable.vue')).default
return mount(page, { global: { plugins: [pinia, router] }, attachTo: document.body })
}
it('휴지통 — 목록 머리 바로 아래 회색 줄 「휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.」(늘 보임 · 닫기 없음)', async () => {
const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })])
const notice = wrapper.get('[data-retention-notice]')
expect(notice.text()).toBe('휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.')
expect(notice.find('button').exists()).toBe(false)
})
it('스팸메일함도 같은 줄 — 목록 보기(표)에서는 표 위', async () => {
const wrapper = await mountBox(JUNK, [summary('j1', { mailboxIds: ['junk'] })], 'table')
const notice = wrapper.get('[data-retention-notice]')
expect(notice.text()).toBe('스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.')
// 표 머리보다 앞
const table = wrapper.get('table').element
expect(notice.element.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('메일이 없으면 줄을 빼고 빈 상태 글에 같은 뜻을 붙인다', async () => {
const wrapper = await mountBox(TRASH, [])
expect(wrapper.find('[data-retention-notice]').exists()).toBe(false)
expect(wrapper.text()).toContain('휴지통이 비어 있습니다')
expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.')
})
it('다른 메일함 · 검색 결과에는 없다', async () => {
const inbox = await mountBox(INBOX, [summary('m1')])
expect(inbox.find('[data-retention-notice]').exists()).toBe(false)
inbox.unmount()
const store = useMailStore()
const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })])
store.listSource = { kind: 'search', q: '견적', scope: 'trash' }
await flushPromises()
expect(wrapper.find('[data-retention-notice]').exists()).toBe(false)
})
})
+49 -2
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
// 그 아래 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다),
// 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
@@ -21,7 +23,7 @@ import { useListArrivalNotice } from '@/composables/useListArrivalNotice'
import { useListPositionMemory } from '@/composables/useListPositionMemory'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { MAIL_SEARCH_TEXT, MAILBOX_RETENTION_NOTICES, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { isScheduledSource } from '@/shared/utils/mail-list-source'
@@ -58,6 +60,17 @@ const empty = computed(() =>
: emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
)
const emptyIcon = computed(() => (scheduled.value ? 'clock' : mailboxIcon({ role: mailbox.value?.role ?? null })))
/**
* 휴지통 · 스팸메일함의 30일 줄 (13단계 — 메일 서버가 들어간 날부터 30일 뒤 지운다). 검색 결과 · 예약됨 · 다른 메일함에는 없다.
* 메일이 없으면 줄을 빼고 빈 상태 글에 같은 뜻을 붙인다 (MAILBOX_EMPTY_HINTS)
*/
const retentionNotice = computed(() => {
if (search.value || scheduled.value) return null
const role = mailbox.value?.role ?? null
const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null
if (text === null) return null
return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text
})
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
@@ -134,6 +147,18 @@ watch(
:title-id="titleId"
:variant="variant"
/>
<p
v-if="retentionNotice"
:class="['mail-list-frame__retention', `mail-list-frame__retention--${variant}`]"
data-retention-notice
>
<AppIcon
name="clock"
:size="14"
class="mail-list-frame__retention-icon"
/>
<span>{{ retentionNotice }}</span>
</p>
<MailSearchScope v-if="search" />
<MailNotifyOffer
v-else-if="!scheduled"
@@ -219,6 +244,28 @@ watch(
background: var(--color-surface);
}
/* 휴지통 · 스팸메일함의 30일 줄 — 바탕 #F8F9FB · 아래 선 #EEF0F3 · 위아래 8 · 12.5px (디자인 원본 MailNoticesCutover). 왼쪽은 머리 글과 같은 선 */
.mail-list-frame__retention {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: var(--space-8) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.mail-list-frame__retention--list {
padding-left: var(--space-20);
}
.mail-list-frame__retention-icon {
flex: 0 0 auto;
color: var(--color-text-muted);
}
.mail-list-frame__more--split {
padding: var(--space-8) var(--space-12) var(--space-16);
}
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { nextTick } from 'vue'
import { CLOCK } from '@/composables/useClock'
import type { TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import TwoFactorGraceNotice from './TwoFactorGraceNotice.vue'
// 2단계 인증 필수의 유예 안내 줄 (13단계 — 디자인 README "13단계 › 유예 기간" · 계약 6절 "2단계 인증 필수")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
// 시작일 2026-12-01 0시(KST)
const DEADLINE = '2026-11-30T15:00:00.000Z'
const NOW = Date.UTC(2026, 10, 2, 1, 0, 0)
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null, isRequired: true, deadline: DEADLINE }
describe('TwoFactorGraceNotice', () => {
let pinia: Pinia
let requests: MockRequest[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountNotice(deadline: string | null, status: TwoFactorStatus = OFF, now = NOW) {
useAuthStore().markAuthenticated(USER, { restriction: null, twoFactorDeadline: deadline })
requests = installMockApi((request) => (request.url === '/two-factor' ? ok(status) : ok(null)))
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(TwoFactorGraceNotice, {
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(now) } },
})
await flushPromises()
return wrapper
}
it('유예 중이면 노란 줄 — 시작일 전날까지(굵게) + [지금 켜기] → 설정 › 2단계 인증, 닫기 단추는 없다', async () => {
const wrapper = await mountNotice(DEADLINE)
const bar = wrapper.get('[data-two-factor-grace-notice]')
expect(bar.attributes('role')).toBe('region')
expect(bar.attributes('aria-label')).toBe('2단계 인증 안내')
expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요 — 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기')
expect(bar.get('strong').text()).toBe('11월 30일까지 2단계 인증을 켜 주세요')
expect(bar.get('[data-two-factor-grace-action]').attributes('href')).toBe('/settings/account/two-factor')
expect(bar.findAll('button')).toHaveLength(0)
expect(requests.map((request) => request.url)).toEqual(['/two-factor'])
})
it('시작일이 내년이면 해를 붙인다', async () => {
const wrapper = await mountNotice('2027-01-03T15:00:00.000Z', { ...OFF, deadline: '2027-01-03T15:00:00.000Z' }, Date.UTC(2026, 11, 20))
expect(wrapper.get('strong').text()).toBe('2027년 1월 3일까지 2단계 인증을 켜 주세요')
})
it('이미 켰으면(다른 곳에서) 그리지 않고, 켜면 사라진다', async () => {
const enabled = await mountNotice(DEADLINE, { ...OFF, isEnabled: true })
expect(enabled.find('[data-two-factor-grace-notice]').exists()).toBe(false)
enabled.unmount()
setActivePinia((pinia = createPinia()))
const wrapper = await mountNotice(DEADLINE)
expect(wrapper.find('[data-two-factor-grace-notice]').exists()).toBe(true)
useTwoFactorStore().apply({ ...OFF, isEnabled: true })
await nextTick()
expect(wrapper.find('[data-two-factor-grace-notice]').exists()).toBe(false)
})
it('유예가 아니면(마감 없음 · 지남) 그리지 않고 2단계 인증 상태도 묻지 않는다(마감 없음)', async () => {
const none = await mountNotice(null)
expect(none.find('[data-two-factor-grace-notice]').exists()).toBe(false)
expect(requests).toEqual([])
none.unmount()
setActivePinia((pinia = createPinia()))
const passed = await mountNotice(DEADLINE, OFF, Date.parse(DEADLINE))
expect(passed.find('[data-two-factor-grace-notice]').exists()).toBe(false)
})
})
@@ -0,0 +1,113 @@
<script setup lang="ts">
// 2단계 인증 필수의 유예 안내 줄 (13단계 — 디자인 원본 LoginTwoFactorSetup "유예 기간" · README "13단계 › 유예 기간" · 계약 6절 "2단계 인증 필수")
// 메일 화면(목록 · 읽기 · 쓰기 · 검색)의 상단 바 바로 아래, 모듈 폭 전체에 노란 줄 하나 — 높이 40 · 줄 안 단추 36(안내 줄 밀도 예외):
// 방패 + "11월 30일까지 2단계 인증을 켜 주세요"(굵게) + " — 그 뒤에는 켜야 로그인할 수 있습니다." + [지금 켜기] → 설정 › 2단계 인증.
// - 날짜는 me · 갱신의 twoFactorDeadline(시작일 0시 KST)의 1ms 앞 — 시작일 전날. 올해가 아니면 해를 붙인다
// - 닫기 단추는 없다 — 켜야 사라진다(two-factor.store 가 켜짐을 알면 바로). 마감이 지났으면 그리지 않는다(다음 갱신 때 제한이 붙는다)
// - 마감을 받았으면 2단계 인증 상태를 한 번 묻는다 — 다른 곳에서 이미 켰으면 줄을 그리지 않게 (못 받으면 줄은 그대로)
import { computed, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useClock } from '@/composables/useClock'
import { TWO_FACTOR_GRACE_TEXT } from '@/shared/constants/session-restriction'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
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 clock = useClock()
const twoFactorRoute = { name: SETTINGS_ROUTES.twoFactor }
/** 줄의 날짜 ("11월 30일") — 줄을 그리지 않으면 null */
const day = computed(() => {
const deadline = authStore.twoFactorDeadline
if (deadline === null || twoFactorStore.twoFactor?.isEnabled === true) return null
const now = clock.now()
return isTwoFactorGraceActive(deadline, now) ? twoFactorDeadlineDayLabel(deadline, now) : null
})
watch(
() => authStore.twoFactorDeadline,
(deadline) => {
if (deadline !== null && twoFactorStore.status === 'idle') void twoFactorStore.load()
},
{ immediate: true },
)
</script>
<template>
<div
v-if="day"
class="grace-notice"
role="region"
:aria-label="TWO_FACTOR_GRACE_TEXT.region"
data-two-factor-grace-notice
>
<AppIcon
name="shield"
:size="15"
:stroke-width="1.9"
class="grace-notice__icon"
/>
<span class="grace-notice__text"><strong class="grace-notice__strong">{{ TWO_FACTOR_GRACE_TEXT.deadline(day) }}</strong>{{ TWO_FACTOR_GRACE_TEXT.barAfter }}</span>
<RouterLink
:to="twoFactorRoute"
class="grace-notice__action"
data-two-factor-grace-action
>
{{ TWO_FACTOR_GRACE_TEXT.action }}
</RouterLink>
</div>
</template>
<style scoped>
/* 노란 줄 40 — 밀도 예외 (--size-notice-bar). 바탕 #FFF7E6 · 아래 선 #F0D9A8 · 글 #7A5200 */
.grace-notice {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-notice-bar);
padding: var(--space-2) var(--space-6) var(--space-2) var(--space-20);
border-bottom: var(--border-width) solid var(--color-warning-border);
background: var(--color-warning-surface);
color: var(--color-warning-text);
}
.grace-notice__icon {
flex: 0 0 auto;
}
.grace-notice__text {
flex: 1 1 auto;
min-width: 0;
word-break: keep-all;
overflow-wrap: anywhere;
}
.grace-notice__strong {
font-weight: 600;
}
/* [지금 켜기] 36 — 밀도 예외 (--size-notice-action), 읽기 화면 안내 줄의 흰 단추와 같다 */
.grace-notice__action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-warning-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-warning-text);
font-weight: 600;
text-decoration: none;
}
.grace-notice__action:hover {
background: var(--color-warning-hover);
color: var(--color-warning-text);
}
</style>
@@ -92,11 +92,11 @@ const hasUnsavedChanges = computed(
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났으면 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
*/
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.isLoggedIn,
unloadActive: () => hasUnsavedChanges.value && authStore.isLoggedIn,
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.canUseApp,
unloadActive: () => hasUnsavedChanges.value && authStore.canUseApp,
})
/** 무엇이든 바꾸면 "저장했습니다"를 내린다 */
@@ -0,0 +1,199 @@
<script setup lang="ts">
// 프로그램별 연결 차례 — 펼침 목록 (13단계 — 디자인 원본 SettingsMailClients · README "13단계 › 설정 › 계정 › 메일 프로그램 연결")
// 테두리 상자 하나 · 줄 사이 선. 줄 52 — 아이콘 18(PC · 휴대폰) · 이름(굵게) / 설명(12px 회색) · 오른쪽 ⌄(펼치면 ⌃).
// - 줄 전체가 버튼(aria-expanded · 펼친 것만 aria-controls)이고 제목(h3) 안에 둔다. 펼친 차례는 region(이름 = 그 버튼) —
// 이름 글과 같은 선(왼쪽 44)에서 번호 줄(청록 굵은 숫자 — 동그라미 없음)
// - 처음에는 모두 접힘, 하나를 펼치면 다른 것은 접힌다(한 번에 하나). 다시 누르면 접힌다
// - 차례 글은 화면 상수(MAIL_CLIENT_GUIDES) — 호스트 이름은 굵게, 글자로만 그린다(v-html 없음)
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_CLIENT_GUIDES, MAIL_CLIENTS_TEXT, type MailClientGuide } from '@/shared/constants/mail-clients'
/** 펼친 프로그램 — 없으면 null */
const expandedId = ref<string | null>(null)
function buttonId(guide: MailClientGuide): string {
return `mail-client-${guide.id}-button`
}
function panelId(guide: MailClientGuide): string {
return `mail-client-${guide.id}-panel`
}
function toggle(guide: MailClientGuide) {
expandedId.value = expandedId.value === guide.id ? null : guide.id
}
</script>
<template>
<ul
class="client-guides"
:aria-label="MAIL_CLIENTS_TEXT.listLabel"
>
<li
v-for="guide in MAIL_CLIENT_GUIDES"
:key="guide.id"
class="client-guides__item"
:data-mail-client="guide.id"
>
<h3 class="client-guides__heading">
<button
:id="buttonId(guide)"
type="button"
class="client-guides__toggle"
:aria-expanded="expandedId === guide.id ? 'true' : 'false'"
:aria-controls="expandedId === guide.id ? panelId(guide) : undefined"
@click="toggle(guide)"
>
<AppIcon
:name="guide.icon"
:size="18"
class="client-guides__icon"
/>
<span class="client-guides__text">
<span class="client-guides__name">{{ guide.name }}</span>
<span class="client-guides__description">{{ guide.description }}</span>
</span>
<AppIcon
:name="expandedId === guide.id ? 'chevron-up' : 'chevron-down'"
:size="16"
class="client-guides__chevron"
/>
</button>
</h3>
<div
v-if="expandedId === guide.id"
:id="panelId(guide)"
class="client-guides__panel"
role="region"
:aria-labelledby="buttonId(guide)"
>
<ol class="client-guides__steps">
<li
v-for="(step, index) in guide.steps"
:key="index"
class="client-guides__step"
>
<span
class="client-guides__number"
aria-hidden="true"
>{{ index + 1 }}</span>
<span>
<template
v-for="(segment, segmentIndex) in step"
:key="segmentIndex"
>
<strong
v-if="typeof segment !== 'string'"
class="client-guides__strong"
>{{ segment.strong }}</strong>
<template v-else>{{ segment }}</template>
</template>
</span>
</li>
</ol>
</div>
</li>
</ul>
</template>
<style scoped>
.client-guides {
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
}
.client-guides__item + .client-guides__item {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.client-guides__heading {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
/* 줄 52 — 줄 전체가 누르는 곳 (이름 · 설명이 줄을 바꿔도) */
.client-guides__toggle {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-12);
min-height: var(--size-header-min);
padding: var(--space-6) var(--space-12) var(--space-6) var(--space-14);
border: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.client-guides__toggle:hover {
background: var(--color-surface-sunken);
}
/* 상자가 모서리를 자르므로(overflow) 초점 고리는 안쪽에 그린다 */
.client-guides__toggle:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.client-guides__icon {
color: var(--color-text-secondary);
}
.client-guides__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.client-guides__name {
font-weight: 600;
}
.client-guides__description {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.client-guides__chevron {
color: var(--color-text-muted);
}
/* 펼친 차례 — 이름 글과 같은 선(왼쪽 44)에서 */
.client-guides__panel {
padding: var(--space-2) var(--space-16) var(--space-16) var(--space-44);
}
.client-guides__steps {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
/* 청록 굵은 숫자 — 동그라미 없음 (숫자 칸 20) */
.client-guides__step {
display: grid;
grid-template-columns: var(--space-20) minmax(0, 1fr);
gap: var(--space-6);
}
.client-guides__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.client-guides__strong {
font-weight: 600;
}
</style>
@@ -3,6 +3,7 @@
// 받는 메일(IMAP) 호스트 · 포트 993 · SSL/TLS / 보내는 메일(SMTP) 호스트 · 포트 465 · SSL/TLS / 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호.
// 호스트 · 포트는 상수 하나(MAIL_CLIENT_SETTINGS)에 둔다 — 전 직원 전환 때 다시 본다.
// compact: 결과 창의 요약 — 비밀번호 줄 없이, 12.5px · 위 선 하나 (화면의 상자는 줄마다 선이 있는 테두리 상자)
// passwordNote: 비밀번호 줄의 글 — 메일 프로그램 연결 화면(13단계)은 2단계 인증 상태에 따라 바꾼다. 좁은 폭(600 이하)에서는 이름(12px 회색) 위 · 값 아래 한 칸
import { computed } from 'vue'
import { APP_PASSWORD_TEXT, MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security'
@@ -11,10 +12,13 @@ interface Props {
/** 직원 주소 — 사용자 이름 */
email: string
compact?: boolean
/** 비밀번호 줄의 글 — 없으면 앱 비밀번호 화면의 글 */
passwordNote?: string
}
const props = withDefaults(defineProps<Props>(), {
compact: false,
passwordNote: APP_PASSWORD_TEXT.passwordNote,
})
const { host, imapPort, smtpPort, security } = MAIL_CLIENT_SETTINGS
@@ -26,7 +30,7 @@ const rows = computed(() => [
{ term: APP_PASSWORD_TEXT.incoming, strong: host, rest: ` · ${serverLine(imapPort)}` },
{ term: APP_PASSWORD_TEXT.outgoing, strong: host, rest: ` · ${serverLine(smtpPort)}` },
{ term: APP_PASSWORD_TEXT.username, strong: props.email, rest: props.compact ? '' : APP_PASSWORD_TEXT.usernameNote },
...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: APP_PASSWORD_TEXT.passwordNote }]),
...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: props.passwordNote }]),
])
</script>
@@ -86,6 +90,23 @@ const rows = computed(() => [
font-weight: 600;
}
/* 좁은 폭 — 이름(12px 회색) 위 · 값 아래 한 칸 (디자인 원본 SettingsMailClients "좁은 폭") */
@media (max-width: 37.5rem) {
.client-settings:not(.client-settings--compact) {
grid-template-columns: minmax(0, 1fr);
}
.client-settings:not(.client-settings--compact) .client-settings__term {
padding-bottom: 0;
border-bottom: 0;
font-size: var(--font-size-small);
}
.client-settings:not(.client-settings--compact) .client-settings__value {
padding-top: var(--space-2);
}
}
/* 결과 창의 요약 — 이름 칸 124 · 12.5px · 위 선 하나 */
.client-settings--compact {
grid-template-columns: 7.75rem minmax(0, 1fr);
@@ -4,6 +4,7 @@
// - 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code"
// maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. 칸의 설명(aria-describedby)은 안내 → 오류 차례 (PasswordField 와 같다)
// - variant="login": 로그인 코드 칸 모양(폭 전체 · 테두리 #8A94A3 · 18px · 글자 사이 0.3em · 이름 12.5px 굵게) — 13단계 새 비밀번호 · 2단계 켜기 단계
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -23,6 +24,8 @@ interface Props {
compactLabel?: boolean
/** 대화상자를 열면 이 칸에 초점 (켜기 창 — 처음 초점은 코드 칸) */
autofocus?: boolean
/** 칸 모양 — 설정 화면(기본) · 로그인 틀 */
variant?: 'settings' | 'login'
}
interface Emits {
@@ -35,6 +38,7 @@ const props = withDefaults(defineProps<Props>(), {
readonly: false,
compactLabel: false,
autofocus: false,
variant: 'settings',
})
const emit = defineEmits<Emits>()
@@ -61,7 +65,7 @@ defineExpose({
</script>
<template>
<div class="otp-field">
<div :class="['otp-field', `otp-field--${variant}`]">
<label
:for="id"
:class="['otp-field__label', { 'otp-field__label--compact': compactLabel }]"
@@ -134,4 +138,26 @@ defineExpose({
background: var(--color-surface-panel);
color: var(--color-text-secondary);
}
/* 로그인 틀의 코드 칸 — 로그인 코드 단계(LoginCodeStep)와 같은 모양 */
.otp-field--login .otp-field__label {
font-size: var(--font-size-label);
color: var(--color-text-strong);
}
.otp-field--login .otp-field__input {
width: 100%;
border-color: var(--color-border-input);
font-size: var(--font-size-title-lg);
letter-spacing: 0.3em;
}
.otp-field--login .otp-field__input:focus {
border-color: var(--color-brand);
}
.otp-field--login .otp-field__input[aria-invalid='true'],
.otp-field--login .otp-field__input[aria-invalid='true']:focus {
border-color: var(--color-danger);
}
</style>
@@ -3,6 +3,7 @@
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. hintIsError 면 안내 글 자체가 오류가 된다 (새 비밀번호의 규칙)
// - 보기 단추의 이름은 바뀌지 않는다 — 켜짐은 aria-pressed 로 알린다 (아이콘만 눈 ↔ 빗금 눈)
// - 칸의 설명(aria-describedby)은 안내 → 오류 순서
// - variant="login": 로그인 틀의 칸 모양(테두리 #8A94A3 · 글자 14px · 이름 12.5px 굵게 — 13단계 새 비밀번호 · 2단계 켜기 단계, 디자인 원본 LoginPasswordChange)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -23,6 +24,10 @@ interface Props {
error?: string | null
/** 이름을 작게(12.5px · 회색) — 2단계 인증 끄기 창처럼 칸이 둘뿐인 확인 창 (디자인 원본 SettingsTwoFactor) */
compactLabel?: boolean
/** 칸 모양 — 설정 화면(기본) · 로그인 틀 */
variant?: 'settings' | 'login'
/** 보내는 중 — 읽기만 */
readonly?: boolean
}
interface Emits {
@@ -36,6 +41,8 @@ const props = withDefaults(defineProps<Props>(), {
hintIsError: false,
error: null,
compactLabel: false,
variant: 'settings',
readonly: false,
})
const emit = defineEmits<Emits>()
@@ -59,12 +66,12 @@ defineExpose({
</script>
<template>
<div class="password-field">
<div :class="['password-field', `password-field--${variant}`]">
<label
:for="id"
:class="['password-field__label', { 'password-field__label--compact': compactLabel }]"
>{{ label }}</label>
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid, 'is-readonly': readonly }]">
<input
:id="id"
ref="inputRef"
@@ -72,6 +79,7 @@ defineExpose({
:type="shown ? 'text' : 'password'"
:autocomplete="autocomplete"
:value="modelValue"
:readonly="readonly"
spellcheck="false"
autocapitalize="off"
:aria-invalid="invalid ? 'true' : 'false'"
@@ -163,4 +171,30 @@ defineExpose({
.password-field__toggle[aria-pressed='true'] {
color: var(--color-brand-strong);
}
/* 보내는 중 — 읽기만 (로그인 코드 칸과 같은 회색 바탕) */
.password-field__box.is-readonly {
background: var(--color-surface-panel);
color: var(--color-text-secondary);
}
/* 로그인 틀의 칸 — 이름 12.5px 굵게 · 테두리 #8A94A3 · 글자 14px (디자인 원본 Login · LoginPasswordChange) */
.password-field--login .password-field__label {
font-size: var(--font-size-label);
color: var(--color-text-strong);
}
.password-field--login .password-field__box {
border-color: var(--color-border-input);
font-size: var(--font-size-body);
}
.password-field--login .password-field__box:has(.form-input__control:focus) {
border-color: var(--color-brand);
}
.password-field--login .password-field__box.is-invalid,
.password-field--login .password-field__box.is-invalid:has(.form-input__control:focus) {
border-color: var(--color-danger);
}
</style>
@@ -66,9 +66,10 @@ function problemOf(problem: VacationProblem): string | null {
return problems.value.includes(problem) ? problemText : null
}
// 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => changed.value && saveState.value !== 'saving' && authStore.isLoggedIn,
unloadActive: () => changed.value && authStore.isLoggedIn,
active: () => changed.value && saveState.value !== 'saving' && authStore.canUseApp,
unloadActive: () => changed.value && authStore.canUseApp,
})
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
@@ -2,6 +2,7 @@
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
// 상단 바 바로 아래 모듈 폭 전체의 안내 줄은 notice 슬롯 (13단계 — 메일 화면의 2단계 인증 유예 줄)
import { computed } from 'vue'
import AppRail from './AppRail.vue'
@@ -29,6 +30,7 @@ const moduleLabel = computed(() => findAppModule(props.moduleId).label)
<slot name="search" />
</template>
</AppTopBar>
<slot name="notice" />
<div class="app-shell__module">
<slot />
</div>
+176 -1
View File
@@ -87,6 +87,8 @@ describe('에러 코드 사전', () => {
'AUTH_007', // 2단계 인증을 켠 직원의 요청에 코드가 없음 — 비밀번호 화면이 2단계 상태를 다시 받고 코드 칸을 보인다
'AUTH_008', // 등록이 없음 · 시간이 지남 · 그사이 바뀜 — 켜기 창이 처음부터 다시 한다
'AUTH_009', // 앱 비밀번호 상한 — 만들기 창이 칸 아래에 보이고 목록을 다시 받는다
// 웹메일 13단계 (전환 준비)
'AUTH_010', // 제한된 세션 — 알림 없이 me 로 제한을 다시 받아 로그인 틀의 그 단계로 (옛 토큰이면 갱신 뒤 한 번 다시 보낸다)
]
for (const code of usedByClient) {
@@ -255,6 +257,162 @@ describe('useApi 인터셉터', () => {
})
})
describe('useApi 403 AUTH_010 — 제한된 세션 (13단계 · 계약 6절)', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers({ toFake: ['Date'] })
clock += 60_000
vi.setSystemTime(clock)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
const lines = (requests: { method: string; url: string }[]) => requests.map((request) => `${request.method} ${request.url}`)
it('알림 없이 me 로 제한을 다시 받아 둔다 — 원래 요청은 다시 보내지 않고 AUTH_010 으로 끝낸다, 확인한 차례가 오른다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER)
const requests = installMockApi(({ url }) =>
url === '/auth/me' ? ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
)
const error = await rejectionOf(api.get('/mailboxes'))
expect(error.code).toBe('AUTH_010')
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me'])
expect(authStore.restriction).toBe('twoFactorSetup')
expect(authStore.isLoggedIn).toBe(true)
expect(authStore.restrictionChecks).toBe(1)
expect(toastMessages()).toEqual([])
})
it('같은 제한이 다시 확인돼도 확인한 차례는 오른다 — 앞 이동이 막혀 보호 화면에 남았으면 화면이 다시 옮긴다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
installMockApi(({ url }) =>
url === '/auth/me' ? ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
)
await rejectionOf(api.post('/messages', {}))
await rejectionOf(api.post('/messages', {}))
expect(authStore.restrictionChecks).toBe(2)
expect(toastMessages()).toEqual([])
})
it('me 를 받지 못하면(장애) 제한을 확인하지 못한 것 — AUTH_010 사전 문구로 알린다 (silent 요청은 알리지 않는다)', async () => {
useAuthStore().markAuthenticated(USER)
installMockApi(({ url }) => (url === '/auth/me' ? fail(503, 'SYS_001') : fail(403, 'AUTH_010')))
await rejectionOf(api.get('/mail-state', { silent: true }))
expect(toastMessages()).toEqual([])
await rejectionOf(api.post('/messages', {}))
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_010])
expect(useAuthStore().restrictionChecks).toBe(0)
})
it('모르는 제한(새 서버)도 제한이다 — 갱신 · 다시 보내기를 하지 않는다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER)
const requests = installMockApi(({ url }) =>
url === '/auth/me' ? ok({ ...USER, restriction: 'brandNew', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
)
await rejectionOf(api.get('/mailboxes'))
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me'])
expect(authStore.restriction).toBe('unknown')
expect(toastMessages()).toEqual([])
})
it('silent 를 주지 않은 요청이어도 알림을 띄우지 않는다 — 화면이 그 단계로 간다', async () => {
useAuthStore().markAuthenticated(USER)
installMockApi(({ url }) =>
url === '/auth/me' ? ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
)
await rejectionOf(api.post('/messages', {}))
expect(useToastStore().toasts).toEqual([])
})
it('me 가 제한 없음이면 옛 토큰이다 — 갱신으로 새 토큰을 받고 원래 요청을 한 번 다시 보낸다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER)
let refreshed = false
const requests = installMockApi(({ url }) => {
if (url === '/auth/me') return ok({ ...USER, restriction: null, twoFactorDeadline: null })
if (url === '/auth/refresh') {
refreshed = true
return ok({ ...USER, restriction: null, twoFactorDeadline: null })
}
return refreshed ? ok({ items: [] }) : fail(403, 'AUTH_010')
})
await expect(api.get('/mailboxes')).resolves.toEqual({ items: [] })
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me', 'POST /auth/refresh', 'GET /mailboxes'])
expect(authStore.restriction).toBeNull()
expect(toastMessages()).toEqual([])
})
it('다시 보낸 요청이 또 AUTH_010 이면 더 묻지 않고 끝낸다 (한 번만) — 확인하지 못했으니 사전 문구로 알린다', async () => {
useAuthStore().markAuthenticated(USER)
const requests = installMockApi(({ url }) => {
if (url === '/auth/me' || url === '/auth/refresh') return ok({ ...USER, restriction: null, twoFactorDeadline: null })
return fail(403, 'AUTH_010')
})
const error = await rejectionOf(api.get('/mailboxes'))
expect(error.code).toBe('AUTH_010')
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me', 'POST /auth/refresh', 'GET /mailboxes'])
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_010])
})
it('동시에 여러 요청이 AUTH_010 을 받아도 me 는 한 번만 묻는다', async () => {
useAuthStore().markAuthenticated(USER)
let releaseMe: (result: MockResult) => void = () => undefined
const meGate = new Promise<MockResult>((resolve) => {
releaseMe = resolve
})
const requests = installMockApi(({ url }) => (url === '/auth/me' ? meGate : fail(403, 'AUTH_010')))
const first = rejectionOf(api.get('/mailboxes'))
const second = rejectionOf(api.get('/quota'))
await flushPromises()
releaseMe(ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
await Promise.all([first, second])
expect(requests.filter((request) => request.url === '/auth/me')).toHaveLength(1)
})
it('묻는 사이 로그아웃했으면 me 의 결과를 쓰지 않는다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER)
let releaseMe: (result: MockResult) => void = () => undefined
const meGate = new Promise<MockResult>((resolve) => {
releaseMe = resolve
})
installMockApi(({ url }) => (url === '/auth/me' ? meGate : fail(403, 'AUTH_010')))
const pending = rejectionOf(api.get('/mailboxes'))
await flushPromises()
authStore.clearAuth()
releaseMe(ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
await pending
expect(authStore.isLoggedIn).toBe(false)
expect(authStore.restriction).toBeNull()
expect(toastMessages()).toEqual([])
})
})
describe('useApi 401 처리 (계약 1절)', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -345,10 +503,27 @@ describe('useApi 401 처리 (계약 1절)', () => {
const authStore = useAuthStore()
installMockApi(() => ({ status: 200, data: { success: true, data: USER } }))
await expect(refreshSession()).resolves.toEqual({ ok: true, user: USER })
await expect(refreshSession()).resolves.toEqual({
ok: true,
user: USER,
session: { restriction: null, twoFactorDeadline: null },
})
expect(authStore.user).toEqual(USER)
})
it('갱신 응답의 제한 · 유예 마감(13단계)을 스토어에 둔다 — 시작일이 지나면 갱신 때 제한이 붙는다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER, { restriction: null, twoFactorDeadline: '2026-11-30T15:00:00.000Z' })
installMockApi(() => ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }))
const outcome = await refreshSession()
expect(outcome).toEqual({ ok: true, user: USER, session: { restriction: 'twoFactorSetup', twoFactorDeadline: null } })
expect(authStore.user).toEqual(USER)
expect(authStore.restriction).toBe('twoFactorSetup')
expect(authStore.twoFactorDeadline).toBeNull()
})
it('동시에 여러 요청이 401 을 받아도 갱신 요청은 한 번만 보낸다', async () => {
useAuthStore().markAuthenticated(USER)
let refreshed = false
+107 -10
View File
@@ -9,8 +9,9 @@ import {
FALLBACK_ERROR_CODE,
type ErrorCode,
} from '@/shared/constants/error-codes.generated'
import type { AuthUser } from '@/shared/types/auth'
import type { AuthSessionResponse, AuthSessionState, AuthUser } from '@/shared/types/auth'
import { joinUrl, resolveApiBaseUrl } from '@/shared/utils/api-url'
import { sessionStateOf, sessionUserOf } from '@/shared/utils/auth-session'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
@@ -28,6 +29,8 @@ declare module 'axios' {
_authRetried?: boolean
/** 내부용 — 요청을 보낼 때 로그인 세션이 있었는지 (401 을 세션 만료로 볼지 가른다) */
_sentWithSession?: boolean
/** 내부용 — 403 AUTH_010 뒤 제한을 다시 맞추고 이미 한 번 다시 보낸 요청인지 (한 번만 다시 보낸다 — 13단계) */
_restrictionRetried?: boolean
}
}
@@ -57,6 +60,12 @@ const API_TIMEOUT_MS = 10000
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
const DUPLICATE_ALERT_WINDOW_MS = 3000
/**
* 제한된 세션(13단계 — 계약 6절)으로 다른 API 를 불렀다. me 로 제한을 다시 받아 확인되면 알리지 않는다 — 화면이 로그인 틀의 그 단계로 간다
* (App 의 useSessionRestriction — 옮기지 못하면 거기서 사전 문구로 알린다). 확인하지 못했으면 사전 문구로 알린다
*/
const RESTRICTED_CODE: ErrorCode = 'AUTH_010'
/** 계약 1절 — 401 이어도 /auth/refresh 로 재시도하지 않는 인증 엔드포인트 */
const AUTH_ENDPOINTS = ['/auth/login', '/auth/refresh', '/auth/me', '/auth/logout'] as const
const REFRESH_ENDPOINT = '/auth/refresh'
@@ -177,22 +186,26 @@ function isSilenced(code: ErrorCode, silent: boolean | readonly ErrorCode[] | un
return silent.includes(code)
}
/** 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다. */
/**
* 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다.
* notify 가 false 면 알리지 않는다 — 화면이 다른 길로 안내할 때 (제한을 확인한 AUTH_010 — 로그인 틀의 그 단계로 간다)
*/
function handle(
rawCode: string,
traceId: string | undefined,
status: number | undefined,
config: InternalAxiosRequestConfig | undefined,
notify = true,
): ApiError {
const authStore = useAuthStore()
const sentWithSession = config?._sentWithSession ?? authStore.isLoggedIn
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE, sentWithSession)
const message = getErrorMessage(code)
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 — 사용자가 끝낸 세션이라 알리지 않는다
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 · 403 AUTH_010 — 사용자가 끝낸 세션이라 알리지 않는다
// (로그인 화면에 "로그인이 필요한 서비스입니다" 가 뜨지 않게)
const endedBySignOut = code === 'AUTH_001' && sentWithSession && authStore.signedOut
const endedBySignOut = (code === 'AUTH_001' || code === RESTRICTED_CODE) && sentWithSession && authStore.signedOut
if (!endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
if (notify && !endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
return { code, message, traceId, status }
}
@@ -225,7 +238,7 @@ export function isSessionEnd(code: ErrorCode): boolean {
return code === 'AUTH_001' || code === 'AUTH_002'
}
export type RefreshOutcome = { ok: true; user: AuthUser } | { ok: false; error: ApiError }
export type RefreshOutcome = { ok: true; user: AuthUser; session: AuthSessionState } | { ok: false; error: ApiError }
let refreshInFlight: Promise<RefreshOutcome> | null = null
@@ -257,15 +270,18 @@ export function refreshSession(): Promise<RefreshOutcome> {
const startedAt = useAuthStore().generation
refreshInFlight = api
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다
.post<AuthUser, AuthUser>(REFRESH_ENDPOINT, null, { silent: true })
.then(async (user): Promise<RefreshOutcome> => {
.post<AuthSessionResponse, AuthSessionResponse>(REFRESH_ENDPOINT, null, { silent: true })
.then(async (response): Promise<RefreshOutcome> => {
const authStore = useAuthStore()
if (authStore.generation !== startedAt || authStore.signedOut) {
await clearCookiesOfLateRefresh()
return { ok: false, error: { code: 'AUTH_001', message: getErrorMessage('AUTH_001') } }
}
authStore.markAuthenticated(user)
return { ok: true, user }
// 새 토큰의 제한 · 유예 마감도 함께 바꾼다 (13단계 — 시작일이 지나면 갱신 때 제한이 붙는다)
const user = sessionUserOf(response)
const session = sessionStateOf(response)
authStore.markAuthenticated(user, session)
return { ok: true, user, session }
})
.catch((error: unknown): RefreshOutcome => ({ ok: false, error: toApiError(error) }))
.finally(() => {
@@ -275,6 +291,79 @@ export function refreshSession(): Promise<RefreshOutcome> {
return refreshInFlight
}
/**
* 지금 세션 — me 를 알림 없이 묻는다(제한 · 유예 마감은 서버 세션 표의 지금 값). 13단계 — 제한을 다시 맞출 때 · 갱신이 장애로 실패했을 때.
* 세션 없이 보낸 요청으로 표시한다 — me 의 401 이 곧바로 세션 만료(AUTH_002)로 승격되지 않게 (갱신해 본 뒤에 정한다)
*/
export function fetchCurrentSession(): Promise<AuthSessionResponse> {
return api.get<AuthSessionResponse, AuthSessionResponse>(ME_ENDPOINT, { silent: true, _sentWithSession: false })
}
/**
* 403 AUTH_010 뒤 제한을 다시 맞춘 결과 —
* renewed: 옛 토큰이었다(제한 없이 새 토큰을 받음 — 원래 요청을 한 번 다시 보내도 된다) ·
* restricted: 제한을 확인했다(화면이 그 단계로 간다 — 알리지 않는다) · stale: 그사이 세션이 바뀌었다 · unknown: 확인하지 못했다(알린다)
*/
export type RestrictionCheck = 'renewed' | 'restricted' | 'stale' | 'unknown'
let restrictionCheck: Promise<RestrictionCheck> | null = null
/**
* 403 AUTH_010 을 받았다 — 알림 없이 me 로 세션의 제한을 다시 받는다 (계약 6절 "제한된 세션").
* - 제한이 있으면 스토어에 두고 확인한 차례를 올린다(noteRestrictionCheck) → 보호 화면이면 App 의 useSessionRestriction 이 로그인 틀의 그 단계로 보낸다.
* 같은 제한이 다시 와도 차례가 올라 다시 옮긴다(앞 이동이 막혔을 때). 모르는 제한(unknown)도 제한이다 — 갱신 · 다시 보내기를 하지 않는다
* - 제한이 없다는데 AUTH_010 이면 이 액세스 토큰이 제한을 풀기 전에 받은 옛것이다 — 갱신으로 새 토큰을 받는다 (renewed)
* - me 가 AUTH_001(액세스 토큰 만료)이면 갱신해 본다 (같은 규칙). 그 밖의 장애는 unknown
* 동시에 여러 요청이 AUTH_010 을 받아도 한 번만 묻는다. 그사이 세션이 바뀌었으면(로그아웃 · 다른 계정) 결과를 쓰지 않는다 (stale)
*/
export function recheckRestriction(): Promise<RestrictionCheck> {
if (!restrictionCheck) {
const startedAt = useAuthStore().generation
const sessionChanged = () => {
const authStore = useAuthStore()
return authStore.generation !== startedAt || authStore.signedOut
}
restrictionCheck = (async (): Promise<RestrictionCheck> => {
try {
const response = await fetchCurrentSession()
if (sessionChanged()) return 'stale'
const session = sessionStateOf(response)
if (session.restriction !== null) {
const authStore = useAuthStore()
authStore.markAuthenticated(sessionUserOf(response), session)
authStore.noteRestrictionCheck()
return 'restricted'
}
} catch (error) {
if (sessionChanged()) return 'stale'
if (!isApiError(error) || error.code !== 'AUTH_001') return 'unknown'
}
const outcome = await refreshSession()
if (sessionChanged()) return 'stale'
// 갱신이 세션 끝(401)이면 세션 만료 흐름(대화상자)이 맡는다
if (!outcome.ok) return isSessionEnd(outcome.error.code) ? 'stale' : 'unknown'
if (outcome.session.restriction !== null) {
useAuthStore().noteRestrictionCheck()
return 'restricted'
}
return 'renewed'
})().finally(() => {
restrictionCheck = null
})
}
return restrictionCheck
}
/** 로그인 중에 보낸 요청의 403 AUTH_010 — 제한을 다시 맞출 대상인가 (인증 엔드포인트 · 로그아웃 뒤의 응답은 아니다) */
function isRestrictedResponse(code: string, config: InternalAxiosRequestConfig): boolean {
return (
code === RESTRICTED_CODE &&
config._sentWithSession === true &&
!isAuthEndpoint(config.url) &&
!useAuthStore().signedOut
)
}
/**
* 이 탭에서 진행 중인 갱신(과 그 뒤의 정리용 로그아웃)이 끝날 때까지 기다린다 — 로그인 직전에 부른다.
* 늦게 온 갱신 응답의 Set-Cookie 가 새 로그인 쿠키를 덮거나, 정리용 로그아웃이 새 세션을 끊지 않게 한다.
@@ -376,6 +465,14 @@ api.interceptors.response.use(
}
}
// 제한된 세션(403 AUTH_010 — 13단계) — 제한을 다시 맞춘다. 옛 토큰이었으면 새 토큰으로 한 번만 다시 보낸다.
// 제한을 확인했으면 알리지 않는다(화면이 그 단계로 간다), 확인하지 못했거나 다시 보낸 요청도 막히면 사전 문구로 알린다
if (config && isRestrictedResponse(code, config)) {
const check: RestrictionCheck = config._restrictionRetried ? 'unknown' : await recheckRestriction()
if (check === 'renewed') return api.request({ ...config, _restrictionRetried: true })
return Promise.reject(handle(code, payload?.error?.traceId, status, config, check === 'unknown'))
}
return Promise.reject(handle(code, payload?.error?.traceId, status, config))
},
)
+54 -2
View File
@@ -20,6 +20,10 @@ import { useAuth } from './useAuth'
import { AUTH_CHANNEL_NAME, closeLogoutChannel } from './useLogoutBroadcast'
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
/** 제한 · 유예가 없는 세션 (13단계) */
const UNRESTRICTED = { restriction: null, twoFactorDeadline: null }
/** 2단계 필수의 시작일 2026-12-01 0시(KST) */
const DEADLINE = '2026-11-30T15:00:00.000Z'
let clock = Date.UTC(2026, 9, 26, 2, 0, 0)
function requestLine(request: { method: string; url: string }) {
@@ -55,6 +59,27 @@ describe('useAuth', () => {
expect(useAuthStore().user).toEqual(USER)
})
it('제한된 세션(13단계)이면 me 의 restriction · twoFactorDeadline 을 둔다 — 새로고침 · 다른 탭도 그 단계로', async () => {
installMockApi(() => ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }))
await useAuth().restoreSession()
const authStore = useAuthStore()
expect(authStore.user).toEqual(USER)
expect(authStore.restriction).toBe('twoFactorSetup')
})
it('복원이 리프레시로 끝나도 그 응답의 제한 · 유예 마감을 둔다', async () => {
installMockApi(({ url }) =>
url === '/auth/me' ? fail(401, 'AUTH_001') : ok({ ...USER, restriction: null, twoFactorDeadline: DEADLINE }),
)
await useAuth().restoreSession()
expect(useAuthStore().restriction).toBeNull()
expect(useAuthStore().twoFactorDeadline).toBe(DEADLINE)
})
it('/auth/me 가 AUTH_001 이면 알림 없이 리프레시로 한 번 더 시도한다 (액세스 토큰만 만료된 경우)', async () => {
const requests = installMockApi(({ url }) =>
url === '/auth/me' ? fail(401, 'AUTH_001') : ok(USER),
@@ -96,7 +121,7 @@ describe('useAuth', () => {
await expect(
useAuth().login({ email: USER.email, password: 'secret' }),
).resolves.toEqual({ status: 'signedIn', user: USER })
).resolves.toEqual({ status: 'signedIn', user: USER, session: UNRESTRICTED })
expect(requests[0]?.data).toEqual({ email: USER.email, password: 'secret' })
expect(useAuthStore().user).toEqual(USER)
@@ -108,10 +133,37 @@ describe('useAuth', () => {
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
status: 'signedIn',
user: USER,
session: UNRESTRICTED,
})
expect(useAuthStore().user).toEqual(USER)
})
it('제한된 세션(13단계 — 계약 6절)도 로그인한다 — 제한 · 유예 마감은 스토어에 두고 결과로도 돌려준다 (사용자 칸에는 넣지 않는다)', async () => {
installMockApi(() =>
ok({ status: 'signedIn', ...USER, restriction: 'passwordChange', twoFactorDeadline: DEADLINE }),
)
const authStore = useAuthStore()
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
status: 'signedIn',
user: USER,
session: { restriction: 'passwordChange', twoFactorDeadline: DEADLINE },
})
expect(authStore.user).toEqual(USER)
expect(authStore.isLoggedIn).toBe(true)
expect(authStore.restriction).toBe('passwordChange')
expect(authStore.twoFactorDeadline).toBe(DEADLINE)
})
it('모르는 제한 이름은 제한이 있는 것(unknown) · 읽을 수 없는 마감은 없는 것으로 읽는다', async () => {
installMockApi(() => ok({ status: 'signedIn', ...USER, restriction: 'somethingNew', twoFactorDeadline: 'soon' }))
await useAuth().login({ email: USER.email, password: 'secret' })
expect(useAuthStore().restriction).toBe('unknown')
expect(useAuthStore().twoFactorDeadline).toBeNull()
})
it('2단계 인증을 켠 직원(twoFactorRequired — 쿠키 없음)이면 세션을 열지 않고 그대로 돌려준다 (계약 5절)', async () => {
installMockApi(() => ok({ status: 'twoFactorRequired', email: null, name: null }))
const authStore = useAuthStore()
@@ -296,7 +348,7 @@ describe('useAuth', () => {
installMockApi(({ url }) => (url === '/auth/refresh' ? ok(USER) : fail(401, 'AUTH_001')))
const outcome = await useAuth().refresh()
expect(outcome).toEqual({ ok: true, user: USER })
expect(outcome).toEqual({ ok: true, user: USER, session: UNRESTRICTED })
expect(authStore.signedOut).toBe(false)
})
+30 -12
View File
@@ -4,7 +4,15 @@ import { ref } from 'vue'
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
import { announceLogout } from './useLogoutBroadcast'
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import type { AuthUser, ChangePasswordPayload, LoginOutcome, LoginPayload, LoginResponse } from '@/shared/types/auth'
import type {
AuthSessionResponse,
ChangePasswordPayload,
LoginOutcome,
LoginPayload,
LoginResponse,
SupportContact,
} from '@/shared/types/auth'
import { sessionStateOf, sessionUserOf } from '@/shared/utils/auth-session'
import { useAuthStore } from '@/stores/auth.store'
export type { LoginOutcome, LoginPayload } from '@/shared/types/auth'
@@ -23,7 +31,8 @@ export function useAuth() {
* 로그인. 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
* AUTH_004(자격 증명 불일치)는 로그인 폼이 직접 안내하므로 기본으로 그 코드만 전역 알림을 끈다.
* 2단계 인증을 켠 직원이고 비밀번호가 맞으면 쿠키 없이 twoFactorRequired 가 온다(12단계 — 계약 5절) — 세션을 열지 않고 그대로 돌려준다.
* 화면은 코드를 받아 비밀번호와 함께 다시 부른다(payload.otpCode)
* 화면은 코드를 받아 비밀번호와 함께 다시 부른다(payload.otpCode).
* 로그인한 세션에 제한(새 비밀번호 · 2단계 켜기 — 13단계 계약 6절)이 붙어 올 수 있다 — 스토어에 두고 결과로도 돌려준다
*/
async function login(payload: LoginPayload, options: LoginRequestOptions = {}): Promise<LoginOutcome> {
loading.value = true
@@ -37,10 +46,11 @@ export function useAuth() {
...options,
})
if (response.status === 'twoFactorRequired') return { status: 'twoFactorRequired' }
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다. 스토어에는 사용자 칸만 둔다
const user: AuthUser = { email: response.email, name: response.name }
authStore.startSession(user)
return { status: 'signedIn', user }
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다. 스토어에는 사용자 칸과 제한 · 유예 마감만 둔다
const user = sessionUserOf(response)
const session = sessionStateOf(response)
authStore.startSession(user, session)
return { status: 'signedIn', user, session }
} finally {
loading.value = false
}
@@ -67,9 +77,9 @@ export function useAuth() {
}
}
/** 현재 세션의 사용자. 로그인 전이면 AUTH_001 — 정상 상태이므로 알림을 띄우지 않는다 (계약 1절) */
function me(): Promise<AuthUser> {
return api.get<AuthUser, AuthUser>('/auth/me', { silent: ['AUTH_001'] })
/** 현재 세션의 사용자(+ 제한 · 유예 마감 — 13단계). 로그인 전이면 AUTH_001 — 정상 상태이므로 알림을 띄우지 않는다 (계약 1절) */
function me(): Promise<AuthSessionResponse> {
return api.get<AuthSessionResponse, AuthSessionResponse>('/auth/me', { silent: ['AUTH_001'] })
}
/**
@@ -78,14 +88,14 @@ export function useAuth() {
* 그러면 /auth/refresh 를 알림 없이 한 번 부르고, 성공하면 복원 완료 · 실패하면 로그아웃 상태로 시작한다.
* (/auth/me 는 인터셉터의 자동 재시도 대상이 아니라서 여기서 명시적으로 부른다)
*/
async function fetchSessionUser(): Promise<AuthUser | null> {
async function fetchSessionUser(): Promise<AuthSessionResponse | null> {
try {
return await me()
} catch (error) {
if (!isApiError(error) || error.code !== 'AUTH_001') return null
}
const outcome = await refreshSession()
return outcome.ok ? outcome.user : null
return outcome.ok ? { ...outcome.user, ...outcome.session } : null
}
/** 앱 시작 시 한 번 세션을 복원한다 (여러 번 불러도 조회는 한 번) */
@@ -107,5 +117,13 @@ export function useAuth() {
await api.put('/auth/password', payload, { timeout: PASSWORD_CHANGE_TIMEOUT_MS, ...options })
}
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword }
/**
* 문의 연락처 (13단계 — 계약 6절 "문의 연락처", 로그인 없음). 정하지 않았으면 text 가 null.
* 받지 못해도 화면은 지금 글 그대로라 알리지 않는다. 세션 없이 보낸 요청으로 표시한다 — 로그인 중 401 을 세션 만료로 승격하지 않게
*/
function getSupportContact(): Promise<SupportContact> {
return api.get<SupportContact, SupportContact>('/support-contact', { silent: true, _sentWithSession: false })
}
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword, getSupportContact }
}
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
// [계속 고치기]면 창을 그대로 두고(stayed — 브라우저가 닫았으면 다시 띄운다), [닫기]면 close
// - 창을 연 채 다른 화면으로: 이동 가드가 답을 기다린다(asking 'route' — [나가기]면 그대로 간다, [계속 고치기]면 이동을 취소한다)
// - 탭 닫기 · 새로고침: 바꾼 것이 있는 동안 beforeunload (브라우저 확인창 — 보내는 중에도)
// - 묻지 않는 때: 바꾼 것이 없다 · 보내는 중(저장 · 지우기 — 결과는 알림으로 알린다) · 세션이 끝났다 (서명 · 부재중 설정과 같다).
// - 묻지 않는 때: 바꾼 것이 없다 · 보내는 중(저장 · 지우기 — 결과는 알림으로 알린다) · 세션이 끝났거나 제한이 붙었다(13단계) (서명 · 부재중 설정과 같다).
// 묻는 중에 그렇게 되면 확인 창을 닫는다 — 닫기 확인은 창을 그대로 두고, 이동 확인은 그 이동을 취소한다(useLeaveConfirm)
// - 닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 이동 확인이 이어받는다 — 같은 확인 창에 글만 바뀐다
@@ -32,11 +32,11 @@ export function useDialogLeaveConfirm(options: DialogLeaveConfirmOptions) {
const closeConfirming = ref<boolean>(false)
/** 지금 닫거나 떠나면 잃는 것이 있다 — 이때만 묻는다 */
const guarded = () => options.changed() && !options.sending() && authStore.isLoggedIn
const guarded = () => options.changed() && !options.sending() && authStore.canUseApp
const route = useLeaveConfirm({
active: guarded,
unloadActive: () => options.changed() && authStore.isLoggedIn,
unloadActive: () => options.changed() && authStore.canUseApp,
})
const asking = computed<DialogLeaveAsking>(() => {
@@ -0,0 +1,70 @@
import { onBeforeUnmount, ref } from 'vue'
import type { Clock, ClockTimer } from './useClock'
import { RESTRICTED_SECRET_WINDOW_MS } from '@/shared/constants/session-restriction'
/**
* 제한된 로그인 단계의 비밀번호 (13단계 — 계약 6절 "비밀번호 바꾸기 강제" · "2단계 인증 필수" 클라이언트).
* 로그인 때 넣은 비밀번호(새 비밀번호를 정한 뒤에는 그것)를 새 비밀번호 단계의 「임시 비밀번호」 · 2단계 켜기의 「지금 비밀번호」로 다시 쓴다.
* 반응형 상태에 넣지 않는다(개발 도구 · 화면에 드러나지 않게) — 반응형은 "있는가"(available)뿐이다. 12단계 useLoginChallenge 와 같은 결.
* 지우는 때: 마지막 단계를 마침 · [다른 계정으로 로그인] · 화면을 떠날 때(unmount) · 10분이 지났을 때 · 서버가 그 비밀번호를 거절했을 때(AUTH_005).
* 10분은 타이머 하나에 기대지 않는다 — 절전 · 뒤에 깔린 탭에서는 타이머가 늦게 돈다. 꺼낼 때(peek) 시계로 한 번 더 잰다.
* 화면이 사라진 뒤에는 기억하지 않는다(remember 가 false) — 떠난 뒤 늦게 온 로그인 응답이 비밀번호를 붙잡지 않게.
* 시계는 주입받는다(useClock) — 테스트는 손으로 돌리는 시계로 10분을 넘긴다
*/
export function useLoginSecret(clock: Clock) {
/** 화면 메모리에 비밀번호가 있다 — 없으면 그 단계가 칸을 보인다 */
const available = ref<boolean>(false)
let secret: string | null = null
/** 기억한 시각 (clock.now) */
let rememberedAt = 0
let timer: ClockTimer | null = null
let disposed = false
function stopTimer() {
if (timer === null) return
clock.clearTimeout(timer)
timer = null
}
/** 비밀번호를 지운다 */
function forget() {
stopTimer()
secret = null
available.value = false
}
/** 서버가 방금 확인한 비밀번호를 10분 동안 기억한다. 화면이 이미 사라졌으면 기억하지 않고 false */
function remember(value: string): boolean {
if (disposed) return false
stopTimer()
secret = value
rememberedAt = clock.now()
available.value = true
timer = clock.setTimeout(() => {
timer = null
forget()
}, RESTRICTED_SECRET_WINDOW_MS)
return true
}
/** 기억한 비밀번호 — 없거나 10분이 지났으면(시계로 다시 잰다) null */
function peek(): string | null {
if (secret === null) return null
if (clock.now() - rememberedAt >= RESTRICTED_SECRET_WINDOW_MS) {
forget()
return null
}
return secret
}
onBeforeUnmount(() => {
disposed = true
forget()
})
return { available, remember, peek, forget }
}
export type LoginSecret = ReturnType<typeof useLoginSecret>
@@ -469,6 +469,41 @@ describe('useMailRealtime (실시간 연결)', () => {
})
describe('401 · 세션', () => {
it('제한된 세션(13단계 — 새 비밀번호 · 2단계 켜기 전)에는 열지 않는다 — 제한이 풀리면 연다', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
const realtime = useMailRealtimeStore(pinia)
const scope = effectScope()
scope.run(() => useMailRealtime({ clock, random: () => 0.5, fetch: server.fetch, isUserViewing: () => false }))
tabs.push({ scope, pinia, realtime, auth, statuses: [] })
await settle()
expect(server.connections).toHaveLength(0)
expect(realtime.status).toBe('off')
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
auth.markAuthenticated(USER, { restriction: null, twoFactorDeadline: null })
await settle()
expect(server.connections).toHaveLength(1)
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(true)
})
it('연결한 뒤 제한이 붙으면(갱신 때 2단계 필수의 시작일이 지남) 닫는다', async () => {
const tab = openTab()
await settle()
expect(server.connections).toHaveLength(1)
tab.auth.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
await settle()
expect(server.last().aborted).toBe(true)
expect(tab.realtime.status).toBe('off')
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
})
it('401 이면 세션을 한 번 갱신하고 바로 다시 잇는다', async () => {
server.reply({ kind: 'status', status: 401 })
const tab = openTab()
+9 -3
View File
@@ -55,6 +55,7 @@ import { useMailRealtimeStore, type MailRealtimeStatus } from '@/stores/mail-rea
// (탭마다 알림 · 그 탭의 쓰기 화면이 그 파일을 "위험 — 보낼 수 없음"으로 바꾼다)
//
// 로그아웃 · 세션 만료 · 다른 계정 로그인(authStore.generation) 때 잠금을 놓고 연결을 닫는다.
// 제한된 세션(13단계 — 새 비밀번호 · 2단계 켜기를 마치기 전, 계약 6절)에는 열지 않는다 — 제한이 풀리면 연다.
export interface NewMailContext {
/** 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus) */
@@ -531,7 +532,7 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
session?.stop()
session = null
const email = authStore.user?.email
if (!email) {
if (!email || authStore.restriction !== null) {
realtimeStore.setStatus('off')
return
}
@@ -548,8 +549,13 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
}
// 로그인 · 세션 복원으로 시작하고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 그만둔다(새 세션이면 다시 시작).
// 토큰 갱신(같은 세션)으로는 다시 시작하지 않는다 — 세대와 주소가 그대로다 (값마다 비교하도록 배열 소스로 둔다)
watch([() => authStore.generation, () => authStore.user?.email ?? null], restart, { immediate: true })
// 토큰 갱신(같은 세션)으로는 다시 시작하지 않는다 — 세대와 주소가 그대로다 (값마다 비교하도록 배열 소스로 둔다).
// 제한이 붙거나 풀리면 다시 본다 — 제한된 동안은 닫아 둔다 (13단계)
watch(
[() => authStore.generation, () => authStore.user?.email ?? null, () => authStore.restriction],
restart,
{ immediate: true },
)
onScopeDispose(() => {
session?.stop()
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h, nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import { useSessionRestriction } from './useSessionRestriction'
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const Host = defineComponent({
setup() {
useSessionRestriction()
return () => h('div')
},
})
describe('useSessionRestriction (13단계 — 계약 6절 "제한된 세션")', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
afterEach(() => {
useToastStore().clear()
document.body.innerHTML = ''
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(Host, { global: { plugins: [pinia, router] } })
return { wrapper, router }
}
it('보호 화면에 있는 동안 제한이 생기면(갱신 · AUTH_010 뒤 me) 로그인 틀로 바꿔 끼운다 — 돌아올 곳은 지금 화면', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER)
const { router } = await mountAt('/mail/inbox/m1?q=1')
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
await nextTick()
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/mail/inbox/m1?q=1')
})
it('가드가 이동을 막아 보호 화면에 남으면 AUTH_010 사전 문구로 알리고, 다음 AUTH_010(같은 제한의 재확인)에서 다시 옮긴다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER)
const { router } = await mountAt('/mail/compose')
let blocked = true
router.beforeEach((to) => !(blocked && to.name === 'login'))
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
await nextTick()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.AUTH_010])
// 같은 제한이 다시 확인됐다 — 값은 그대로지만 확인한 차례가 오른다 (useApi.recheckRestriction)
blocked = false
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
authStore.noteRestrictionCheck()
await nextTick()
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/mail/compose')
})
it('보호 화면이 아니면(로그인 · 공개 화면) 옮기지 않는다', async () => {
const authStore = useAuthStore()
authStore.markAuthenticated(USER)
const { router } = await mountAt('/s/token-abc')
authStore.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
await nextTick()
await flushPromises()
expect(router.currentRoute.value.name).toBe('public-share')
})
})
@@ -0,0 +1,36 @@
import { watch } from 'vue'
import { isNavigationFailure, NavigationFailureType, useRouter } from 'vue-router'
import { getErrorMessage, notifyApiError } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth.store'
/**
* 세션에 제한이 생기면 보호 화면을 떠나 로그인 틀의 그 단계로 간다 (13단계 — 계약 6절 "제한된 세션").
* 앱 최상위(App.vue)에서 한 번 부른다. 제한은 로그인할 때만 붙는 것이 아니다 — 2단계 인증 필수의 시작일이 지나면
* 이미 로그인한 세션도 다음 갱신 때 붙고, 403 AUTH_010 을 받으면 useApi 가 me 로 다시 받아 둔다.
* - 제한이 바뀔 때뿐 아니라 서버가 제한을 다시 확인해 줄 때마다(authStore.restrictionChecks) 옮긴다 — 앞 이동이 막혀
* 보호 화면에 남았어도 다음 AUTH_010 에서 다시 옮긴다
* - 나가기 확인은 제한된 세션에서 묻지 않는다(authStore.canUseApp). 그래도 다른 가드가 이동을 막아 보호 화면에 남으면
* AUTH_010 사전 문구로 알린다 — 그 상태의 [보내기] 등이 조용히 끝나지 않게
* 처음 들어올 때 · 주소를 바꿔 들어올 때는 라우터 가드가 같은 규칙으로 막는다 (router/index.ts).
* 돌아올 곳(redirect)은 지금 화면 — 단계를 마치면 로그인 화면이 그곳으로 보낸다
*/
export function useSessionRestriction(): void {
const authStore = useAuthStore()
const router = useRouter()
async function leaveProtectedRoute() {
const current = router.currentRoute.value
if (!current.meta.requiresAuth) return
const failure = await router.replace({ name: 'login', query: { redirect: current.fullPath } })
// 가드가 이동을 막았고 아직 제한된 채 보호 화면이다 — 알린다 (더 새 이동에 밀려 취소된 것은 그 이동이 맡는다)
const blocked = isNavigationFailure(failure, NavigationFailureType.aborted)
if (blocked && authStore.restriction !== null && router.currentRoute.value.meta.requiresAuth) {
notifyApiError({ code: 'AUTH_010', message: getErrorMessage('AUTH_010') })
}
}
watch([() => authStore.restriction, () => authStore.restrictionChecks], ([restriction]) => {
if (restriction !== null) void leaveProtectedRoute()
})
}
@@ -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'],
],
[
+25 -1
View File
@@ -1,13 +1,15 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { fail, installMockApi, restoreApiAdapter } from '@/test-utils/mock-api'
import { useAuthStore } from '@/stores/auth.store'
import { fail, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import router from './index'
// 라우터 가드 — 공개 화면(11단계 내려받기 /s/:token)은 세션을 묻지 않는다 (계약 4절 "화면 /s/{token}")
// 받는 쪽은 우리 직원이 아니다 — 쿠키 없는 바깥 브라우저에서 /auth/me · /auth/refresh 가 오가지 않게
const TOKEN = 'Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
describe('router 가드', () => {
beforeEach(() => {
@@ -41,4 +43,26 @@ describe('router 가드', () => {
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/settings/mail/large-files')
})
// 제한된 세션 (13단계 — 계약 6절) — 로그인은 됐지만 새 비밀번호 · 2단계 켜기를 마칠 때까지 보호 화면 대신 로그인 틀의 그 단계
it('제한된 세션이면 보호 화면 대신 로그인으로 — 돌아올 곳은 가려던 주소', async () => {
installMockApi(() => ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
await router.push('/contacts')
expect(useAuthStore().isLoggedIn).toBe(true)
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/contacts')
})
it('제한된 세션은 로그인 화면에 머문다 (그 단계를 그린다) — 제한이 풀리면 가려던 주소로 보낸다', async () => {
installMockApi(() => ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }))
await router.push('/login?redirect=/mail/inbox')
expect(router.currentRoute.value.name).toBe('login')
useAuthStore().markAuthenticated(USER, { restriction: null, twoFactorDeadline: null })
await router.push('/login?redirect=/settings/account/two-factor')
expect(router.currentRoute.value.fullPath).toBe('/settings/account/two-factor')
})
})
+4 -3
View File
@@ -35,11 +35,12 @@ router.beforeEach(async (to) => {
await useAuth().restoreSession()
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
// 로그인 후 원래 가려던 화면으로 돌아온다
// 로그인 전 · 제한된 세션(13단계 — 새 비밀번호 · 2단계 켜기를 마치기 전, 계약 6절)은 보호 화면 대신 로그인 틀로.
// 로그인 화면이 제한의 단계를 그리고, 끝나면 원래 가려던 화면으로 돌아온다
if (to.meta.requiresAuth && (!authStore.isLoggedIn || authStore.restriction !== null)) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.guestOnly && authStore.isLoggedIn) {
if (to.meta.guestOnly && authStore.isLoggedIn && authStore.restriction === null) {
return toSafeRedirect(to.query.redirect) ?? { name: 'mail' }
}
return true
+8 -1
View File
@@ -96,7 +96,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
// 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -122,6 +122,13 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/AccountAppPasswordsPage.vue'),
meta: { title: '앱 비밀번호' },
},
{
// 메일 프로그램 연결 (13단계 — 디자인 README "13단계 › 설정 › 계정 › 메일 프로그램 연결") — 화면 상수로만 그린다
path: 'account/mail-clients',
name: SETTINGS_ROUTES.mailClients,
component: () => import('@/pages/settings/AccountMailClientsPage.vue'),
meta: { title: '메일 프로그램 연결' },
},
{
path: 'account/login-history',
name: SETTINGS_ROUTES.loginHistory,
@@ -57,7 +57,6 @@ export const LOGIN_TWO_FACTOR_TEXT = {
codeLabel: '인증 앱에 표시된 6자리 코드',
submit: '확인',
submitting: '확인하는 중…',
help: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
note: 'Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.',
/** 코드 단계에 10분 넘게 머문 뒤 [확인] — 첫 단계의 위쪽 상자 */
expired: '코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.',
+7
View File
@@ -323,6 +323,13 @@ export const ICONS = {
monitor: [rect(2, 3, 20, 14, 2), line(8, 21, 16, 21), line(12, 17, 12, 21)],
/** 로그인된 브라우저 — 휴대폰 */
smartphone: [rect(5, 2, 14, 20, 2), line(12, 18, 12.01, 18)],
/** 설정 메뉴의 메일 프로그램 연결 — PC · 휴대폰 (13단계, lucide monitor-smartphone) */
'monitor-smartphone': [
path('M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8'),
path('M10 19v-3.96 3.15'),
path('M7 19h5'),
rect(16, 12, 6, 10, 2),
],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
@@ -0,0 +1,132 @@
// 설정 › 계정 › 메일 프로그램 연결 (13단계 — 계약 6절 "메일 프로그램 연결", 디자인 README "13단계 › 설정 › 계정 › 메일 프로그램 연결")
// API 는 없다 — 이 상수로만 그린다. 설정값(호스트 · 포트)은 앱 비밀번호 화면과 같은 MAIL_CLIENT_SETTINGS 하나.
// 차례 글은 README 그대로다 — 누르는 것 [ ], 고르는 것 「 」, 호스트 이름은 굵게. 판에 따라 메뉴 이름이 조금 다를 수 있다.
import { MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security'
import type { IconName } from '@/shared/constants/icons'
const { host, imapPort, smtpPort, security } = MAIL_CLIENT_SETTINGS
/** 차례 글의 한 조각 — 그냥 글 또는 굵은 글(호스트 이름) */
export type MailClientStepSegment = string | { strong: string }
export interface MailClientGuide {
/** 펼침 줄 · 차례의 id 앞머리 */
id: string
name: string
description: string
/** PC · 휴대폰 */
icon: Extract<IconName, 'monitor' | 'smartphone'>
steps: readonly (readonly MailClientStepSegment[])[]
}
export const MAIL_CLIENTS_TEXT = {
title: '메일 프로그램 연결',
description: '아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.',
settingsTitle: '메일 프로그램 설정값',
/** 설정값 상자의 비밀번호 줄 — 2단계 인증을 켠 직원 (상태를 모르면 이쪽 — 더 안전한 쪽) */
passwordOn: '앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
/** 설정값 상자의 비밀번호 줄 — 켜지 않은 직원 */
passwordOff: '앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다',
requiredTitle: '비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요',
requiredText: '2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다.',
recommendTitle: '앱 비밀번호로 연결하기를 권합니다',
recommendText:
'지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다.',
createAppPassword: '앱 비밀번호 만들기',
stepsTitle: '프로그램별 연결 차례',
stepsHint: '쓰는 프로그램을 누르면 차례가 펼쳐집니다.',
/** 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 이 줄을 지운다 */
cloudWarning:
'새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
listLabel: '메일 프로그램',
notes: [
'연결이 안 되면 사용자 이름에 메일 주소 전체를, 비밀번호 칸에 앱 비밀번호(띄어쓰기 없이 40자)를 넣었는지 확인해 주세요.',
'POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.',
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 프로그램마다 새로 만들어 넣어 주세요.',
'그래도 안 되면 관리자에게 문의해 주세요.',
],
/** 앱 비밀번호 화면의 설정값 상자 아래 한 줄 — 「메일 프로그램 연결」이 이 화면 링크 */
shortcutBefore: '프로그램별 연결 방법은 ',
shortcutLink: '메일 프로그램 연결',
shortcutAfter: '에서 볼 수 있습니다.',
} as const
const PORTS = `${imapPort} · ${smtpPort}`
/** 프로그램별 연결 차례 — 아웃룩 · 새 Outlook · 썬더버드 · iPhone · 안드로이드 (맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다) */
export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
{
id: 'outlook',
name: '아웃룩 (Windows)',
description: 'Microsoft 365 · Outlook 2016 이후의 데스크톱 Outlook',
icon: 'monitor',
steps: [
['Outlook 에서 [파일] → [계정 추가]를 누릅니다.'],
['메일 주소를 넣고 [고급 옵션] → 「내 계정을 수동으로 설정」을 고른 뒤 [연결]을 누릅니다.'],
['계정 유형에서 「IMAP」을 고릅니다.'],
[
'받는 메일 · 보내는 메일 모두 서버 ',
{ strong: host },
`, 포트는 ${PORTS}, 암호화 방법은 「${security}」를 넣고 [다음]을 누릅니다.`,
],
['비밀번호 칸에 앱 비밀번호를 넣고 [연결]을 누릅니다.'],
],
},
{
id: 'new-outlook',
name: '새 Outlook',
description: 'Windows 의 새 Outlook — 메일이 Microsoft 클라우드를 거칩니다',
icon: 'monitor',
steps: [
['[설정](톱니) → [계정] → [메일 계정] → [계정 추가]를 누릅니다.'],
['메일 주소를 넣고 [계속] → 공급자에서 「IMAP」을 고릅니다.'],
['[고급 설정 표시]를 누르고 받는 · 보내는 서버에 위 설정값을, 비밀번호 칸 둘 다에 앱 비밀번호를 넣습니다.'],
['[계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다 — 위 주의를 읽고 정합니다.'],
],
},
{
id: 'thunderbird',
name: '썬더버드',
description: 'Windows · Mac — 주소와 앱 비밀번호만 넣으면 됩니다',
icon: 'monitor',
steps: [
['썬더버드에서 [≡] → [새로 만들기] → [기존 메일 계정]을 누릅니다.'],
['이름 · 메일 주소 · 비밀번호(앱 비밀번호)를 넣고 [계속]을 누릅니다.'],
['메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다 — 「IMAP」이 보이면 [완료]를 누릅니다.'],
['찾지 못하면 [수동 설정]에서 위 설정값을 넣습니다.'],
],
},
{
id: 'iphone',
name: 'iPhone · iPad',
description: '기본 메일 앱',
icon: 'smartphone',
steps: [
['[설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일])'],
['이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다.'],
[
'「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 ',
{ strong: host },
', 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다.',
],
[`[다음] → [저장]을 누릅니다. 포트는 스스로 ${PORTS} 로 맞춥니다.`],
],
},
{
id: 'android',
name: '안드로이드',
description: 'Gmail 앱 — 다른 계정(IMAP) · 삼성 이메일 앱도 같은 값',
icon: 'smartphone',
steps: [
['Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.'],
['메일 주소를 넣고 [수동 설정] → 「개인(IMAP)」을 고릅니다.'],
['비밀번호에 앱 비밀번호를 넣습니다.'],
[
'받는 서버 · 보내는 서버에 ',
{ strong: host },
`, 포트는 ${PORTS}, 보안 유형은 「${security}」를 넣고 [다음]을 누릅니다.`,
],
],
},
]
+12 -2
View File
@@ -36,12 +36,22 @@ export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
inbox: '새 메일이 오면 여기에 표시됩니다.',
drafts: '임시 저장한 메일이 여기에 표시됩니다.',
sent: '보낸 메일이 여기에 표시됩니다.',
junk: '스팸으로 분류된 메일이 여기에 표시됩니다.',
trash: '삭제한 메일이 여기에 표시됩니다.',
// 휴지통 · 스팸메일함은 메일 서버가 30일 뒤 지운다 — 메일이 없을 때는 목록 위 줄 대신 이 글에 붙인다 (13단계 — 디자인 README "휴지통 · 스팸메일함 — 30일 줄")
junk: '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
trash: '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
archive: '보관한 메일이 여기에 표시됩니다.',
custom: '이 메일함으로 옮긴 메일이 여기에 표시됩니다.',
}
/**
* 휴지통 · 스팸메일함의 30일 안내 줄 (13단계 — 계약 6절 "휴지통 · 스팸 30일", 디자인 원본 MailNoticesCutover).
* 메일 서버가 그 메일함에 들어간 날부터 30일, 매일 새벽 3시(KST)에 지운다 — API 는 없고 목록 머리 아래 줄만 둔다
*/
export const MAILBOX_RETENTION_NOTICES: Readonly<Partial<Record<MailboxRole, string>>> = {
trash: '휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.',
junk: '스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.',
}
/** 받는 사람 칸을 보여 주는 메일함 — 목록에 보낸 사람 대신 "받는 사람: …" 을 쓴다 */
export const OUTGOING_MAILBOX_ROLES: readonly MailboxRole[] = ['sent', 'drafts']
@@ -0,0 +1,89 @@
// 전환 준비(13단계) — 제한된 세션의 로그인 단계 · 2단계 인증 필수의 유예 안내 · 문의 줄의 글
// 계약 6절 "전환 준비 — 13단계" · 디자인 README "13단계 — 전환 준비" (디자인 원본 LoginPasswordChange · LoginTwoFactorSetup · MailNoticesCutover).
// 서버가 거절한 까닭의 문구는 에러 코드 사전(AUTH_005 · AUTH_006 · AUTH_008 · VAL_001 · REQ_001)에서만 온다.
/**
* 제한된 단계에서 로그인 때 넣은 비밀번호를 화면 메모리에 두는 시간 — 넘으면 지우고 「임시 비밀번호」 · ③「지금 비밀번호」 칸을 보인다
* (계약 6절 "비밀번호 바꾸기 강제" 클라이언트 — 12단계 코드 단계와 같은 10분)
*/
export const RESTRICTED_SECRET_WINDOW_MS = 10 * 60 * 1000
/** 문의 연락처의 길이 상한 — 서버 설정값과 같은 200자 (넘는 글은 자른다) */
export const SUPPORT_CONTACT_MAX_LENGTH = 200
/** 제한된 단계 공용 — 단계는 마쳤지만 다음으로 넘기지 못했을 때(갱신 · me 모두 장애) 위쪽 상자의 단추 */
export const RESTRICTED_STEP_TEXT = {
retry: '다시 시도',
} as const
/** 로그인 — 새 비밀번호 정하기 (디자인 원본 LoginPasswordChange) */
export const LOGIN_PASSWORD_CHANGE_TEXT = {
title: '새 비밀번호를 정해 주세요',
subtitle: '관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.',
temporary: '임시 비밀번호',
showTemporary: '임시 비밀번호 보기',
/** 화면 메모리에 임시 비밀번호가 없을 때(새로고침 · 다른 탭 · 10분 지남) — 비우고 보내면 이 글이 빨간 오류가 된다 */
temporaryHint: '관리자에게 받은 임시 비밀번호를 다시 넣어 주세요.',
next: '새 비밀번호',
showNext: '새 비밀번호 보기',
/** 규칙(8자 이상 · 임시 비밀번호와 다름)에 맞지 않으면 이 글이 빨간 오류가 된다 */
nextHint: '8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
confirm: '새 비밀번호 확인',
mismatch: '새 비밀번호가 서로 다릅니다.',
/** 2단계 인증을 켠 직원만 — 맨 아래 (로그인 때 쓴 코드는 2분 동안 다시 받지 않는다) */
code: '인증 앱 코드',
codeHint: '로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.',
submit: '비밀번호 바꾸고 계속',
submitting: '바꾸는 중…',
note: '메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.',
/** 끝난 뒤 알림 */
done: '새 비밀번호를 정했습니다.',
} as const
/** 로그인 — 2단계 인증 켜기(필수) (디자인 원본 LoginTwoFactorSetup). QR · 키 · [복사]의 글은 12단계 TWO_FACTOR_ENABLE_TEXT */
export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
title: '2단계 인증을 켜야 합니다',
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.',
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
scanHint:
'인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.',
codeLabel: '앱에 표시된 6자리 코드',
/** 화면 메모리에 지금 비밀번호가 없을 때만 ③ */
passwordLabel: '지금 비밀번호',
showPassword: '지금 비밀번호 보기',
passwordHint: '새로고침해서 다시 넣어야 합니다.',
submit: '켜고 계속',
submitting: '켜는 중…',
note: 'Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.',
/** 켠 뒤 알림 — [앱 비밀번호 만들기](단추가 있는 알림 10초) */
done: '2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.',
doneAction: '앱 비밀번호 만들기',
} as const
/**
* 문의 줄 — 연락처(GET /support-contact)가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 지금 글 그대로
* (디자인 README "13단계 › 문의 줄")
*/
export const SUPPORT_CONTACT_TEXT = {
/** 로그인 첫 화면 */
passwordQuestion: '비밀번호를 잊으셨나요?',
passwordFallback: '비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.',
/** 로그인 코드 단계 · 2단계 인증 켜기 */
authenticatorQuestion: '인증 앱을 쓸 수 없나요?',
authenticatorFallback: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
} as const
/** 2단계 인증 필수의 유예 기간 — 메일 화면 위 노란 줄 · 설정 › 2단계 인증 (디자인 README "13단계 › 유예 기간") */
export const TWO_FACTOR_GRACE_TEXT = {
region: '2단계 인증 안내',
/** "11월 30일까지 2단계 인증을 켜 주세요" (굵게) — 날짜는 시작일 전날, 올해가 아니면 해를 붙인다 */
deadline: (day: string) => `${day}까지 2단계 인증을 켜 주세요`,
barAfter: ' — 그 뒤에는 켜야 로그인할 수 있습니다.',
action: '지금 켜기',
/** 설정 › 2단계 인증 머리 아래 갈색 상자의 둘째 줄 */
settingsText: '회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다.',
/** 필수인 직원이 켠 뒤 — [2단계 인증 끄기] 대신 */
cannotDisable: '회사 정책에 따라 끌 수 없습니다',
/** 필수인 직원이 켠 뒤 — 아래 안내의 첫 줄 (12단계 「끈 뒤 새 휴대폰으로 다시 켜 주세요」 대신) */
noteChangePhone: '휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.',
} as const
@@ -1,6 +1,6 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래 ·
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록)
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 · "13단계" — 앱 비밀번호 아래 메일 프로그램 연결)
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크)가 이 이름을 함께 쓴다.
@@ -12,6 +12,8 @@ export const SETTINGS_ROUTES = {
/** 2단계 인증 · 앱 비밀번호 · 로그인 기록 (12단계) */
twoFactor: 'settings-account-two-factor',
appPasswords: 'settings-account-app-passwords',
/** 메일 프로그램 연결 — 설정값 · 프로그램별 연결 차례 (13단계) */
mailClients: 'settings-account-mail-clients',
loginHistory: 'settings-account-login-history',
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
@@ -46,6 +48,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.password, label: '비밀번호', icon: 'lock' },
{ routeName: SETTINGS_ROUTES.twoFactor, label: '2단계 인증', icon: 'shield-check' },
{ routeName: SETTINGS_ROUTES.appPasswords, label: '앱 비밀번호', icon: 'key' },
{ routeName: SETTINGS_ROUTES.mailClients, label: '메일 프로그램 연결', icon: 'monitor-smartphone' },
{ routeName: SETTINGS_ROUTES.loginHistory, label: '로그인 기록', icon: 'history' },
],
},
@@ -6,6 +6,10 @@ export interface TwoFactorStatus {
isEnabled: boolean
/** 웹메일에서 켠 때 — 엔진 관리 화면에서 켰으면 null */
enabledAt: string | null
/** 이 직원에게 필수인가 (13단계 — 회사 정책 · 예외 주소). 없으면(13단계 전 서버) 필수가 아니다 */
isRequired?: boolean
/** 2단계 필수의 유예 마감 — 시작일 0시(KST), ISO (13단계). 유예 중이 아니면 null */
deadline?: string | null
}
/** POST /two-factor/enrollments (201 · no-store) — 비밀은 이 응답에만, 창을 닫으면 버린다 */
+34 -3
View File
@@ -7,6 +7,30 @@ export interface AuthUser {
name: string
}
/**
* 제한된 세션 (13단계 — 계약 6절 "제한된 세션") — 로그인은 됐지만 이 단계를 마칠 때까지 다른 API 는 403 AUTH_010 이다.
* 차례는 passwordChange(관리자가 준 임시 비밀번호 바꾸기) 먼저, 그다음 twoFactorSetup(회사가 2단계 인증을 필수로 정함).
* unknown — 이 화면이 모르는 제한 이름(새 서버). 제한이 있는 것으로 본다: 갱신 · 다시 보내기를 하지 않고, 로그인 틀은 AUTH_010 사전 문구와
* [다른 계정으로 로그인]만 보인다
*/
export type SessionRestriction = 'passwordChange' | 'twoFactorSetup' | 'unknown'
/** 세션에 붙은 상태 — 제한 · 2단계 인증 필수의 유예 마감 (13단계) */
export interface AuthSessionState {
restriction: SessionRestriction | null
/** 2단계 필수의 유예 중이면 시작일 0시(KST)의 시각 — ISO 8601 UTC. 아니면 null */
twoFactorDeadline: string | null
}
/**
* /auth/me · /auth/refresh 응답 (13단계에 restriction · twoFactorDeadline 이 더해졌다).
* 두 칸이 없는 응답(13단계 전 서버)은 제한 없음으로 읽는다 — shared/utils/auth-session.ts 의 sessionStateOf
*/
export interface AuthSessionResponse extends AuthUser {
restriction?: string | null
twoFactorDeadline?: string | null
}
/** POST /auth/login 요청 본문 */
export interface LoginPayload {
email: string
@@ -23,11 +47,13 @@ export interface LoginPayload {
* status 가 없는 응답(12단계 전 서버)은 signedIn 으로 읽는다
*/
export type LoginResponse =
| { status?: 'signedIn'; email: string; name: string }
| ({ status?: 'signedIn' } & AuthSessionResponse)
| { status: 'twoFactorRequired'; email: null; name: null }
/** 로그인의 결과 — 로그인했거나, 코드 단계로 가야 한다 */
export type LoginOutcome = { status: 'signedIn'; user: AuthUser } | { status: 'twoFactorRequired' }
/** 로그인의 결과 — 로그인했거나(제한이 붙었을 수 있다 — 13단계), 코드 단계로 가야 한다 */
export type LoginOutcome =
| { status: 'signedIn'; user: AuthUser; session: AuthSessionState }
| { status: 'twoFactorRequired' }
/**
* PUT /auth/password 요청 본문 (4단계) — 성공은 200 data: null, 지금 비밀번호가 틀리면 400 AUTH_005.
@@ -43,3 +69,8 @@ export interface ChangePasswordPayload {
*/
otpCode?: string
}
/** GET /support-contact (13단계 — 로그인 없음) — 문의 연락처. 정하지 않았으면 null (200자 이하 · 한 줄) */
export interface SupportContact {
text: string | null
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import {
isTwoFactorGraceActive,
restrictionOf,
sessionStateOf,
sessionUserOf,
twoFactorDeadlineDayLabel,
} from './auth-session'
// 시작일 2026-12-01 0시(KST) = 2026-11-30T15:00:00Z
const DEADLINE = '2026-11-30T15:00:00.000Z'
describe('restrictionOf', () => {
it('아는 제한 이름은 그대로, 없거나 빈 값은 null', () => {
expect(restrictionOf('passwordChange')).toBe('passwordChange')
expect(restrictionOf('twoFactorSetup')).toBe('twoFactorSetup')
expect(restrictionOf(null)).toBeNull()
expect(restrictionOf(undefined)).toBeNull()
expect(restrictionOf('')).toBeNull()
})
it('모르는 제한 이름(새 서버)은 제한이 있는 것(unknown)으로 읽는다 — 막힌 세션을 막히지 않은 것처럼 다루지 않게', () => {
expect(restrictionOf('somethingNew')).toBe('unknown')
expect(restrictionOf(1)).toBe('unknown')
})
})
describe('sessionStateOf · sessionUserOf', () => {
it('세션 응답에서 제한 · 유예 마감과 직원 칸을 따로 꺼낸다', () => {
const response = { email: 'ara.kim@arasystem.test', name: '김아라', restriction: 'passwordChange', twoFactorDeadline: DEADLINE }
expect(sessionStateOf(response)).toEqual({ restriction: 'passwordChange', twoFactorDeadline: DEADLINE })
expect(sessionUserOf(response)).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
it('두 칸이 없는 응답(13단계 전 서버)은 제한 없음 · 읽을 수 없는 마감은 null', () => {
expect(sessionStateOf({ email: 'a@b.c', name: 'a', restriction: 'brandNew' }).restriction).toBe('unknown')
expect(sessionStateOf({ email: 'a@b.c', name: 'a' })).toEqual({ restriction: null, twoFactorDeadline: null })
expect(sessionStateOf({ email: 'a@b.c', name: 'a', twoFactorDeadline: 'soon' }).twoFactorDeadline).toBeNull()
expect(sessionStateOf(null)).toEqual({ restriction: null, twoFactorDeadline: null })
})
})
describe('isTwoFactorGraceActive', () => {
it('마감이 아직 오지 않았을 때만 유예 중이다', () => {
expect(isTwoFactorGraceActive(DEADLINE, Date.parse(DEADLINE) - 1)).toBe(true)
expect(isTwoFactorGraceActive(DEADLINE, Date.parse(DEADLINE))).toBe(false)
expect(isTwoFactorGraceActive(null, 0)).toBe(false)
})
})
describe('twoFactorDeadlineDayLabel', () => {
it('시작일의 전날(KST)이다 — 시작일이 12월 1일이면 「11월 30일」', () => {
expect(twoFactorDeadlineDayLabel(DEADLINE, Date.parse('2026-11-02T03:00:00Z'))).toBe('11월 30일')
})
it('올해(KST)가 아니면 해를 붙인다 — 시작일 2027-01-04 면 「2027년 1월 3일」', () => {
expect(twoFactorDeadlineDayLabel('2027-01-03T15:00:00.000Z', Date.parse('2026-12-20T03:00:00Z'))).toBe('2027년 1월 3일')
})
it('지금이 한국 날짜로 새해면(UTC 로는 아직 지난해여도) 올해로 본다', () => {
// 2026-12-31T16:00Z = 2027-01-01 01:00 KST
expect(twoFactorDeadlineDayLabel('2027-01-03T15:00:00.000Z', Date.parse('2026-12-31T16:00:00Z'))).toBe('1월 3일')
})
it('읽을 수 없는 값이면 null', () => {
expect(twoFactorDeadlineDayLabel('soon', 0)).toBeNull()
})
})
+49
View File
@@ -0,0 +1,49 @@
// 세션 응답 읽기 · 2단계 인증 필수의 유예 날짜 — 계약 6절 "제한된 세션" · "2단계 인증 필수" (13단계 — 순수 함수만 둔다)
// /auth/login(signedIn) · /auth/me · /auth/refresh 의 restriction · twoFactorDeadline 을 화면이 쓰는 모양으로 바꾼다.
// 칸이 없거나 비었으면 없는 것(null) — 13단계 전 서버는 두 칸을 보내지 않는다. 모르는 제한 이름은 제한이 있는 것(unknown)으로 읽는다
// (막힌 세션을 막히지 않은 것처럼 다루지 않게). 읽을 수 없는 시각은 null.
import type { AuthSessionResponse, AuthSessionState, AuthUser, SessionRestriction } from '@/shared/types/auth'
import { kstDayOf } from '@/shared/utils/kst-date'
const KNOWN_RESTRICTIONS: readonly SessionRestriction[] = ['passwordChange', 'twoFactorSetup']
/** 제한 이름 — 없거나 빈 값이면 null, 아는 이름이면 그 이름, 그 밖의 값은 unknown(모르는 제한) */
export function restrictionOf(value: unknown): SessionRestriction | null {
if (value === null || value === undefined || value === '') return null
return KNOWN_RESTRICTIONS.find((restriction) => restriction === value) ?? 'unknown'
}
/** 세션 응답에서 직원 칸만 — 스토어의 user 에는 이것만 둔다 */
export function sessionUserOf(response: AuthSessionResponse): AuthUser {
return { email: response.email, name: response.name }
}
/** 세션 응답의 제한 · 유예 마감 — 칸이 없거나 읽을 수 없으면 null */
export function sessionStateOf(response: Partial<AuthSessionResponse> | null | undefined): AuthSessionState {
const deadline = response?.twoFactorDeadline
return {
restriction: restrictionOf(response?.restriction),
twoFactorDeadline: typeof deadline === 'string' && Number.isFinite(Date.parse(deadline)) ? deadline : null,
}
}
/** 유예 마감이 아직 오지 않았는가 — 지났으면 서버가 다음 갱신 때 제한(twoFactorSetup)을 붙인다 */
export function isTwoFactorGraceActive(deadline: string | null, now: number): boolean {
if (deadline === null) return false
const time = Date.parse(deadline)
return Number.isFinite(time) && time > now
}
/**
* 유예 안내의 날짜 — 시작일 0시(KST)의 1ms 앞, 곧 시작일 전날 (부재중의 끝나는 날과 같은 규칙).
* 올해(KST)면 "11월 30일", 아니면 해를 붙여 "2027년 1월 3일". 읽을 수 없으면 null
*/
export function twoFactorDeadlineDayLabel(deadline: string, now: number): string | null {
const time = Date.parse(deadline)
if (!Number.isFinite(time)) return null
const day = kstDayOf(time - 1)
const today = kstDayOf(now)
if (!day || !today) return null
return day.year === today.year ? `${day.month}월 ${day.day}일` : `${day.year}년 ${day.month}월 ${day.day}일`
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { supportContactTextOf } from './support-contact'
describe('supportContactTextOf', () => {
it('설정값을 한 줄로 다듬는다 — 줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지운다', () => {
expect(supportContactTextOf(' IT 담당 홍길동 ·\n02-000-0000 · 사내 메신저 @it ')).toBe(
'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it',
)
})
it('정하지 않음(null · 빈 글 · 글이 아닌 값)은 null — 화면은 지금 글 그대로', () => {
expect(supportContactTextOf(null)).toBeNull()
expect(supportContactTextOf(' ')).toBeNull()
expect(supportContactTextOf(42)).toBeNull()
})
it('200자에서 자른다', () => {
expect(supportContactTextOf('가'.repeat(250))).toHaveLength(200)
})
})
@@ -0,0 +1,12 @@
// 문의 연락처 — 서버 설정값을 화면에 보일 한 줄로 다듬는다 (13단계 — 계약 6절 "문의 연락처" · 순수 함수만 둔다)
// 서버가 200자 이하 · 한 줄로 받지만 화면도 한 번 더 지킨다: 줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지우고, 200자에서 자른다.
// 글자로만 보인다(링크 · HTML 없음 — 화면은 텍스트 보간으로만 그린다). 빈 글 · 글이 아닌 값은 null(정하지 않음 — 지금 글 그대로)
import { SUPPORT_CONTACT_MAX_LENGTH } from '@/shared/constants/session-restriction'
export function supportContactTextOf(value: unknown): string | null {
if (typeof value !== 'string') return null
const line = value.replace(/\s+/g, ' ').trim()
if (line === '') return null
return line.length > SUPPORT_CONTACT_MAX_LENGTH ? line.slice(0, SUPPORT_CONTACT_MAX_LENGTH) : line
}
+48 -7
View File
@@ -1,16 +1,38 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { AuthUser } from '@/shared/types/auth'
import type { AuthSessionResponse, AuthSessionState, AuthUser, SessionRestriction } from '@/shared/types/auth'
import { sessionStateOf, sessionUserOf } from '@/shared/utils/auth-session'
/** 제한 · 유예가 없는 세션 */
const UNRESTRICTED: AuthSessionState = { restriction: null, twoFactorDeadline: null }
// 인증 세션 상태 스토어
// 웹은 JWT 를 HttpOnly 쿠키로만 주고받으므로(보안 규칙) JS 에서 토큰을 저장·조회하지 않는다.
// 여기서는 "누가 로그인했는지"와 세션 복원·만료 상태만 관리한다.
// 13단계(계약 6절): 세션에 붙은 제한(restriction — 새 비밀번호 · 2단계 켜기를 마칠 때까지 다른 API 는 403 AUTH_010)과
// 2단계 인증 필수의 유예 마감(twoFactorDeadline)도 둔다. 제한이 있으면 라우터가 보호 화면 대신 로그인 틀의 그 단계로 보낸다.
// 로그인 · me · refresh 응답이 올 때마다 그 값으로 바꾼다 — 비밀(비밀번호 · 코드)은 여기 두지 않는다.
export const useAuthStore = defineStore('auth', () => {
/** 로그인한 직원 (email·name). null 이면 로그인 전이거나 세션이 정리된 상태 */
const user = ref<AuthUser | null>(null)
const isLoggedIn = computed<boolean>(() => user.value !== null)
/** 세션의 제한 (13단계) — 있으면 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기). 로그인 전이면 null */
const restriction = ref<SessionRestriction | null>(null)
/** 2단계 인증 필수의 유예 마감 (13단계 — 시작일 0시 KST, ISO). 유예 중이 아니면 null */
const twoFactorDeadline = ref<string | null>(null)
/**
* 보호 화면(메일 · 주소록 · 설정)을 쓸 수 있는 세션 — 로그인했고 제한이 없다.
* 나가기 확인은 이것으로 본다 — 제한된 세션은 세션이 끝난 때처럼 묻지 않는다(저장은 서버가 AUTH_010 으로 막는다)
*/
const canUseApp = computed<boolean>(() => user.value !== null && restriction.value === null)
/**
* 서버가 제한을 확인해 준 차례 (13단계) — 403 AUTH_010 뒤 me · 갱신이 제한을 알려 올 때마다 오른다.
* 같은 제한이 다시 와도(값은 그대로) 보호 화면에 남아 있으면 useSessionRestriction 이 다시 옮긴다
*/
const restrictionChecks = ref<number>(0)
/** 세션 만료(AUTH_002)로 정리됨 — SessionExpiredDialog 가 이 값을 보고 안내한다 */
const sessionExpired = ref<boolean>(false)
@@ -31,17 +53,29 @@ export const useAuthStore = defineStore('auth', () => {
const restored = ref<boolean>(false)
let restoring: Promise<void> | null = null
// 세션 복원·토큰 갱신 때 호출 — 같은 세션이 이어지므로 세대는 그대로다
function markAuthenticated(next: AuthUser) {
/** 세션 응답의 제한 · 유예 마감을 둔다 (주지 않으면 제한 없음) */
function applySessionState(state: AuthSessionState) {
restriction.value = state.restriction
twoFactorDeadline.value = state.twoFactorDeadline
}
// 세션 복원·토큰 갱신 때 호출 — 같은 세션이 이어지므로 세대는 그대로다. 제한 · 유예는 응답의 값으로 바꾼다
function markAuthenticated(next: AuthUser, state: AuthSessionState = UNRESTRICTED) {
user.value = next
applySessionState(state)
sessionExpired.value = false
signedOut.value = false
}
// 로그인 성공 때 호출 — 새 세션이다
function startSession(next: AuthUser) {
function startSession(next: AuthUser, state: AuthSessionState = UNRESTRICTED) {
generation.value += 1
markAuthenticated(next)
markAuthenticated(next, state)
}
/** 서버가 제한을 확인해 주었다 (403 AUTH_010 뒤 — useApi.recheckRestriction) */
function noteRestrictionCheck() {
restrictionChecks.value += 1
}
/**
@@ -51,6 +85,7 @@ export const useAuthStore = defineStore('auth', () => {
function clearAuth() {
generation.value += 1
user.value = null
applySessionState(UNRESTRICTED)
sessionExpired.value = false
signedOut.value = true
}
@@ -63,6 +98,7 @@ export const useAuthStore = defineStore('auth', () => {
if (!user.value) return
generation.value += 1
user.value = null
applySessionState(UNRESTRICTED)
sessionExpired.value = true
}
@@ -78,13 +114,13 @@ export const useAuthStore = defineStore('auth', () => {
* 조회 함수는 useAuth 가 넘긴다. 스토어가 직접 useApi 를 부르면
* useApi → auth.store → useApi 순환 import 가 생기기 때문이다.
*/
function restore(loadUser: () => Promise<AuthUser | null>): Promise<void> {
function restore(loadUser: () => Promise<AuthSessionResponse | null>): Promise<void> {
if (!restoring) {
const startedAt = generation.value
restoring = loadUser()
.then((loaded) => {
// 복원하는 사이 세대가 바뀌었으면(다른 탭의 로그아웃 알림 등) 늦게 온 결과를 쓰지 않는다
if (loaded && generation.value === startedAt) markAuthenticated(loaded)
if (loaded && generation.value === startedAt) markAuthenticated(sessionUserOf(loaded), sessionStateOf(loaded))
})
.catch(() => {
// 복원 실패 = 로그인 전 상태로 시작한다 (알림은 조회 쪽에서 이미 처리했다)
@@ -99,12 +135,17 @@ export const useAuthStore = defineStore('auth', () => {
return {
user,
isLoggedIn,
restriction,
twoFactorDeadline,
canUseApp,
restrictionChecks,
sessionExpired,
signedOut,
generation,
restored,
markAuthenticated,
startSession,
noteRestrictionCheck,
clearAuth,
expireSession,
acknowledgeSessionExpired,
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useToastStore } from '@/stores/toast.store'
import { fail, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
import { useSupportContactStore } from './support-contact.store'
// 문의 연락처 (13단계 — 계약 6절 "문의 연락처", GET /api/support-contact · 로그인 없음)
describe('support-contact.store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
it('한 번만 묻는다 — 몇 번을 불러도 요청은 하나, 받은 뒤에는 다시 묻지 않는다', async () => {
const requests = installMockApi(() => ok({ text: ' IT 담당 · 02-000-0000 ' }))
const store = useSupportContactStore()
await Promise.all([store.ensureLoaded(), store.ensureLoaded()])
await store.ensureLoaded()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /support-contact'])
expect(store.text).toBe('IT 담당 · 02-000-0000')
})
it('받지 못하면 알리지 않고 null(지금 글 그대로) — 다음에 다시 묻는다', async () => {
let reply: MockResult = fail(503, 'SYS_001')
const requests = installMockApi(() => reply)
const store = useSupportContactStore()
await store.ensureLoaded()
expect(store.text).toBeNull()
expect(useToastStore().toasts).toEqual([])
reply = ok({ text: null })
await store.ensureLoaded()
expect(requests).toHaveLength(2)
expect(store.text).toBeNull()
})
})
@@ -0,0 +1,39 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useAuth } from '@/composables/useAuth'
import { supportContactTextOf } from '@/shared/utils/support-contact'
// 문의 연락처 스토어 — 계약 6절 "문의 연락처" (13단계, GET /api/support-contact — 로그인 없음)
// 로그인 화면 · 코드 단계 · 2단계 켜기의 문의 줄이 쓴다. 한 번 받으면 이 탭이 열려 있는 동안 다시 묻지 않는다(운영 도구로 바꾸면 새로고침 때 반영).
// 받지 못하면 알리지 않고 null 로 둔다 — 화면은 지금 글 그대로다(디자인 README "13단계 › 문의 줄"). 실패했으면 다음에 화면을 열 때 다시 묻는다.
// 직원마다 다른 값이 아니라(공개 설정) 로그아웃 때 지우지 않는다
export const useSupportContactStore = defineStore('support-contact', () => {
/** 연락처 한 줄 — 정하지 않았거나 받지 못했으면 null */
const text = ref<string | null>(null)
let loading: Promise<void> | null = null
let loaded = false
/** 아직 받지 않았으면 받는다 (몇 번을 불러도 요청은 하나) */
function ensureLoaded(): Promise<void> {
if (loaded) return Promise.resolve()
if (!loading) {
loading = useAuth()
.getSupportContact()
.then((result) => {
text.value = supportContactTextOf(result?.text)
loaded = true
})
.catch(() => {
// 받지 못했다 — 지금 글 그대로 (다음에 다시 묻는다)
})
.finally(() => {
loading = null
})
}
return loading
}
return { text, ensureLoaded }
})
+7
View File
@@ -64,6 +64,13 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 13단계 — 메일 프로그램 연결 (router/routes.ts 와 같은 이름 · 모양)
{
path: '/settings/account/mail-clients',
name: 'settings-account-mail-clients',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/account/login-history',
name: 'settings-account-login-history',