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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:37:14 +09:00
parent 452d9fef1d
commit 1881ec7a42
62 changed files with 4773 additions and 100 deletions
+12 -2
View File
@@ -12,9 +12,13 @@ interface Props {
value: string
/** 그림의 이름 (스크린리더) */
label: string
/** 148 — 로그인 틀의 2단계 인증 켜기(양식 칸 360 안에 키를 옆에 둔다 — 13단계). 기본은 160 */
compact?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
compact: false,
})
/** 바깥 여백 칸 수 */
const QUIET_ZONE = 2
@@ -26,7 +30,7 @@ const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`)
<template>
<svg
class="qr-code"
:class="['qr-code', { 'qr-code--compact': compact }]"
role="img"
:aria-label="label"
:viewBox="viewBox"
@@ -58,6 +62,12 @@ const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`)
height: 10rem;
}
/* 148 — 로그인 틀 (디자인 원본 LoginTwoFactorSetup) */
.qr-code--compact {
width: 9.25rem;
height: 9.25rem;
}
.qr-code__background {
fill: var(--color-surface);
}
@@ -0,0 +1,97 @@
<script setup lang="ts">
// 로그인 틀의 주소 줄 (디자인 원본 LoginTwoFactor · LoginPasswordChange · LoginTwoFactorSetup)
// 48 — 바탕 #FBFBFC · 테두리, 사람 아이콘 + 주소(넘치면 줄임) + 오른쪽 끝 [다른 계정으로 로그인](44).
// 보내는 동안은 누를 수 없다 — disabled 가 아니라 aria-disabled(초점을 잃지 않게), 누름은 무시한다
import AppIcon from '@/components/AppIcon.vue'
import { LOGIN_TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
interface Props {
email: string
busy?: boolean
}
interface Emits {
(e: 'other-account'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
function onOtherAccount() {
if (!props.busy) emit('other-account')
}
</script>
<template>
<div class="account-row">
<AppIcon
name="user"
class="account-row__icon"
/>
<span
class="account-row__email"
data-account-email
>{{ email }}</span>
<button
type="button"
class="account-row__other"
:aria-disabled="busy ? 'true' : undefined"
data-account-other
@click="onOtherAccount"
>
{{ LOGIN_TWO_FACTOR_TEXT.otherAccount }}
</button>
</div>
</template>
<style scoped>
.account-row {
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--space-48);
padding: var(--space-1) var(--space-1) var(--space-1) var(--space-12);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-sunken);
}
.account-row__icon {
color: var(--color-text-muted);
}
.account-row__email {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.account-row__other {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-brand);
font-size: var(--font-size-label);
font-weight: 600;
}
.account-row__other:hover:not([aria-disabled='true']) {
color: var(--color-brand-strong);
background: var(--color-brand-subtle);
}
.account-row__other[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
+13 -10
View File
@@ -6,11 +6,14 @@
// - 코드 틀림(AUTH_006)은 칸 아래 빨간 글(부모가 칸을 비우고 초점을 돌려준다), 시도 과다(REQ_001)는 위쪽 상자 — 로그인 오류와 같은 자리 · 같은 모양
// - 확인 중: 「확인하는 중…」(로그인 단추와 같은 모양 · aria-busy), 칸은 읽기만, [다른 계정으로 로그인]도 누를 수 없다(aria-disabled).
// [확인]은 disabled 가 아니라 aria-disabled — 키보드 초점을 잃지 않게(시도 과다 · 장애 뒤에도 그 자리에 남는다). 바쁜 동안의 누름은 부모가 무시한다
// - 도움말은 문의 줄(13단계 — LoginSupportLine): 연락처가 있으면 "인증 앱을 쓸 수 없나요?" 다음 줄에 연락처, 없으면 지금 글
// 상태는 부모(LoginPage)가 가진다 — 비밀번호를 다시 실어 보내는 일이 부모 몫이라서. 고정 id: #login-code · #login-code-error · #login-code-alert
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import { LOGIN_TWO_FACTOR_TEXT, OTP_TEXT } from '@/shared/constants/account-security'
import { SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction'
import { normalizeOtpInput } from '@/shared/utils/account-security'
interface Props {
@@ -23,6 +26,8 @@ interface Props {
codeError: string | null
/** 위쪽 상자 — 사전 문구(REQ_001) */
alert: string | null
/** 문의 연락처 — 없으면 지금 글 (13단계) */
supportContact?: string | null
}
interface Emits {
@@ -31,7 +36,9 @@ interface Emits {
(e: 'other-account'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
supportContact: null,
})
const emit = defineEmits<Emits>()
const inputRef = ref<HTMLInputElement | null>(null)
@@ -147,9 +154,11 @@ defineExpose({
</button>
</form>
<p class="code-step__help">
{{ LOGIN_TWO_FACTOR_TEXT.help }}
</p>
<LoginSupportLine
:question="SUPPORT_CONTACT_TEXT.authenticatorQuestion"
:fallback="SUPPORT_CONTACT_TEXT.authenticatorFallback"
:contact="supportContact"
/>
<p class="code-step__note">
{{ LOGIN_TWO_FACTOR_TEXT.note }}
</p>
@@ -327,12 +336,6 @@ defineExpose({
cursor: progress;
}
.code-step__help {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.code-step__note {
margin: 0;
padding-top: var(--space-16);
@@ -0,0 +1,488 @@
<script setup lang="ts">
// 로그인 — 새 비밀번호 정하기 (13단계 — 디자인 원본 LoginPasswordChange · README "13단계 › 로그인 — 새 비밀번호 정하기" · 계약 6절 "비밀번호 바꾸기 강제")
// 세션에 restriction: passwordChange 가 붙었을 때 로그인 틀이 이 단계를 그린다. 틀은 로그인 화면과 같다:
// 제목 · 설명 → 주소 + [다른 계정으로 로그인] → (위쪽 빨간 상자 — 시도 과다) → 칸들 → [비밀번호 바꾸고 계속] → 맨 아래 안내(위쪽 선 · 12px).
// - 「임시 비밀번호」는 화면 메모리(LoginSecret — 로그인 때 넣은 것, 10분)에 없을 때만 맨 위에 보인다(새로고침 · 다른 탭 · 10분 지남)
// - 칸 규칙은 설정 › 비밀번호(PasswordChangeForm)와 같다: 8자 이상 · 임시 비밀번호와 다름(어기면 새 비밀번호 안내 글이 빨간 오류) · 확인 칸 일치 ·
// 임시 비밀번호를 비움(그 칸의 안내 글이 빨간 오류) · 128자 초과(VAL_001). 보내기 전에 보고, 처음 걸린 칸에 초점을 둔다
// - 2단계 인증을 켠 직원(코드 단계를 거쳐 왔거나 GET /two-factor 가 켜짐, 서버가 AUTH_007 로 코드를 요구함)은 맨 아래 「인증 앱 코드」 —
// 로그인 때 쓴 코드는 2분 동안 다시 받지 않아 앱의 다음 코드를 넣는다
// - 서버 거절은 칸 아래에(알림 없이): 임시 비밀번호 틀림 AUTH_005(메모리의 비밀번호였으면 지우고 칸을 보인다) · 엔진 규칙 VAL_001(새 비밀번호 칸) ·
// 코드 틀림 AUTH_006(칸을 비운다) · 코드 없음 AUTH_007(칸을 보인다). 시도 과다 REQ_001 은 위쪽 빨간 상자 — 칸 · 단추는 그대로
// - 보내는 동안 「바꾸는 중…」(aria-busy) · 칸은 읽기만 · [다른 계정으로 로그인]도 누를 수 없다. 요청 한도 30초(useAuth — 설정 › 비밀번호와 같다)
// - 바뀌면 새 비밀번호를 화면 메모리에 남기고(다음 단계 2단계 켜기의 지금 비밀번호) done — 갱신 · 다음 단계 · 이동은 부모(LoginPage)가 한다.
// 부모가 넘기지 못하면(finishError — 갱신 · me 모두 장애) 위쪽 상자에 사전 문구 + [다시 시도](retry-finish), 그동안 칸 · 단추는 막는다(이미 바꿨다)
// - 비밀번호 바꾸기 요청은 이 단계가 보낸다(useAuth) — 비밀번호 · 코드가 Pinia 액션의 인자로 개발 도구에 남지 않게
// 고정 id — #restricted-password-temporary · #restricted-password-next · #restricted-password-confirm · #restricted-password-code · #restricted-password-alert
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginAccountRow from '@/components/auth/LoginAccountRow.vue'
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
import PasswordField from '@/components/settings/PasswordField.vue'
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import type { LoginSecret } from '@/composables/useLoginSecret'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { LOGIN_PASSWORD_CHANGE_TEXT, RESTRICTED_STEP_TEXT } from '@/shared/constants/session-restriction'
import { isCompleteOtp } from '@/shared/utils/account-security'
import {
hasPasswordChangeProblem,
passwordChangeProblems,
type PasswordChangeProblems,
} from '@/shared/utils/password-change'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
interface Props {
email: string
/** 로그인 때 넣은 비밀번호(임시 비밀번호)의 화면 메모리 */
secret: LoginSecret
/** 코드 단계를 거쳐 왔다 — 2단계 인증을 켠 직원 */
knownTwoFactor?: boolean
/** 부모가 갱신 · 다음 단계로 넘기는 중 — 단추는 바쁨, 칸은 읽기만 */
finishing?: boolean
/** 바꿨지만 부모가 다음으로 넘기지 못했다 — 사전 문구 (위쪽 상자 + [다시 시도]) */
finishError?: string | null
}
interface Emits {
/** 바꿨다 — 새 비밀번호는 secret 에 남겼다 */
(e: 'done'): void
/** [다시 시도] — 부모가 다시 넘겨 본다 */
(e: 'retry-finish'): void
(e: 'other-account'): void
}
const props = withDefaults(defineProps<Props>(), {
knownTwoFactor: false,
finishing: false,
finishError: null,
})
const emit = defineEmits<Emits>()
/** 이 단계가 직접 보이는 서버 거절 — 알림을 끈다 (그 밖의 장애는 인터셉터의 알림) */
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001', 'AUTH_006', 'AUTH_007', 'REQ_001']
const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null }
const { changePassword } = useAuth()
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const temporaryRef = ref<InstanceType<typeof PasswordField> | null>(null)
const nextRef = ref<InstanceType<typeof PasswordField> | null>(null)
const confirmRef = ref<InstanceType<typeof PasswordField> | null>(null)
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
const temporary = ref<string>('')
const next = ref<string>('')
const confirm = ref<string>('')
const code = ref<string>('')
const showTemporary = ref<boolean>(false)
const showNext = ref<boolean>(false)
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
const codeError = ref<string | null>(null)
/** 서버가 코드를 요구했다(AUTH_007) — 상태를 다시 받지 못해도 칸을 보인다 */
const codeRequired = ref<boolean>(false)
/** 위쪽 빨간 상자 — 시도 과다(REQ_001) */
const alert = ref<string | null>(null)
const submitting = ref<boolean>(false)
let unmounted = false
const busy = computed(() => submitting.value || props.finishing)
/** 칸 · 단추를 막는다 — 바쁘거나, 이미 바꿨는데 넘기지 못했다(다시 보내면 안 된다) */
const blocked = computed(() => busy.value || props.finishError !== null)
/** 「임시 비밀번호」 칸 — 화면 메모리에 없을 때만 */
const askTemporary = computed(() => !props.secret.available.value)
/** 「인증 앱 코드」 칸 — 2단계 인증을 켠 직원 */
const showCode = computed(() => props.knownTwoFactor || twoFactorStore.isEnabled || codeRequired.value)
const temporaryError = computed(() =>
serverError.value?.field === 'current' ? serverError.value.message : null,
)
const nextError = computed(() => {
if (problems.value.next === 'tooLong') return getErrorMessage('VAL_001')
return serverError.value?.field === 'next' ? serverError.value.message : null
})
const confirmError = computed(() => (problems.value.confirm === 'mismatch' ? LOGIN_PASSWORD_CHANGE_TEXT.mismatch : null))
// 칸을 고치면 그 칸에 걸린 오류를 내린다 (임시 비밀번호를 고치면 "임시 비밀번호와 같음"도 다시 본다)
function setTemporary(value: string) {
temporary.value = value
problems.value = { ...problems.value, current: null, next: problems.value.next === 'rule' ? null : problems.value.next }
if (serverError.value?.field === 'current') serverError.value = null
}
function setNext(value: string) {
next.value = value
problems.value = { ...problems.value, next: null, confirm: null }
if (serverError.value?.field === 'next') serverError.value = null
}
function setConfirm(value: string) {
confirm.value = value
problems.value = { ...problems.value, confirm: null }
}
function setCode(value: string) {
code.value = value
codeError.value = null
}
function focusFirstProblem(found: PasswordChangeProblems) {
if (found.current) temporaryRef.value?.focus()
else if (found.next) nextRef.value?.focus()
else if (found.confirm) confirmRef.value?.focus()
}
function clearFields() {
temporary.value = ''
next.value = ''
confirm.value = ''
code.value = ''
showTemporary.value = false
showNext.value = false
}
async function focusCodeWithError(message: string) {
codeError.value = message
code.value = ''
await nextTick()
codeRef.value?.focus()
}
async function submit() {
if (blocked.value) return
alert.value = null
serverError.value = null
const remembered = props.secret.peek()
const current = remembered ?? temporary.value
const found = passwordChangeProblems({ current, next: next.value, confirm: confirm.value })
problems.value = found
const codeMissing = showCode.value && !isCompleteOtp(code.value)
codeError.value = codeMissing ? getErrorMessage('AUTH_007') : null
if (hasPasswordChangeProblem(found)) {
// 10분이 막 지나 「임시 비밀번호」 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점
await nextTick()
focusFirstProblem(found)
return
}
if (codeMissing) {
codeRef.value?.focus()
return
}
const session = authStore.generation
const newPassword = next.value
submitting.value = true
try {
await changePassword(
{ currentPassword: current, newPassword, ...(showCode.value ? { otpCode: code.value } : {}) },
{ silent: INLINE_CODES },
)
// 기다리는 사이 로그아웃 · 다른 계정 — 이 세션의 일이 아니다
if (authStore.generation !== session) return
clearFields()
problems.value = NO_PROBLEMS
// 바뀐 비밀번호가 다음 단계(2단계 켜기)의 지금 비밀번호다 — 화면이 사라졌으면 남기지 않는다
if (unmounted) return
props.secret.remember(newPassword)
emit('done')
} catch (error) {
const apiError = toApiError(error)
if (authStore.generation !== session) return
if (unmounted) {
if (INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
return
}
if (apiError.code === 'AUTH_005') {
// 메모리의 비밀번호였으면 지우고 칸을 보인다 — 그 칸 아래에 알린다
props.secret.forget()
serverError.value = { field: 'current', message: apiError.message }
await nextTick()
temporaryRef.value?.focus()
} else if (apiError.code === 'VAL_001') {
serverError.value = { field: 'next', message: apiError.message }
nextRef.value?.focus()
} else if (apiError.code === 'AUTH_006') {
await focusCodeWithError(apiError.message)
} else if (apiError.code === 'AUTH_007') {
// 그사이 다른 곳에서 2단계 인증을 켰다 — 칸을 보이고 상태를 다시 받는다
codeRequired.value = true
await focusCodeWithError(apiError.message)
void twoFactorStore.refresh()
} else if (apiError.code === 'REQ_001') {
alert.value = apiError.message
}
// 그 밖의 실패(메일 서버 장애 등)는 인터셉터가 알렸다 — 칸 · 단추는 그대로
} finally {
submitting.value = false
}
}
/** 처음 초점 — 보이는 첫 칸 */
function focus() {
if (askTemporary.value) temporaryRef.value?.focus()
else nextRef.value?.focus()
}
onBeforeUnmount(() => {
unmounted = true
clearFields()
})
// 코드 단계를 거치지 않았으면(새로고침 · 다른 탭) 2단계 인증 상태를 묻는다 — 제한된 세션도 부를 수 있다(계약 6절). 못 받아도 AUTH_007 이 칸을 부른다
if (!props.knownTwoFactor) void twoFactorStore.load()
defineExpose({ focus })
</script>
<template>
<div class="restricted-step">
<div class="restricted-step__heading">
<h1 class="restricted-step__title">
{{ LOGIN_PASSWORD_CHANGE_TEXT.title }}
</h1>
<p class="restricted-step__subtitle">
{{ LOGIN_PASSWORD_CHANGE_TEXT.subtitle }}
</p>
</div>
<LoginAccountRow
:email="email"
:busy="busy"
@other-account="emit('other-account')"
/>
<div
v-if="finishError"
id="restricted-password-finish-error"
class="restricted-step__alert restricted-step__alert--retry"
role="alert"
data-restricted-finish-error
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span class="restricted-step__alert-text">{{ finishError }}</span>
<button
type="button"
class="restricted-step__retry"
data-restricted-retry
@click="emit('retry-finish')"
>
{{ RESTRICTED_STEP_TEXT.retry }}
</button>
</div>
<p
v-else-if="alert"
id="restricted-password-alert"
class="restricted-step__alert"
role="alert"
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span>{{ alert }}</span>
</p>
<form
class="restricted-step__form"
novalidate
@submit.prevent="submit"
>
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
<input
type="text"
name="username"
autocomplete="username"
:value="email"
hidden
readonly
>
<PasswordField
v-if="askTemporary"
id="restricted-password-temporary"
ref="temporaryRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.temporary"
:model-value="temporary"
autocomplete="current-password"
:shown="showTemporary"
:toggle-label="LOGIN_PASSWORD_CHANGE_TEXT.showTemporary"
:hint="LOGIN_PASSWORD_CHANGE_TEXT.temporaryHint"
:hint-is-error="problems.current === 'missing'"
:error="temporaryError"
:readonly="blocked"
@update:model-value="setTemporary"
@toggle="showTemporary = !showTemporary"
/>
<PasswordField
id="restricted-password-next"
ref="nextRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.next"
:model-value="next"
autocomplete="new-password"
:shown="showNext"
:toggle-label="LOGIN_PASSWORD_CHANGE_TEXT.showNext"
:hint="LOGIN_PASSWORD_CHANGE_TEXT.nextHint"
:hint-is-error="problems.next === 'rule'"
:error="nextError"
:readonly="blocked"
@update:model-value="setNext"
@toggle="showNext = !showNext"
/>
<PasswordField
id="restricted-password-confirm"
ref="confirmRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.confirm"
:model-value="confirm"
autocomplete="new-password"
:shown="showNext"
:error="confirmError"
:readonly="blocked"
@update:model-value="setConfirm"
/>
<OtpCodeField
v-if="showCode"
id="restricted-password-code"
ref="codeRef"
variant="login"
:label="LOGIN_PASSWORD_CHANGE_TEXT.code"
:model-value="code"
:hint="LOGIN_PASSWORD_CHANGE_TEXT.codeHint"
:error="codeError"
:readonly="blocked"
@update:model-value="setCode"
/>
<button
type="submit"
class="restricted-step__submit"
:aria-disabled="blocked ? 'true' : undefined"
:aria-busy="busy ? 'true' : 'false'"
data-restricted-submit
>
{{ busy ? LOGIN_PASSWORD_CHANGE_TEXT.submitting : LOGIN_PASSWORD_CHANGE_TEXT.submit }}
</button>
</form>
<p class="restricted-step__note">
{{ LOGIN_PASSWORD_CHANGE_TEXT.note }}
</p>
</div>
</template>
<style scoped>
/* 로그인 화면의 패널과 같은 간격 — 묶음 사이 22 */
.restricted-step {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.restricted-step__heading {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.restricted-step__title {
margin: 0;
font-size: var(--font-size-heading-lg);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
}
.restricted-step__subtitle {
margin: 0;
color: var(--color-text-secondary);
}
/* 위쪽 빨간 상자 — 로그인 오류와 같은 모양 */
.restricted-step__alert {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
.restricted-step__alert-icon {
margin-top: var(--space-1);
}
/* 마쳤지만 넘기지 못함 — 사전 문구 + [다시 시도](36 — 밀도 예외 --size-notice-action) */
.restricted-step__alert--retry {
flex-wrap: wrap;
align-items: center;
}
.restricted-step__alert-text {
flex: 1 1 12rem;
min-width: 0;
}
.restricted-step__retry {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.restricted-step__retry:hover {
background: var(--color-surface-muted);
}
.restricted-step__form {
display: flex;
flex-direction: column;
gap: var(--space-14);
}
.restricted-step__submit {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--size-touch);
margin-top: var(--space-6);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-body);
font-weight: 600;
}
.restricted-step__submit:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 바꾸는 동안 — 흐리게 (disabled 가 아니라 aria-disabled — 초점을 잃지 않게, 누름은 무시한다) */
.restricted-step__submit[aria-disabled='true'] {
opacity: 0.7;
cursor: progress;
}
.restricted-step__note {
margin: 0;
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-small);
line-height: 1.6;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
// 로그인 틀의 문의 줄 (13단계 — 디자인 README "13단계 › 문의 줄" · 계약 6절 "문의 연락처")
// 연락처가 있으면 물음(12.5px 회색) 다음 줄에 연락처(#344054 · 500), 없거나 받지 못했으면 지금 글 그대로(fallback).
// 연락처는 운영 도구로 넣는 설정값이다 — 글자로만 그린다(텍스트 보간 — 링크 · HTML 없음). 길어도 낱말 가운데서 끊지 않는다
interface Props {
/** 물음 — "비밀번호를 잊으셨나요?" · "인증 앱을 쓸 수 없나요?" */
question: string
/** 연락처가 없을 때의 글 */
fallback: string
/** 연락처 한 줄 — 없으면 null */
contact: string | null
}
defineProps<Props>()
</script>
<template>
<p
class="support-line"
data-support-line
>
<template v-if="contact">
{{ question }}<br><span
class="support-line__contact"
data-support-contact
>{{ contact }}</span>
</template>
<template v-else>
{{ fallback }}
</template>
</p>
</template>
<style scoped>
.support-line {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
word-break: keep-all;
overflow-wrap: anywhere;
}
.support-line__contact {
color: var(--color-text-strong);
font-weight: 500;
}
</style>
@@ -0,0 +1,274 @@
<script setup lang="ts">
// 로그인 틀 · 2단계 인증 켜기의 ① — QR · 키 · [복사] (13단계 — 디자인 원본 LoginTwoFactorSetup · README "13단계 › 로그인 — 2단계 인증 켜기")
// 12단계 켜기 창의 ①(TwoFactorEnrollmentScan)을 양식 칸 360 에 맞춘 꼴이다: 제목 → 안내(12.5px) → QR(148) 오른쪽에 키 이름(12px 굵게) ·
// 32자 키(4자씩 · 두 줄 · 고정폭 12px — 띄어쓰기 없이 조각만, 골라 복사해도 키 그대로) · [복사](36) → 그 아래 "앱에는 「ARA SYSTEM (주소)」로 저장됩니다."
// - 키를 받는 동안은 QR 자리에 회색 상자(aria-busy), 못 받으면 사전 문구 + [다시 시도](부모가 다시 받는다)
// - 키는 부모(켜기 단계)의 화면 메모리에서만 받는다 — 여기서도 남기지 않는다. [복사]는 키를 띄어쓰기 없이
// 고정 id — #login-two-factor-key-label
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import QrCodeImage from '@/components/QrCodeImage.vue'
import type { ApiError } from '@/composables/useApi'
import { useToast } from '@/composables/useToast'
import { TWO_FACTOR_ENABLE_TEXT, TWO_FACTOR_KEY_GROUPS_PER_LINE } from '@/shared/constants/account-security'
import { LOGIN_TWO_FACTOR_SETUP_TEXT } from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
import { chunkItems, groupCharacters } from '@/shared/utils/account-security'
import { copyText } from '@/shared/utils/clipboard'
interface Props {
/** 등록 — 키 · otpauth 주소. 받는 중이거나 못 받았으면 null */
enrollment: TwoFactorEnrollment | null
status: 'loading' | 'ready' | 'error'
/** 못 받은 까닭 — 사전 문구 */
error: ApiError | null
/** 직원 주소 — "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." */
email: string
}
interface Emits {
(e: 'retry'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const toast = useToast()
/** 키의 줄 — 줄마다 4자 묶음 넷 */
const keyLines = computed(() =>
props.enrollment ? chunkItems(groupCharacters(props.enrollment.secret), TWO_FACTOR_KEY_GROUPS_PER_LINE) : [],
)
async function copyKey() {
const secret = props.enrollment?.secret
if (!secret) return
if (await copyText(secret)) toast.success(TWO_FACTOR_ENABLE_TEXT.copied)
else toast.error(TWO_FACTOR_ENABLE_TEXT.copyFailed)
}
</script>
<template>
<div class="login-scan">
<span class="login-scan__title">{{ LOGIN_TWO_FACTOR_SETUP_TEXT.scanTitle }}</span>
<p class="login-scan__hint">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.scanHint }}
</p>
<div
v-if="status === 'error' && error"
class="login-scan__load-error"
role="alert"
data-setup-load-error
>
<span>{{ error.message }}</span>
<button
type="button"
class="login-scan__retry"
@click="emit('retry')"
>
{{ TWO_FACTOR_ENABLE_TEXT.retry }}
</button>
</div>
<template v-else>
<div class="login-scan__row">
<div
class="login-scan__qr-frame"
:aria-busy="status === 'loading' ? 'true' : undefined"
>
<QrCodeImage
v-if="enrollment"
:value="enrollment.otpauthUrl"
:label="TWO_FACTOR_ENABLE_TEXT.qrLabel"
compact
/>
<span
v-else
class="login-scan__qr-placeholder"
>
<span class="visually-hidden">{{ TWO_FACTOR_ENABLE_TEXT.loading }}</span>
</span>
</div>
<div class="login-scan__key">
<span
id="login-two-factor-key-label"
class="login-scan__key-label"
>{{ TWO_FACTOR_ENABLE_TEXT.keyLabel }}</span>
<code
class="login-scan__key-value"
aria-labelledby="login-two-factor-key-label"
data-setup-key
>
<span
v-for="(line, lineIndex) in keyLines"
:key="lineIndex"
class="login-scan__key-line"
>
<span
v-for="(group, index) in line"
:key="index"
class="login-scan__key-group"
>{{ group }}</span>
</span>
</code>
<button
type="button"
class="login-scan__copy"
:aria-label="TWO_FACTOR_ENABLE_TEXT.copyLabel"
aria-describedby="login-two-factor-key-label"
:aria-disabled="enrollment ? undefined : 'true'"
data-setup-copy
@click="copyKey"
>
<AppIcon
name="copy"
:size="14"
/>
<span>{{ TWO_FACTOR_ENABLE_TEXT.copy }}</span>
</button>
</div>
</div>
<span class="login-scan__saved">{{ TWO_FACTOR_ENABLE_TEXT.savedAs(email) }}</span>
</template>
</div>
</template>
<style scoped>
.login-scan {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.login-scan__title {
font-weight: 600;
}
.login-scan__hint {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.login-scan__row {
display: flex;
align-items: flex-start;
gap: var(--space-12);
}
/* QR 상자 — 흰 바탕 · 테두리 · 안쪽 6 */
.login-scan__qr-frame {
flex: 0 0 auto;
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.login-scan__qr-placeholder {
display: block;
width: 9.25rem;
height: 9.25rem;
border-radius: var(--radius-sm);
background: var(--color-skeleton);
}
.login-scan__key {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-6);
}
.login-scan__key-label {
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 키 — 고정폭 12px, 4자 조각 사이를 여백으로(띄어쓰기 없이). 누르면 한 번에 고른다 */
.login-scan__key-value {
align-self: stretch;
display: block;
padding: var(--space-6) var(--space-8);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-panel);
font-family: Consolas, 'SFMono-Regular', Menlo, monospace;
font-size: var(--font-size-small);
line-height: 1.6;
color: var(--color-text);
user-select: all;
}
/* 한 줄 — 상자 폭을 다 차지해 두 줄로 쌓인다(골라 복사할 때 줄바꿈이 끼지 않게 block 이 아니라 inline-block) */
.login-scan__key-line {
display: inline-block;
width: 100%;
}
.login-scan__key-group {
display: inline-block;
white-space: nowrap;
}
.login-scan__key-group:not(:last-child) {
margin-right: 0.5em;
}
/* [복사] 36 — 밀도 예외 (--size-notice-action) */
.login-scan__copy {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.login-scan__copy:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.login-scan__copy[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.login-scan__saved {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* 키를 받지 못했을 때 — 사전 문구 + [다시 시도] */
.login-scan__load-error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-12);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
color: var(--color-danger);
}
.login-scan__retry {
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.login-scan__retry:hover {
background: var(--color-surface-muted);
}
</style>
@@ -0,0 +1,530 @@
<script setup lang="ts">
// 로그인 — 2단계 인증 켜기(필수) (13단계 — 디자인 원본 LoginTwoFactorSetup · README "13단계 › 로그인 — 2단계 인증 켜기(필수)" · 계약 6절 "2단계 인증 필수")
// 세션에 restriction: twoFactorSetup 이 붙었을 때 로그인 틀이 이 단계를 그린다. 틀은 로그인 화면과 같다:
// 제목 · 설명 → 주소 + [다른 계정으로 로그인] → (위쪽 빨간 상자) → 차례 ① QR · 키 ② 코드 (③ 지금 비밀번호) → [켜고 계속] → 문의 줄 → 맨 아래 안내.
// - 12단계 API 그대로 — 열 때마다 새 키(POST /two-factor/enrollments — 10분 안에 켠다), 켜기 POST /two-factor/activation.
// 키 · 주소는 이 단계의 화면 메모리에만 두고 떠나면(다른 계정 · 새로고침) 버린다
// - 지금 비밀번호는 화면 메모리(LoginSecret — 로그인 때 넣은 것, 방금 새 비밀번호를 정했으면 그것 · 10분)에서 쓴다. 없을 때만 ③ 칸
// - 코드 틀림 AUTH_006 — 칸 아래 · 칸을 비우고 초점(QR · 키는 그대로). 지금 비밀번호 틀림 AUTH_005 — 메모리의 비밀번호였으면 지우고 ③ 칸을 보인다.
// 둘 다 틀렸으면 코드부터(서버가 코드를 먼저 본다)
// - 시간 지남 · 그사이 바뀜 AUTH_008 — 다른 곳에서 이미 켰으면 이 단계를 건너뛴다(enabled-elsewhere), 아니면 위쪽 상자에 사전 문구 + 새 키(코드 칸은 비움)
// - 시도 과다 REQ_001 — 위쪽 상자. 켜는 동안 「켜는 중…」(aria-busy) · 칸은 읽기만 · [다른 계정으로 로그인]도 누를 수 없다. 요청 한도 30초(TWO_FACTOR_CHANGE_TIMEOUT_MS)
// - 켜기 요청은 이 단계가 보낸다(useAccountSecurity) — 비밀번호 · 코드가 Pinia 액션의 인자로 남지 않게. 스토어에는 비밀 없는 결과만(apply)
// - 켰으면 done — 갱신 · 이동 · 알림은 부모(LoginPage)가 한다. 부모가 넘기지 못하면(finishError) 위쪽 상자에 사전 문구 + [다시 시도](retry-finish),
// 그동안 칸 · 단추는 막는다(이미 켰다)
// - ③ 지금 비밀번호를 비우고 보내면 그 칸의 안내 글이 빨간 오류가 된다 (새 비밀번호 단계의 임시 비밀번호와 같다)
// 고정 id — #restricted-setup-code · #restricted-setup-password · #restricted-setup-alert
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginAccountRow from '@/components/auth/LoginAccountRow.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import LoginTwoFactorScan from '@/components/auth/LoginTwoFactorScan.vue'
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
import PasswordField from '@/components/settings/PasswordField.vue'
import { useAccountSecurity } from '@/composables/useAccountSecurity'
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
import type { LoginSecret } from '@/composables/useLoginSecret'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import {
LOGIN_TWO_FACTOR_SETUP_TEXT,
RESTRICTED_STEP_TEXT,
SUPPORT_CONTACT_TEXT,
} from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
import { isCompleteOtp } from '@/shared/utils/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
interface Props {
email: string
/** 지금 비밀번호의 화면 메모리 */
secret: LoginSecret
/** 문의 연락처 — 없으면 지금 글 */
supportContact: string | null
/** 부모가 갱신 · 이동하는 중 — 단추는 바쁨, 칸은 읽기만 */
finishing?: boolean
/** 켰지만 부모가 다음으로 넘기지 못했다 — 사전 문구 (위쪽 상자 + [다시 시도]) */
finishError?: string | null
}
interface Emits {
/** 켰다 — 상태는 스토어에 넣었다 */
(e: 'done'): void
/** 다른 곳에서 이미 켰다 — 이 단계를 건너뛴다 */
(e: 'enabled-elsewhere'): void
/** [다시 시도] — 부모가 다시 넘겨 본다 */
(e: 'retry-finish'): void
(e: 'other-account'): void
}
const props = withDefaults(defineProps<Props>(), {
finishing: false,
finishError: null,
})
const emit = defineEmits<Emits>()
/** 이 단계가 직접 보이는 서버 거절 — 알림을 끈다 */
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'AUTH_006', 'AUTH_008', 'REQ_001']
const api = useAccountSecurity()
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
const passwordRef = ref<InstanceType<typeof PasswordField> | null>(null)
/** 등록 — 비밀이 들어 있다. 반응형 깊이 없이 둔다 */
const enrollment = shallowRef<TwoFactorEnrollment | null>(null)
const enrollStatus = ref<'loading' | 'ready' | 'error'>('loading')
const enrollError = ref<ApiError | null>(null)
/** 위쪽 빨간 상자 — 처음부터 다시(AUTH_008) · 시도 과다(REQ_001) */
const alert = ref<string | null>(null)
const code = ref<string>('')
const password = ref<string>('')
const showPassword = ref<boolean>(false)
const codeError = ref<string | null>(null)
const passwordError = ref<string | null>(null)
/** ③ 지금 비밀번호를 비우고 보냈다 — 그 칸의 안내 글이 빨간 오류가 된다 */
const passwordMissing = ref<boolean>(false)
const submitting = ref<boolean>(false)
/** 등록을 받는 차례 — 다시 받으면 앞의 응답은 버린다 */
let enrollSeq = 0
let unmounted = false
const busy = computed(() => submitting.value || props.finishing)
/** 칸 · 단추를 막는다 — 바쁘거나, 이미 켰는데 넘기지 못했다(다시 보내면 안 된다) */
const blocked = computed(() => busy.value || props.finishError !== null)
/** ③ 지금 비밀번호 — 화면 메모리에 없을 때만 */
const askPassword = computed(() => !props.secret.available.value)
/** [켜고 계속]을 누를 수 없다 — 키가 아직 없거나 켜는 중 · 이미 켰다 */
const unavailable = computed(() => enrollStatus.value !== 'ready' || blocked.value)
function clearSecrets() {
enrollSeq += 1
enrollment.value = null
code.value = ''
password.value = ''
showPassword.value = false
}
/** 이 단계가 아직 있고 세션도 그대로인가 — 늦게 온 응답을 쓸지 */
function stillHere(session: number): boolean {
return !unmounted && authStore.generation === session
}
/** 그사이 다른 곳에서 켰는지 — 켰으면 이 단계를 건너뛴다(true) */
async function skipIfEnabledElsewhere(session: number): Promise<boolean> {
const status = await twoFactorStore.refresh()
if (!status?.isEnabled) return false
if (stillHere(session)) emit('enabled-elsewhere')
return true
}
async function startEnrollment() {
enrollSeq += 1
const current = enrollSeq
const session = authStore.generation
enrollment.value = null
enrollStatus.value = 'loading'
enrollError.value = null
try {
const result = await api.startTwoFactorEnrollment({ silent: true })
if (current !== enrollSeq || !stillHere(session)) return
enrollment.value = result
enrollStatus.value = 'ready'
} catch (caught) {
if (current !== enrollSeq || !stillHere(session)) return
const apiError = toApiError(caught)
// 이미 켜져 있다 — 다른 곳에서 켰다
if (apiError.code === 'AUTH_008' && (await skipIfEnabledElsewhere(session))) return
if (current !== enrollSeq || !stillHere(session)) return
enrollStatus.value = 'error'
enrollError.value = apiError
}
}
/** 처음부터 다시(AUTH_008) — 켜졌으면 건너뛰고, 아니면 위쪽 상자에 알리고 새 키로 다시 그린다 */
async function restart(error: ApiError, session: number) {
if (await skipIfEnabledElsewhere(session)) return
if (!stillHere(session)) return
alert.value = error.message
code.value = ''
codeError.value = null
passwordError.value = null
await startEnrollment()
await nextTick()
if (stillHere(session)) codeRef.value?.focus()
}
function setCode(value: string) {
code.value = value
codeError.value = null
}
function setPassword(value: string) {
password.value = value
passwordError.value = null
passwordMissing.value = false
}
async function submit() {
const current = enrollment.value
if (unavailable.value || !current) return
alert.value = null
const remembered = props.secret.peek()
const currentPassword = remembered ?? password.value
const codeProblem = isCompleteOtp(code.value) ? null : getErrorMessage('AUTH_007')
codeError.value = codeProblem
passwordError.value = null
passwordMissing.value = currentPassword === ''
if (codeProblem) {
codeRef.value?.focus()
return
}
if (passwordMissing.value) {
// 10분이 막 지나 ③ 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점
await nextTick()
passwordRef.value?.focus()
return
}
const session = authStore.generation
submitting.value = true
try {
const status = await api.activateTwoFactor(
{ enrollmentId: current.enrollmentId, otpCode: code.value, currentPassword },
{ silent: INLINE_CODES },
)
if (authStore.generation !== session) return
// 서버가 켰다 — 화면이 사라졌어도 상태는 바꾼다
twoFactorStore.apply(status)
if (unmounted) return
clearSecrets()
props.secret.forget()
emit('done')
} catch (caught) {
const apiError = toApiError(caught)
if (!stillHere(session)) return
if (apiError.code === 'AUTH_006') {
codeError.value = apiError.message
code.value = ''
codeRef.value?.focus()
} else if (apiError.code === 'AUTH_005') {
// 메모리의 비밀번호였으면 지우고 ③ 칸을 보인다 — 그 칸 아래에 알린다
props.secret.forget()
passwordError.value = apiError.message
await nextTick()
passwordRef.value?.focus()
} else if (apiError.code === 'AUTH_008') {
await restart(apiError, session)
} else if (apiError.code === 'REQ_001') {
alert.value = apiError.message
}
// 그 밖의 실패(메일 서버 장애 등)는 인터셉터가 알렸다
} finally {
submitting.value = false
}
}
/** 처음 초점 — 코드 칸 */
function focus() {
codeRef.value?.focus()
}
onMounted(() => {
void startEnrollment()
})
onBeforeUnmount(() => {
unmounted = true
clearSecrets()
})
defineExpose({ focus })
</script>
<template>
<div class="restricted-step">
<div class="restricted-step__heading">
<h1 class="restricted-step__title">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.title }}
</h1>
<p class="restricted-step__subtitle">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.subtitle }}
</p>
</div>
<LoginAccountRow
:email="email"
:busy="busy"
@other-account="emit('other-account')"
/>
<div
v-if="finishError"
id="restricted-setup-finish-error"
class="restricted-step__alert restricted-step__alert--retry"
role="alert"
data-restricted-finish-error
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span class="restricted-step__alert-text">{{ finishError }}</span>
<button
type="button"
class="restricted-step__retry"
data-restricted-retry
@click="emit('retry-finish')"
>
{{ RESTRICTED_STEP_TEXT.retry }}
</button>
</div>
<p
v-else-if="alert"
id="restricted-setup-alert"
class="restricted-step__alert"
role="alert"
>
<AppIcon
name="alert-circle"
class="restricted-step__alert-icon"
/>
<span>{{ alert }}</span>
</p>
<form
class="restricted-step__form"
novalidate
@submit.prevent="submit"
>
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
<input
type="text"
name="username"
autocomplete="username"
:value="email"
hidden
readonly
>
<ol class="restricted-step__steps">
<li class="restricted-step__step">
<span
class="restricted-step__number"
aria-hidden="true"
>1</span>
<div class="restricted-step__step-body">
<LoginTwoFactorScan
:enrollment="enrollment"
:status="enrollStatus"
:error="enrollError"
:email="email"
@retry="startEnrollment"
/>
</div>
</li>
<li class="restricted-step__step">
<span
class="restricted-step__number"
aria-hidden="true"
>2</span>
<div class="restricted-step__step-body">
<OtpCodeField
id="restricted-setup-code"
ref="codeRef"
variant="login"
:label="LOGIN_TWO_FACTOR_SETUP_TEXT.codeLabel"
:model-value="code"
:error="codeError"
:readonly="blocked"
@update:model-value="setCode"
/>
</div>
</li>
<li
v-if="askPassword"
class="restricted-step__step"
>
<span
class="restricted-step__number"
aria-hidden="true"
>3</span>
<div class="restricted-step__step-body">
<PasswordField
id="restricted-setup-password"
ref="passwordRef"
variant="login"
:label="LOGIN_TWO_FACTOR_SETUP_TEXT.passwordLabel"
:model-value="password"
autocomplete="current-password"
:shown="showPassword"
:toggle-label="LOGIN_TWO_FACTOR_SETUP_TEXT.showPassword"
:hint="LOGIN_TWO_FACTOR_SETUP_TEXT.passwordHint"
:hint-is-error="passwordMissing"
:error="passwordError"
:readonly="blocked"
@update:model-value="setPassword"
@toggle="showPassword = !showPassword"
/>
</div>
</li>
</ol>
<button
type="submit"
class="restricted-step__submit"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-busy="busy ? 'true' : 'false'"
data-restricted-submit
>
{{ busy ? LOGIN_TWO_FACTOR_SETUP_TEXT.submitting : LOGIN_TWO_FACTOR_SETUP_TEXT.submit }}
</button>
</form>
<LoginSupportLine
:question="SUPPORT_CONTACT_TEXT.authenticatorQuestion"
:fallback="SUPPORT_CONTACT_TEXT.authenticatorFallback"
:contact="supportContact"
/>
<p class="restricted-step__note">
{{ LOGIN_TWO_FACTOR_SETUP_TEXT.note }}
</p>
</div>
</template>
<style scoped>
/* 로그인 화면의 패널과 같은 간격 — 묶음 사이 22 */
.restricted-step {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.restricted-step__heading {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.restricted-step__title {
margin: 0;
font-size: var(--font-size-heading-lg);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
}
.restricted-step__subtitle {
margin: 0;
color: var(--color-text-secondary);
}
/* 위쪽 빨간 상자 — 로그인 오류와 같은 모양 */
.restricted-step__alert {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
.restricted-step__alert-icon {
margin-top: var(--space-1);
}
/* 마쳤지만 넘기지 못함 — 사전 문구 + [다시 시도](36 — 밀도 예외 --size-notice-action) */
.restricted-step__alert--retry {
flex-wrap: wrap;
align-items: center;
}
.restricted-step__alert-text {
flex: 1 1 12rem;
min-width: 0;
}
.restricted-step__retry {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.restricted-step__retry:hover {
background: var(--color-surface-muted);
}
.restricted-step__form {
display: flex;
flex-direction: column;
gap: var(--space-14);
}
/* 차례 1 · 2 · 3 — 청록 굵은 숫자(동그라미 없음) · 숫자 칸 22 · 사이 18 (12단계 켜기 창과 같다) */
.restricted-step__steps {
display: flex;
flex-direction: column;
gap: var(--space-18);
margin: 0;
padding: 0;
list-style: none;
}
.restricted-step__step {
display: grid;
grid-template-columns: var(--space-22) minmax(0, 1fr);
gap: var(--space-6);
}
.restricted-step__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.restricted-step__step-body {
min-width: 0;
}
.restricted-step__submit {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--size-touch);
margin-top: var(--space-6);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-body);
font-weight: 600;
}
.restricted-step__submit:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 키를 받는 중 · 켜는 동안 — 흐리게 (disabled 가 아니라 aria-disabled — 초점을 잃지 않게, 누름은 무시한다) */
.restricted-step__submit[aria-disabled='true'] {
opacity: 0.7;
cursor: progress;
}
.restricted-step__note {
margin: 0;
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-small);
line-height: 1.6;
color: var(--color-text-muted);
}
</style>
@@ -182,3 +182,75 @@ describe('MailListFrame (목록 칸의 틀)', () => {
expect(document.activeElement).toBe(section)
})
})
// 휴지통 · 스팸메일함 — 30일 줄 (13단계 — 계약 6절 "휴지통 · 스팸 30일" · 디자인 원본 MailNoticesCutover)
describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계)', () => {
let pinia: Pinia
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 2 }
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 1 }
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountBox(mailbox: Mailbox, messages: MessageSummary[], component: 'list' | 'table' = 'list') {
const store = useMailStore()
store.mailboxes = [INBOX, TRASH, JUNK]
store.currentMailboxId = mailbox.id
store.messages = messages
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${mailbox.id}`)
await router.isReady()
const page = component === 'list' ? MessageList : (await import('./MessageTable.vue')).default
return mount(page, { global: { plugins: [pinia, router] }, attachTo: document.body })
}
it('휴지통 — 목록 머리 바로 아래 회색 줄 「휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.」(늘 보임 · 닫기 없음)', async () => {
const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })])
const notice = wrapper.get('[data-retention-notice]')
expect(notice.text()).toBe('휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.')
expect(notice.find('button').exists()).toBe(false)
})
it('스팸메일함도 같은 줄 — 목록 보기(표)에서는 표 위', async () => {
const wrapper = await mountBox(JUNK, [summary('j1', { mailboxIds: ['junk'] })], 'table')
const notice = wrapper.get('[data-retention-notice]')
expect(notice.text()).toBe('스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.')
// 표 머리보다 앞
const table = wrapper.get('table').element
expect(notice.element.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('메일이 없으면 줄을 빼고 빈 상태 글에 같은 뜻을 붙인다', async () => {
const wrapper = await mountBox(TRASH, [])
expect(wrapper.find('[data-retention-notice]').exists()).toBe(false)
expect(wrapper.text()).toContain('휴지통이 비어 있습니다')
expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.')
})
it('다른 메일함 · 검색 결과에는 없다', async () => {
const inbox = await mountBox(INBOX, [summary('m1')])
expect(inbox.find('[data-retention-notice]').exists()).toBe(false)
inbox.unmount()
const store = useMailStore()
const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })])
store.listSource = { kind: 'search', q: '견적', scope: 'trash' }
await flushPromises()
expect(wrapper.find('[data-retention-notice]').exists()).toBe(false)
})
})
+49 -2
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
// 그 아래 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다),
// 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
@@ -21,7 +23,7 @@ import { useListArrivalNotice } from '@/composables/useListArrivalNotice'
import { useListPositionMemory } from '@/composables/useListPositionMemory'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { MAIL_SEARCH_TEXT, MAILBOX_RETENTION_NOTICES, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { isScheduledSource } from '@/shared/utils/mail-list-source'
@@ -58,6 +60,17 @@ const empty = computed(() =>
: emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
)
const emptyIcon = computed(() => (scheduled.value ? 'clock' : mailboxIcon({ role: mailbox.value?.role ?? null })))
/**
* 휴지통 · 스팸메일함의 30일 줄 (13단계 — 메일 서버가 들어간 날부터 30일 뒤 지운다). 검색 결과 · 예약됨 · 다른 메일함에는 없다.
* 메일이 없으면 줄을 빼고 빈 상태 글에 같은 뜻을 붙인다 (MAILBOX_EMPTY_HINTS)
*/
const retentionNotice = computed(() => {
if (search.value || scheduled.value) return null
const role = mailbox.value?.role ?? null
const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null
if (text === null) return null
return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text
})
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
@@ -134,6 +147,18 @@ watch(
:title-id="titleId"
:variant="variant"
/>
<p
v-if="retentionNotice"
:class="['mail-list-frame__retention', `mail-list-frame__retention--${variant}`]"
data-retention-notice
>
<AppIcon
name="clock"
:size="14"
class="mail-list-frame__retention-icon"
/>
<span>{{ retentionNotice }}</span>
</p>
<MailSearchScope v-if="search" />
<MailNotifyOffer
v-else-if="!scheduled"
@@ -219,6 +244,28 @@ watch(
background: var(--color-surface);
}
/* 휴지통 · 스팸메일함의 30일 줄 — 바탕 #F8F9FB · 아래 선 #EEF0F3 · 위아래 8 · 12.5px (디자인 원본 MailNoticesCutover). 왼쪽은 머리 글과 같은 선 */
.mail-list-frame__retention {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: var(--space-8) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.mail-list-frame__retention--list {
padding-left: var(--space-20);
}
.mail-list-frame__retention-icon {
flex: 0 0 auto;
color: var(--color-text-muted);
}
.mail-list-frame__more--split {
padding: var(--space-8) var(--space-12) var(--space-16);
}
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { nextTick } from 'vue'
import { CLOCK } from '@/composables/useClock'
import type { TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import TwoFactorGraceNotice from './TwoFactorGraceNotice.vue'
// 2단계 인증 필수의 유예 안내 줄 (13단계 — 디자인 README "13단계 › 유예 기간" · 계약 6절 "2단계 인증 필수")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
// 시작일 2026-12-01 0시(KST)
const DEADLINE = '2026-11-30T15:00:00.000Z'
const NOW = Date.UTC(2026, 10, 2, 1, 0, 0)
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null, isRequired: true, deadline: DEADLINE }
describe('TwoFactorGraceNotice', () => {
let pinia: Pinia
let requests: MockRequest[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountNotice(deadline: string | null, status: TwoFactorStatus = OFF, now = NOW) {
useAuthStore().markAuthenticated(USER, { restriction: null, twoFactorDeadline: deadline })
requests = installMockApi((request) => (request.url === '/two-factor' ? ok(status) : ok(null)))
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(TwoFactorGraceNotice, {
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(now) } },
})
await flushPromises()
return wrapper
}
it('유예 중이면 노란 줄 — 시작일 전날까지(굵게) + [지금 켜기] → 설정 › 2단계 인증, 닫기 단추는 없다', async () => {
const wrapper = await mountNotice(DEADLINE)
const bar = wrapper.get('[data-two-factor-grace-notice]')
expect(bar.attributes('role')).toBe('region')
expect(bar.attributes('aria-label')).toBe('2단계 인증 안내')
expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요 — 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기')
expect(bar.get('strong').text()).toBe('11월 30일까지 2단계 인증을 켜 주세요')
expect(bar.get('[data-two-factor-grace-action]').attributes('href')).toBe('/settings/account/two-factor')
expect(bar.findAll('button')).toHaveLength(0)
expect(requests.map((request) => request.url)).toEqual(['/two-factor'])
})
it('시작일이 내년이면 해를 붙인다', async () => {
const wrapper = await mountNotice('2027-01-03T15:00:00.000Z', { ...OFF, deadline: '2027-01-03T15:00:00.000Z' }, Date.UTC(2026, 11, 20))
expect(wrapper.get('strong').text()).toBe('2027년 1월 3일까지 2단계 인증을 켜 주세요')
})
it('이미 켰으면(다른 곳에서) 그리지 않고, 켜면 사라진다', async () => {
const enabled = await mountNotice(DEADLINE, { ...OFF, isEnabled: true })
expect(enabled.find('[data-two-factor-grace-notice]').exists()).toBe(false)
enabled.unmount()
setActivePinia((pinia = createPinia()))
const wrapper = await mountNotice(DEADLINE)
expect(wrapper.find('[data-two-factor-grace-notice]').exists()).toBe(true)
useTwoFactorStore().apply({ ...OFF, isEnabled: true })
await nextTick()
expect(wrapper.find('[data-two-factor-grace-notice]').exists()).toBe(false)
})
it('유예가 아니면(마감 없음 · 지남) 그리지 않고 2단계 인증 상태도 묻지 않는다(마감 없음)', async () => {
const none = await mountNotice(null)
expect(none.find('[data-two-factor-grace-notice]').exists()).toBe(false)
expect(requests).toEqual([])
none.unmount()
setActivePinia((pinia = createPinia()))
const passed = await mountNotice(DEADLINE, OFF, Date.parse(DEADLINE))
expect(passed.find('[data-two-factor-grace-notice]').exists()).toBe(false)
})
})
@@ -0,0 +1,113 @@
<script setup lang="ts">
// 2단계 인증 필수의 유예 안내 줄 (13단계 — 디자인 원본 LoginTwoFactorSetup "유예 기간" · README "13단계 › 유예 기간" · 계약 6절 "2단계 인증 필수")
// 메일 화면(목록 · 읽기 · 쓰기 · 검색)의 상단 바 바로 아래, 모듈 폭 전체에 노란 줄 하나 — 높이 40 · 줄 안 단추 36(안내 줄 밀도 예외):
// 방패 + "11월 30일까지 2단계 인증을 켜 주세요"(굵게) + " — 그 뒤에는 켜야 로그인할 수 있습니다." + [지금 켜기] → 설정 › 2단계 인증.
// - 날짜는 me · 갱신의 twoFactorDeadline(시작일 0시 KST)의 1ms 앞 — 시작일 전날. 올해가 아니면 해를 붙인다
// - 닫기 단추는 없다 — 켜야 사라진다(two-factor.store 가 켜짐을 알면 바로). 마감이 지났으면 그리지 않는다(다음 갱신 때 제한이 붙는다)
// - 마감을 받았으면 2단계 인증 상태를 한 번 묻는다 — 다른 곳에서 이미 켰으면 줄을 그리지 않게 (못 받으면 줄은 그대로)
import { computed, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useClock } from '@/composables/useClock'
import { TWO_FACTOR_GRACE_TEXT } from '@/shared/constants/session-restriction'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { isTwoFactorGraceActive, twoFactorDeadlineDayLabel } from '@/shared/utils/auth-session'
import { useAuthStore } from '@/stores/auth.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const clock = useClock()
const twoFactorRoute = { name: SETTINGS_ROUTES.twoFactor }
/** 줄의 날짜 ("11월 30일") — 줄을 그리지 않으면 null */
const day = computed(() => {
const deadline = authStore.twoFactorDeadline
if (deadline === null || twoFactorStore.twoFactor?.isEnabled === true) return null
const now = clock.now()
return isTwoFactorGraceActive(deadline, now) ? twoFactorDeadlineDayLabel(deadline, now) : null
})
watch(
() => authStore.twoFactorDeadline,
(deadline) => {
if (deadline !== null && twoFactorStore.status === 'idle') void twoFactorStore.load()
},
{ immediate: true },
)
</script>
<template>
<div
v-if="day"
class="grace-notice"
role="region"
:aria-label="TWO_FACTOR_GRACE_TEXT.region"
data-two-factor-grace-notice
>
<AppIcon
name="shield"
:size="15"
:stroke-width="1.9"
class="grace-notice__icon"
/>
<span class="grace-notice__text"><strong class="grace-notice__strong">{{ TWO_FACTOR_GRACE_TEXT.deadline(day) }}</strong>{{ TWO_FACTOR_GRACE_TEXT.barAfter }}</span>
<RouterLink
:to="twoFactorRoute"
class="grace-notice__action"
data-two-factor-grace-action
>
{{ TWO_FACTOR_GRACE_TEXT.action }}
</RouterLink>
</div>
</template>
<style scoped>
/* 노란 줄 40 — 밀도 예외 (--size-notice-bar). 바탕 #FFF7E6 · 아래 선 #F0D9A8 · 글 #7A5200 */
.grace-notice {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-notice-bar);
padding: var(--space-2) var(--space-6) var(--space-2) var(--space-20);
border-bottom: var(--border-width) solid var(--color-warning-border);
background: var(--color-warning-surface);
color: var(--color-warning-text);
}
.grace-notice__icon {
flex: 0 0 auto;
}
.grace-notice__text {
flex: 1 1 auto;
min-width: 0;
word-break: keep-all;
overflow-wrap: anywhere;
}
.grace-notice__strong {
font-weight: 600;
}
/* [지금 켜기] 36 — 밀도 예외 (--size-notice-action), 읽기 화면 안내 줄의 흰 단추와 같다 */
.grace-notice__action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-warning-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-warning-text);
font-weight: 600;
text-decoration: none;
}
.grace-notice__action:hover {
background: var(--color-warning-hover);
color: var(--color-warning-text);
}
</style>
@@ -92,11 +92,11 @@ const hasUnsavedChanges = computed(
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났으면 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
*/
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.isLoggedIn,
unloadActive: () => hasUnsavedChanges.value && authStore.isLoggedIn,
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.canUseApp,
unloadActive: () => hasUnsavedChanges.value && authStore.canUseApp,
})
/** 무엇이든 바꾸면 "저장했습니다"를 내린다 */
@@ -0,0 +1,199 @@
<script setup lang="ts">
// 프로그램별 연결 차례 — 펼침 목록 (13단계 — 디자인 원본 SettingsMailClients · README "13단계 › 설정 › 계정 › 메일 프로그램 연결")
// 테두리 상자 하나 · 줄 사이 선. 줄 52 — 아이콘 18(PC · 휴대폰) · 이름(굵게) / 설명(12px 회색) · 오른쪽 ⌄(펼치면 ⌃).
// - 줄 전체가 버튼(aria-expanded · 펼친 것만 aria-controls)이고 제목(h3) 안에 둔다. 펼친 차례는 region(이름 = 그 버튼) —
// 이름 글과 같은 선(왼쪽 44)에서 번호 줄(청록 굵은 숫자 — 동그라미 없음)
// - 처음에는 모두 접힘, 하나를 펼치면 다른 것은 접힌다(한 번에 하나). 다시 누르면 접힌다
// - 차례 글은 화면 상수(MAIL_CLIENT_GUIDES) — 호스트 이름은 굵게, 글자로만 그린다(v-html 없음)
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_CLIENT_GUIDES, MAIL_CLIENTS_TEXT, type MailClientGuide } from '@/shared/constants/mail-clients'
/** 펼친 프로그램 — 없으면 null */
const expandedId = ref<string | null>(null)
function buttonId(guide: MailClientGuide): string {
return `mail-client-${guide.id}-button`
}
function panelId(guide: MailClientGuide): string {
return `mail-client-${guide.id}-panel`
}
function toggle(guide: MailClientGuide) {
expandedId.value = expandedId.value === guide.id ? null : guide.id
}
</script>
<template>
<ul
class="client-guides"
:aria-label="MAIL_CLIENTS_TEXT.listLabel"
>
<li
v-for="guide in MAIL_CLIENT_GUIDES"
:key="guide.id"
class="client-guides__item"
:data-mail-client="guide.id"
>
<h3 class="client-guides__heading">
<button
:id="buttonId(guide)"
type="button"
class="client-guides__toggle"
:aria-expanded="expandedId === guide.id ? 'true' : 'false'"
:aria-controls="expandedId === guide.id ? panelId(guide) : undefined"
@click="toggle(guide)"
>
<AppIcon
:name="guide.icon"
:size="18"
class="client-guides__icon"
/>
<span class="client-guides__text">
<span class="client-guides__name">{{ guide.name }}</span>
<span class="client-guides__description">{{ guide.description }}</span>
</span>
<AppIcon
:name="expandedId === guide.id ? 'chevron-up' : 'chevron-down'"
:size="16"
class="client-guides__chevron"
/>
</button>
</h3>
<div
v-if="expandedId === guide.id"
:id="panelId(guide)"
class="client-guides__panel"
role="region"
:aria-labelledby="buttonId(guide)"
>
<ol class="client-guides__steps">
<li
v-for="(step, index) in guide.steps"
:key="index"
class="client-guides__step"
>
<span
class="client-guides__number"
aria-hidden="true"
>{{ index + 1 }}</span>
<span>
<template
v-for="(segment, segmentIndex) in step"
:key="segmentIndex"
>
<strong
v-if="typeof segment !== 'string'"
class="client-guides__strong"
>{{ segment.strong }}</strong>
<template v-else>{{ segment }}</template>
</template>
</span>
</li>
</ol>
</div>
</li>
</ul>
</template>
<style scoped>
.client-guides {
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
}
.client-guides__item + .client-guides__item {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.client-guides__heading {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
/* 줄 52 — 줄 전체가 누르는 곳 (이름 · 설명이 줄을 바꿔도) */
.client-guides__toggle {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-12);
min-height: var(--size-header-min);
padding: var(--space-6) var(--space-12) var(--space-6) var(--space-14);
border: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.client-guides__toggle:hover {
background: var(--color-surface-sunken);
}
/* 상자가 모서리를 자르므로(overflow) 초점 고리는 안쪽에 그린다 */
.client-guides__toggle:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.client-guides__icon {
color: var(--color-text-secondary);
}
.client-guides__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.client-guides__name {
font-weight: 600;
}
.client-guides__description {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.client-guides__chevron {
color: var(--color-text-muted);
}
/* 펼친 차례 — 이름 글과 같은 선(왼쪽 44)에서 */
.client-guides__panel {
padding: var(--space-2) var(--space-16) var(--space-16) var(--space-44);
}
.client-guides__steps {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
/* 청록 굵은 숫자 — 동그라미 없음 (숫자 칸 20) */
.client-guides__step {
display: grid;
grid-template-columns: var(--space-20) minmax(0, 1fr);
gap: var(--space-6);
}
.client-guides__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.client-guides__strong {
font-weight: 600;
}
</style>
@@ -3,6 +3,7 @@
// 받는 메일(IMAP) 호스트 · 포트 993 · SSL/TLS / 보내는 메일(SMTP) 호스트 · 포트 465 · SSL/TLS / 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호.
// 호스트 · 포트는 상수 하나(MAIL_CLIENT_SETTINGS)에 둔다 — 전 직원 전환 때 다시 본다.
// compact: 결과 창의 요약 — 비밀번호 줄 없이, 12.5px · 위 선 하나 (화면의 상자는 줄마다 선이 있는 테두리 상자)
// passwordNote: 비밀번호 줄의 글 — 메일 프로그램 연결 화면(13단계)은 2단계 인증 상태에 따라 바꾼다. 좁은 폭(600 이하)에서는 이름(12px 회색) 위 · 값 아래 한 칸
import { computed } from 'vue'
import { APP_PASSWORD_TEXT, MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security'
@@ -11,10 +12,13 @@ interface Props {
/** 직원 주소 — 사용자 이름 */
email: string
compact?: boolean
/** 비밀번호 줄의 글 — 없으면 앱 비밀번호 화면의 글 */
passwordNote?: string
}
const props = withDefaults(defineProps<Props>(), {
compact: false,
passwordNote: APP_PASSWORD_TEXT.passwordNote,
})
const { host, imapPort, smtpPort, security } = MAIL_CLIENT_SETTINGS
@@ -26,7 +30,7 @@ const rows = computed(() => [
{ term: APP_PASSWORD_TEXT.incoming, strong: host, rest: ` · ${serverLine(imapPort)}` },
{ term: APP_PASSWORD_TEXT.outgoing, strong: host, rest: ` · ${serverLine(smtpPort)}` },
{ term: APP_PASSWORD_TEXT.username, strong: props.email, rest: props.compact ? '' : APP_PASSWORD_TEXT.usernameNote },
...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: APP_PASSWORD_TEXT.passwordNote }]),
...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: props.passwordNote }]),
])
</script>
@@ -86,6 +90,23 @@ const rows = computed(() => [
font-weight: 600;
}
/* 좁은 폭 — 이름(12px 회색) 위 · 값 아래 한 칸 (디자인 원본 SettingsMailClients "좁은 폭") */
@media (max-width: 37.5rem) {
.client-settings:not(.client-settings--compact) {
grid-template-columns: minmax(0, 1fr);
}
.client-settings:not(.client-settings--compact) .client-settings__term {
padding-bottom: 0;
border-bottom: 0;
font-size: var(--font-size-small);
}
.client-settings:not(.client-settings--compact) .client-settings__value {
padding-top: var(--space-2);
}
}
/* 결과 창의 요약 — 이름 칸 124 · 12.5px · 위 선 하나 */
.client-settings--compact {
grid-template-columns: 7.75rem minmax(0, 1fr);
@@ -4,6 +4,7 @@
// - 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code"
// maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. 칸의 설명(aria-describedby)은 안내 → 오류 차례 (PasswordField 와 같다)
// - variant="login": 로그인 코드 칸 모양(폭 전체 · 테두리 #8A94A3 · 18px · 글자 사이 0.3em · 이름 12.5px 굵게) — 13단계 새 비밀번호 · 2단계 켜기 단계
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -23,6 +24,8 @@ interface Props {
compactLabel?: boolean
/** 대화상자를 열면 이 칸에 초점 (켜기 창 — 처음 초점은 코드 칸) */
autofocus?: boolean
/** 칸 모양 — 설정 화면(기본) · 로그인 틀 */
variant?: 'settings' | 'login'
}
interface Emits {
@@ -35,6 +38,7 @@ const props = withDefaults(defineProps<Props>(), {
readonly: false,
compactLabel: false,
autofocus: false,
variant: 'settings',
})
const emit = defineEmits<Emits>()
@@ -61,7 +65,7 @@ defineExpose({
</script>
<template>
<div class="otp-field">
<div :class="['otp-field', `otp-field--${variant}`]">
<label
:for="id"
:class="['otp-field__label', { 'otp-field__label--compact': compactLabel }]"
@@ -134,4 +138,26 @@ defineExpose({
background: var(--color-surface-panel);
color: var(--color-text-secondary);
}
/* 로그인 틀의 코드 칸 — 로그인 코드 단계(LoginCodeStep)와 같은 모양 */
.otp-field--login .otp-field__label {
font-size: var(--font-size-label);
color: var(--color-text-strong);
}
.otp-field--login .otp-field__input {
width: 100%;
border-color: var(--color-border-input);
font-size: var(--font-size-title-lg);
letter-spacing: 0.3em;
}
.otp-field--login .otp-field__input:focus {
border-color: var(--color-brand);
}
.otp-field--login .otp-field__input[aria-invalid='true'],
.otp-field--login .otp-field__input[aria-invalid='true']:focus {
border-color: var(--color-danger);
}
</style>
@@ -3,6 +3,7 @@
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. hintIsError 면 안내 글 자체가 오류가 된다 (새 비밀번호의 규칙)
// - 보기 단추의 이름은 바뀌지 않는다 — 켜짐은 aria-pressed 로 알린다 (아이콘만 눈 ↔ 빗금 눈)
// - 칸의 설명(aria-describedby)은 안내 → 오류 순서
// - variant="login": 로그인 틀의 칸 모양(테두리 #8A94A3 · 글자 14px · 이름 12.5px 굵게 — 13단계 새 비밀번호 · 2단계 켜기 단계, 디자인 원본 LoginPasswordChange)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -23,6 +24,10 @@ interface Props {
error?: string | null
/** 이름을 작게(12.5px · 회색) — 2단계 인증 끄기 창처럼 칸이 둘뿐인 확인 창 (디자인 원본 SettingsTwoFactor) */
compactLabel?: boolean
/** 칸 모양 — 설정 화면(기본) · 로그인 틀 */
variant?: 'settings' | 'login'
/** 보내는 중 — 읽기만 */
readonly?: boolean
}
interface Emits {
@@ -36,6 +41,8 @@ const props = withDefaults(defineProps<Props>(), {
hintIsError: false,
error: null,
compactLabel: false,
variant: 'settings',
readonly: false,
})
const emit = defineEmits<Emits>()
@@ -59,12 +66,12 @@ defineExpose({
</script>
<template>
<div class="password-field">
<div :class="['password-field', `password-field--${variant}`]">
<label
:for="id"
:class="['password-field__label', { 'password-field__label--compact': compactLabel }]"
>{{ label }}</label>
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid, 'is-readonly': readonly }]">
<input
:id="id"
ref="inputRef"
@@ -72,6 +79,7 @@ defineExpose({
:type="shown ? 'text' : 'password'"
:autocomplete="autocomplete"
:value="modelValue"
:readonly="readonly"
spellcheck="false"
autocapitalize="off"
:aria-invalid="invalid ? 'true' : 'false'"
@@ -163,4 +171,30 @@ defineExpose({
.password-field__toggle[aria-pressed='true'] {
color: var(--color-brand-strong);
}
/* 보내는 중 — 읽기만 (로그인 코드 칸과 같은 회색 바탕) */
.password-field__box.is-readonly {
background: var(--color-surface-panel);
color: var(--color-text-secondary);
}
/* 로그인 틀의 칸 — 이름 12.5px 굵게 · 테두리 #8A94A3 · 글자 14px (디자인 원본 Login · LoginPasswordChange) */
.password-field--login .password-field__label {
font-size: var(--font-size-label);
color: var(--color-text-strong);
}
.password-field--login .password-field__box {
border-color: var(--color-border-input);
font-size: var(--font-size-body);
}
.password-field--login .password-field__box:has(.form-input__control:focus) {
border-color: var(--color-brand);
}
.password-field--login .password-field__box.is-invalid,
.password-field--login .password-field__box.is-invalid:has(.form-input__control:focus) {
border-color: var(--color-danger);
}
</style>
@@ -66,9 +66,10 @@ function problemOf(problem: VacationProblem): string | null {
return problems.value.includes(problem) ? problemText : null
}
// 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => changed.value && saveState.value !== 'saving' && authStore.isLoggedIn,
unloadActive: () => changed.value && authStore.isLoggedIn,
active: () => changed.value && saveState.value !== 'saving' && authStore.canUseApp,
unloadActive: () => changed.value && authStore.canUseApp,
})
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
@@ -2,6 +2,7 @@
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
// 상단 바 바로 아래 모듈 폭 전체의 안내 줄은 notice 슬롯 (13단계 — 메일 화면의 2단계 인증 유예 줄)
import { computed } from 'vue'
import AppRail from './AppRail.vue'
@@ -29,6 +30,7 @@ const moduleLabel = computed(() => findAppModule(props.moduleId).label)
<slot name="search" />
</template>
</AppTopBar>
<slot name="notice" />
<div class="app-shell__module">
<slot />
</div>