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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user