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