generated from ttipo/project-base
docs: 12단계 계정 보안 — 계약 5절 · 디자인 보드 넷 · README #9
Generated
+7
@@ -19,6 +19,7 @@
|
||||
"dompurify": "^3.4.17",
|
||||
"pinia": "^3.0.4",
|
||||
"pretendard": "^1.3.9",
|
||||
"uqr": "0.1.3",
|
||||
"vue": "^3.5.43",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
@@ -6811,6 +6812,12 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uqr": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
|
||||
"integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"dompurify": "^3.4.17",
|
||||
"pinia": "^3.0.4",
|
||||
"pretendard": "^1.3.9",
|
||||
"uqr": "0.1.3",
|
||||
"vue": "^3.5.43",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
// 글 가운데 앱 안 링크 하나 — "켜기 전에 [앱 비밀번호]를 만들어 두면 편합니다." 처럼 앞 · 링크 · 뒤 글을 빈칸 없이 잇는다
|
||||
// (RouterLink 를 글 사이에 바로 쓰면 템플릿의 줄바꿈이 링크 글 앞뒤의 빈칸이 된다 — 링크는 RouterLink 의 custom 슬롯으로 <a> 하나만 그린다)
|
||||
// 사용 예: <LinkedText :before="…" link="앱 비밀번호" :after="…" :to="{ name: SETTINGS_ROUTES.appPasswords }" />
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
before?: string
|
||||
link: string
|
||||
after?: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
before: '',
|
||||
after: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ before }}<RouterLink
|
||||
v-slot="{ href, navigate }"
|
||||
:to="to"
|
||||
custom
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
>{{ link }}</a>
|
||||
</RouterLink>{{ after }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR)
|
||||
// 칸은 shared/utils/qr-code.ts 가 고르고 여기서는 SVG <rect> 를 v-for 로 그린다 — v-html 을 쓰지 않는다.
|
||||
// 바깥 여백(quiet zone)은 칸 2개 + 상자 안쪽 여백 — 읽는 앱이 그림의 끝을 알아보게 둔다.
|
||||
// 이름(label)은 role="img" 의 aria-label — 그림의 내용(비밀이 든 주소)은 읽어 주지 않는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { qrModuleRuns } from '@/shared/utils/qr-code'
|
||||
|
||||
interface Props {
|
||||
/** QR 에 담을 글 — otpauth 주소 */
|
||||
value: string
|
||||
/** 그림의 이름 (스크린리더) */
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** 바깥 여백 칸 수 */
|
||||
const QUIET_ZONE = 2
|
||||
|
||||
const modules = computed(() => qrModuleRuns(props.value))
|
||||
const extent = computed(() => modules.value.size + QUIET_ZONE * 2)
|
||||
const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="qr-code"
|
||||
role="img"
|
||||
:aria-label="label"
|
||||
:viewBox="viewBox"
|
||||
shape-rendering="crispEdges"
|
||||
data-qr-code
|
||||
>
|
||||
<rect
|
||||
class="qr-code__background"
|
||||
:width="extent"
|
||||
:height="extent"
|
||||
/>
|
||||
<rect
|
||||
v-for="run in modules.runs"
|
||||
:key="`${run.y}-${run.x}`"
|
||||
class="qr-code__module"
|
||||
:x="run.x + QUIET_ZONE"
|
||||
:y="run.y + QUIET_ZONE"
|
||||
:width="run.width"
|
||||
height="1"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 160 — 실제 otpauth 주소는 49칸 남짓이라 디자인 보드(128 — 25칸짜리 예시)보다 키워 칸 하나가 3px 를 넘게 한다 */
|
||||
.qr-code {
|
||||
display: block;
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
.qr-code__background {
|
||||
fill: var(--color-surface);
|
||||
}
|
||||
|
||||
.qr-code__module {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
// 로그인 — 코드 단계 (디자인 원본 LoginTwoFactor · README "12단계 › 로그인 — 코드 단계" · 계약 5절 "로그인")
|
||||
// 틀은 로그인 화면과 같다: 제목 "2단계 인증" + 설명 → 주소 + [다른 계정으로 로그인] → (위쪽 빨간 상자) → 코드 칸 + [확인] → 도움말 → 메일 프로그램 안내.
|
||||
// - 코드 칸: 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code".
|
||||
// maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다
|
||||
// - 코드 틀림(AUTH_006)은 칸 아래 빨간 글(부모가 칸을 비우고 초점을 돌려준다), 시도 과다(REQ_001)는 위쪽 상자 — 로그인 오류와 같은 자리 · 같은 모양
|
||||
// - 확인 중: 「확인하는 중…」(로그인 단추와 같은 모양 · aria-busy), 칸은 읽기만, [다른 계정으로 로그인]도 누를 수 없다(aria-disabled).
|
||||
// [확인]은 disabled 가 아니라 aria-disabled — 키보드 초점을 잃지 않게(시도 과다 · 장애 뒤에도 그 자리에 남는다). 바쁜 동안의 누름은 부모가 무시한다
|
||||
// 상태는 부모(LoginPage)가 가진다 — 비밀번호를 다시 실어 보내는 일이 부모 몫이라서. 고정 id: #login-code · #login-code-error · #login-code-alert
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { LOGIN_TWO_FACTOR_TEXT, OTP_TEXT } from '@/shared/constants/account-security'
|
||||
import { normalizeOtpInput } from '@/shared/utils/account-security'
|
||||
|
||||
interface Props {
|
||||
/** 비밀번호를 확인한 주소 */
|
||||
email: string
|
||||
/** 코드 칸의 값 (v-model:code) */
|
||||
code: string
|
||||
submitting: boolean
|
||||
/** 칸 아래 오류 — 사전 문구(AUTH_006 · AUTH_007) */
|
||||
codeError: string | null
|
||||
/** 위쪽 상자 — 사전 문구(REQ_001) */
|
||||
alert: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:code', value: string): void
|
||||
(e: 'submit'): void
|
||||
(e: 'other-account'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const normalized = normalizeOtpInput(target.value)
|
||||
// 값이 그대로면 다시 그리지 않으므로(숫자가 아닌 글자를 쳤을 때) 칸의 글도 직접 맞춘다
|
||||
if (target.value !== normalized) target.value = normalized
|
||||
emit('update:code', normalized)
|
||||
}
|
||||
|
||||
function onOtherAccount() {
|
||||
if (!props.submitting) emit('other-account')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-step">
|
||||
<div class="code-step__heading">
|
||||
<h1 class="code-step__title">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.title }}
|
||||
</h1>
|
||||
<p class="code-step__subtitle">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="code-step__account">
|
||||
<AppIcon
|
||||
name="user"
|
||||
class="code-step__account-icon"
|
||||
/>
|
||||
<span
|
||||
class="code-step__email"
|
||||
data-code-step-email
|
||||
>{{ email }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="code-step__other"
|
||||
:aria-disabled="submitting ? 'true' : undefined"
|
||||
data-code-step-other
|
||||
@click="onOtherAccount"
|
||||
>
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.otherAccount }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="alert"
|
||||
id="login-code-alert"
|
||||
class="code-step__alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="code-step__alert-icon"
|
||||
/>
|
||||
<span>{{ alert }}</span>
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="code-step__form"
|
||||
novalidate
|
||||
@submit.prevent="emit('submit')"
|
||||
>
|
||||
<div class="code-step__field">
|
||||
<label
|
||||
for="login-code"
|
||||
class="code-step__label"
|
||||
>{{ LOGIN_TWO_FACTOR_TEXT.codeLabel }}</label>
|
||||
<input
|
||||
id="login-code"
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
name="one-time-code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
spellcheck="false"
|
||||
:placeholder="OTP_TEXT.placeholder"
|
||||
:value="code"
|
||||
:readonly="submitting"
|
||||
:class="['code-step__input', { 'is-invalid': codeError }]"
|
||||
:aria-invalid="codeError ? 'true' : undefined"
|
||||
:aria-describedby="codeError ? 'login-code-error' : undefined"
|
||||
@input="onInput"
|
||||
>
|
||||
<p
|
||||
v-if="codeError"
|
||||
id="login-code-error"
|
||||
class="code-step__error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
class="code-step__error-icon"
|
||||
/>
|
||||
<span>{{ codeError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="code-step__submit"
|
||||
:aria-disabled="submitting ? 'true' : undefined"
|
||||
:aria-busy="submitting ? 'true' : 'false'"
|
||||
>
|
||||
{{ submitting ? LOGIN_TWO_FACTOR_TEXT.submitting : LOGIN_TWO_FACTOR_TEXT.submit }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="code-step__help">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.help }}
|
||||
</p>
|
||||
<p class="code-step__note">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.note }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 로그인 화면의 패널과 같은 간격 — 묶음 사이 22 */
|
||||
.code-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.code-step__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.code-step__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-heading-lg);
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-heading);
|
||||
}
|
||||
|
||||
.code-step__subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 주소 줄 48 — 바탕 #FBFBFC · 테두리, 오른쪽 끝에 [다른 계정으로 로그인](44) */
|
||||
.code-step__account {
|
||||
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);
|
||||
}
|
||||
|
||||
.code-step__account-icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.code-step__email {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-step__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;
|
||||
}
|
||||
|
||||
.code-step__other:hover:not([aria-disabled='true']) {
|
||||
color: var(--color-brand-strong);
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.code-step__other[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* 위쪽 빨간 상자 — 로그인 오류와 같은 모양 */
|
||||
.code-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);
|
||||
}
|
||||
|
||||
.code-step__alert-icon {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.code-step__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
}
|
||||
|
||||
.code-step__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.code-step__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 18px · 글자 사이 0.3em · 고른 폭 숫자 (디자인 원본 LoginTwoFactor) */
|
||||
.code-step__input {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-title-lg);
|
||||
letter-spacing: 0.3em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.code-step__input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-danger);
|
||||
}
|
||||
|
||||
.code-step__input:read-only {
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.code-step__error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.code-step__error-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.code-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;
|
||||
}
|
||||
|
||||
.code-step__submit:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 확인하는 동안 — 흐리게 (disabled 가 아니라 aria-disabled — 초점을 잃지 않게, 누름은 부모가 무시한다) */
|
||||
.code-step__submit[aria-disabled='true'] {
|
||||
opacity: 0.7;
|
||||
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);
|
||||
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,184 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호 만들기 창 — 모달 400(medium) · 처음 초점은 이름 칸 (디자인 원본 SettingsAppPasswords "만들기 창" · 계약 5절 "앱 비밀번호")
|
||||
// 「이름」 + "어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지." → [만들기] [취소]
|
||||
// - 칸 아래(알림 없이): "이름을 넣어 주세요." · "같은 이름의 앱 비밀번호가 있습니다."(앞뒤 공백을 지운 값으로 화면이 먼저 본다) ·
|
||||
// 서버의 거절(그사이 다른 창에서 5개를 채움 AUTH_009 · 형식 VAL_001)은 부모가 사전 문구를 error 로 넘긴다
|
||||
// - Enter 는 [만들기]와 같다(한글 조합 중의 Enter 는 글자 확정이라 무시한다). 만드는 동안 [만들기]는 aria-disabled · aria-busy(초점을 지킨다)
|
||||
// - 요청은 부모(앱 비밀번호 화면)가 보낸다 — 값(password)이 든 응답이 이 창의 이벤트로 오가지 않게, 결과 창도 부모가 연다
|
||||
// 고정 id — #app-password-create-title · #app-password-name · #app-password-name-help
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import {
|
||||
APP_PASSWORD_CREATE_TEXT,
|
||||
APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
} from '@/shared/constants/account-security'
|
||||
import type { AppPassword } from '@/shared/types/account-security'
|
||||
import { appPasswordNameProblem, type AppPasswordNameProblem } from '@/shared/utils/account-security'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 지금 목록 — 같은 이름을 먼저 본다 */
|
||||
existing: readonly AppPassword[]
|
||||
/** 만드는 중 — [만들기]를 다시 누를 수 없다 */
|
||||
pending?: boolean
|
||||
/** 서버가 거절한 까닭(사전 문구) — 이름을 고치면 부모가 지운다(clear-error) */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 앞뒤 공백을 지운 이름 */
|
||||
(e: 'submit', name: string): void
|
||||
(e: 'close'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pending: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const PROBLEM_TEXT: Readonly<Record<AppPasswordNameProblem, string>> = {
|
||||
empty: APP_PASSWORD_CREATE_TEXT.empty,
|
||||
duplicate: APP_PASSWORD_CREATE_TEXT.duplicate,
|
||||
tooLong: getErrorMessage('VAL_001'),
|
||||
}
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const name = ref<string>('')
|
||||
/** 보내기 전에 막은 까닭 */
|
||||
const formatError = ref<string | null>(null)
|
||||
|
||||
const shownError = computed(() => formatError.value ?? props.error)
|
||||
const message = computed(() => shownError.value ?? APP_PASSWORD_CREATE_TEXT.nameHelp)
|
||||
|
||||
function onInput(event: Event) {
|
||||
name.value = (event.target as HTMLInputElement).value
|
||||
formatError.value = null
|
||||
if (props.error) emit('clear-error')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (props.pending) return
|
||||
const problem = appPasswordNameProblem(name.value, props.existing)
|
||||
if (problem) {
|
||||
formatError.value = PROBLEM_TEXT[problem]
|
||||
inputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
emit('submit', name.value.trim())
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
// 열 때마다 빈 칸으로
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
name.value = ''
|
||||
formatError.value = null
|
||||
},
|
||||
)
|
||||
|
||||
// 서버가 거절하면 칸에 초점을 돌려준다 — 고쳐 쓰기 쉽게
|
||||
watch(
|
||||
() => props.error,
|
||||
(error) => {
|
||||
if (error) void nextTick(() => inputRef.value?.focus())
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
medium
|
||||
:title="APP_PASSWORD_CREATE_TEXT.title"
|
||||
title-id="app-password-create-title"
|
||||
described-by="app-password-name-help"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="create-name">
|
||||
<label
|
||||
for="app-password-name"
|
||||
class="create-name__label"
|
||||
>{{ APP_PASSWORD_CREATE_TEXT.nameLabel }}</label>
|
||||
<input
|
||||
id="app-password-name"
|
||||
ref="inputRef"
|
||||
class="form-input create-name__input"
|
||||
type="text"
|
||||
:value="name"
|
||||
:maxlength="APP_PASSWORD_NAME_MAX_LENGTH"
|
||||
:placeholder="APP_PASSWORD_CREATE_TEXT.namePlaceholder"
|
||||
autocomplete="off"
|
||||
:aria-invalid="shownError ? 'true' : 'false'"
|
||||
aria-describedby="app-password-name-help"
|
||||
autofocus
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<p
|
||||
id="app-password-name-help"
|
||||
:class="['form-message', { 'form-message--error': shownError }]"
|
||||
data-app-password-name-message
|
||||
>
|
||||
<AppIcon
|
||||
v-if="shownError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ message }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:aria-disabled="pending ? 'true' : undefined"
|
||||
:aria-busy="pending ? 'true' : undefined"
|
||||
data-app-password-create-submit
|
||||
@click="submit"
|
||||
>
|
||||
{{ APP_PASSWORD_CREATE_TEXT.submit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-app-password-create-cancel
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ APP_PASSWORD_CREATE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.create-name__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.create-name__input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호 지우기 확인 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 취소 (디자인 원본 SettingsAppPasswords "지우기 확인 창")
|
||||
// "‘휴대폰 메일 앱’ 앱 비밀번호를 지울까요?" / "이 비밀번호로 연결한 메일 프로그램은 다음 연결부터 로그인하지 못합니다. 지운 비밀번호는 되돌릴 수 없습니다."
|
||||
// [지우기](빨강 채움) [취소]. 지우는 동안 [지우기]는 aria-disabled · aria-busy(초점을 지킨다)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { APP_PASSWORD_DELETE_TEXT } from '@/shared/constants/account-security'
|
||||
|
||||
interface Props {
|
||||
/** 지울 앱 비밀번호의 이름 — 없으면 닫힌다 */
|
||||
name: string | null
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() => (props.name === null ? '' : APP_PASSWORD_DELETE_TEXT.title(props.name)))
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.busy) emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="name !== null"
|
||||
role="alertdialog"
|
||||
medium
|
||||
:title="title"
|
||||
:message="APP_PASSWORD_DELETE_TEXT.message"
|
||||
title-id="app-password-delete-title"
|
||||
message-id="app-password-delete-message"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
data-app-password-delete-confirm
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ APP_PASSWORD_DELETE_TEXT.confirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
data-app-password-delete-cancel
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ APP_PASSWORD_DELETE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호를 만든 결과 창 — 모달 620(wide) · 처음 초점 [복사] (디자인 원본 SettingsAppPasswords "만든 결과 창" · 계약 5절 "앱 비밀번호")
|
||||
// "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
|
||||
// - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다(조각 사이를 여백으로 — 골라 복사해도 값 그대로) — [복사]는 값 그대로 복사한다.
|
||||
// "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." — 글자 수는 받은 값의 길이로 쓴다.
|
||||
// 묶음은 줄바꿈 없는 inline-block 덩어리 — 넓으면 한 줄, 좁으면 묶음 사이에서만 줄이 바뀐다(묶음이 둘로 갈리지 않는다 — 2026-10-31 브라우저 점검)
|
||||
// - 경고 "이 창을 닫으면 다시 볼 수 없습니다. …" + 설정값 요약 + [닫기]
|
||||
// - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
|
||||
// - [닫기] · Esc 로 닫으면 부모가 값을 화면 메모리에서도 지운다. 복사하지 않고 닫아도 다시 묻지 않는다
|
||||
// [복사]는 44 — 디자인 보드 크기(README 의 36 은 밀도 예외 줄의 단추와 묶어 적은 것 — 창의 주 동작이라 보통 크기로 둔다)
|
||||
// 고정 id — #app-password-result-title · #app-password-result-message
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import MailClientSettings from '@/components/settings/MailClientSettings.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { APP_PASSWORD_RESULT_TEXT } from '@/shared/constants/account-security'
|
||||
import { groupCharacters } from '@/shared/utils/account-security'
|
||||
import { copyText } from '@/shared/utils/clipboard'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
/** 만든 앱 비밀번호의 이름 · 값 — 없으면 닫힌다 */
|
||||
result: { name: string; password: string } | null
|
||||
/** 직원 주소 — 설정값 요약의 사용자 이름 */
|
||||
email: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const groups = computed(() => (props.result ? groupCharacters(props.result.password) : []))
|
||||
const message = computed(() => (props.result ? APP_PASSWORD_RESULT_TEXT.message(props.result.name) : ''))
|
||||
const lengthNote = computed(() =>
|
||||
APP_PASSWORD_RESULT_TEXT.lengthNote(formatCount(props.result?.password.length ?? 0)),
|
||||
)
|
||||
|
||||
async function copy() {
|
||||
const value = props.result?.password
|
||||
if (!value) return
|
||||
if (await copyText(value)) toast.success(APP_PASSWORD_RESULT_TEXT.copied)
|
||||
else toast.error(APP_PASSWORD_RESULT_TEXT.copyFailed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="result !== null"
|
||||
wide
|
||||
:title="APP_PASSWORD_RESULT_TEXT.title"
|
||||
:message="message"
|
||||
title-id="app-password-result-title"
|
||||
message-id="app-password-result-message"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="result__value-box">
|
||||
<code
|
||||
class="result__value"
|
||||
data-app-password-value
|
||||
><span
|
||||
v-for="(group, index) in groups"
|
||||
:key="index"
|
||||
class="result__group"
|
||||
>{{ group }}</span></code>
|
||||
<button
|
||||
type="button"
|
||||
class="result__copy"
|
||||
:aria-label="APP_PASSWORD_RESULT_TEXT.copyLabel"
|
||||
autofocus
|
||||
data-app-password-copy
|
||||
@click="copy"
|
||||
>
|
||||
<AppIcon
|
||||
name="copy"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ APP_PASSWORD_RESULT_TEXT.copy }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="result__length">
|
||||
{{ lengthNote }}
|
||||
</p>
|
||||
<div class="result__warning">
|
||||
<AppIcon
|
||||
name="alert-triangle"
|
||||
:stroke-width="1.9"
|
||||
class="result__warning-icon"
|
||||
/>
|
||||
<span class="result__warning-text"><strong class="result__warning-strong">{{
|
||||
APP_PASSWORD_RESULT_TEXT.warningStrong
|
||||
}}</strong>{{ APP_PASSWORD_RESULT_TEXT.warningAfter }}</span>
|
||||
</div>
|
||||
<MailClientSettings
|
||||
:email="email"
|
||||
compact
|
||||
/>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
data-app-password-result-close
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ APP_PASSWORD_RESULT_TEXT.close }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 값 상자 — 테두리 · 바탕 #F8F9FB · 안쪽 8 8 8 16 */
|
||||
.result__value-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-8) var(--space-8) var(--space-8) var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
/* 값 — 고정폭 15px, 4자 조각 사이를 여백으로(띄어쓰기 없이). 누르면 한 번에 고른다 */
|
||||
.result__value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: Consolas, 'SFMono-Regular', Menlo, monospace;
|
||||
font-size: var(--font-size-title-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/*
|
||||
* 4자 묶음 — 줄바꿈 없는 덩어리. inline-block 앞뒤가 줄이 바뀔 수 있는 자리라 좁으면 묶음 사이에서만 줄이 바뀐다.
|
||||
* block · flex 로 나누지 않는다 — 골라 복사한 값에 줄바꿈이 끼면 메일 프로그램이 받지 않는다
|
||||
*/
|
||||
.result__group {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result__group:not(:last-child) {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
.result__copy {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result__copy:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.result__length {
|
||||
margin: calc(var(--space-4) * -1) 0 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 경고 — 노란 상자 · 경고 삼각형 */
|
||||
.result__warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.result__warning-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.result__warning-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.result__warning-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호 표 (디자인 원본 SettingsAppPasswords — 캔버스는 grid 로 그렸지만 구현은 실제 table)
|
||||
// 이름 · 만든 날 · 마지막 사용 · [지우기](36 — 밀도 예외, 빨간 글). 최근에 만든 것이 위, 긴 이름은 말줄임.
|
||||
// 마지막 사용은 "오늘 오전 9:12 · IMAP · 211.186.95.252" — 쓰지 않았으면 「아직 쓰지 않음」(회색 — 오래 쓰지 않은 줄은 지워도 된다는 신호)
|
||||
// 지우는 중인 줄의 단추는 aria-disabled · aria-busy (다시 누르는 것은 부모가 막는다)
|
||||
import { APP_PASSWORD_TEXT } from '@/shared/constants/account-security'
|
||||
import type { AppPassword } from '@/shared/types/account-security'
|
||||
import { appPasswordLastUsedText } from '@/shared/utils/account-security'
|
||||
import { formatBlockedDay } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
items: readonly AppPassword[]
|
||||
/** 지금 시각(ms) — "오늘 · 어제" 와 연도 표기의 기준 */
|
||||
now: number
|
||||
/** 지우는 중인 줄 */
|
||||
removingId?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'delete', item: AppPassword): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
removingId: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-password-table">
|
||||
<table class="app-password-table__table">
|
||||
<caption class="visually-hidden">
|
||||
{{ APP_PASSWORD_TEXT.listLabel }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col>
|
||||
<col class="app-password-table__col-created">
|
||||
<col class="app-password-table__col-used">
|
||||
<col class="app-password-table__col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
{{ APP_PASSWORD_TEXT.columnName }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ APP_PASSWORD_TEXT.columnCreated }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ APP_PASSWORD_TEXT.columnLastUsed }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="visually-hidden">{{ APP_PASSWORD_TEXT.columnActions }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:data-app-password="item.id"
|
||||
>
|
||||
<td
|
||||
class="app-password-table__name"
|
||||
:title="item.name"
|
||||
>
|
||||
{{ item.name }}
|
||||
</td>
|
||||
<td class="app-password-table__created">
|
||||
{{ formatBlockedDay(item.createdAt, new Date(now)) }}
|
||||
</td>
|
||||
<td
|
||||
:class="['app-password-table__used', { 'app-password-table__used--never': !item.lastUsed }]"
|
||||
data-app-password-used
|
||||
>
|
||||
{{ appPasswordLastUsedText(item.lastUsed, now) ?? APP_PASSWORD_TEXT.neverUsed }}
|
||||
</td>
|
||||
<td class="app-password-table__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="app-password-table__delete"
|
||||
:aria-label="APP_PASSWORD_TEXT.deleteLabel(item.name)"
|
||||
:aria-disabled="removingId ? 'true' : undefined"
|
||||
:aria-busy="removingId === item.id ? 'true' : undefined"
|
||||
data-app-password-delete
|
||||
@click="emit('delete', item)"
|
||||
>
|
||||
{{ APP_PASSWORD_TEXT.delete }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-password-table {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 칸: 이름(나머지) · 만든 날 88 · 마지막 사용 264 · 지우기 72, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ap-grid) */
|
||||
.app-password-table__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.app-password-table__col-created {
|
||||
width: calc(5.5rem + var(--space-12));
|
||||
}
|
||||
|
||||
.app-password-table__col-used {
|
||||
width: calc(16.5rem + var(--space-12));
|
||||
}
|
||||
|
||||
.app-password-table__col-actions {
|
||||
width: calc(4.5rem + var(--space-12) + var(--space-8));
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0 0 0 var(--space-12);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--space-14);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
/* 머리 줄 32 — 바탕 #FBFBFC · 12px 굵게 회색 */
|
||||
thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 줄 44 */
|
||||
tbody td {
|
||||
height: var(--size-touch);
|
||||
}
|
||||
|
||||
tbody tr + tr td {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.app-password-table__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-password-table__created {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-password-table__used {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-password-table__used--never {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.app-password-table__actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* [지우기] 36 — 밀도 예외 (--size-notice-action), 빨간 글 테두리 단추 */
|
||||
.app-password-table__delete {
|
||||
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-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-password-table__delete:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
.app-password-table__delete[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// [다른 곳 모두 로그아웃] 확인 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 취소 (디자인 원본 SettingsLoginHistory "다른 곳 모두 로그아웃 — 확인 창")
|
||||
// "다른 곳의 로그인을 모두 끊을까요?" / "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. …" + 작은 글 "메일 프로그램은 끊기지 않습니다 — …"
|
||||
// [모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소]. 끊는 동안 [모두 로그아웃]은 aria-disabled · aria-busy(초점을 지킨다)
|
||||
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 제목 · 글이 "이 브라우저도 로그아웃된다"로 바뀐다 — 부모가 넘긴다
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { END_OTHER_SESSIONS_TEXT } from '@/shared/constants/account-security'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 창 제목 */
|
||||
title: string
|
||||
/** 창 글 — 끊을 곳의 수와 이름 (endOtherSessionsMessage) */
|
||||
message: string
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.busy) emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
medium
|
||||
:title="title"
|
||||
:message="message"
|
||||
title-id="end-other-sessions-title"
|
||||
message-id="end-other-sessions-message"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<p class="end-others__note">
|
||||
{{ END_OTHER_SESSIONS_TEXT.note }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
data-end-others-confirm
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ END_OTHER_SESSIONS_TEXT.confirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
data-end-others-cancel
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ END_OTHER_SESSIONS_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.end-others__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 로그인 기록 › 최근 로그인 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인 기록")
|
||||
// 「최근 로그인」 + "최근 90일 · 웹메일과 메일 프로그램"(실패만이면 "최근 90일 · 실패 4번") · 오른쪽 전환 [전체 · 실패만](36 — .segmented 밀도 예외)
|
||||
// 표(구현은 실제 table): 시각 · 방식 · IP · 기기 · 결과 — 최근 순서 · 20개씩 [더 보기](44)
|
||||
// - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
|
||||
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
|
||||
// - 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」 — 실패는 빨간 굵은 글자만(뱃지 없음)
|
||||
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."
|
||||
// - 보기를 바꾸면 그 보기로 처음부터 받는다 — 받는 동안 지난 목록은 그대로 두고 aria-busy (login-history.store)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
|
||||
import type { LoginEvent } from '@/shared/types/account-security'
|
||||
import {
|
||||
loginAppPasswordLabel,
|
||||
loginDeviceLabel,
|
||||
loginMethodLabel,
|
||||
loginResultLabel,
|
||||
} from '@/shared/utils/account-security'
|
||||
import { formatCount, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import { useLoginHistoryStore, type LoginEventFilter } from '@/stores/login-history.store'
|
||||
|
||||
interface Props {
|
||||
/** 지금 시각(ms) — "오늘 오전 9:30" 의 기준 */
|
||||
now: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const loginHistoryStore = useLoginHistoryStore()
|
||||
|
||||
const FILTERS: readonly { value: LoginEventFilter; label: string }[] = [
|
||||
{ value: 'all', label: LOGIN_HISTORY_TEXT.filterAll },
|
||||
{ value: 'failures', label: LOGIN_HISTORY_TEXT.filterFailures },
|
||||
]
|
||||
|
||||
const events = computed(() => loginHistoryStore.events)
|
||||
const failuresShown = computed(() => loginHistoryStore.loadedFilter === 'failures')
|
||||
const hint = computed(() => {
|
||||
if (!failuresShown.value) return LOGIN_HISTORY_TEXT.eventsHintAll
|
||||
const total = loginHistoryStore.eventsPageInfo?.total
|
||||
return typeof total === 'number'
|
||||
? LOGIN_HISTORY_TEXT.eventsHintFailures(formatCount(total))
|
||||
: LOGIN_HISTORY_TEXT.eventsHintFailuresUnknown
|
||||
})
|
||||
const emptyTitle = computed(() =>
|
||||
failuresShown.value ? LOGIN_HISTORY_TEXT.emptyFailuresTitle : LOGIN_HISTORY_TEXT.emptyAllTitle,
|
||||
)
|
||||
const emptyHint = computed(() =>
|
||||
failuresShown.value ? LOGIN_HISTORY_TEXT.emptyFailuresHint : LOGIN_HISTORY_TEXT.emptyAllHint,
|
||||
)
|
||||
|
||||
function timeText(event: LoginEvent): string {
|
||||
return formatRelativeDayTime(event.occurredAt, new Date(props.now))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="events"
|
||||
aria-labelledby="login-events-title"
|
||||
>
|
||||
<div class="events__head">
|
||||
<div class="events__heading">
|
||||
<h2
|
||||
id="login-events-title"
|
||||
class="events__title"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.eventsTitle }}
|
||||
</h2>
|
||||
<span
|
||||
class="events__hint"
|
||||
data-events-hint
|
||||
>{{ hint }}</span>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
class="segmented segmented--outline"
|
||||
:aria-label="LOGIN_HISTORY_TEXT.filterLabel"
|
||||
>
|
||||
<button
|
||||
v-for="filter in FILTERS"
|
||||
:key="filter.value"
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="loginHistoryStore.eventsFilter === filter.value"
|
||||
:data-events-filter="filter.value"
|
||||
@click="loginHistoryStore.setFilter(filter.value)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="events.length > 0"
|
||||
class="events__table-box"
|
||||
:aria-busy="loginHistoryStore.eventsBusy ? 'true' : undefined"
|
||||
>
|
||||
<table class="events__table">
|
||||
<caption class="visually-hidden">
|
||||
{{ LOGIN_HISTORY_TEXT.eventsTitle }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="events__col-time">
|
||||
<col>
|
||||
<col class="events__col-ip">
|
||||
<col class="events__col-device">
|
||||
<col class="events__col-result">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnTime }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnMethod }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnIp }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnKind }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnResult }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:data-login-event="event.id"
|
||||
>
|
||||
<td class="events__time">
|
||||
{{ timeText(event) }}
|
||||
</td>
|
||||
<td class="events__method">
|
||||
<span class="events__method-name">{{ loginMethodLabel(event.method) }}</span>
|
||||
<span
|
||||
v-if="loginAppPasswordLabel(event)"
|
||||
class="events__app-password"
|
||||
data-event-app-password
|
||||
>{{ loginAppPasswordLabel(event) }}</span>
|
||||
</td>
|
||||
<td class="events__ip">
|
||||
<template v-if="event.ip">
|
||||
{{ event.ip }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.unknownValue }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td
|
||||
class="events__device"
|
||||
data-event-device
|
||||
>
|
||||
<template v-if="loginDeviceLabel(event)">
|
||||
{{ loginDeviceLabel(event) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="events__unknown"
|
||||
aria-hidden="true"
|
||||
>—</span>
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.unknownValue }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td
|
||||
:class="['events__result', { 'events__result--failure': loginResultLabel(event).isFailure }]"
|
||||
data-event-result
|
||||
>
|
||||
{{ loginResultLabel(event).label }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="events__empty"
|
||||
:aria-busy="loginHistoryStore.eventsBusy ? 'true' : undefined"
|
||||
data-events-empty
|
||||
>
|
||||
<p class="events__empty-title">
|
||||
{{ emptyTitle }}
|
||||
</p>
|
||||
<p class="events__empty-hint">
|
||||
{{ emptyHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loginHistoryStore.hasMoreEvents"
|
||||
class="events__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="events__more-button"
|
||||
:aria-disabled="loginHistoryStore.loadingMore || loginHistoryStore.eventsBusy ? 'true' : undefined"
|
||||
:aria-busy="loginHistoryStore.loadingMore ? 'true' : undefined"
|
||||
data-events-more
|
||||
@click="loginHistoryStore.loadMoreEvents()"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.more }}
|
||||
</button>
|
||||
<span class="events__more-hint">{{ LOGIN_HISTORY_TEXT.moreHint }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.events__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-16);
|
||||
min-height: var(--size-touch);
|
||||
}
|
||||
|
||||
.events__heading {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
}
|
||||
|
||||
.events__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.events__hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.events__table-box {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 다시 받는 동안 — 지난 목록을 흐리게 */
|
||||
.events__table-box[aria-busy='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 칸: 시각 124 · 방식(나머지) · IP 112 · 기기 120 · 결과 142, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .lh-grid) */
|
||||
.events__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.events__col-time {
|
||||
width: calc(7.75rem + var(--space-14));
|
||||
}
|
||||
|
||||
.events__col-ip {
|
||||
width: calc(7rem + var(--space-12));
|
||||
}
|
||||
|
||||
.events__col-device {
|
||||
width: calc(7.5rem + var(--space-12));
|
||||
}
|
||||
|
||||
.events__col-result {
|
||||
width: calc(8.875rem + var(--space-12) + var(--space-8));
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-4) 0 var(--space-4) var(--space-12);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--space-14);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
thead th {
|
||||
height: var(--size-table-head);
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
height: var(--size-touch);
|
||||
}
|
||||
|
||||
tbody tr + tr td {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.events__time {
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.events__method {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.events__method-name {
|
||||
display: block;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.events__app-password {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.events__ip {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.events__device {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.events__unknown {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.events__result {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 실패 — 빨간 굵은 글자만 (뱃지 없음) */
|
||||
.events__result--failure {
|
||||
font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 빈 상태 — 가운데 정렬 상자 (디자인 원본 SettingsLoginHistory "빈 상태") */
|
||||
.events__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-28) var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-sunken);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events__empty[aria-busy='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.events__empty-title {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events__empty-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.events__more {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-14);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* [더 보기] 44 — 흰 바탕 · 테두리 */
|
||||
.events__more-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events__more-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.events__more-button[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.events__more-hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 로그인 기록 › 로그인된 브라우저 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저")
|
||||
// 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
|
||||
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
|
||||
// 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르는 브라우저는 "알 수 없는 브라우저", 모르는 IP 는 「—」.
|
||||
// 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
|
||||
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
|
||||
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
|
||||
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함 — 서버는 모두 끊는다) 확인 창이 이 브라우저도 로그아웃된다고 알리고,
|
||||
// 끊은 뒤에는 목록을 다시 받는다(스토어 — 이 브라우저도 끊겼으면 세션 만료 창으로 이어진다)
|
||||
// - 세션이 바뀌면 확인 창을 닫는다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LinkedText from '@/components/LinkedText.vue'
|
||||
import EndOtherSessionsDialog from '@/components/settings/EndOtherSessionsDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { END_OTHER_SESSIONS_TEXT, LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { AuthSession } from '@/shared/types/account-security'
|
||||
import { deviceLabel, endOtherSessionsMessage, isMobileDevice, otherSessions } from '@/shared/utils/account-security'
|
||||
import { formatCount, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useLoginHistoryStore } from '@/stores/login-history.store'
|
||||
|
||||
interface Props {
|
||||
sessions: readonly AuthSession[]
|
||||
/** 지금 시각(ms) — "오늘 오전 9:05" 의 기준 */
|
||||
now: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const loginHistoryStore = useLoginHistoryStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const titleRef = ref<HTMLElement | null>(null)
|
||||
const tableRef = ref<HTMLElement | null>(null)
|
||||
/** 끊는 중인 줄 */
|
||||
const endingId = ref<string | null>(null)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
const endingOthers = ref<boolean>(false)
|
||||
|
||||
const others = computed(() => otherSessions(props.sessions))
|
||||
/** 서버가 이 브라우저를 알아봤다(지금 이 브라우저 줄이 있다) — 없으면 [다른 곳 모두 로그아웃]이 이 브라우저까지 끊는다 */
|
||||
const hasCurrent = computed(() => props.sessions.some((session) => session.isCurrent))
|
||||
const confirmTitle = computed(() => (hasCurrent.value ? END_OTHER_SESSIONS_TEXT.title : END_OTHER_SESSIONS_TEXT.titleAll))
|
||||
const countText = computed(() => LOGIN_HISTORY_TEXT.sessionsCount(formatCount(props.sessions.length)))
|
||||
/** 아래 안내의 앞 글 — 다른 곳이 없으면 "다른 곳에 로그인된 브라우저가 없습니다." 를 앞에 */
|
||||
const noteBefore = computed(() =>
|
||||
others.value.length === 0
|
||||
? `${LOGIN_HISTORY_TEXT.noOthers}${LOGIN_HISTORY_TEXT.sessionsNoteBefore}`
|
||||
: LOGIN_HISTORY_TEXT.sessionsNoteBefore,
|
||||
)
|
||||
const confirmMessage = computed(() => endOtherSessionsMessage(others.value, !hasCurrent.value))
|
||||
const appPasswordsRoute = { name: SETTINGS_ROUTES.appPasswords }
|
||||
|
||||
function lastActiveText(session: AuthSession): string {
|
||||
return formatRelativeDayTime(session.lastActiveAt, new Date(props.now))
|
||||
}
|
||||
|
||||
/** 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 묶음 제목 */
|
||||
async function focusAfterRemoval(index: number) {
|
||||
await nextTick()
|
||||
const active = document.activeElement
|
||||
if (active !== null && active !== document.body) return
|
||||
const buttons = Array.from(tableRef.value?.querySelectorAll<HTMLButtonElement>('[data-session-end]') ?? [])
|
||||
const target = buttons[index] ?? buttons[index - 1]
|
||||
if (target) target.focus()
|
||||
else titleRef.value?.focus()
|
||||
}
|
||||
|
||||
async function endSession(session: AuthSession) {
|
||||
if (endingId.value || endingOthers.value) return
|
||||
const index = others.value.findIndex((item) => item.id === session.id)
|
||||
endingId.value = session.id
|
||||
try {
|
||||
await loginHistoryStore.endSession(session.id)
|
||||
toast.success(LOGIN_HISTORY_TEXT.sessionEnded(deviceLabel(session.device)))
|
||||
await focusAfterRemoval(index)
|
||||
} catch {
|
||||
// 인터셉터가 알렸다 — 줄은 그대로
|
||||
} finally {
|
||||
endingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmEndOthers() {
|
||||
if (endingOthers.value) return
|
||||
const session = authStore.generation
|
||||
endingOthers.value = true
|
||||
try {
|
||||
const { ended, includedThisBrowser } = await loginHistoryStore.endOtherSessions()
|
||||
if (authStore.generation !== session) return
|
||||
confirmOpen.value = false
|
||||
const count = formatCount(ended)
|
||||
toast.success(includedThisBrowser ? LOGIN_HISTORY_TEXT.allEnded(count) : LOGIN_HISTORY_TEXT.othersEnded(count))
|
||||
// 누른 단추가 사라졌다 — 묶음 제목으로 (이 브라우저까지 끊었으면 목록을 다시 받는 중이라 제목이 없을 수 있다)
|
||||
await nextTick()
|
||||
titleRef.value?.focus()
|
||||
} catch {
|
||||
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
|
||||
} finally {
|
||||
endingOthers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 세션이 바뀌면 확인 창을 닫는다 — 세션 만료 창과 겹치지 않게
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
confirmOpen.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="sessions"
|
||||
aria-labelledby="login-sessions-title"
|
||||
>
|
||||
<div class="sessions__head">
|
||||
<div class="sessions__heading">
|
||||
<h2
|
||||
id="login-sessions-title"
|
||||
ref="titleRef"
|
||||
class="sessions__title"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.sessionsTitle }}
|
||||
</h2>
|
||||
<span
|
||||
class="sessions__count"
|
||||
data-sessions-count
|
||||
>{{ countText }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="others.length > 0"
|
||||
type="button"
|
||||
class="sessions__end-others"
|
||||
data-sessions-end-others
|
||||
@click="confirmOpen = true"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.endOthers }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="tableRef"
|
||||
class="sessions__table-box"
|
||||
>
|
||||
<table class="sessions__table">
|
||||
<caption class="visually-hidden">
|
||||
{{ LOGIN_HISTORY_TEXT.sessionsTitle }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="sessions__col-icon">
|
||||
<col>
|
||||
<col class="sessions__col-ip">
|
||||
<col class="sessions__col-active">
|
||||
<col class="sessions__col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.columnKind }}</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnDevice }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnIp }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnLastActive }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.columnActions }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
:data-session="session.id"
|
||||
>
|
||||
<td class="sessions__icon">
|
||||
<AppIcon
|
||||
:name="isMobileDevice(session.device) ? 'smartphone' : 'monitor'"
|
||||
:label="isMobileDevice(session.device) ? LOGIN_HISTORY_TEXT.kindMobile : LOGIN_HISTORY_TEXT.kindPc"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="sessions__device"
|
||||
data-session-device
|
||||
>
|
||||
{{ deviceLabel(session.device) }}
|
||||
</td>
|
||||
<td class="sessions__ip">
|
||||
<template v-if="session.ip">
|
||||
{{ session.ip }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.unknownValue }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td
|
||||
:class="['sessions__active', { 'sessions__active--current': session.isCurrent }]"
|
||||
data-session-active
|
||||
>
|
||||
{{ session.isCurrent ? LOGIN_HISTORY_TEXT.current : lastActiveText(session) }}
|
||||
</td>
|
||||
<td class="sessions__actions">
|
||||
<button
|
||||
v-if="!session.isCurrent"
|
||||
type="button"
|
||||
class="sessions__end"
|
||||
:aria-label="LOGIN_HISTORY_TEXT.endSessionLabel(deviceLabel(session.device))"
|
||||
:aria-disabled="endingId || endingOthers ? 'true' : undefined"
|
||||
:aria-busy="endingId === session.id ? 'true' : undefined"
|
||||
data-session-end
|
||||
@click="endSession(session)"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.endSession }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="sessions__note"
|
||||
data-sessions-note
|
||||
>
|
||||
<LinkedText
|
||||
:before="noteBefore"
|
||||
:link="LOGIN_HISTORY_TEXT.sessionsNoteLink"
|
||||
:after="LOGIN_HISTORY_TEXT.sessionsNoteAfter"
|
||||
:to="appPasswordsRoute"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<EndOtherSessionsDialog
|
||||
:open="confirmOpen"
|
||||
:title="confirmTitle"
|
||||
:message="confirmMessage"
|
||||
:busy="endingOthers"
|
||||
@confirm="confirmEndOthers"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.sessions__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-16);
|
||||
min-height: var(--size-touch);
|
||||
}
|
||||
|
||||
.sessions__heading {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
}
|
||||
|
||||
.sessions__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.sessions__title:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sessions__title:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--color-brand);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.sessions__count {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* [다른 곳 모두 로그아웃] 44 — 흰 바탕 · 테두리 */
|
||||
.sessions__end-others {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions__end-others:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.sessions__table-box {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 칸: 아이콘 16 · 브라우저(나머지) · IP 112 · 마지막 활동 150 · 단추 84, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ss-grid) */
|
||||
.sessions__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.sessions__col-icon {
|
||||
width: calc(var(--space-14) + 1rem);
|
||||
}
|
||||
|
||||
.sessions__col-ip {
|
||||
width: calc(7rem + var(--space-12));
|
||||
}
|
||||
|
||||
.sessions__col-active {
|
||||
width: calc(9.375rem + var(--space-12));
|
||||
}
|
||||
|
||||
.sessions__col-actions {
|
||||
width: calc(5.25rem + var(--space-12) + var(--space-8));
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0 0 0 var(--space-12);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--space-14);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
height: var(--size-touch);
|
||||
}
|
||||
|
||||
tbody tr + tr td {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sessions__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.sessions__device {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions__ip {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sessions__active {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 지금 이 브라우저 — 청록 굵게, 단추 없음 */
|
||||
.sessions__active--current {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.sessions__actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* [로그아웃] 36 — 밀도 예외 (--size-notice-action) */
|
||||
.sessions__end {
|
||||
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;
|
||||
}
|
||||
|
||||
.sessions__end:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.sessions__end[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.sessions__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 프로그램 설정값 (디자인 원본 SettingsAppPasswords — 화면의 설정값 상자 · 만든 결과 창 아래 요약)
|
||||
// 받는 메일(IMAP) 호스트 · 포트 993 · SSL/TLS / 보내는 메일(SMTP) 호스트 · 포트 465 · SSL/TLS / 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호.
|
||||
// 호스트 · 포트는 상수 하나(MAIL_CLIENT_SETTINGS)에 둔다 — 전 직원 전환 때 다시 본다.
|
||||
// compact: 결과 창의 요약 — 비밀번호 줄 없이, 12.5px · 위 선 하나 (화면의 상자는 줄마다 선이 있는 테두리 상자)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { APP_PASSWORD_TEXT, MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security'
|
||||
|
||||
interface Props {
|
||||
/** 직원 주소 — 사용자 이름 */
|
||||
email: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const { host, imapPort, smtpPort, security } = MAIL_CLIENT_SETTINGS
|
||||
|
||||
const serverLine = (port: number) =>
|
||||
props.compact ? `${port} · ${security}` : `${APP_PASSWORD_TEXT.port(port)} · ${security}`
|
||||
|
||||
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 }]),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dl
|
||||
:class="['client-settings', { 'client-settings--compact': compact }]"
|
||||
data-mail-client-settings
|
||||
>
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.term"
|
||||
>
|
||||
<dt class="client-settings__term">
|
||||
{{ row.term }}
|
||||
</dt>
|
||||
<dd class="client-settings__value">
|
||||
<span
|
||||
v-if="row.strong"
|
||||
class="client-settings__strong"
|
||||
>{{ row.strong }}</span>{{ row.rest }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 화면의 상자 — 이름 칸 136 · 줄마다 선 · 안쪽 9 14 */
|
||||
.client-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem minmax(0, 1fr);
|
||||
margin: 0;
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.client-settings__term,
|
||||
.client-settings__value {
|
||||
margin: 0;
|
||||
padding: var(--space-9) var(--space-14);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.client-settings__term:nth-last-of-type(1),
|
||||
.client-settings__value:nth-last-of-type(1) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.client-settings__term {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.client-settings__value {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.client-settings__strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 결과 창의 요약 — 이름 칸 124 · 12.5px · 위 선 하나 */
|
||||
.client-settings--compact {
|
||||
grid-template-columns: 7.75rem minmax(0, 1fr);
|
||||
gap: var(--space-6) var(--space-12);
|
||||
padding-top: var(--space-12);
|
||||
border: 0;
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__term,
|
||||
.client-settings--compact .client-settings__value {
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__term {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__value {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
// 인증 앱 코드 칸 — 이름 · 칸(폭 200 · 44 · 16px · 글자 사이 0.24em · 고른 폭 숫자) · 안내 · 오류 (디자인 원본 SettingsTwoFactor)
|
||||
// 2단계 인증 켜기 · 끄기 창과 비밀번호 화면(2단계 인증을 켠 직원 — 양식 맨 아래)이 쓴다. 로그인 코드 단계는 로그인 화면 모양의 칸을 따로 둔다(LoginCodeStep).
|
||||
// - 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code"
|
||||
// maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다
|
||||
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. 칸의 설명(aria-describedby)은 안내 → 오류 차례 (PasswordField 와 같다)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { OTP_TEXT } from '@/shared/constants/account-security'
|
||||
import { normalizeOtpInput } from '@/shared/utils/account-security'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
label: string
|
||||
modelValue: string
|
||||
hint?: string
|
||||
/** 오류 문구 — 사전 문구 */
|
||||
error?: string | null
|
||||
/** 보내는 중 — 읽기만 */
|
||||
readonly?: boolean
|
||||
/** 이름을 작게(12.5px · 회색) — 끄기 창처럼 칸이 둘뿐인 확인 창 */
|
||||
compactLabel?: boolean
|
||||
/** 대화상자를 열면 이 칸에 초점 (켜기 창 — 처음 초점은 코드 칸) */
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hint: '',
|
||||
error: null,
|
||||
readonly: false,
|
||||
compactLabel: false,
|
||||
autofocus: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hintId = computed(() => `${props.id}-hint`)
|
||||
const errorId = computed(() => `${props.id}-error`)
|
||||
const describedBy = computed(() => {
|
||||
const ids = [props.hint ? hintId.value : '', props.error ? errorId.value : ''].filter(Boolean)
|
||||
return ids.length > 0 ? ids.join(' ') : undefined
|
||||
})
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const normalized = normalizeOtpInput(target.value)
|
||||
// 값이 그대로면 다시 그리지 않으므로(숫자가 아닌 글자를 쳤을 때) 칸의 글도 직접 맞춘다
|
||||
if (target.value !== normalized) target.value = normalized
|
||||
emit('update:modelValue', normalized)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="otp-field">
|
||||
<label
|
||||
:for="id"
|
||||
:class="['otp-field__label', { 'otp-field__label--compact': compactLabel }]"
|
||||
>{{ label }}</label>
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
spellcheck="false"
|
||||
:placeholder="OTP_TEXT.placeholder"
|
||||
:value="modelValue"
|
||||
:readonly="readonly"
|
||||
:autofocus="autofocus"
|
||||
class="form-input otp-field__input"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
>
|
||||
<p
|
||||
v-if="hint"
|
||||
:id="hintId"
|
||||
class="form-message"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otp-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.otp-field__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 끄기 창 — 칸 이름 12.5px · 회색 (디자인 원본 SettingsTwoFactor "끄기 창") */
|
||||
.otp-field__label--compact {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 폭 200 · 16px · 글자 사이 0.24em · 고른 폭 숫자 */
|
||||
.otp-field__input {
|
||||
width: 12.5rem;
|
||||
max-width: 100%;
|
||||
font-size: var(--font-size-title);
|
||||
letter-spacing: 0.24em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.otp-field__input:read-only {
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -7,46 +7,62 @@
|
||||
// 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError).
|
||||
// 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다)
|
||||
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
|
||||
// 고정 id — #password-current · #password-next · #password-confirm · #password-submit · #password-status
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
// - 2단계 인증을 켠 직원(12단계 — 계약 5절 "비밀번호 바꾸기")은 맨 아래 「인증 앱 코드」 칸(코드가 30초마다 바뀌어 다른 칸을 다 쓴 뒤 넣는다).
|
||||
// 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007). 코드 틀림(AUTH_006)은 그 칸 아래 · 칸을 비운다.
|
||||
// 다른 탭에서 켜서 코드 없이 보냈으면(AUTH_007) 2단계 상태를 다시 받고 칸을 보인다 — 못 받아도 칸은 보인다
|
||||
// 고정 id — #password-current · #password-next · #password-confirm · #password-code · #password-submit · #password-status
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.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 { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
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'
|
||||
|
||||
/** 칸 아래에 보이는 서버 거절 — 알림은 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001']
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001', 'AUTH_006', 'AUTH_007']
|
||||
const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null }
|
||||
|
||||
const { changePassword } = useAuth()
|
||||
const authStore = useAuthStore()
|
||||
const twoFactorStore = useTwoFactorStore()
|
||||
|
||||
const currentRef = 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 current = ref<string>('')
|
||||
const next = ref<string>('')
|
||||
const confirm = ref<string>('')
|
||||
const code = ref<string>('')
|
||||
const showCurrent = ref<boolean>(false)
|
||||
const showNext = ref<boolean>(false)
|
||||
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
|
||||
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
|
||||
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
|
||||
/** 코드 칸 아래 — 6자리가 아님(AUTH_007 문구) · 서버의 AUTH_006 · AUTH_007 */
|
||||
const codeError = ref<string | null>(null)
|
||||
/** 서버가 코드를 요구했다(AUTH_007) — 상태를 다시 받지 못해도 칸을 보인다 */
|
||||
const codeRequired = ref<boolean>(false)
|
||||
const submitting = ref<boolean>(false)
|
||||
const done = ref<boolean>(false)
|
||||
/** 화면이 사라졌다 — 칸 아래 글을 볼 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
/** 「인증 앱 코드」 칸을 보인다 — 2단계 인증을 켠 직원 */
|
||||
const showCode = computed(() => twoFactorStore.isEnabled || codeRequired.value)
|
||||
|
||||
const currentError = computed(() => {
|
||||
if (problems.value.current === 'missing') return getErrorMessage('VAL_001')
|
||||
return serverError.value?.field === 'current' ? serverError.value.message : null
|
||||
@@ -78,6 +94,12 @@ function setConfirm(value: string) {
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function setCode(value: string) {
|
||||
code.value = value
|
||||
codeError.value = null
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function focusFirstProblem(found: PasswordChangeProblems) {
|
||||
if (found.current) currentRef.value?.focus()
|
||||
else if (found.next) nextRef.value?.focus()
|
||||
@@ -88,23 +110,52 @@ function clearFields() {
|
||||
current.value = ''
|
||||
next.value = ''
|
||||
confirm.value = ''
|
||||
code.value = ''
|
||||
showCurrent.value = false
|
||||
showNext.value = false
|
||||
}
|
||||
|
||||
/** 코드 칸 아래에 알리고 칸을 비운 뒤 초점 — 다른 탭에서 켜 칸이 막 생겼으면 그린 뒤에 */
|
||||
async function showCodeError(message: string) {
|
||||
codeError.value = message
|
||||
code.value = ''
|
||||
await nextTick()
|
||||
codeRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 다른 탭에서 2단계 인증을 켰다(AUTH_007) — 상태를 다시 받고 칸을 보인다 */
|
||||
async function requireCode(message: string) {
|
||||
codeRequired.value = true
|
||||
await showCodeError(message)
|
||||
void twoFactorStore.refresh()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
done.value = false
|
||||
serverError.value = null
|
||||
const found = passwordChangeProblems({ current: current.value, 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)) {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
if (codeMissing) {
|
||||
codeRef.value?.focus()
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await changePassword({ currentPassword: current.value, newPassword: next.value }, { silent: INLINE_CODES })
|
||||
await changePassword(
|
||||
{
|
||||
currentPassword: current.value,
|
||||
newPassword: next.value,
|
||||
...(showCode.value ? { otpCode: code.value } : {}),
|
||||
},
|
||||
{ silent: INLINE_CODES },
|
||||
)
|
||||
clearFields()
|
||||
problems.value = NO_PROBLEMS
|
||||
done.value = true
|
||||
@@ -120,6 +171,10 @@ async function submit() {
|
||||
} else if (apiError.code === 'VAL_001') {
|
||||
serverError.value = { field: 'next', message: apiError.message }
|
||||
nextRef.value?.focus()
|
||||
} else if (apiError.code === 'AUTH_006') {
|
||||
await showCodeError(apiError.message)
|
||||
} else if (apiError.code === 'AUTH_007') {
|
||||
await requireCode(apiError.message)
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -182,6 +237,17 @@ onBeforeUnmount(() => {
|
||||
:error="confirmError"
|
||||
@update:model-value="setConfirm"
|
||||
/>
|
||||
<OtpCodeField
|
||||
v-if="showCode"
|
||||
id="password-code"
|
||||
ref="codeRef"
|
||||
:label="PASSWORD_TEXT.code"
|
||||
:model-value="code"
|
||||
:hint="PASSWORD_TEXT.codeHint"
|
||||
:error="codeError"
|
||||
:readonly="submitting"
|
||||
@update:model-value="setCode"
|
||||
/>
|
||||
<div class="password-form__actions">
|
||||
<button
|
||||
id="password-submit"
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
hintIsError?: boolean
|
||||
/** 오류 문구 — 사전 문구 또는 디자인 문구 */
|
||||
error?: string | null
|
||||
/** 이름을 작게(12.5px · 회색) — 2단계 인증 끄기 창처럼 칸이 둘뿐인 확인 창 (디자인 원본 SettingsTwoFactor) */
|
||||
compactLabel?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -33,6 +35,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
hint: '',
|
||||
hintIsError: false,
|
||||
error: null,
|
||||
compactLabel: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -59,7 +62,7 @@ defineExpose({
|
||||
<div class="password-field">
|
||||
<label
|
||||
:for="id"
|
||||
class="password-field__label"
|
||||
:class="['password-field__label', { 'password-field__label--compact': compactLabel }]"
|
||||
>{{ label }}</label>
|
||||
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
|
||||
<input
|
||||
@@ -127,6 +130,11 @@ defineExpose({
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.password-field__label--compact {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 상자 안에 칸과 단추 — 상자가 테두리 · 초점을 그린다 (base.css .form-input). 칸 · 단추가 상자 높이(44)를 채운다 */
|
||||
.password-field__box {
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
|
||||
// 제목 바로 옆(같은 줄 · 글자 아래 선)에 작은 글을 둘 때는 title-aside 슬롯 (디자인 원본 SettingsSendRead 의 "3 / 500")
|
||||
// 설명 글 안에 링크를 넣을 때는 description 슬롯 — 주면 description 글 대신 그린다 (디자인 원본 SettingsLoginHistory 의 「비밀번호를 바꾸고」 · 「2단계 인증」)
|
||||
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
@@ -33,7 +34,9 @@ defineProps<Props>()
|
||||
<slot name="title-aside" />
|
||||
</div>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
// 2단계 인증 끄기 창 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 같다 (디자인 원본 SettingsTwoFactor "끄기 창" · 계약 5절 "2단계 인증")
|
||||
// "2단계 인증을 끌까요?" / "끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다." + 지금 비밀번호 · 인증 앱 코드 → [끄기](빨강) [취소].
|
||||
// 두 칸에서 Enter 도 [끄기]와 같다
|
||||
// - 틀리면 켜기 창과 같은 칸 아래 글(알림 없이): 지금 비밀번호 AUTH_005 · 코드 AUTH_006(칸을 비운다). 서버가 비밀번호를 먼저 본다 — 둘 다 비었으면 비밀번호부터
|
||||
// - 끄기 요청은 이 창이 보낸다 — 지금 비밀번호 · 코드가 스토어 액션의 인자로 개발 도구(Pinia 타임라인)에 남지 않게. 스토어에는 비밀 없는 결과만(apply)
|
||||
// - 끄는 동안 [끄기]는 aria-disabled · aria-busy(초점을 지킨다). 몇 번째로 연 창인가(openSeq)로 닫았다 다시 연 창에 앞 요청의 거절을 넣지 않는다
|
||||
// - 세션이 바뀌면(로그아웃 · 만료 · 다른 기기의 [다른 곳 모두 로그아웃]) 두 칸을 비우고 닫는다. 닫을 때도 두 칸을 비운다
|
||||
// 고정 id — #two-factor-disable-title · #two-factor-disable-message · #two-factor-disable-password · #two-factor-disable-code
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
|
||||
import PasswordField from '@/components/settings/PasswordField.vue'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { TWO_FACTOR_DISABLE_TEXT } from '@/shared/constants/account-security'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { isCompleteOtp } from '@/shared/utils/account-security'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
/** 껐다 — 상태는 스토어에 넣었다 */
|
||||
(e: 'disabled'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 칸 아래에 보이는 서버 거절 — 알림을 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'AUTH_006']
|
||||
|
||||
const api = useAccountSecurity()
|
||||
const authStore = useAuthStore()
|
||||
const twoFactorStore = useTwoFactorStore()
|
||||
|
||||
const passwordRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
|
||||
|
||||
const password = ref<string>('')
|
||||
const code = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const passwordError = ref<string | null>(null)
|
||||
const codeError = ref<string | null>(null)
|
||||
const submitting = ref<boolean>(false)
|
||||
/** 창을 연 차례 — 열 때 · 세션이 바뀔 때 오른다. 보낼 때 잡아 두고 응답이 오면 견준다 */
|
||||
let openSeq = 0
|
||||
let unmounted = false
|
||||
|
||||
function resetForm() {
|
||||
password.value = ''
|
||||
code.value = ''
|
||||
showPassword.value = false
|
||||
passwordError.value = null
|
||||
codeError.value = null
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
function setPassword(value: string) {
|
||||
password.value = value
|
||||
passwordError.value = null
|
||||
}
|
||||
|
||||
function setCode(value: string) {
|
||||
code.value = value
|
||||
codeError.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const passwordProblem = password.value === '' ? getErrorMessage('VAL_001') : null
|
||||
const codeProblem = isCompleteOtp(code.value) ? null : getErrorMessage('AUTH_007')
|
||||
passwordError.value = passwordProblem
|
||||
codeError.value = codeProblem
|
||||
if (passwordProblem) {
|
||||
passwordRef.value?.focus()
|
||||
return
|
||||
}
|
||||
if (codeProblem) {
|
||||
codeRef.value?.focus()
|
||||
return
|
||||
}
|
||||
const opened = openSeq
|
||||
const session = authStore.generation
|
||||
submitting.value = true
|
||||
try {
|
||||
const status = await api.deactivateTwoFactor(
|
||||
{ currentPassword: password.value, otpCode: code.value },
|
||||
{ silent: INLINE_CODES },
|
||||
)
|
||||
if (authStore.generation !== session) return
|
||||
// 서버가 껐다 — 창을 닫았거나 다시 열었어도 화면의 상태는 바꾼다
|
||||
twoFactorStore.apply(status)
|
||||
if (unmounted) return
|
||||
if (opened === openSeq) resetForm()
|
||||
emit('disabled')
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
if (authStore.generation !== session) return
|
||||
if (unmounted || !props.open || opened !== openSeq) {
|
||||
// 보낸 창이 없다 — 닫힌 채면 칸 아래 글 대신 알림으로, 다시 연 창에는 앞 요청의 거절을 넣지 않는다
|
||||
if (opened === openSeq && INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'AUTH_005') {
|
||||
passwordError.value = apiError.message
|
||||
passwordRef.value?.focus()
|
||||
} else if (apiError.code === 'AUTH_006') {
|
||||
codeError.value = apiError.message
|
||||
code.value = ''
|
||||
codeRef.value?.focus()
|
||||
}
|
||||
// 그 밖의 실패(시도 과다 REQ_001 · 메일 서버 장애 등)는 인터셉터가 알렸다
|
||||
} finally {
|
||||
if (opened === openSeq) submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호 · 코드 칸에서 Enter — [끄기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다) */
|
||||
function onFieldKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
|
||||
// 열 때 · 닫을 때 두 칸을 비운다 — 열 때마다 차례를 올린다
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) openSeq += 1
|
||||
resetForm()
|
||||
},
|
||||
)
|
||||
|
||||
// 세션이 바뀌면 두 칸을 비우고 닫는다 — 앞 요청의 결과도 버린다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
openSeq += 1
|
||||
resetForm()
|
||||
if (props.open) emit('close')
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
resetForm()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
medium
|
||||
:title="TWO_FACTOR_DISABLE_TEXT.title"
|
||||
:message="TWO_FACTOR_DISABLE_TEXT.message"
|
||||
title-id="two-factor-disable-title"
|
||||
message-id="two-factor-disable-message"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="disable__fields"
|
||||
@keydown="onFieldKeydown"
|
||||
>
|
||||
<PasswordField
|
||||
id="two-factor-disable-password"
|
||||
ref="passwordRef"
|
||||
:label="TWO_FACTOR_DISABLE_TEXT.passwordLabel"
|
||||
:model-value="password"
|
||||
autocomplete="current-password"
|
||||
:shown="showPassword"
|
||||
:toggle-label="TWO_FACTOR_DISABLE_TEXT.showPassword"
|
||||
:error="passwordError"
|
||||
compact-label
|
||||
@update:model-value="setPassword"
|
||||
@toggle="showPassword = !showPassword"
|
||||
/>
|
||||
<OtpCodeField
|
||||
id="two-factor-disable-code"
|
||||
ref="codeRef"
|
||||
:label="TWO_FACTOR_DISABLE_TEXT.codeLabel"
|
||||
:model-value="code"
|
||||
:error="codeError"
|
||||
:readonly="submitting"
|
||||
compact-label
|
||||
@update:model-value="setCode"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:aria-disabled="submitting ? 'true' : undefined"
|
||||
:aria-busy="submitting ? 'true' : undefined"
|
||||
data-disable-submit
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitting ? TWO_FACTOR_DISABLE_TEXT.submitting : TWO_FACTOR_DISABLE_TEXT.submit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
data-disable-cancel
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ TWO_FACTOR_DISABLE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.disable__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
// 2단계 인증 켜기 창 — 모달 620(wide · 필터 고치기 창과 같은 폭) · 처음 초점은 코드 칸 (디자인 원본 SettingsTwoFactor "켜기 창" · 계약 5절 "2단계 인증")
|
||||
// ① QR · 키 · [복사](TwoFactorEnrollmentScan) ② 앱에 표시된 6자리 코드 ③ 지금 비밀번호 → [켜기] [취소]. 두 칸에서 Enter 도 [켜기]와 같다
|
||||
// - 키는 창을 열 때마다 새로 만든다(POST /two-factor/enrollments — 10분 안에 켠다). 키 · 주소는 이 창의 화면 메모리에만 두고 닫으면 버린다
|
||||
// - 칸 아래 오류(알림 없이 · 첫 잘못된 칸에 초점): 코드 AUTH_006(칸을 비운다) · 지금 비밀번호 AUTH_005. 둘 다 틀렸으면 코드부터 — 서버가 코드를 먼저 본다.
|
||||
// QR · 키는 그대로라 다시 찍지 않아도 된다
|
||||
// - AUTH_008(등록이 없음 · 10분 지남 · 더 새 등록 · 그사이 다른 곳에서 켜짐) — 처음부터 다시: 상태를 다시 받아 켜졌으면 창을 닫고 알리고,
|
||||
// 아니면 창 위에 사전 문구를 보이고 새 키로 다시 그린다(코드는 비운다)
|
||||
// - 켜기 요청은 이 창이 보낸다 — 지금 비밀번호 · 코드가 스토어 액션의 인자로 개발 도구(Pinia 타임라인)에 남지 않게. 스토어에는 비밀 없는 결과만(apply)
|
||||
// - 켜는 동안 「켜는 중…」 · [켜기]는 aria-disabled(초점을 지킨다) · [취소]는 누를 수 있다(그래도 서버가 켰으면 화면은 「켜짐」이 된다)
|
||||
// - 몇 번째로 연 창인가(openSeq)를 보낼 때 잡아 둔다 — 닫았다 다시 연 창에 앞 요청의 거절 · 처음부터 다시를 넣지 않고, 다시 열면 「켜는 중」도 처음으로
|
||||
// - 세션이 바뀌면(로그아웃 · 만료 · 다른 기기의 [다른 곳 모두 로그아웃]) 키 · 칸을 지우고 닫는다 — 세션 만료 창 뒤에 QR · 키 · 비밀번호가 남지 않게
|
||||
// 고정 id — #two-factor-enable-title · #two-factor-enable-code · #two-factor-enable-password
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
|
||||
import PasswordField from '@/components/settings/PasswordField.vue'
|
||||
import TwoFactorEnrollmentScan from '@/components/settings/TwoFactorEnrollmentScan.vue'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { TWO_FACTOR_ENABLE_TEXT } from '@/shared/constants/account-security'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
|
||||
import { isCompleteOtp } from '@/shared/utils/account-security'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 직원 주소 — "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." */
|
||||
email: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
/** 켰다 — 상태는 스토어에 넣었다 */
|
||||
(e: 'enabled'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 칸 아래 · 창 위에 보이는 서버 거절 — 알림을 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'AUTH_006', 'AUTH_008']
|
||||
|
||||
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) */
|
||||
const notice = 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 submitting = ref<boolean>(false)
|
||||
|
||||
/** 창을 연 차례 — 열 때 · 세션이 바뀔 때 오른다. 보낼 때 잡아 두고 응답이 오면 견준다 */
|
||||
let openSeq = 0
|
||||
/** 등록을 받는 차례 — 창을 닫거나 다시 받으면 앞의 응답은 버린다 */
|
||||
let enrollSeq = 0
|
||||
let unmounted = false
|
||||
|
||||
/** [켜기]를 누를 수 없다 — 키가 아직 없거나 켜는 중 */
|
||||
const unavailable = computed(() => enrollStatus.value !== 'ready' || submitting.value)
|
||||
|
||||
function clearSecrets() {
|
||||
enrollSeq += 1
|
||||
enrollment.value = null
|
||||
code.value = ''
|
||||
password.value = ''
|
||||
showPassword.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
clearSecrets()
|
||||
enrollStatus.value = 'loading'
|
||||
enrollError.value = null
|
||||
notice.value = null
|
||||
codeError.value = null
|
||||
passwordError.value = null
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
/** 보낸 그 창이 아직 열려 있고 세션도 그대로인가 — 늦게 온 응답을 이 창에 쓸지 */
|
||||
function stillHere(session: number, opened: number): boolean {
|
||||
return !unmounted && props.open && opened === openSeq && authStore.generation === session
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증이 그사이 다른 곳에서 켜졌는지 상태를 다시 받아 본다 — 켜졌으면(true) 보낸 창이 아직 있을 때만 닫고 사전 문구로 알린다.
|
||||
* 상태를 못 받았거나 아직 꺼져 있으면 false (부르는 쪽이 새 키로 다시 한다)
|
||||
*/
|
||||
async function closeIfEnabledElsewhere(error: ApiError, session: number, opened: number): Promise<boolean> {
|
||||
const status = await twoFactorStore.refresh()
|
||||
if (!status?.isEnabled) return false
|
||||
if (stillHere(session, opened)) {
|
||||
notifyApiError(error)
|
||||
emit('close')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function startEnrollment() {
|
||||
enrollSeq += 1
|
||||
const current = enrollSeq
|
||||
const opened = openSeq
|
||||
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, opened)) return
|
||||
enrollment.value = result
|
||||
enrollStatus.value = 'ready'
|
||||
} catch (caught) {
|
||||
if (current !== enrollSeq || !stillHere(session, opened)) return
|
||||
const apiError = toApiError(caught)
|
||||
// 이미 켜져 있다 — 그사이 다른 곳에서 켰다
|
||||
if (apiError.code === 'AUTH_008' && (await closeIfEnabledElsewhere(apiError, session, opened))) return
|
||||
if (current !== enrollSeq || !stillHere(session, opened)) return
|
||||
enrollStatus.value = 'error'
|
||||
enrollError.value = apiError
|
||||
}
|
||||
}
|
||||
|
||||
/** 처음부터 다시(AUTH_008) — 켜졌으면 닫고, 아니면 창 위에 알리고 새 키로 다시 그린다 */
|
||||
async function restart(error: ApiError, session: number, opened: number) {
|
||||
if (await closeIfEnabledElsewhere(error, session, opened)) return
|
||||
if (!stillHere(session, opened)) return
|
||||
notice.value = error.message
|
||||
code.value = ''
|
||||
codeError.value = null
|
||||
passwordError.value = null
|
||||
await startEnrollment()
|
||||
await nextTick()
|
||||
if (stillHere(session, opened)) codeRef.value?.focus()
|
||||
}
|
||||
|
||||
function setCode(value: string) {
|
||||
code.value = value
|
||||
codeError.value = null
|
||||
}
|
||||
|
||||
function setPassword(value: string) {
|
||||
password.value = value
|
||||
passwordError.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const current = enrollment.value
|
||||
if (unavailable.value || !current) return
|
||||
const codeProblem = isCompleteOtp(code.value) ? null : getErrorMessage('AUTH_007')
|
||||
const passwordProblem = password.value === '' ? getErrorMessage('VAL_001') : null
|
||||
codeError.value = codeProblem
|
||||
passwordError.value = passwordProblem
|
||||
if (codeProblem) {
|
||||
codeRef.value?.focus()
|
||||
return
|
||||
}
|
||||
if (passwordProblem) {
|
||||
passwordRef.value?.focus()
|
||||
return
|
||||
}
|
||||
const opened = openSeq
|
||||
const session = authStore.generation
|
||||
submitting.value = true
|
||||
try {
|
||||
const status = await api.activateTwoFactor(
|
||||
{ enrollmentId: current.enrollmentId, otpCode: code.value, currentPassword: password.value },
|
||||
{ silent: INLINE_CODES },
|
||||
)
|
||||
if (authStore.generation !== session) return
|
||||
// 서버가 켰다 — 창을 닫았거나 다시 열었어도 화면의 상태는 바꾼다
|
||||
twoFactorStore.apply(status)
|
||||
if (unmounted) return
|
||||
if (opened === openSeq) clearSecrets()
|
||||
emit('enabled')
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
if (authStore.generation !== session) return
|
||||
if (!stillHere(session, opened)) {
|
||||
// 보낸 창이 없다 — 닫힌 채면 칸 아래 글 대신 알림으로(처음부터 다시는 다음에 열 때 새 키로 한다), 다시 연 창에는 넣지 않는다
|
||||
if (opened === openSeq && apiError.code !== 'AUTH_008' && INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'AUTH_006') {
|
||||
codeError.value = apiError.message
|
||||
code.value = ''
|
||||
codeRef.value?.focus()
|
||||
} else if (apiError.code === 'AUTH_005') {
|
||||
passwordError.value = apiError.message
|
||||
passwordRef.value?.focus()
|
||||
} else if (apiError.code === 'AUTH_008') {
|
||||
await restart(apiError, session, opened)
|
||||
}
|
||||
// 그 밖의 실패(시도 과다 REQ_001 · 메일 서버 장애 등)는 인터셉터가 알렸다
|
||||
} finally {
|
||||
if (opened === openSeq) submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 코드 · 비밀번호 칸에서 Enter — [켜기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다) */
|
||||
function onFieldKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 열 때마다 새 키 — 닫으면 키 · 칸을 버린다
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) openSeq += 1
|
||||
resetForm()
|
||||
if (open) void startEnrollment()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 세션이 바뀌면 키 · 칸을 지우고 닫는다 — 앞 요청의 결과도 버린다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
openSeq += 1
|
||||
resetForm()
|
||||
if (props.open) emit('close')
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
clearSecrets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
wide
|
||||
:title="TWO_FACTOR_ENABLE_TEXT.title"
|
||||
title-id="two-factor-enable-title"
|
||||
@cancel="cancel"
|
||||
>
|
||||
<p
|
||||
v-if="notice"
|
||||
class="enable__notice"
|
||||
role="alert"
|
||||
data-enable-notice
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="enable__notice-icon"
|
||||
/>
|
||||
<span>{{ notice }}</span>
|
||||
</p>
|
||||
<ol
|
||||
class="enable__steps"
|
||||
@keydown="onFieldKeydown"
|
||||
>
|
||||
<li class="enable__step">
|
||||
<span
|
||||
class="enable__number"
|
||||
aria-hidden="true"
|
||||
>1</span>
|
||||
<div class="enable__step-body">
|
||||
<TwoFactorEnrollmentScan
|
||||
:enrollment="enrollment"
|
||||
:status="enrollStatus"
|
||||
:error="enrollError"
|
||||
:email="email"
|
||||
@retry="startEnrollment"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li class="enable__step">
|
||||
<span
|
||||
class="enable__number"
|
||||
aria-hidden="true"
|
||||
>2</span>
|
||||
<div class="enable__step-body">
|
||||
<OtpCodeField
|
||||
id="two-factor-enable-code"
|
||||
ref="codeRef"
|
||||
:label="TWO_FACTOR_ENABLE_TEXT.codeLabel"
|
||||
:model-value="code"
|
||||
:error="codeError"
|
||||
:readonly="submitting"
|
||||
autofocus
|
||||
@update:model-value="setCode"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li class="enable__step">
|
||||
<span
|
||||
class="enable__number"
|
||||
aria-hidden="true"
|
||||
>3</span>
|
||||
<div class="enable__step-body enable__step-body--password">
|
||||
<PasswordField
|
||||
id="two-factor-enable-password"
|
||||
ref="passwordRef"
|
||||
:label="TWO_FACTOR_ENABLE_TEXT.passwordLabel"
|
||||
:model-value="password"
|
||||
autocomplete="current-password"
|
||||
:shown="showPassword"
|
||||
:toggle-label="TWO_FACTOR_ENABLE_TEXT.showPassword"
|
||||
:error="passwordError"
|
||||
@update:model-value="setPassword"
|
||||
@toggle="showPassword = !showPassword"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
:aria-busy="submitting ? 'true' : undefined"
|
||||
data-enable-submit
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitting ? TWO_FACTOR_ENABLE_TEXT.submitting : TWO_FACTOR_ENABLE_TEXT.submit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-enable-cancel
|
||||
@click="cancel"
|
||||
>
|
||||
{{ TWO_FACTOR_ENABLE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 창 위 안내 — 처음부터 다시 (로그인 오류와 같은 빨간 상자) */
|
||||
.enable__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: var(--space-4) 0 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);
|
||||
}
|
||||
|
||||
.enable__notice-icon {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* 차례 1 · 2 · 3 — 청록 굵은 숫자(동그라미 없음) · 숫자 칸 22 · 사이 18 */
|
||||
.enable__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-18);
|
||||
margin: var(--space-8) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.enable__step {
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-22) minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.enable__number {
|
||||
font-weight: 700;
|
||||
color: var(--color-brand-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.enable__step-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 지금 비밀번호 칸 — 최대 320 */
|
||||
.enable__step-body--password {
|
||||
max-width: 20rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
// 2단계 인증 켜기 창의 ① — QR · 키 · [복사] (디자인 원본 SettingsTwoFactor "켜기 창" · 계약 5절 "2단계 인증")
|
||||
// "인증 앱으로 QR 코드를 찍어 주세요" + QR(화면이 otpauthUrl 로 그린다 — QrCodeImage) + "인증 앱에서 [+] → 「QR 코드 스캔」…" +
|
||||
// "찍을 수 없으면 이 키를 입력하세요" — 32자 키(4자씩 띄움 · 고정폭 · 띄어쓰기 없이 조각만 — 골라 복사해도 키 그대로) + [복사](36 — 밀도 예외) +
|
||||
// "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." + 켜지 않고 닫으면 키를 버린다는 안내
|
||||
// - 키를 받는 동안은 QR 자리에 회색 상자(aria-busy), 못 받으면 사전 문구 + [다시 시도](부모가 다시 받는다)
|
||||
// - 키는 부모(켜기 창)의 화면 메모리에서만 받는다 — 여기서도 남기지 않는다. [복사]는 키를 띄어쓰기 없이
|
||||
// - 키는 일부러 두 줄(한 줄에 4묶음)로 그리고, 줄은 묶음 사이에서만 바뀐다 — 묶음 · 줄은 줄바꿈 없는 inline-block 덩어리다.
|
||||
// block 으로 나누면 골라 복사할 때 줄바꿈이 끼므로 inline-block 으로 둔다(글자 사이에 띄어쓰기 · 줄바꿈 문자를 넣지 않는다 — 읽어 주는 글도 32자 그대로).
|
||||
// 좁은 폭에서는 줄 안에서도 묶음 사이에서만 줄이 바뀐다 (2026-10-31 브라우저 점검 — 620 창에서 마지막 묶음이 "EL / WN" 으로 갈렸다)
|
||||
// 고정 id — #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 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="scan">
|
||||
<span class="scan__title">{{ TWO_FACTOR_ENABLE_TEXT.scanTitle }}</span>
|
||||
<div
|
||||
v-if="status === 'error' && error"
|
||||
class="scan__load-error"
|
||||
role="alert"
|
||||
data-enable-error
|
||||
>
|
||||
<span>{{ error.message }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="scan__retry"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ TWO_FACTOR_ENABLE_TEXT.retry }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="scan__body"
|
||||
>
|
||||
<div
|
||||
class="scan__qr-frame"
|
||||
:aria-busy="status === 'loading' ? 'true' : undefined"
|
||||
>
|
||||
<QrCodeImage
|
||||
v-if="enrollment"
|
||||
:value="enrollment.otpauthUrl"
|
||||
:label="TWO_FACTOR_ENABLE_TEXT.qrLabel"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="scan__qr-placeholder"
|
||||
>
|
||||
<span class="visually-hidden">{{ TWO_FACTOR_ENABLE_TEXT.loading }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="scan__text">
|
||||
<p class="scan__hint">
|
||||
{{ TWO_FACTOR_ENABLE_TEXT.scanHint }}
|
||||
</p>
|
||||
<div class="scan__key">
|
||||
<span
|
||||
id="two-factor-key-label"
|
||||
class="scan__key-label"
|
||||
>{{ TWO_FACTOR_ENABLE_TEXT.keyLabel }}</span>
|
||||
<div class="scan__key-row">
|
||||
<code
|
||||
class="scan__key-value"
|
||||
data-enable-key
|
||||
>
|
||||
<span
|
||||
v-for="(line, lineIndex) in keyLines"
|
||||
:key="lineIndex"
|
||||
class="scan__key-line"
|
||||
>
|
||||
<span
|
||||
v-for="(group, index) in line"
|
||||
:key="index"
|
||||
class="scan__key-group"
|
||||
>{{ group }}</span>
|
||||
</span>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="scan__copy"
|
||||
:aria-label="TWO_FACTOR_ENABLE_TEXT.copyLabel"
|
||||
aria-describedby="two-factor-key-label"
|
||||
:aria-disabled="enrollment ? undefined : 'true'"
|
||||
data-enable-copy
|
||||
@click="copyKey"
|
||||
>
|
||||
<AppIcon
|
||||
name="copy"
|
||||
:size="14"
|
||||
/>
|
||||
<span>{{ TWO_FACTOR_ENABLE_TEXT.copy }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="scan__key-note">{{ TWO_FACTOR_ENABLE_TEXT.savedAs(email) }}</span>
|
||||
<span class="scan__key-note">{{ TWO_FACTOR_ENABLE_TEXT.discardNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.scan__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scan__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-16);
|
||||
}
|
||||
|
||||
/* QR 상자 — 흰 바탕 · 테두리 · 안쪽 6 */
|
||||
.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);
|
||||
}
|
||||
|
||||
.scan__qr-placeholder {
|
||||
display: block;
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.scan__text {
|
||||
flex: 1 1 15rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.scan__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.scan__key {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
padding-top: var(--space-10);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.scan__key-label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.scan__key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 키 — 고정폭 13px, 4자 조각 사이를 여백으로(띄어쓰기 없이 — 골라 복사해도 키 그대로). 누르면 한 번에 고른다 */
|
||||
.scan__key-value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: var(--space-6) var(--space-10);
|
||||
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-base);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/*
|
||||
* 한 줄 — 상자 폭을 다 차지해 두 줄(4묶음 + 4묶음)로 쌓인다. block 이 아니라 inline-block 이다 —
|
||||
* block 으로 나누면 골라 복사한 글에 줄바꿈이 낀다. 상자가 4묶음보다 좁으면 줄 안에서도 묶음 사이에서만 줄이 바뀐다
|
||||
*/
|
||||
.scan__key-line {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 4자 묶음 — 줄바꿈 없는 덩어리 (inline-block 앞뒤가 줄이 바뀔 수 있는 자리다 — 묶음 가운데서는 바뀌지 않는다) */
|
||||
.scan__key-group {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scan__key-group:not(:last-child) {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
/* [복사] 36 — 밀도 예외 (--size-notice-action, 차단 풀기와 같다) */
|
||||
.scan__copy {
|
||||
flex: 0 0 auto;
|
||||
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;
|
||||
}
|
||||
|
||||
.scan__copy:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.scan__copy[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scan__key-note {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 키를 받지 못했을 때 — 사전 문구 + [다시 시도] */
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.scan__retry:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useApi } from './useApi'
|
||||
import { AUTH_SESSION_LIST_LIMIT, TWO_FACTOR_CHANGE_TIMEOUT_MS } from '@/shared/constants/account-security'
|
||||
import type {
|
||||
AppPassword,
|
||||
AuthSession,
|
||||
CreatedAppPassword,
|
||||
EndedSessions,
|
||||
LoginEvent,
|
||||
LoginEventQuery,
|
||||
TwoFactorActivationPayload,
|
||||
TwoFactorDeactivationPayload,
|
||||
TwoFactorEnrollment,
|
||||
TwoFactorStatus,
|
||||
} from '@/shared/types/account-security'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
export type AccountSecurityRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
|
||||
/** 경로에 넣는 id 는 항상 인코딩한다 */
|
||||
const segment = (value: string) => encodeURIComponent(value)
|
||||
|
||||
// 계정 보안 API Composable — docs/mail/api-contract.md 5절 "계정 보안 — 12단계" (모두 로그인 필요)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
// 비밀(2단계 인증 키 · otpauth 주소 · 앱 비밀번호 값)이 든 응답은 부르는 쪽이 화면 메모리에만 두고 창을 닫으면 버린다 — 여기서는 남기지 않는다.
|
||||
// 로그인 · 비밀번호 바꾸기에 더한 칸(otpCode)은 useAuth 가 보낸다
|
||||
export function useAccountSecurity() {
|
||||
const api = useApi()
|
||||
|
||||
/** 2단계 인증 상태 — 엔진이 기준이다 */
|
||||
function getTwoFactor(options?: AccountSecurityRequestOptions): Promise<TwoFactorStatus> {
|
||||
return api.get<TwoFactorStatus, TwoFactorStatus>('/two-factor', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 시작 (꺼져 있을 때만) — 새 키 · otpauth 주소 · 이때까지 켜야 하는 시각(10분).
|
||||
* 직원마다 기다리는 등록은 하나다 — 새로 시작하면 앞의 것은 버린다. 이미 켜져 있으면 409 AUTH_008
|
||||
*/
|
||||
function startTwoFactorEnrollment(options?: AccountSecurityRequestOptions): Promise<TwoFactorEnrollment> {
|
||||
return api.post<TwoFactorEnrollment, TwoFactorEnrollment>('/two-factor/enrollments', null, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 켜기 — 코드를 등록의 키로 먼저 확인한 뒤 엔진에 쓴다. 코드 틀림 400 AUTH_006 · 지금 비밀번호 틀림 400 AUTH_005 ·
|
||||
* 등록이 없음 · 10분 지남 · 그사이 바뀜 409 AUTH_008. 서버가 엔진을 여러 번 부르므로 한도를 길게 둔다
|
||||
*/
|
||||
function activateTwoFactor(
|
||||
payload: TwoFactorActivationPayload,
|
||||
options?: AccountSecurityRequestOptions,
|
||||
): Promise<TwoFactorStatus> {
|
||||
return api.post<TwoFactorStatus, TwoFactorStatus>('/two-factor/activation', payload, {
|
||||
timeout: TWO_FACTOR_CHANGE_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 끄기 — 비밀번호를 먼저 확인하고(AUTH_005) 코드를 본다(AUTH_006). 이미 꺼져 있으면 200 (같은 결과) */
|
||||
function deactivateTwoFactor(
|
||||
payload: TwoFactorDeactivationPayload,
|
||||
options?: AccountSecurityRequestOptions,
|
||||
): Promise<TwoFactorStatus> {
|
||||
return api.post<TwoFactorStatus, TwoFactorStatus>('/two-factor/deactivation', payload, {
|
||||
timeout: TWO_FACTOR_CHANGE_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 앱 비밀번호 — 한 쪽(엔진 상한이 작다), 최근에 만든 것이 위 */
|
||||
function listAppPasswords(options?: AccountSecurityRequestOptions): Promise<PaginatedResult<AppPassword>> {
|
||||
return api.get<PaginatedResult<AppPassword>, PaginatedResult<AppPassword>>('/app-passwords', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 만들기 — 값(password)은 이 응답에만 온다. 상한 409 AUTH_009, 이름이 비었거나 40자를 넘거나 이미 있는 이름이면 400 VAL_001
|
||||
*/
|
||||
function createAppPassword(name: string, options?: AccountSecurityRequestOptions): Promise<CreatedAppPassword> {
|
||||
return api.post<CreatedAppPassword, CreatedAppPassword>('/app-passwords', { name }, options)
|
||||
}
|
||||
|
||||
/** 지우기 — 이미 없어도 200 */
|
||||
function deleteAppPassword(id: string, options?: AccountSecurityRequestOptions): Promise<null> {
|
||||
return api.delete<null, null>(`/app-passwords/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 로그인된 브라우저 — 지금 세션이 맨 앞, 그다음 최근 활동 차례 (서버 최대 100 을 한 번에) */
|
||||
function listAuthSessions(options?: AccountSecurityRequestOptions): Promise<PaginatedResult<AuthSession>> {
|
||||
return api.get<PaginatedResult<AuthSession>, PaginatedResult<AuthSession>>('/auth/sessions', {
|
||||
params: { limit: AUTH_SESSION_LIST_LIMIT },
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 세션 하나 끊기 — 지금 세션은 400 VAL_001(로그아웃을 쓴다), 남의 것 · 없는 것 · 끝난 것은 404 RES_001 */
|
||||
function endAuthSession(id: string, options?: AccountSecurityRequestOptions): Promise<null> {
|
||||
return api.delete<null, null>(`/auth/sessions/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 이 요청의 세션만 남기고 모두 끊는다 */
|
||||
function endOtherAuthSessions(options?: AccountSecurityRequestOptions): Promise<EndedSessions> {
|
||||
return api.delete<EndedSessions, EndedSessions>('/auth/sessions', options)
|
||||
}
|
||||
|
||||
/** 로그인 기록 — 최근 차례 · 커서 페이지. isSuccess=false 면 실패만 */
|
||||
function listLoginEvents(
|
||||
query: LoginEventQuery,
|
||||
options?: AccountSecurityRequestOptions,
|
||||
): Promise<PaginatedResult<LoginEvent>> {
|
||||
return api.get<PaginatedResult<LoginEvent>, PaginatedResult<LoginEvent>>('/login-events', {
|
||||
params: query,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getTwoFactor,
|
||||
startTwoFactorEnrollment,
|
||||
activateTwoFactor,
|
||||
deactivateTwoFactor,
|
||||
listAppPasswords,
|
||||
createAppPassword,
|
||||
deleteAppPassword,
|
||||
listAuthSessions,
|
||||
endAuthSession,
|
||||
endOtherAuthSessions,
|
||||
listLoginEvents,
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,11 @@ describe('에러 코드 사전', () => {
|
||||
'FILE_005', // 보관 한도 — 그 파일 줄에 사전 문구 + [대용량 첨부 보기]
|
||||
'FILE_006', // 서버 저장 공간 — 그 파일 줄에 사전 문구, 멈추고 [다시 시도]
|
||||
'MAIL_016', // 대용량 파일을 쓸 수 없음 — 쓰기 화면이 파일마다 상태를 다시 묻는다
|
||||
// 웹메일 12단계 (계정 보안) — 화면이 넷 모두로 분기한다 (계약 5절 "에러 코드")
|
||||
'AUTH_006', // 2단계 인증 코드가 틀림 · 다시 씀 — 로그인 코드 단계 · 켜기 · 끄기 창 · 비밀번호 화면이 코드 칸 아래에 보이고 칸을 비운다
|
||||
'AUTH_007', // 2단계 인증을 켠 직원의 요청에 코드가 없음 — 비밀번호 화면이 2단계 상태를 다시 받고 코드 칸을 보인다
|
||||
'AUTH_008', // 등록이 없음 · 시간이 지남 · 그사이 바뀜 — 켜기 창이 처음부터 다시 한다
|
||||
'AUTH_009', // 앱 비밀번호 상한 — 만들기 창이 칸 아래에 보이고 목록을 다시 받는다
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
|
||||
@@ -96,12 +96,46 @@ describe('useAuth', () => {
|
||||
|
||||
await expect(
|
||||
useAuth().login({ email: USER.email, password: 'secret' }),
|
||||
).resolves.toEqual(USER)
|
||||
).resolves.toEqual({ status: 'signedIn', user: USER })
|
||||
|
||||
expect(requests[0]?.data).toEqual({ email: USER.email, password: 'secret' })
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('응답의 status(12단계)는 스토어에 두지 않는다 — signedIn 이면 사용자 칸만', async () => {
|
||||
installMockApi(() => ok({ status: 'signedIn', ...USER }))
|
||||
|
||||
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
|
||||
status: 'signedIn',
|
||||
user: USER,
|
||||
})
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('2단계 인증을 켠 직원(twoFactorRequired — 쿠키 없음)이면 세션을 열지 않고 그대로 돌려준다 (계약 5절)', async () => {
|
||||
installMockApi(() => ok({ status: 'twoFactorRequired', email: null, name: null }))
|
||||
const authStore = useAuthStore()
|
||||
const generation = authStore.generation
|
||||
|
||||
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
|
||||
status: 'twoFactorRequired',
|
||||
})
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.generation).toBe(generation)
|
||||
})
|
||||
|
||||
it('코드 단계는 비밀번호와 코드를 함께 싣고, 부르는 쪽이 고른 코드의 알림을 끈다', async () => {
|
||||
const requests = installMockApi(() => fail(429, 'REQ_001'))
|
||||
|
||||
const error: unknown = await useAuth()
|
||||
.login({ email: USER.email, password: 'secret', otpCode: '123456' }, { silent: ['AUTH_004', 'AUTH_006', 'REQ_001'] })
|
||||
.catch((reason: unknown) => reason)
|
||||
|
||||
expect(requests[0]?.data).toEqual({ email: USER.email, password: 'secret', otpCode: '123456' })
|
||||
expect(isApiError(error) && error.code).toBe('REQ_001')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('AUTH_004 는 전역 알림 없이 던진다 — 로그인 폼이 안내한다', async () => {
|
||||
installMockApi(() => fail(401, 'AUTH_004'))
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ import { ref } from 'vue'
|
||||
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
|
||||
import { announceLogout } from './useLogoutBroadcast'
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import type { AuthUser, ChangePasswordPayload, LoginPayload } from '@/shared/types/auth'
|
||||
import type { AuthUser, ChangePasswordPayload, LoginOutcome, LoginPayload, LoginResponse } from '@/shared/types/auth'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type { LoginPayload } from '@/shared/types/auth'
|
||||
export type { LoginOutcome, LoginPayload } from '@/shared/types/auth'
|
||||
|
||||
/** 로그인 요청의 알림 끄기 — 첫 단계는 AUTH_004 만(폼이 안내한다), 코드 단계는 코드 틀림 · 시도 과다도 화면이 직접 보인다 */
|
||||
export type LoginRequestOptions = Pick<AxiosRequestConfig, 'silent'>
|
||||
|
||||
// 인증 API Composable — 쿠키 기반 (docs/mail/api-contract.md 1절)
|
||||
// 토큰은 HttpOnly 쿠키로만 오가므로 여기서는 응답의 사용자(email·name)만 다룬다.
|
||||
@@ -18,21 +21,26 @@ export function useAuth() {
|
||||
|
||||
/**
|
||||
* 로그인. 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* AUTH_004(자격 증명 불일치)는 로그인 폼이 직접 안내하므로 그 코드만 전역 알림을 끈다.
|
||||
* AUTH_004(자격 증명 불일치)는 로그인 폼이 직접 안내하므로 기본으로 그 코드만 전역 알림을 끈다.
|
||||
* 2단계 인증을 켠 직원이고 비밀번호가 맞으면 쿠키 없이 twoFactorRequired 가 온다(12단계 — 계약 5절) — 세션을 열지 않고 그대로 돌려준다.
|
||||
* 화면은 코드를 받아 비밀번호와 함께 다시 부른다(payload.otpCode)
|
||||
*/
|
||||
async function login(payload: LoginPayload): Promise<AuthUser> {
|
||||
async function login(payload: LoginPayload, options: LoginRequestOptions = {}): Promise<LoginOutcome> {
|
||||
loading.value = true
|
||||
try {
|
||||
// 이 탭에서 진행 중인 갱신이 있으면 끝난 뒤에 보낸다 (계약 1절) — 로그아웃 전에 시작한 갱신의 늦은 응답이
|
||||
// 새 로그인 쿠키를 덮거나, 그 뒤의 정리용 로그아웃이 새 세션을 끊지 않게 한다
|
||||
await settleRefresh()
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다
|
||||
const user = await api.post<AuthUser, AuthUser>('/auth/login', payload, {
|
||||
const response = await api.post<LoginResponse, LoginResponse>('/auth/login', payload, {
|
||||
silent: ['AUTH_004'],
|
||||
...options,
|
||||
})
|
||||
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다
|
||||
if (response.status === 'twoFactorRequired') return { status: 'twoFactorRequired' }
|
||||
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다. 스토어에는 사용자 칸만 둔다
|
||||
const user: AuthUser = { email: response.email, name: response.name }
|
||||
authStore.startSession(user)
|
||||
return user
|
||||
return { status: 'signedIn', user }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -88,7 +96,8 @@ export function useAuth() {
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" — 4단계). 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* 지금 비밀번호가 틀리면 400 AUTH_005(세션은 그대로). 401 이 아니므로 토큰이 만료됐을 때만 갱신 뒤 같은 요청을 다시 보낸다.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 — 이 탭의 세션은 이어진다.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊고 앱 비밀번호도 모두 지운다 — 이 탭의 세션은 이어진다.
|
||||
* 2단계 인증을 켠 직원은 otpCode 가 필수다(12단계 — 없으면 400 AUTH_007, 틀리면 400 AUTH_006).
|
||||
* 시간 한도는 30초다 (PASSWORD_CHANGE_TIMEOUT_MS — 서버가 엔진을 두 번 부르고 세션을 끊는 시간까지 기다린다)
|
||||
*/
|
||||
async function changePassword(
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import type { Clock, ClockTimer } from './useClock'
|
||||
import { TWO_FACTOR_LOGIN_WINDOW_MS } from '@/shared/constants/account-security'
|
||||
|
||||
/** 코드 단계에서 다시 보낼 자격 — 서버는 비밀번호를 들고 있지 않아 코드와 함께 한 번 더 싣는다 */
|
||||
export interface LoginChallengeCredentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 코드 단계 (12단계 — 계약 5절 "로그인" 클라이언트 · 디자인 README "로그인 — 코드 단계").
|
||||
* 확인한 비밀번호는 화면 메모리에만 둔다 — 반응형 상태에 넣지 않는다(개발 도구 · 화면에 드러나지 않게). 반응형은 단계 · 주소 · 시간 지남뿐이다.
|
||||
* 지우는 때: 성공 · [다른 계정으로 로그인] · 비밀번호가 틀림(AUTH_004 — 첫 단계로) · 화면을 떠날 때(unmount) · 10분이 지났을 때.
|
||||
* 10분이 지나면 비밀번호만 먼저 지우고 화면은 그대로 둔다 — [확인]을 누를 때 「다시 로그인해 주세요」와 함께 첫 단계로 간다(부르는 쪽).
|
||||
* 10분은 타이머 하나에 기대지 않는다 — 절전 · 뒤에 깔린 탭에서는 타이머가 늦게 돈다. 꺼낼 때(credentials) 시계로 한 번 더 잰다.
|
||||
* 화면이 사라진 뒤에는 시작하지 않는다(start 가 false) — 떠난 뒤 늦게 온 twoFactorRequired 가 비밀번호를 붙잡지 않게.
|
||||
* 시계는 주입받는다(useClock) — 테스트는 손으로 돌리는 시계로 10분을 넘긴다
|
||||
*/
|
||||
export function useLoginChallenge(clock: Clock) {
|
||||
/** 코드 단계를 보이는 중 */
|
||||
const active = ref<boolean>(false)
|
||||
/** 비밀번호를 확인한 주소 — 코드 단계에 보이고, 첫 단계로 돌아갈 때 주소 칸에 남긴다 */
|
||||
const email = ref<string>('')
|
||||
/** 10분이 지나 비밀번호를 지웠다 */
|
||||
const expired = ref<boolean>(false)
|
||||
|
||||
let password: string | null = null
|
||||
/** 코드 단계를 시작한 시각 (clock.now) */
|
||||
let startedAt = 0
|
||||
let timer: ClockTimer | null = null
|
||||
/** 화면이 사라졌다 — 더는 시작하지 않는다 */
|
||||
let disposed = false
|
||||
|
||||
function stopTimer() {
|
||||
if (timer === null) return
|
||||
clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
/** 10분이 지났다 — 비밀번호를 지운다 (화면은 그대로 — [확인] 때 첫 단계로) */
|
||||
function expire() {
|
||||
stopTimer()
|
||||
password = null
|
||||
expired.value = true
|
||||
}
|
||||
|
||||
/** 비밀번호가 맞았고 코드가 필요하다 — 코드 단계로. 화면이 이미 사라졌으면 시작하지 않고 false */
|
||||
function start(nextEmail: string, nextPassword: string): boolean {
|
||||
if (disposed) return false
|
||||
stopTimer()
|
||||
email.value = nextEmail
|
||||
password = nextPassword
|
||||
startedAt = clock.now()
|
||||
expired.value = false
|
||||
active.value = true
|
||||
timer = clock.setTimeout(() => {
|
||||
timer = null
|
||||
expire()
|
||||
}, TWO_FACTOR_LOGIN_WINDOW_MS)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 코드와 함께 다시 보낼 자격 — 시간이 지났거나(시계로 다시 잰다) 끝냈으면 null */
|
||||
function credentials(): LoginChallengeCredentials | null {
|
||||
if (!active.value || password === null) return null
|
||||
if (clock.now() - startedAt >= TWO_FACTOR_LOGIN_WINDOW_MS) {
|
||||
expire()
|
||||
return null
|
||||
}
|
||||
return { email: email.value, password }
|
||||
}
|
||||
|
||||
/** 코드 단계를 끝내고 비밀번호를 지운다 */
|
||||
function clear() {
|
||||
stopTimer()
|
||||
password = null
|
||||
active.value = false
|
||||
expired.value = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
clear()
|
||||
})
|
||||
|
||||
return { active, email, expired, start, credentials, clear }
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { TWO_FACTOR_LOGIN_WINDOW_MS } from '@/shared/constants/account-security'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
@@ -123,3 +127,300 @@ describe('LoginPage', () => {
|
||||
expect(button.text()).toBe('로그인')
|
||||
})
|
||||
})
|
||||
|
||||
// ── 코드 단계 (12단계 — 계약 5절 "로그인" · 디자인 원본 LoginTwoFactor) ─────────────
|
||||
|
||||
const PASSWORD = 'Ara!2026-mail'
|
||||
const TWO_FACTOR_REQUIRED = ok({ status: 'twoFactorRequired', email: null, name: null })
|
||||
const SIGNED_IN = ok({ status: 'signedIn', ...USER })
|
||||
|
||||
describe('LoginPage — 2단계 인증 코드 단계', () => {
|
||||
let pinia: Pinia
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
clock = createManualClock(Date.UTC(2026, 9, 31, 1, 0, 0))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** 첫 요청(비밀번호만)은 twoFactorRequired, 코드가 실린 요청은 codeReply */
|
||||
async function mountAtCodeStep(
|
||||
codeReply: (request: MockRequest) => MockResult | Promise<MockResult> = () => SIGNED_IN,
|
||||
path = '/login',
|
||||
) {
|
||||
requests = installMockApi((request) => {
|
||||
const body = request.data as { otpCode?: string }
|
||||
return body.otpCode === undefined ? TWO_FACTOR_REQUIRED : codeReply(request)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(LoginPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await wrapper.get('#login-email').setValue(` ${USER.email} `)
|
||||
await wrapper.get('#login-password').setValue(PASSWORD)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const loginRequests = () => requests.filter((request) => request.url === '/auth/login')
|
||||
|
||||
async function submitCode(wrapper: VueWrapper, code: string) {
|
||||
await wrapper.get('#login-code').setValue(code)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('비밀번호가 맞고 2단계 인증을 켰으면(twoFactorRequired) 코드 단계로 — 로그인하지 않고, 비밀번호 칸은 없어지고, 초점은 코드 칸', async () => {
|
||||
const { wrapper } = await mountAtCodeStep()
|
||||
|
||||
expect(loginRequests().map((request) => request.data)).toEqual([{ email: USER.email, password: PASSWORD }])
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
expect(wrapper.get('h1').text()).toBe('2단계 인증')
|
||||
expect(wrapper.text()).toContain('비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.')
|
||||
expect(wrapper.get('[data-code-step-email]').text()).toBe(USER.email)
|
||||
expect(wrapper.find('#login-password').exists()).toBe(false)
|
||||
expect(wrapper.get('label[for="login-code"]').text()).toBe('인증 앱에 표시된 6자리 코드')
|
||||
const input = wrapper.get('#login-code')
|
||||
expect(input.attributes()).toMatchObject({ inputmode: 'numeric', autocomplete: 'one-time-code', placeholder: '000000' })
|
||||
// 붙여 넣은 "123 456" 을 브라우저가 먼저 자르지 않게 maxlength 를 걸지 않는다
|
||||
expect(input.attributes('maxlength')).toBeUndefined()
|
||||
expect(document.activeElement?.id).toBe('login-code')
|
||||
expect(wrapper.text()).toContain('인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.')
|
||||
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
|
||||
})
|
||||
|
||||
it('코드 칸은 숫자만 — 붙여 넣은 띄어쓰기 · 하이픈은 지운다', async () => {
|
||||
const { wrapper } = await mountAtCodeStep()
|
||||
const input = wrapper.get('#login-code')
|
||||
|
||||
await input.setValue('123 456')
|
||||
expect((input.element as HTMLInputElement).value).toBe('123456')
|
||||
await input.setValue('12-34ab')
|
||||
expect((input.element as HTMLInputElement).value).toBe('1234')
|
||||
})
|
||||
|
||||
it('6자리가 아니면 보내지 않고 칸 아래에 사전 문구(AUTH_007)', async () => {
|
||||
const { wrapper } = await mountAtCodeStep()
|
||||
|
||||
await submitCode(wrapper, '1234')
|
||||
|
||||
expect(loginRequests()).toHaveLength(1)
|
||||
expect(wrapper.get('#login-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
|
||||
expect(wrapper.get('#login-code').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('맞으면 비밀번호와 코드를 함께 싣고 로그인 — 로그인 전에 가려던 주소로', async () => {
|
||||
const { wrapper, router } = await mountAtCodeStep(() => SIGNED_IN, '/login?redirect=/settings/account/two-factor')
|
||||
|
||||
await submitCode(wrapper, '482913')
|
||||
|
||||
expect(loginRequests().map((request) => request.data)).toEqual([
|
||||
{ email: USER.email, password: PASSWORD },
|
||||
{ email: USER.email, password: PASSWORD, otpCode: '482913' },
|
||||
])
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/account/two-factor')
|
||||
})
|
||||
|
||||
it('확인하는 동안 — 「확인하는 중…」(aria-disabled · aria-busy — 초점을 지킨다) · 칸은 읽기만 · 다시 눌러도 요청은 하나 · [다른 계정으로 로그인]도 누를 수 없다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const { wrapper } = await mountAtCodeStep(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
const button = wrapper.get('form button')
|
||||
;(button.element as HTMLButtonElement).focus()
|
||||
await submitCode(wrapper, '482913')
|
||||
|
||||
expect(button.text()).toBe('확인하는 중…')
|
||||
// disabled 가 아니다 — 꺼진 단추는 키보드 초점을 잃는다
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
expect(button.attributes('aria-disabled')).toBe('true')
|
||||
expect(button.attributes('aria-busy')).toBe('true')
|
||||
expect(document.activeElement).toBe(button.element)
|
||||
expect(wrapper.get('#login-code').attributes('readonly')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(loginRequests()).toHaveLength(2)
|
||||
expect(wrapper.get('[data-code-step-other]').attributes('aria-disabled')).toBe('true')
|
||||
await wrapper.get('[data-code-step-other]').trigger('click')
|
||||
expect(wrapper.find('#login-code').exists()).toBe(true)
|
||||
|
||||
// 시도 과다 — 위쪽 상자에 알리고, 초점은 [확인]에 그대로 남는다
|
||||
release(fail(429, 'REQ_001'))
|
||||
await flushPromises()
|
||||
expect(button.text()).toBe('확인')
|
||||
expect(button.attributes('aria-disabled')).toBeUndefined()
|
||||
expect(button.attributes('aria-busy')).toBe('false')
|
||||
expect(wrapper.get('#login-code-alert').text()).toBe(ERROR_CODE_MESSAGES.REQ_001)
|
||||
expect(document.activeElement).toBe(button.element)
|
||||
})
|
||||
|
||||
it('코드가 틀리면(400 AUTH_006) 칸 아래 빨간 글 · 칸을 비우고 초점 · 알림 없음', async () => {
|
||||
const { wrapper } = await mountAtCodeStep(() => fail(400, 'AUTH_006'))
|
||||
|
||||
await submitCode(wrapper, '000111')
|
||||
|
||||
expect(wrapper.get('#login-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
|
||||
expect((wrapper.get('#login-code').element as HTMLInputElement).value).toBe('')
|
||||
expect(document.activeElement?.id).toBe('login-code')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
|
||||
// 다시 치면 내린다
|
||||
await wrapper.get('#login-code').setValue('4')
|
||||
expect(wrapper.find('#login-code-error').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('시도가 너무 많으면(429 REQ_001) 위쪽 빨간 상자 — 알림 없이, 칸 · 단추는 그대로', async () => {
|
||||
const { wrapper } = await mountAtCodeStep(() => fail(429, 'REQ_001'))
|
||||
|
||||
await submitCode(wrapper, '482913')
|
||||
|
||||
expect(wrapper.get('#login-code-alert').text()).toBe(ERROR_CODE_MESSAGES.REQ_001)
|
||||
expect(wrapper.get('#login-code-alert').attributes('role')).toBe('alert')
|
||||
expect((wrapper.get('#login-code').element as HTMLInputElement).value).toBe('482913')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('그사이 비밀번호가 바뀌었으면(401 AUTH_004) 첫 단계로 — 주소는 남기고 비밀번호는 비우고 위쪽 상자에 사전 문구', async () => {
|
||||
const { wrapper } = await mountAtCodeStep(() => fail(401, 'AUTH_004'))
|
||||
|
||||
await submitCode(wrapper, '482913')
|
||||
|
||||
expect(wrapper.find('#login-code').exists()).toBe(false)
|
||||
expect(wrapper.get('h1').text()).toBe('로그인')
|
||||
expect((wrapper.get('#login-email').element as HTMLInputElement).value).toBe(USER.email)
|
||||
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
|
||||
expect(wrapper.get('#login-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_004)
|
||||
expect(wrapper.get('#login-password').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('login-password')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('[다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다', async () => {
|
||||
const { wrapper } = await mountAtCodeStep()
|
||||
|
||||
await wrapper.get('[data-code-step-other]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('로그인')
|
||||
expect((wrapper.get('#login-email').element as HTMLInputElement).value).toBe('')
|
||||
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
|
||||
expect(wrapper.find('#login-error').exists()).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('login-email')
|
||||
expect(loginRequests()).toHaveLength(1)
|
||||
// 10분 타이머도 거뒀다
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('10분 넘게 머물면 비밀번호를 지운다 — [확인]은 보내지 않고 「다시 로그인해 주세요」와 함께 첫 단계로(주소는 남긴다)', async () => {
|
||||
const { wrapper } = await mountAtCodeStep()
|
||||
|
||||
clock.advance(TWO_FACTOR_LOGIN_WINDOW_MS - 1)
|
||||
await wrapper.get('#login-code').setValue('482913')
|
||||
clock.advance(1)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(loginRequests()).toHaveLength(1)
|
||||
expect(wrapper.get('h1').text()).toBe('로그인')
|
||||
expect(wrapper.get('#login-error').text()).toBe('코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.')
|
||||
// 안내일 뿐 — 칸을 빨갛게 하지 않는다
|
||||
expect(wrapper.get('#login-password').attributes('aria-invalid')).toBeUndefined()
|
||||
expect((wrapper.get('#login-email').element as HTMLInputElement).value).toBe(USER.email)
|
||||
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
|
||||
expect(document.activeElement?.id).toBe('login-password')
|
||||
})
|
||||
|
||||
it('화면을 떠나면 코드 단계의 10분 타이머를 거둔다 (비밀번호도 함께 버린다)', async () => {
|
||||
const { wrapper } = await mountAtCodeStep()
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('10분은 타이머 하나에 기대지 않는다 — 절전 · 뒤에 깔린 탭으로 타이머가 돌지 않았어도 [확인] 때 시계로 다시 재 첫 단계로', async () => {
|
||||
// 타이머는 부르지 않고 시각만 10분 앞으로 간 시계 (절전에서 깨어난 PC · 타이머가 늦춰진 탭)
|
||||
let slept = 0
|
||||
const sleepy = {
|
||||
now: () => clock.now() + slept,
|
||||
setTimeout: clock.setTimeout,
|
||||
clearTimeout: clock.clearTimeout,
|
||||
}
|
||||
requests = installMockApi((request) =>
|
||||
(request.data as { otpCode?: string }).otpCode === undefined ? TWO_FACTOR_REQUIRED : SIGNED_IN,
|
||||
)
|
||||
const router = createTestRouter()
|
||||
await router.push('/login')
|
||||
await router.isReady()
|
||||
const wrapper = mount(LoginPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: sleepy } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await wrapper.get('#login-email').setValue(USER.email)
|
||||
await wrapper.get('#login-password').setValue(PASSWORD)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#login-code').exists()).toBe(true)
|
||||
|
||||
slept = TWO_FACTOR_LOGIN_WINDOW_MS
|
||||
await submitCode(wrapper, '482913')
|
||||
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
expect(loginRequests()).toHaveLength(1)
|
||||
expect(wrapper.get('#login-error').text()).toBe('코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.')
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('기다리는 사이 화면을 떠났으면 늦게 온 twoFactorRequired 로 코드 단계를 시작하지 않는다 (비밀번호를 붙잡지 않는다)', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
requests = installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
const router = createTestRouter()
|
||||
await router.push('/login')
|
||||
await router.isReady()
|
||||
const wrapper = mount(LoginPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await wrapper.get('#login-email').setValue(USER.email)
|
||||
await wrapper.get('#login-password').setValue(PASSWORD)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
wrapper.unmount()
|
||||
release(TWO_FACTOR_REQUIRED)
|
||||
await flushPromises()
|
||||
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,34 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// 로그인 (디자인 원본 Login) — 직원 계정 = 메일 계정
|
||||
// 로그인 (디자인 원본 Login · LoginTwoFactor) — 직원 계정 = 메일 계정
|
||||
// 실패 문구는 에러 코드 사전(AUTH_004)에서만 온다. 계정 없음·비밀번호 틀림을 구분하지 않는다.
|
||||
import { ref } from 'vue'
|
||||
// 2단계 인증을 켠 직원은 비밀번호가 맞으면 코드 단계로 간다 (12단계 — 계약 5절 "로그인" · 디자인 README "로그인 — 코드 단계"):
|
||||
// - 확인한 비밀번호는 화면 메모리에만(useLoginChallenge) — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분 지나면 지운다. 새로고침도 첫 단계
|
||||
// - 코드 틀림(AUTH_006)은 칸 아래 · 칸을 비우고 초점, 시도 과다(REQ_001)는 위쪽 상자, 비밀번호 틀림(AUTH_004 — 그사이 바뀜)은 첫 단계로 돌아가 위쪽 상자
|
||||
// - 10분 넘게 머문 뒤 [확인]은 보내지 않고 「다시 로그인해 주세요」와 함께 첫 단계로(주소는 남긴다)
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import logoFull from '@/assets/brand/logo-arasystem-white.png'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { isApiError } from '@/composables/useApi'
|
||||
import LoginCodeStep from '@/components/auth/LoginCodeStep.vue'
|
||||
import { getErrorMessage, isApiError, toApiError } from '@/composables/useApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useLoginChallenge } from '@/composables/useLoginChallenge'
|
||||
import { LOGIN_TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
|
||||
import { APP_NAME, COMPANY_MAIL_DOMAIN } from '@/shared/constants/brand'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { isCompleteOtp } from '@/shared/utils/account-security'
|
||||
import { toSafeRedirect } from '@/shared/utils/redirect'
|
||||
|
||||
/** 코드 단계가 직접 보이는 서버 거절 — 알림을 끈다 (그 밖의 장애는 인터셉터의 알림) */
|
||||
const CODE_STEP_SILENT: readonly ErrorCode[] = ['AUTH_004', 'AUTH_006', 'REQ_001']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { login } = useAuth()
|
||||
const clock = useClock()
|
||||
const {
|
||||
active: codeStepActive,
|
||||
email: challengedEmail,
|
||||
start: startChallenge,
|
||||
credentials: challengeCredentials,
|
||||
clear: clearChallenge,
|
||||
} = useLoginChallenge(clock)
|
||||
|
||||
const email = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const submitting = ref<boolean>(false)
|
||||
/** 자격 증명 불일치(AUTH_004) 안내 — 사전 문구 그대로 */
|
||||
/** 자격 증명 불일치(AUTH_004) 안내 — 사전 문구 그대로. 칸도 빨갛게 */
|
||||
const credentialError = ref<string | null>(null)
|
||||
/** 첫 단계 위쪽 상자의 안내 — 코드 단계 시간 지남 (칸은 빨갛게 하지 않는다) */
|
||||
const stepNotice = ref<string | null>(null)
|
||||
const emailRef = ref<HTMLInputElement | null>(null)
|
||||
const passwordRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// ── 코드 단계 ─────────────
|
||||
const codeStepRef = ref<InstanceType<typeof LoginCodeStep> | null>(null)
|
||||
const code = ref<string>('')
|
||||
const codeSubmitting = ref<boolean>(false)
|
||||
const codeError = ref<string | null>(null)
|
||||
const codeAlert = ref<string | null>(null)
|
||||
|
||||
function goToRedirect() {
|
||||
return router.replace(toSafeRedirect(route.query.redirect) ?? { name: 'mail' })
|
||||
}
|
||||
|
||||
function resetCodeStep() {
|
||||
code.value = ''
|
||||
codeError.value = null
|
||||
codeAlert.value = null
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
credentialError.value = null
|
||||
stepNotice.value = null
|
||||
const address = email.value.trim()
|
||||
try {
|
||||
await login({ email: email.value.trim(), password: password.value })
|
||||
await router.replace(toSafeRedirect(route.query.redirect) ?? { name: 'mail' })
|
||||
const outcome = await login({ email: address, password: password.value })
|
||||
if (outcome.status === 'twoFactorRequired') {
|
||||
// 확인한 비밀번호는 코드 단계의 메모리로만 옮기고 칸은 비운다 — 기다리는 사이 화면을 떠났으면 붙잡지 않는다(start 가 false)
|
||||
const started = startChallenge(address, password.value)
|
||||
password.value = ''
|
||||
if (!started) return
|
||||
resetCodeStep()
|
||||
await nextTick()
|
||||
codeStepRef.value?.focus()
|
||||
return
|
||||
}
|
||||
await goToRedirect()
|
||||
} catch (error) {
|
||||
if (isApiError(error) && error.code === 'AUTH_004') {
|
||||
credentialError.value = error.message
|
||||
@@ -40,6 +93,73 @@ async function onSubmit() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 코드 단계를 끝내고 첫 단계로 — 비밀번호는 지운다. 주소가 남으면 비밀번호 칸, 없으면 주소 칸에 초점 */
|
||||
async function returnToPasswordStep(next: { email: string; error?: string; notice?: string }) {
|
||||
clearChallenge()
|
||||
resetCodeStep()
|
||||
email.value = next.email
|
||||
password.value = ''
|
||||
credentialError.value = next.error ?? null
|
||||
stepNotice.value = next.notice ?? null
|
||||
await nextTick()
|
||||
if (next.email) passwordRef.value?.focus()
|
||||
else emailRef.value?.focus()
|
||||
}
|
||||
|
||||
function focusCodeWithError(message: string) {
|
||||
codeError.value = message
|
||||
code.value = ''
|
||||
codeStepRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 코드를 고치면 칸 아래 오류를 내린다 (다른 양식과 같다) */
|
||||
function setCode(value: string) {
|
||||
code.value = value
|
||||
codeError.value = null
|
||||
}
|
||||
|
||||
async function onSubmitCode() {
|
||||
if (codeSubmitting.value) return
|
||||
const credentials = challengeCredentials()
|
||||
if (!credentials) {
|
||||
await returnToPasswordStep({ email: challengedEmail.value, notice: LOGIN_TWO_FACTOR_TEXT.expired })
|
||||
return
|
||||
}
|
||||
codeAlert.value = null
|
||||
if (!isCompleteOtp(code.value)) {
|
||||
// 비었거나 6자리가 아니다 — 보내지 않는다 (서버도 400 VAL_001 로 거절한다)
|
||||
codeError.value = getErrorMessage('AUTH_007')
|
||||
codeStepRef.value?.focus()
|
||||
return
|
||||
}
|
||||
codeSubmitting.value = true
|
||||
codeError.value = null
|
||||
try {
|
||||
const outcome = await login({ ...credentials, otpCode: code.value }, { silent: CODE_STEP_SILENT })
|
||||
if (outcome.status === 'twoFactorRequired') {
|
||||
// 코드를 실었는데도 코드를 묻는다 — 받아들여지지 않은 코드로 본다
|
||||
focusCodeWithError(getErrorMessage('AUTH_006'))
|
||||
return
|
||||
}
|
||||
clearChallenge()
|
||||
await goToRedirect()
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
if (apiError.code === 'AUTH_006') focusCodeWithError(apiError.message)
|
||||
else if (apiError.code === 'AUTH_004') await returnToPasswordStep({ email: credentials.email, error: apiError.message })
|
||||
else if (apiError.code === 'REQ_001') codeAlert.value = apiError.message
|
||||
// 그 밖의 실패(메일 엔진 장애 등)는 인터셉터가 알렸다 — 칸 · 단추는 그대로
|
||||
} finally {
|
||||
codeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** [다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다 */
|
||||
function onOtherAccount() {
|
||||
if (codeSubmitting.value) return
|
||||
void returnToPasswordStep({ email: '' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,85 +188,101 @@ async function onSubmit() {
|
||||
|
||||
<main class="login__main">
|
||||
<div class="login__panel">
|
||||
<div class="login__heading">
|
||||
<h1 class="login__title">
|
||||
로그인
|
||||
</h1>
|
||||
<p class="login__subtitle">
|
||||
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
|
||||
</p>
|
||||
</div>
|
||||
<LoginCodeStep
|
||||
v-if="codeStepActive"
|
||||
ref="codeStepRef"
|
||||
:code="code"
|
||||
:email="challengedEmail"
|
||||
:submitting="codeSubmitting"
|
||||
:code-error="codeError"
|
||||
:alert="codeAlert"
|
||||
@update:code="setCode"
|
||||
@submit="onSubmitCode"
|
||||
@other-account="onOtherAccount"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="credentialError"
|
||||
id="login-error"
|
||||
class="login__error"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="login__error-icon"
|
||||
/>
|
||||
<span>{{ credentialError }}</span>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="login__heading">
|
||||
<h1 class="login__title">
|
||||
로그인
|
||||
</h1>
|
||||
<p class="login__subtitle">
|
||||
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="login__form"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="login__field">
|
||||
<label
|
||||
for="login-email"
|
||||
class="login__label"
|
||||
>메일 주소</label>
|
||||
<input
|
||||
id="login-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="username"
|
||||
:placeholder="`name@${COMPANY_MAIL_DOMAIN}`"
|
||||
required
|
||||
:class="['login__input', { 'is-invalid': credentialError }]"
|
||||
:aria-invalid="credentialError ? 'true' : undefined"
|
||||
:aria-describedby="credentialError ? 'login-error' : undefined"
|
||||
>
|
||||
</div>
|
||||
<div class="login__field">
|
||||
<label
|
||||
for="login-password"
|
||||
class="login__label"
|
||||
>비밀번호</label>
|
||||
<input
|
||||
id="login-password"
|
||||
ref="passwordRef"
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="비밀번호 입력"
|
||||
required
|
||||
:class="['login__input', { 'is-invalid': credentialError }]"
|
||||
:aria-invalid="credentialError ? 'true' : undefined"
|
||||
:aria-describedby="credentialError ? 'login-error' : undefined"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="login__submit"
|
||||
:disabled="submitting"
|
||||
:aria-busy="submitting"
|
||||
<p
|
||||
v-if="credentialError || stepNotice"
|
||||
id="login-error"
|
||||
class="login__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ submitting ? '로그인하는 중…' : '로그인' }}
|
||||
</button>
|
||||
</form>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="login__error-icon"
|
||||
/>
|
||||
<span>{{ credentialError ?? stepNotice }}</span>
|
||||
</p>
|
||||
|
||||
<p class="login__help">
|
||||
비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.
|
||||
</p>
|
||||
<p class="login__note">
|
||||
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
|
||||
</p>
|
||||
<form
|
||||
class="login__form"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="login__field">
|
||||
<label
|
||||
for="login-email"
|
||||
class="login__label"
|
||||
>메일 주소</label>
|
||||
<input
|
||||
id="login-email"
|
||||
ref="emailRef"
|
||||
v-model="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="username"
|
||||
:placeholder="`name@${COMPANY_MAIL_DOMAIN}`"
|
||||
required
|
||||
:class="['login__input', { 'is-invalid': credentialError }]"
|
||||
:aria-invalid="credentialError ? 'true' : undefined"
|
||||
:aria-describedby="credentialError || stepNotice ? 'login-error' : undefined"
|
||||
>
|
||||
</div>
|
||||
<div class="login__field">
|
||||
<label
|
||||
for="login-password"
|
||||
class="login__label"
|
||||
>비밀번호</label>
|
||||
<input
|
||||
id="login-password"
|
||||
ref="passwordRef"
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="비밀번호 입력"
|
||||
required
|
||||
:class="['login__input', { 'is-invalid': credentialError }]"
|
||||
:aria-invalid="credentialError ? 'true' : undefined"
|
||||
:aria-describedby="credentialError || stepNotice ? 'login-error' : undefined"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="login__submit"
|
||||
:disabled="submitting"
|
||||
:aria-busy="submitting"
|
||||
>
|
||||
{{ submitting ? '로그인하는 중…' : '로그인' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login__help">
|
||||
비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.
|
||||
</p>
|
||||
<p class="login__note">
|
||||
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { AppPassword, CreatedAppPassword } from '@/shared/types/account-security'
|
||||
import { formatBlockedDay, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import resultSource from '@/components/settings/AppPasswordResultDialog.vue?raw'
|
||||
import AccountAppPasswordsPage from './AccountAppPasswordsPage.vue'
|
||||
|
||||
// 설정 › 계정 › 앱 비밀번호 (12단계 — 디자인 원본 SettingsAppPasswords · 계약 5절 "앱 비밀번호")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 한국 시간 2026-10-31 (토) 낮 12시 */
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z')
|
||||
const VALUE = 'app_7qm2xk9vtn3wpl1srb3hcy7djf2gaz1euk0n'
|
||||
|
||||
const ITEMS: AppPassword[] = [
|
||||
{ id: 'c', name: '노트북 썬더버드', createdAt: '2026-10-31T00:20:00Z', lastUsed: null },
|
||||
{
|
||||
id: 'b',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: '2026-10-27T00:00:00Z',
|
||||
lastUsed: { at: '2026-10-31T00:12:00Z', method: 'imap', ip: '211.186.95.252' },
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
name: '휴대폰 메일 앱',
|
||||
createdAt: '2026-10-20T00:00:00Z',
|
||||
lastUsed: { at: '2026-10-30T09:40:00Z', method: 'smtp', ip: '223.38.120.77' },
|
||||
},
|
||||
]
|
||||
|
||||
const page = (items: AppPassword[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
|
||||
|
||||
function created(name: string): CreatedAppPassword {
|
||||
return { id: 'd', name, createdAt: '2026-10-31T02:59:00Z', lastUsed: null, password: VALUE }
|
||||
}
|
||||
|
||||
interface Server {
|
||||
list?: () => MockResult
|
||||
create?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
remove?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('AccountAppPasswordsPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
Reflect.deleteProperty(navigator, 'clipboard')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'GET') return server.list ? server.list() : ok(page(ITEMS))
|
||||
if (request.method === 'POST') {
|
||||
if (server.create) return server.create(request)
|
||||
return ok(created((request.data as { name: string }).name), 201)
|
||||
}
|
||||
return server.remove ? server.remove(request) : ok(null)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/app-passwords')
|
||||
await router.isReady()
|
||||
const wrapper = mount(AccountAppPasswordsPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(NOW) } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const rows = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('tbody tr').map((row) => row.findAll('td').slice(0, 3).map((cell) => cell.text()))
|
||||
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const dialogOpen = (titleId: string) =>
|
||||
document.querySelector(`dialog[aria-labelledby="${titleId}"]`)?.hasAttribute('open') === true
|
||||
|
||||
async function openCreate(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-app-password-create]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function create(wrapper: VueWrapper, name: string) {
|
||||
await wrapper.get('#app-password-name').setValue(name)
|
||||
await wrapper.get('[data-app-password-create-submit]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('머리 "앱 비밀번호" · "3 / 5개" · [만들기] → 실제 표(이름 · 만든 날 · 마지막 사용 · [지우기]) → 설정값 상자 → 안내', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /app-passwords'])
|
||||
expect(wrapper.get('#app-passwords-page-title').text()).toBe('앱 비밀번호')
|
||||
expect(wrapper.get('[data-app-password-count]').text()).toBe('3 / 5개')
|
||||
expect(wrapper.get('[data-app-password-create]').attributes('aria-disabled')).toBeUndefined()
|
||||
expect(wrapper.findAll('thead th').map((cell) => cell.text())).toEqual(['이름', '만든 날', '마지막 사용', '지우기'])
|
||||
expect(wrapper.findAll('thead th').every((cell) => cell.attributes('scope') === 'col')).toBe(true)
|
||||
const nowDate = new Date(NOW)
|
||||
expect(rows(wrapper)).toEqual([
|
||||
['노트북 썬더버드', formatBlockedDay(ITEMS[0]?.createdAt ?? '', nowDate), '아직 쓰지 않음'],
|
||||
[
|
||||
'회사 PC 아웃룩',
|
||||
formatBlockedDay(ITEMS[1]?.createdAt ?? '', nowDate),
|
||||
`${formatRelativeDayTime('2026-10-31T00:12:00Z', nowDate)} · IMAP · 211.186.95.252`,
|
||||
],
|
||||
[
|
||||
'휴대폰 메일 앱',
|
||||
formatBlockedDay(ITEMS[2]?.createdAt ?? '', nowDate),
|
||||
`${formatRelativeDayTime('2026-10-30T09:40:00Z', nowDate)} · SMTP · 223.38.120.77`,
|
||||
],
|
||||
])
|
||||
expect(rows(wrapper)[1]?.[2]).toBe('오늘 오전 9:12 · IMAP · 211.186.95.252')
|
||||
expect(wrapper.findAll('[data-app-password-used]')[0]?.classes()).toContain('app-password-table__used--never')
|
||||
expect(wrapper.findAll('[data-app-password-delete]').map((button) => button.attributes('aria-label'))).toEqual([
|
||||
'‘노트북 썬더버드’ 앱 비밀번호 지우기',
|
||||
'‘회사 PC 아웃룩’ 앱 비밀번호 지우기',
|
||||
'‘휴대폰 메일 앱’ 앱 비밀번호 지우기',
|
||||
])
|
||||
const settings = wrapper.get('[data-mail-client-settings]').text()
|
||||
expect(settings).toContain('mx1.arasystem.it.kr · 포트 993 · SSL/TLS')
|
||||
expect(settings).toContain('mx1.arasystem.it.kr · 포트 465 · SSL/TLS')
|
||||
expect(settings).toContain('ara.kim@arasystem.it.kr — 메일 주소 전체')
|
||||
expect(settings).toContain('여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다')
|
||||
expect(wrapper.get('[data-app-password-notes]').text()).toContain(
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.',
|
||||
)
|
||||
})
|
||||
|
||||
it('빈 상태 — "0 / 5개" + "만든 앱 비밀번호가 없습니다", 설정값 상자 · 안내는 그대로', async () => {
|
||||
const wrapper = await mountPage({ list: () => ok(page([])) })
|
||||
|
||||
expect(wrapper.get('[data-app-password-count]').text()).toBe('0 / 5개')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-app-password-empty]').text()).toContain('만든 앱 비밀번호가 없습니다')
|
||||
expect(wrapper.find('[data-mail-client-settings]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('목록을 받지 못하면 사전 문구 + [다시 시도] — 그동안 [만들기]는 막는다(개수를 모르므로)', async () => {
|
||||
let reply: MockResult = fail(503, 'MAIL_001')
|
||||
const wrapper = await mountPage({ list: () => reply })
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(wrapper.find('[data-app-password-count]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-app-password-create]').attributes('aria-disabled')).toBe('true')
|
||||
await openCreate(wrapper)
|
||||
expect(dialogOpen('app-password-create-title')).toBe(false)
|
||||
|
||||
reply = ok(page(ITEMS))
|
||||
await wrapper.get('.mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-app-password-count]').text()).toBe('3 / 5개')
|
||||
})
|
||||
|
||||
it('5개를 모두 만들었으면 [만들기]는 aria-disabled + 머리 아래 까닭 — 누르면 창을 열지 않고 까닭을 읽어 준다', async () => {
|
||||
const five = [...ITEMS, { ...ITEMS[0], id: 'e', name: '집 PC 아웃룩' }, { ...ITEMS[0], id: 'f', name: '태블릿' }] as AppPassword[]
|
||||
const wrapper = await mountPage({ list: () => ok(page(five)) })
|
||||
|
||||
const button = wrapper.get('[data-app-password-create]')
|
||||
expect(wrapper.get('[data-app-password-count]').text()).toBe('5 / 5개')
|
||||
expect(button.attributes('aria-disabled')).toBe('true')
|
||||
expect(button.attributes('aria-describedby')).toBe('app-passwords-full')
|
||||
expect(wrapper.get('#app-passwords-full').text()).toBe('5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.')
|
||||
|
||||
await openCreate(wrapper)
|
||||
expect(dialogOpen('app-password-create-title')).toBe(false)
|
||||
expect(wrapper.get('[data-app-password-status]').attributes('role')).toBe('status')
|
||||
expect(wrapper.get('[data-app-password-status]').text()).toBe(
|
||||
'5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
|
||||
)
|
||||
})
|
||||
|
||||
describe('만들기', () => {
|
||||
it('이름 창 — 처음 초점은 이름 칸(40자) · 비었거나 같은 이름(앞뒤 공백을 지운 값)이면 보내지 않고 칸 아래', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openCreate(wrapper)
|
||||
|
||||
expect(dialogOpen('app-password-create-title')).toBe(true)
|
||||
expect(document.activeElement?.id).toBe('app-password-name')
|
||||
expect(wrapper.get('#app-password-name').attributes('maxlength')).toBe('40')
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
|
||||
|
||||
await create(wrapper, ' ')
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('이름을 넣어 주세요.')
|
||||
await create(wrapper, ' 회사 PC 아웃룩 ')
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('같은 이름의 앱 비밀번호가 있습니다.')
|
||||
expect(wrapper.get('#app-password-name').attributes('aria-invalid')).toBe('true')
|
||||
expect(requests.filter((request) => request.method === 'POST')).toEqual([])
|
||||
})
|
||||
|
||||
it('만들면 결과 창(620) — 값은 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다, 처음 초점 [복사] · 닫으면 목록 맨 위에 새 줄 · 값은 화면에서 지운다', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
const wrapper = await mountPage()
|
||||
await openCreate(wrapper)
|
||||
await create(wrapper, ' 집 노트북 메일 ')
|
||||
|
||||
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
|
||||
{ name: '집 노트북 메일' },
|
||||
])
|
||||
expect(dialogOpen('app-password-create-title')).toBe(false)
|
||||
expect(dialogOpen('app-password-result-title')).toBe(true)
|
||||
expect(document.querySelector('#app-password-result-message')?.textContent?.trim()).toBe(
|
||||
'‘집 노트북 메일’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.',
|
||||
)
|
||||
expect(wrapper.findAll('.result__group').map((group) => group.text())).toEqual([
|
||||
'app_',
|
||||
'7qm2',
|
||||
'xk9v',
|
||||
'tn3w',
|
||||
'pl1s',
|
||||
'rb3h',
|
||||
'cy7d',
|
||||
'jf2g',
|
||||
'az1e',
|
||||
'uk0n',
|
||||
])
|
||||
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
|
||||
expect(wrapper.get('.result__length').text()).toBe('40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.')
|
||||
expect(document.activeElement?.hasAttribute('data-app-password-copy')).toBe(true)
|
||||
expect(wrapper.get('.result__warning').text()).toContain('이 창을 닫으면 다시 볼 수 없습니다.')
|
||||
|
||||
await wrapper.get('[data-app-password-copy]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(writeText).toHaveBeenCalledWith(VALUE)
|
||||
expect(toastMessages()).toEqual(['앱 비밀번호를 복사했습니다.'])
|
||||
|
||||
await wrapper.get('[data-app-password-result-close]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('app-password-result-title')).toBe(false)
|
||||
expect(wrapper.find('[data-app-password-value]').text()).toBe('')
|
||||
expect(document.body.textContent).not.toContain('7qm2')
|
||||
expect(rows(wrapper)[0]?.[0]).toBe('집 노트북 메일')
|
||||
expect(wrapper.get('[data-app-password-count]').text()).toBe('4 / 5개')
|
||||
})
|
||||
|
||||
it('값의 묶음은 가운데서 줄이 바뀌지 않는다 — 넓으면 한 줄, 좁으면 묶음 사이에서만 (줄바꿈 없는 inline-block · 글자 사이에 아무것도 넣지 않는다)', async () => {
|
||||
const css = resultSource.slice(resultSource.indexOf('<style'))
|
||||
const rule = (selector: string) => new RegExp(`${selector.replace(/[.-]/g, '\\$&')}\\s*\\{([^}]*)\\}`).exec(css)?.[1] ?? ''
|
||||
expect(rule('.result__group')).toMatch(/display:\s*inline-block/)
|
||||
expect(rule('.result__group')).toMatch(/white-space:\s*nowrap/)
|
||||
expect(rule('.result__value')).not.toMatch(/overflow-wrap|word-break/)
|
||||
|
||||
const wrapper = await mountPage()
|
||||
await openCreate(wrapper)
|
||||
await create(wrapper, '집 노트북 메일')
|
||||
// 골라 복사해도 값 그대로 — 묶음 사이에 띄어쓰기 · 줄바꿈 글자가 없다
|
||||
expect(wrapper.get('[data-app-password-value]').element.textContent).toBe(VALUE)
|
||||
})
|
||||
|
||||
it('복사하지 못하면 「값을 직접 골라 복사해 주세요」 알림', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openCreate(wrapper)
|
||||
await create(wrapper, '집 노트북 메일')
|
||||
|
||||
await wrapper.get('[data-app-password-copy]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.at(-1)).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.',
|
||||
})
|
||||
})
|
||||
|
||||
it('세션이 바뀌면(다른 곳에서 이 세션을 끊음 → 세션 만료) 결과 창을 닫고 값을 화면에서 지운다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openCreate(wrapper)
|
||||
await create(wrapper, '집 노트북 메일')
|
||||
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen('app-password-result-title')).toBe(false)
|
||||
expect(document.body.textContent).not.toContain('7qm2')
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 만들기 창도 닫는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openCreate(wrapper)
|
||||
await wrapper.get('#app-password-name').setValue('집 노트북 메일')
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
expect(dialogOpen('app-password-create-title')).toBe(false)
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 지우기 확인 창도 닫는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.findAll('[data-app-password-delete]')[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('app-password-delete-title')).toBe(true)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
expect(dialogOpen('app-password-delete-title')).toBe(false)
|
||||
})
|
||||
|
||||
it('만드는 사이 세션이 바뀌었으면 늦게 온 값을 그리지도 목록에 넣지도 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const wrapper = await mountPage({
|
||||
create: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
})
|
||||
await openCreate(wrapper)
|
||||
await create(wrapper, '집 노트북 메일')
|
||||
|
||||
useAuthStore().startSession(USER)
|
||||
release(ok(created('집 노트북 메일'), 201))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen('app-password-result-title')).toBe(false)
|
||||
expect(document.body.textContent).not.toContain('7qm2')
|
||||
expect(rows(wrapper).map((row) => row[0])).not.toContain('집 노트북 메일')
|
||||
})
|
||||
|
||||
it('닫았다 다시 연 만들기 창에는 앞 요청의 거절을 넣지 않는다 — [만들기]의 바쁨도 처음으로', async () => {
|
||||
const replies: ((result: MockResult) => void)[] = []
|
||||
const wrapper = await mountPage({
|
||||
create: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
replies.push(resolve)
|
||||
}),
|
||||
})
|
||||
await openCreate(wrapper)
|
||||
await create(wrapper, '집 노트북 메일')
|
||||
expect(wrapper.get('[data-app-password-create-submit]').attributes('aria-busy')).toBe('true')
|
||||
await wrapper.get('[data-app-password-create-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await openCreate(wrapper)
|
||||
expect(wrapper.get('[data-app-password-create-submit]').attributes('aria-busy')).toBeUndefined()
|
||||
replies[0]?.(fail(400, 'VAL_001'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('그사이 다른 창에서 5개를 채웠으면(409 AUTH_009) 칸 아래 사전 문구 · 목록을 다시 받아 개수를 맞춘다', async () => {
|
||||
let list = ITEMS
|
||||
const five = [...ITEMS, { ...ITEMS[0], id: 'e', name: '집 PC 아웃룩' }, { ...ITEMS[0], id: 'f', name: '태블릿' }] as AppPassword[]
|
||||
const wrapper = await mountPage({
|
||||
list: () => ok(page(list)),
|
||||
create: () => {
|
||||
list = five
|
||||
return fail(409, 'AUTH_009')
|
||||
},
|
||||
})
|
||||
await openCreate(wrapper)
|
||||
await create(wrapper, '집 노트북 메일')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#app-password-name-help').text()).toBe(ERROR_CODE_MESSAGES.AUTH_009)
|
||||
expect(toastMessages()).toEqual([])
|
||||
expect(requests.filter((request) => request.method === 'GET')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-app-password-count]').text()).toBe('5 / 5개')
|
||||
expect(dialogOpen('app-password-result-title')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('지우기', () => {
|
||||
it('확인 창(alertdialog · 처음 초점 「취소」) → DELETE · 「‘…’ 앱 비밀번호를 지웠습니다.」 — 줄이 빠지고 개수가 준다, 초점은 다음 줄', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.findAll('[data-app-password-delete]')[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const dialog = document.querySelector('dialog[aria-labelledby="app-password-delete-title"]')
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
expect(dialog?.getAttribute('role')).toBe('alertdialog')
|
||||
expect(document.querySelector('#app-password-delete-title')?.textContent?.trim()).toBe(
|
||||
'‘회사 PC 아웃룩’ 앱 비밀번호를 지울까요?',
|
||||
)
|
||||
expect(document.activeElement?.hasAttribute('data-app-password-delete-cancel')).toBe(true)
|
||||
|
||||
await wrapper.get('[data-app-password-delete-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/app-passwords/b',
|
||||
])
|
||||
expect(toastMessages()).toEqual(['‘회사 PC 아웃룩’ 앱 비밀번호를 지웠습니다.'])
|
||||
expect(rows(wrapper).map((row) => row[0])).toEqual(['노트북 썬더버드', '휴대폰 메일 앱'])
|
||||
expect(wrapper.get('[data-app-password-count]').text()).toBe('2 / 5개')
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘휴대폰 메일 앱’ 앱 비밀번호 지우기')
|
||||
})
|
||||
|
||||
it('취소하면 지우지 않는다 · 지우지 못하면(장애) 창을 그대로 두고 알림은 인터셉터', async () => {
|
||||
const wrapper = await mountPage({ remove: () => fail(503, 'MAIL_001') })
|
||||
await wrapper.findAll('[data-app-password-delete]')[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-app-password-delete-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.method === 'DELETE')).toEqual([])
|
||||
|
||||
await wrapper.findAll('[data-app-password-delete]')[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-app-password-delete-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
expect(document.querySelector('dialog[aria-labelledby="app-password-delete-title"]')?.hasAttribute('open')).toBe(true)
|
||||
expect(rows(wrapper)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,430 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 앱 비밀번호 (디자인 원본 SettingsAppPasswords · README "12단계 › 설정 › 계정 › 앱 비밀번호" · 계약 5절 "앱 비밀번호")
|
||||
// — /settings/account/app-passwords · 탭 제목 "앱 비밀번호"
|
||||
// 머리 + 설명 · 오른쪽 "3 / 5개" + [앱 비밀번호 만들기] → 표(AppPasswordTable) 또는 빈 상태 → 메일 프로그램 설정값 상자 → 아래 안내 상자
|
||||
// - 들어올 때마다 새로 받는다(app-passwords.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도" — 그동안 [만들기]는 막는다(개수를 모르므로)
|
||||
// - 5개를 모두 만들었으면 [만들기]는 aria-disabled + 머리 아래 한 줄(까닭) — 누르면 창을 열지 않고 까닭을 읽어 준다(role=status)
|
||||
// - [만들기] → 이름 창(AppPasswordCreateDialog) → 결과 창(AppPasswordResultDialog — 값은 이 화면 메모리에만, 닫으면 지운다) → 목록 맨 위에 새 줄.
|
||||
// 만들기 요청은 이 화면이 보낸다 — 값이 든 응답을 스토어(Pinia 타임라인) · 창의 이벤트로 넘기지 않는다(스토어에는 값을 뺀 줄만)
|
||||
// - [지우기] → 확인 창(처음 초점 「취소」) → 「‘…’ 앱 비밀번호를 지웠습니다.」 — 줄이 빠지고 개수가 준다(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · [만들기])
|
||||
// - 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃] · 로그아웃 · 만료) 창을 모두 닫고 값을 지운다 — 세션 만료 창 뒤에 앱 비밀번호가 남지 않게
|
||||
// - 만들기 창을 닫았다 다시 열면 앞 요청의 거절은 새 창에 넣지 않는다(몇 번째로 연 창인가 — createOpenSeq). 앞 요청이 만들었으면 결과 창은 연다(값은 그때 한 번만 온다)
|
||||
// 고정 id — #app-passwords-page-title · #app-passwords-full
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import AppPasswordCreateDialog from '@/components/settings/AppPasswordCreateDialog.vue'
|
||||
import AppPasswordDeleteDialog from '@/components/settings/AppPasswordDeleteDialog.vue'
|
||||
import AppPasswordResultDialog from '@/components/settings/AppPasswordResultDialog.vue'
|
||||
import AppPasswordTable from '@/components/settings/AppPasswordTable.vue'
|
||||
import MailClientSettings from '@/components/settings/MailClientSettings.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import { notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { APP_PASSWORD_MAX, APP_PASSWORD_TEXT } from '@/shared/constants/account-security'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { AppPassword } from '@/shared/types/account-security'
|
||||
import { appPasswordCountText } from '@/shared/utils/account-security'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useAppPasswordsStore } from '@/stores/app-passwords.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const FULL_REASON_ID = 'app-passwords-full'
|
||||
/** 만들기 창의 칸 아래에 보이는 서버 거절 — 알림을 끈다 (그사이 5개를 채움 · 형식) */
|
||||
const CREATE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001', 'AUTH_009']
|
||||
|
||||
const api = useAccountSecurity()
|
||||
const appPasswordsStore = useAppPasswordsStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
const clock = useClock()
|
||||
|
||||
/** 화면을 연 때 — "오늘 · 어제" 의 기준 */
|
||||
const now = clock.now()
|
||||
|
||||
const createRef = ref<HTMLButtonElement | null>(null)
|
||||
const tableRef = ref<InstanceType<typeof AppPasswordTable> | null>(null)
|
||||
const createOpen = ref<boolean>(false)
|
||||
const creating = ref<boolean>(false)
|
||||
/** 만들기 창의 서버 거절 — 사전 문구 */
|
||||
const createError = ref<string | null>(null)
|
||||
/** 만든 결과 — 값이 들어 있다. 이 화면 메모리에만 두고 창을 닫으면 지운다 */
|
||||
const result = shallowRef<{ name: string; password: string } | null>(null)
|
||||
/** 지울지 묻는 줄 */
|
||||
const deleteTarget = ref<AppPassword | null>(null)
|
||||
const removing = ref<boolean>(false)
|
||||
/** 막힌 [만들기]를 눌렀을 때 읽어 줄 까닭 (role=status) */
|
||||
const announcement = ref<string>('')
|
||||
/** 만들기 창을 연 차례 — 닫았다 다시 연 창에 앞 요청의 결과를 넣지 않는다 */
|
||||
let createOpenSeq = 0
|
||||
let unmounted = false
|
||||
|
||||
const ready = computed(() => appPasswordsStore.status === 'ready')
|
||||
const items = computed(() => appPasswordsStore.items)
|
||||
const isFull = computed(() => ready.value && appPasswordsStore.isFull)
|
||||
/** [만들기]를 누를 수 있다 — 목록을 받았고 상한 아래 */
|
||||
const canCreate = computed(() => ready.value && !appPasswordsStore.isFull)
|
||||
const countText = computed(() => appPasswordCountText(items.value.length, APP_PASSWORD_MAX))
|
||||
const fullReason = APP_PASSWORD_TEXT.full(formatCount(APP_PASSWORD_MAX))
|
||||
const email = computed(() => authStore.user?.email ?? '')
|
||||
|
||||
async function onCreateClick() {
|
||||
if (canCreate.value) {
|
||||
createOpenSeq += 1
|
||||
creating.value = false
|
||||
createError.value = null
|
||||
createOpen.value = true
|
||||
return
|
||||
}
|
||||
if (!isFull.value) return
|
||||
// 같은 글이어도 다시 읽게 비웠다가 넣는다
|
||||
announcement.value = ''
|
||||
await nextTick()
|
||||
announcement.value = fullReason
|
||||
}
|
||||
|
||||
/**
|
||||
* 만들기 — 값(password)은 이 응답에만 온다. 스토어에는 값을 뺀 줄만 넣고 값은 결과 창(화면 메모리)으로.
|
||||
* 기다리는 사이 세션이 바뀌었으면 아무것도 쓰지 않는다. 창을 닫았거나 다시 열었어도 만들어졌으면 결과 창은 연다(값을 다시 받을 길이 없다)
|
||||
*/
|
||||
async function submitCreate(name: string) {
|
||||
if (creating.value) return
|
||||
const opened = createOpenSeq
|
||||
const session = authStore.generation
|
||||
creating.value = true
|
||||
createError.value = null
|
||||
try {
|
||||
const created = await api.createAppPassword(name, { silent: CREATE_INLINE_CODES })
|
||||
if (authStore.generation !== session || unmounted) return
|
||||
appPasswordsStore.add({ id: created.id, name: created.name, createdAt: created.createdAt, lastUsed: created.lastUsed })
|
||||
createOpen.value = false
|
||||
result.value = { name: created.name, password: created.password }
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
if (authStore.generation !== session) return
|
||||
// 그사이 다른 창에서 채웠다 — 개수를 맞춘다 (화면의 [만들기]도 막힌다)
|
||||
if (apiError.code === 'AUTH_009' && !unmounted) void appPasswordsStore.refresh()
|
||||
if (!CREATE_INLINE_CODES.includes(apiError.code)) return
|
||||
if (unmounted || opened !== createOpenSeq || !createOpen.value) {
|
||||
// 보낸 창이 없다 — 닫힌 채면 알림으로, 다시 연 창에는 앞 요청의 거절을 넣지 않는다
|
||||
if (opened === createOpenSeq) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
createError.value = apiError.message
|
||||
} finally {
|
||||
if (opened === createOpenSeq) creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
createOpen.value = false
|
||||
}
|
||||
|
||||
function closeResult() {
|
||||
result.value = null
|
||||
}
|
||||
|
||||
/** 모든 창을 닫고 값을 지운다 — 세션이 바뀌었다 */
|
||||
function closeAll() {
|
||||
createOpenSeq += 1
|
||||
createOpen.value = false
|
||||
creating.value = false
|
||||
createError.value = null
|
||||
result.value = null
|
||||
deleteTarget.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 [만들기].
|
||||
* 초점이 갈 곳을 잃었을 때만 옮긴다 — 누른 줄의 단추가 사라졌거나, 닫힌 확인 창 안에 남았다(창을 연 단추가 이미 없어 돌려줄 곳이 없었다)
|
||||
*/
|
||||
async function focusAfterRemoval(index: number) {
|
||||
await nextTick()
|
||||
const active = document.activeElement
|
||||
const lost = active === null || active === document.body || active.closest('dialog:not([open])') !== null
|
||||
if (!lost) return
|
||||
const root: unknown = tableRef.value?.$el
|
||||
const buttons = root instanceof HTMLElement ? Array.from(root.querySelectorAll<HTMLButtonElement>('[data-app-password-delete]')) : []
|
||||
const target = buttons[index] ?? buttons[index - 1]
|
||||
if (target) target.focus()
|
||||
else createRef.value?.focus()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const target = deleteTarget.value
|
||||
if (!target || removing.value) return
|
||||
const index = items.value.findIndex((item) => item.id === target.id)
|
||||
removing.value = true
|
||||
try {
|
||||
await appPasswordsStore.remove(target.id)
|
||||
deleteTarget.value = null
|
||||
toast.success(APP_PASSWORD_TEXT.deleted(target.name))
|
||||
await focusAfterRemoval(index)
|
||||
} catch {
|
||||
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
|
||||
} finally {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
result.value = null
|
||||
})
|
||||
|
||||
// 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃]으로 이 세션이 끊김 · 로그아웃 · 만료) 창을 모두 닫고 값을 지운다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => closeAll(),
|
||||
)
|
||||
|
||||
// 처음 그리기 전에 — 다른 탭에서 만들었거나, 비밀번호를 바꿔 모두 지워졌을 수 있다
|
||||
void appPasswordsStore.load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="app-passwords-page-title"
|
||||
:title="APP_PASSWORD_TEXT.title"
|
||||
:description="APP_PASSWORD_TEXT.description"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="app-passwords__head-actions">
|
||||
<span
|
||||
v-if="ready"
|
||||
class="app-passwords__count"
|
||||
data-app-password-count
|
||||
>{{ countText }}</span>
|
||||
<button
|
||||
ref="createRef"
|
||||
type="button"
|
||||
class="app-passwords__create"
|
||||
:aria-disabled="canCreate ? undefined : 'true'"
|
||||
:aria-describedby="isFull ? FULL_REASON_ID : undefined"
|
||||
data-app-password-create
|
||||
@click="onCreateClick"
|
||||
>
|
||||
<AppIcon
|
||||
name="plus"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ APP_PASSWORD_TEXT.create }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p
|
||||
v-if="isFull"
|
||||
:id="FULL_REASON_ID"
|
||||
class="app-passwords__full"
|
||||
data-app-password-full
|
||||
>
|
||||
{{ fullReason }}
|
||||
</p>
|
||||
<span
|
||||
class="visually-hidden"
|
||||
role="status"
|
||||
data-app-password-status
|
||||
>{{ announcement }}</span>
|
||||
|
||||
<MailLoadError
|
||||
v-if="appPasswordsStore.status === 'error' && appPasswordsStore.error"
|
||||
:error="appPasswordsStore.error"
|
||||
leave-label=""
|
||||
@retry="appPasswordsStore.load()"
|
||||
/>
|
||||
|
||||
<template v-else-if="ready">
|
||||
<AppPasswordTable
|
||||
v-if="items.length > 0"
|
||||
ref="tableRef"
|
||||
:items="items"
|
||||
:now="now"
|
||||
:removing-id="removing ? (deleteTarget?.id ?? null) : null"
|
||||
@delete="deleteTarget = $event"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="app-passwords__empty"
|
||||
data-app-password-empty
|
||||
>
|
||||
<AppIcon
|
||||
name="key"
|
||||
:size="28"
|
||||
:stroke-width="1.5"
|
||||
class="app-passwords__empty-icon"
|
||||
/>
|
||||
<p class="app-passwords__empty-title">
|
||||
{{ APP_PASSWORD_TEXT.emptyTitle }}
|
||||
</p>
|
||||
<p class="app-passwords__empty-hint">
|
||||
{{ APP_PASSWORD_TEXT.emptyHint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="app-passwords__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="app-passwords__settings"
|
||||
aria-labelledby="app-passwords-settings-title"
|
||||
>
|
||||
<h2
|
||||
id="app-passwords-settings-title"
|
||||
class="app-passwords__settings-title"
|
||||
>
|
||||
{{ APP_PASSWORD_TEXT.settingsTitle }}
|
||||
</h2>
|
||||
<MailClientSettings :email="email" />
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="app-passwords__notes"
|
||||
data-app-password-notes
|
||||
>
|
||||
<p
|
||||
v-for="note in APP_PASSWORD_TEXT.notes"
|
||||
:key="note"
|
||||
class="app-passwords__note"
|
||||
>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ note }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AppPasswordCreateDialog
|
||||
:open="createOpen"
|
||||
:existing="items"
|
||||
:pending="creating"
|
||||
:error="createError"
|
||||
@submit="submitCreate"
|
||||
@close="closeCreate"
|
||||
@clear-error="createError = null"
|
||||
/>
|
||||
<AppPasswordResultDialog
|
||||
:result="result"
|
||||
:email="email"
|
||||
@close="closeResult"
|
||||
/>
|
||||
<AppPasswordDeleteDialog
|
||||
:name="deleteTarget?.name ?? null"
|
||||
:busy="removing"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="deleteTarget = null"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-passwords__head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.app-passwords__count {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-passwords__create {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-passwords__create:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 막힌 [만들기] — 회색 바탕 (디자인 원본 SettingsAppPasswords "5개를 모두 만들었을 때") */
|
||||
.app-passwords__create[aria-disabled='true'] {
|
||||
background: var(--color-border-strong);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 머리 아래 한 줄 — 묶음 사이(22)보다 붙여 머리에 딸린 글로 보이게 (위로 10 당긴다) */
|
||||
.app-passwords__full {
|
||||
margin: calc(var(--space-10) * -1) 0 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 빈 상태 — 가운데 정렬 상자 (디자인 원본 SettingsAppPasswords "빈 상태") */
|
||||
.app-passwords__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-28) var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-sunken);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-passwords__empty-icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.app-passwords__empty-title {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-passwords__empty-hint {
|
||||
max-width: 30rem;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.app-passwords__placeholder {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.app-passwords__settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.app-passwords__settings-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 아래 안내 — 바탕 #F3F8F8 */
|
||||
.app-passwords__notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-14) var(--space-16);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.app-passwords__note {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,506 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { AuthSession, LoginEvent } from '@/shared/types/account-security'
|
||||
import { formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
|
||||
|
||||
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 한국 시간 2026-10-31 (토) 낮 12시 */
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z')
|
||||
|
||||
const SESSIONS: AuthSession[] = [
|
||||
{
|
||||
id: 'now',
|
||||
createdAt: '2026-10-31T00:00:00Z',
|
||||
lastActiveAt: '2026-10-31T02:50:00Z',
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Chrome', os: 'Windows' },
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
id: 'edge',
|
||||
createdAt: '2026-10-30T00:00:00Z',
|
||||
lastActiveAt: '2026-10-31T00:05:00Z',
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Edge', os: 'Windows' },
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
createdAt: '2026-10-29T00:00:00Z',
|
||||
lastActiveAt: '2026-10-30T13:31:00Z',
|
||||
ip: null,
|
||||
device: { browser: 'Safari', os: 'iOS' },
|
||||
isCurrent: false,
|
||||
},
|
||||
]
|
||||
|
||||
function loginEvent(id: string, changes: Partial<LoginEvent>): LoginEvent {
|
||||
return {
|
||||
id,
|
||||
occurredAt: '2026-10-31T00:30:00Z',
|
||||
method: 'webmail',
|
||||
isSuccess: true,
|
||||
isTwoFactor: false,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Chrome', os: 'Windows' },
|
||||
appPassword: null,
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
const EVENTS: LoginEvent[] = [
|
||||
loginEvent('e1', { occurredAt: '2026-10-31T00:30:00Z', isTwoFactor: true }),
|
||||
loginEvent('e2', { occurredAt: '2026-10-31T00:29:00Z', isSuccess: false, failureReason: 'wrongCode' }),
|
||||
loginEvent('e3', {
|
||||
occurredAt: '2026-10-31T00:12:00Z',
|
||||
method: 'imap',
|
||||
device: null,
|
||||
appPassword: { id: 'b', name: '회사 PC 아웃룩' },
|
||||
}),
|
||||
loginEvent('e4', {
|
||||
occurredAt: '2026-10-30T14:58:00Z',
|
||||
method: 'smtp',
|
||||
device: null,
|
||||
isSuccess: false,
|
||||
failureReason: 'appPasswordRequired',
|
||||
ip: '45.133.10.21',
|
||||
}),
|
||||
loginEvent('e5', { occurredAt: '2026-10-29T06:21:00Z', device: null, isSuccess: false, failureReason: 'wrongPassword' }),
|
||||
]
|
||||
|
||||
const pageOf = <T>(items: T[], next: string | null = null, total: number | null = items.length) => ({
|
||||
items,
|
||||
pageInfo: { hasNext: next !== null, nextCursor: next, total },
|
||||
})
|
||||
|
||||
interface Server {
|
||||
sessions?: () => MockResult | Promise<MockResult>
|
||||
events?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
endSession?: (request: MockRequest) => MockResult
|
||||
endOthers?: () => MockResult
|
||||
}
|
||||
|
||||
describe('AccountLoginHistoryPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/sessions' && request.method === 'GET') {
|
||||
return server.sessions ? server.sessions() : ok(pageOf(SESSIONS))
|
||||
}
|
||||
if (request.url === '/auth/sessions') return server.endOthers ? server.endOthers() : ok({ ended: 2 })
|
||||
if (request.url.startsWith('/auth/sessions/')) return server.endSession ? server.endSession(request) : ok(null)
|
||||
if (request.url === '/login-events') return server.events ? server.events(request) : ok(pageOf(EVENTS))
|
||||
return ok(null)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/login-history')
|
||||
await router.isReady()
|
||||
const wrapper = mount(AccountLoginHistoryPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(NOW) } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const sessionRows = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('[data-session]').map((row) => row.findAll('td').slice(1, 4).map((cell) => cell.text()))
|
||||
const eventRows = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('[data-login-event]').map((row) => row.findAll('td').map((cell) => cell.text()))
|
||||
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const eventRequests = () => requests.filter((request) => request.url === '/login-events')
|
||||
|
||||
it('머리 설명의 「비밀번호를 바꾸고」 · 「2단계 인증」은 링크 — 둘을 함께 받는다(세션 100 · 기록 20)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url, request.params])).toEqual([
|
||||
['GET', '/auth/sessions', { limit: 100 }],
|
||||
['GET', '/login-events', { limit: 20 }],
|
||||
])
|
||||
expect(wrapper.get('#login-history-page-title').text()).toBe('로그인 기록')
|
||||
const description = wrapper.get('.settings-page__description')
|
||||
expect(description.text()).toBe(
|
||||
'최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요.',
|
||||
)
|
||||
expect(description.findAll('a').map((link) => [link.text(), link.attributes('href')])).toEqual([
|
||||
['비밀번호를 바꾸고', '/settings/account/password'],
|
||||
['2단계 인증', '/settings/account/two-factor'],
|
||||
])
|
||||
})
|
||||
|
||||
describe('로그인된 브라우저', () => {
|
||||
it('"웹메일 3곳" · 줄마다 PC · 휴대폰 아이콘 · 브라우저 · 운영체제 / IP / 마지막 활동 — 지금 이 브라우저는 단추 없이 글자만', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const nowDate = new Date(NOW)
|
||||
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳')
|
||||
expect(wrapper.findAll('thead').at(0)?.findAll('th').map((cell) => cell.text())).toEqual([
|
||||
'기기',
|
||||
'브라우저 · 운영체제',
|
||||
'IP',
|
||||
'마지막 활동',
|
||||
'로그아웃',
|
||||
])
|
||||
expect(sessionRows(wrapper)).toEqual([
|
||||
['Chrome · Windows', '211.186.95.252', '지금 이 브라우저'],
|
||||
['Edge · Windows', '211.186.95.252', formatRelativeDayTime('2026-10-31T00:05:00Z', nowDate)],
|
||||
['Safari · iOS', '—알 수 없음', formatRelativeDayTime('2026-10-30T13:31:00Z', nowDate)],
|
||||
])
|
||||
expect(sessionRows(wrapper)[1]?.[2]).toBe('오늘 오전 9:05')
|
||||
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual(['PC', 'PC', '휴대폰'])
|
||||
expect(wrapper.get('[data-session="now"]').find('[data-session-end]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[data-session-end]').map((button) => button.attributes('aria-label'))).toEqual([
|
||||
'‘Edge · Windows’ 로그아웃',
|
||||
'‘Safari · iOS’ 로그아웃',
|
||||
])
|
||||
expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃')
|
||||
const note = wrapper.get('[data-sessions-note]')
|
||||
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.')
|
||||
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
})
|
||||
|
||||
it('모르는 브라우저는 "알 수 없는 브라우저"', async () => {
|
||||
const unknown: AuthSession = { ...SESSIONS[1], id: 'old', ip: null, device: null } as AuthSession
|
||||
const wrapper = await mountPage({ sessions: () => ok(pageOf([SESSIONS[0] as AuthSession, unknown])) })
|
||||
|
||||
expect(wrapper.get('[data-session="old"] [data-session-device]').text()).toBe('알 수 없는 브라우저')
|
||||
})
|
||||
|
||||
it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’의 로그인을 끊었습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const button = wrapper.findAll('[data-session-end]')[0]
|
||||
;(button?.element as HTMLButtonElement).focus()
|
||||
await button?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/auth/sessions/edge',
|
||||
])
|
||||
expect(toastMessages()).toEqual(['‘Edge · Windows’의 로그인을 끊었습니다.'])
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows', 'Safari · iOS'])
|
||||
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳')
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
|
||||
})
|
||||
|
||||
it('이미 끝난 세션(404 RES_001)도 끊긴 것과 같다 — 오류 알림 없이 줄을 뺀다', async () => {
|
||||
const wrapper = await mountPage({ endSession: () => fail(404, 'RES_001') })
|
||||
await wrapper.findAll('[data-session-end]')[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual(['‘Safari · iOS’의 로그인을 끊었습니다.'])
|
||||
expect(sessionRows(wrapper)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const dialog = document.querySelector('dialog[aria-labelledby="end-other-sessions-title"]')
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
expect(dialog?.getAttribute('role')).toBe('alertdialog')
|
||||
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
expect(dialog?.textContent).toContain('메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.')
|
||||
expect(document.activeElement?.hasAttribute('data-end-others-cancel')).toBe(true)
|
||||
// 데이터를 지우는 일이 아니다 — 청록 주 단추
|
||||
expect(wrapper.get('[data-end-others-confirm]').classes()).not.toContain('modal-dialog__action--danger')
|
||||
|
||||
await wrapper.get('[data-end-others-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual(['/auth/sessions'])
|
||||
expect(toastMessages()).toEqual(['다른 곳의 로그인 2곳을 끊었습니다.'])
|
||||
expect(dialog?.hasAttribute('open')).toBe(false)
|
||||
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
|
||||
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-sessions-note]').text()).toBe(
|
||||
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('login-sessions-title')
|
||||
})
|
||||
|
||||
it('지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 확인 창이 이 브라우저도 로그아웃된다고 알리고, 끊은 뒤 다시 받아 세션 만료로 이어진다', async () => {
|
||||
const unknown = SESSIONS.map((session) => ({ ...session, isCurrent: false }))
|
||||
const wrapper = await mountPage({ sessions: () => ok(pageOf(unknown)) })
|
||||
// 끊은 뒤 — 이 브라우저의 세션도 끝났다: 다시 받으면 401, 갱신도 401
|
||||
restoreApiAdapter()
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/sessions' && request.method === 'DELETE') return ok({ ended: 3 })
|
||||
return fail(401, 'AUTH_001')
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-session-end]')).toHaveLength(3)
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('#end-other-sessions-title')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?',
|
||||
)
|
||||
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
|
||||
'이 브라우저를 알아보지 못해 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-end-others-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'DELETE /auth/sessions',
|
||||
'GET /auth/sessions',
|
||||
'POST /auth/refresh',
|
||||
])
|
||||
expect(toastMessages()).toContain('이 브라우저를 포함한 3곳의 로그인을 끊었습니다.')
|
||||
// 빈 목록을 그려 두지 않는다 — 다시 받은 요청이 세션 만료로 이어졌다
|
||||
expect(wrapper.find('[data-session]').exists()).toBe(false)
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 [다른 곳 모두 로그아웃] 확인 창을 닫는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('dialog[aria-labelledby="end-other-sessions-title"]')?.hasAttribute('open')).toBe(true)
|
||||
|
||||
useAuthStore().startSession(USER)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('dialog[aria-labelledby="end-other-sessions-title"]')?.hasAttribute('open')).toBe(false)
|
||||
})
|
||||
|
||||
it('목록을 받지 못하면 그 묶음만 사전 문구 + [다시 시도]', async () => {
|
||||
let reply: MockResult = fail(503, 'SYS_001')
|
||||
const wrapper = await mountPage({ sessions: () => reply })
|
||||
|
||||
expect(wrapper.get('[data-sessions-error]').text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(wrapper.find('[data-login-event]').exists()).toBe(true)
|
||||
expect(toastMessages()).toEqual([])
|
||||
|
||||
reply = ok(pageOf(SESSIONS))
|
||||
await wrapper.get('[data-sessions-error] .mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sessionRows(wrapper)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('최근 로그인', () => {
|
||||
it('시각 · 방식(앱 비밀번호 이름) · IP · 기기(메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const nowDate = new Date(NOW)
|
||||
const time = (value: string) => formatRelativeDayTime(value, nowDate)
|
||||
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
|
||||
expect(eventRows(wrapper)).toEqual([
|
||||
[time('2026-10-31T00:30:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
|
||||
[time('2026-10-31T00:29:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
|
||||
[time('2026-10-31T00:12:00Z'), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
|
||||
[time('2026-10-30T14:58:00Z'), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
|
||||
[time('2026-10-29T06:21:00Z'), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
|
||||
])
|
||||
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
|
||||
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
])
|
||||
expect(wrapper.find('[data-events-more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[실패만] — isSuccess=false 로 처음부터 받는다 · "최근 90일 · 실패 4번"(서버의 total) · 전환은 aria-pressed', async () => {
|
||||
const failures = EVENTS.filter((event) => !event.isSuccess)
|
||||
const wrapper = await mountPage({
|
||||
events: (request) => (request.params.isSuccess === false ? ok(pageOf(failures, null, 4)) : ok(pageOf(EVENTS))),
|
||||
})
|
||||
const all = wrapper.get('[data-events-filter="all"]')
|
||||
const onlyFailures = wrapper.get('[data-events-filter="failures"]')
|
||||
expect(all.attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await onlyFailures.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(eventRequests().map((request) => request.params)).toEqual([{ limit: 20 }, { limit: 20, isSuccess: false }])
|
||||
expect(onlyFailures.attributes('aria-pressed')).toBe('true')
|
||||
expect(all.attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 4번')
|
||||
expect(eventRows(wrapper).map((row) => row[4])).toEqual([
|
||||
'실패 — 코드 틀림',
|
||||
'실패 — 앱 비밀번호 필요',
|
||||
'실패 — 비밀번호 틀림',
|
||||
])
|
||||
|
||||
// 같은 보기를 다시 누르면 받지 않는다
|
||||
await onlyFailures.trigger('click')
|
||||
await flushPromises()
|
||||
expect(eventRequests()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."', async () => {
|
||||
const wrapper = await mountPage({
|
||||
events: (request) => (request.params.isSuccess === false ? ok(pageOf([], null, 0)) : ok(pageOf(EVENTS))),
|
||||
})
|
||||
await wrapper.get('[data-events-filter="failures"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-login-event]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-events-empty]').text()).toContain('최근 90일 동안 실패한 로그인이 없습니다')
|
||||
expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 봅니다.')
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 0번')
|
||||
})
|
||||
|
||||
it('[더 보기] — 커서로 20개씩 이어 받는다(실패만이면 실패만) · 다 받으면 단추가 사라진다', async () => {
|
||||
const failures = EVENTS.filter((event) => !event.isSuccess)
|
||||
const more = [loginEvent('e9', { occurredAt: '2026-10-12T19:02:00Z', isSuccess: false, failureReason: 'wrongPassword' })]
|
||||
const wrapper = await mountPage({
|
||||
events: (request) => {
|
||||
if (request.params.cursor === 'next-failures') return ok(pageOf(more, null, 4))
|
||||
if (request.params.isSuccess === false) return ok(pageOf(failures, 'next-failures', 4))
|
||||
return ok(pageOf(EVENTS, 'next-all', 143))
|
||||
},
|
||||
})
|
||||
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
|
||||
expect(wrapper.text()).toContain('최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.')
|
||||
|
||||
await wrapper.get('[data-events-filter="failures"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-events-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(eventRequests().map((request) => request.params)).toEqual([
|
||||
{ limit: 20 },
|
||||
{ limit: 20, isSuccess: false },
|
||||
{ limit: 20, cursor: 'next-failures', isSuccess: false },
|
||||
])
|
||||
expect(wrapper.findAll('[data-login-event]').map((row) => row.attributes('data-login-event'))).toEqual([
|
||||
'e2',
|
||||
'e4',
|
||||
'e5',
|
||||
'e9',
|
||||
])
|
||||
expect(wrapper.find('[data-events-more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('보기를 거듭 바꾸면 마지막 보기의 응답만 쓴다 — 받는 동안 지난 목록은 그대로(aria-busy)', async () => {
|
||||
const gates: ((result: MockResult) => void)[] = []
|
||||
const wrapper = await mountPage()
|
||||
restoreApiAdapter()
|
||||
requests = installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
gates.push(resolve)
|
||||
}),
|
||||
)
|
||||
|
||||
await wrapper.get('[data-events-filter="failures"]').trigger('click')
|
||||
await wrapper.get('[data-events-filter="all"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(gates).toHaveLength(2)
|
||||
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
|
||||
|
||||
// 늦게 온 「실패만」 응답은 버린다
|
||||
gates[1]?.(ok(pageOf([EVENTS[0] as LoginEvent])))
|
||||
gates[0]?.(ok(pageOf([EVENTS[1] as LoginEvent], null, 1)))
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-login-event]').map((row) => row.attributes('data-login-event'))).toEqual(['e1'])
|
||||
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
|
||||
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('다시 들어오면 지난번 목록을 그리지 않고 「전체」부터 새로 받는다', async () => {
|
||||
const failures = EVENTS.filter((event) => !event.isSuccess)
|
||||
const first = await mountPage({
|
||||
events: (request) => (request.params.isSuccess === false ? ok(pageOf(failures, null, 3)) : ok(pageOf(EVENTS))),
|
||||
})
|
||||
await first.get('[data-events-filter="failures"]').trigger('click')
|
||||
await flushPromises()
|
||||
first.unmount()
|
||||
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const again = await mountPage({
|
||||
events: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
})
|
||||
expect(again.find('[data-login-event]').exists()).toBe(false)
|
||||
expect(again.findAll('.login-history__placeholder')).toHaveLength(1)
|
||||
expect(eventRequests().at(-1)?.params).toEqual({ limit: 20 })
|
||||
|
||||
release(ok(pageOf(EVENTS)))
|
||||
await flushPromises()
|
||||
expect(again.get('[data-events-filter="all"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(again.findAll('[data-login-event]')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('기록을 받지 못하면 그 묶음만 사전 문구 + [다시 시도]', async () => {
|
||||
let reply: MockResult = fail(503, 'SYS_001')
|
||||
const wrapper = await mountPage({ events: () => reply })
|
||||
|
||||
expect(wrapper.get('[data-events-error]').text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(sessionRows(wrapper)).toHaveLength(3)
|
||||
|
||||
reply = ok(pageOf(EVENTS))
|
||||
await wrapper.get('[data-events-error] .mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
it('끊는 사이 세션이 바뀌었으면(로그아웃) 늦게 온 결과를 목록에 쓰지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const wrapper = await mountPage()
|
||||
restoreApiAdapter()
|
||||
installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
await wrapper.get('[data-sessions-end-others]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-end-others-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession({ email: 'seojun.park@arasystem.it.kr', name: '박서준' })
|
||||
release(ok({ ended: 2 }))
|
||||
await flushPromises()
|
||||
|
||||
// 다음 직원의 목록은 비어 있다(새로 받기 전) — 이전 직원의 끊기 결과가 들어오지 않는다
|
||||
expect(wrapper.findAll('[data-session]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 로그인 기록 (디자인 원본 SettingsLoginHistory · README "12단계 › 설정 › 계정 › 로그인 기록" · 계약 5절 "로그인된 브라우저" · "로그인 기록")
|
||||
// — /settings/account/login-history · 탭 제목 "로그인 기록"
|
||||
// 머리 "로그인 기록" + 설명(「비밀번호를 바꾸고」 · 「2단계 인증」 링크) → 로그인된 브라우저(LoginSessionList) → 최근 로그인(LoginEventTable)
|
||||
// - 들어올 때마다 둘 다 새로 받는다(login-history.store). 묶음마다 받는 동안은 자리만, 못 받으면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도])
|
||||
// - 끊긴 브라우저는 다음 요청에서 바로 로그인 화면으로 간다(서버가 끊은 세션 id 를 막는다 — 계약 5절)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import LinkedText from '@/components/LinkedText.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import LoginEventTable from '@/components/settings/LoginEventTable.vue'
|
||||
import LoginSessionList from '@/components/settings/LoginSessionList.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import { useLoginHistoryStore } from '@/stores/login-history.store'
|
||||
|
||||
const loginHistoryStore = useLoginHistoryStore()
|
||||
const clock = useClock()
|
||||
|
||||
/** 화면을 연 때 — "오늘 · 어제" 의 기준 */
|
||||
const now = clock.now()
|
||||
|
||||
const passwordRoute = { name: SETTINGS_ROUTES.password }
|
||||
const twoFactorRoute = { name: SETTINGS_ROUTES.twoFactor }
|
||||
const sessionsReady = computed(() => loginHistoryStore.sessionsStatus === 'ready')
|
||||
const eventsReady = computed(() => loginHistoryStore.eventsStatus === 'ready')
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게. 보기는 「전체」부터
|
||||
loginHistoryStore.loadPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="login-history-page-title"
|
||||
:title="LOGIN_HISTORY_TEXT.title"
|
||||
:description="LOGIN_HISTORY_TEXT.description"
|
||||
>
|
||||
<template #description>
|
||||
<LinkedText
|
||||
:before="LOGIN_HISTORY_TEXT.descriptionBefore"
|
||||
:link="LOGIN_HISTORY_TEXT.descriptionPasswordLink"
|
||||
:after="LOGIN_HISTORY_TEXT.descriptionMiddle"
|
||||
:to="passwordRoute"
|
||||
/><LinkedText
|
||||
:link="LOGIN_HISTORY_TEXT.descriptionTwoFactorLink"
|
||||
:after="LOGIN_HISTORY_TEXT.descriptionAfter"
|
||||
:to="twoFactorRoute"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<MailLoadError
|
||||
v-if="loginHistoryStore.sessionsStatus === 'error' && loginHistoryStore.sessionsError"
|
||||
:error="loginHistoryStore.sessionsError"
|
||||
leave-label=""
|
||||
data-sessions-error
|
||||
@retry="loginHistoryStore.loadSessions()"
|
||||
/>
|
||||
<LoginSessionList
|
||||
v-else-if="sessionsReady"
|
||||
:sessions="loginHistoryStore.sessions"
|
||||
:now="now"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="login-history__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
|
||||
<MailLoadError
|
||||
v-if="loginHistoryStore.eventsStatus === 'error' && loginHistoryStore.eventsError"
|
||||
:error="loginHistoryStore.eventsError"
|
||||
leave-label=""
|
||||
data-events-error
|
||||
@retry="loginHistoryStore.loadEvents()"
|
||||
/>
|
||||
<LoginEventTable
|
||||
v-else-if="eventsReady"
|
||||
:now="now"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="login-history__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-history__placeholder {
|
||||
min-height: 10rem;
|
||||
}
|
||||
</style>
|
||||
@@ -40,8 +40,15 @@ describe('AccountPasswordPage', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null)) {
|
||||
requests = installMockApi((request) => (request.url === '/auth/password' ? reply(request) : ok(null)))
|
||||
async function mountPage(
|
||||
reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null),
|
||||
twoFactor: () => MockResult = () => ok({ isEnabled: false, enabledAt: null }),
|
||||
) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/password') return reply(request)
|
||||
if (request.url === '/two-factor') return twoFactor()
|
||||
return ok(null)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/password')
|
||||
await router.isReady()
|
||||
@@ -74,7 +81,18 @@ describe('AccountPasswordPage', () => {
|
||||
expect(wrapper.get('#password-next-hint').text()).toBe(
|
||||
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain('메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.')
|
||||
// 모든 직원 — 다른 곳 로그인이 끊기고 앱 비밀번호도 모두 지워진다 (12단계)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain(
|
||||
'바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain(
|
||||
'앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
)
|
||||
// 2단계 인증이 꺼진 직원 — 머리 설명은 그대로, 코드 칸이 없다
|
||||
expect(wrapper.get('.settings-page__description').text()).toBe(
|
||||
'웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
|
||||
)
|
||||
expect(wrapper.find('#password-code').exists()).toBe(false)
|
||||
|
||||
const toggles = wrapper.findAll('.password-field__toggle')
|
||||
expect(toggles.map((toggle) => toggle.attributes('aria-label'))).toEqual(['지금 비밀번호 보기', '새 비밀번호 보기'])
|
||||
@@ -195,4 +213,96 @@ describe('AccountPasswordPage', () => {
|
||||
])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
})
|
||||
|
||||
describe('2단계 인증을 켠 직원 (12단계 — 계약 5절 "비밀번호 바꾸기")', () => {
|
||||
const ENABLED = () => ok({ isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' })
|
||||
|
||||
it('머리 설명이 바뀌고 양식 맨 아래에 「인증 앱 코드」 칸(숫자 · one-time-code · 안내)이 생긴다', async () => {
|
||||
const wrapper = await mountPage(undefined, ENABLED)
|
||||
|
||||
expect(wrapper.get('.settings-page__description').text()).toBe(
|
||||
'웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다.',
|
||||
)
|
||||
expect(wrapper.findAll('label').map((label) => label.text())).toEqual([
|
||||
'지금 비밀번호',
|
||||
'새 비밀번호',
|
||||
'새 비밀번호 확인',
|
||||
'인증 앱 코드',
|
||||
])
|
||||
expect(wrapper.get('#password-code').attributes()).toMatchObject({
|
||||
inputmode: 'numeric',
|
||||
autocomplete: 'one-time-code',
|
||||
'aria-describedby': 'password-code-hint',
|
||||
})
|
||||
expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.')
|
||||
})
|
||||
|
||||
it('코드는 숫자만 받는다 — 붙여 넣은 "123 456" · "123-456" 은 다듬고, 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007)', async () => {
|
||||
const wrapper = await mountPage(undefined, ENABLED)
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
await wrapper.get('#password-code').setValue('12 3')
|
||||
await submit(wrapper)
|
||||
|
||||
expect(passwordRequests()).toEqual([])
|
||||
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('123')
|
||||
expect(errorOf(wrapper, 'password-code').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
|
||||
expect(document.activeElement?.id).toBe('password-code')
|
||||
|
||||
await wrapper.get('#password-code').setValue('123-456')
|
||||
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('123456')
|
||||
expect(errorOf(wrapper, 'password-code').exists()).toBe(false)
|
||||
await submit(wrapper)
|
||||
|
||||
expect(passwordRequests().map((request) => request.data)).toEqual([
|
||||
{ currentPassword: CURRENT, newPassword: NEXT, otpCode: '123456' },
|
||||
])
|
||||
// 바꾸면 코드 칸도 비운다
|
||||
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('코드가 틀리면(400 AUTH_006) 알림 없이 코드 칸 아래에 사전 문구 · 칸을 비우고 초점', async () => {
|
||||
const wrapper = await mountPage(() => fail(400, 'AUTH_006'), ENABLED)
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
await wrapper.get('#password-code').setValue('654321')
|
||||
await submit(wrapper)
|
||||
|
||||
expect(errorOf(wrapper, 'password-code').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
|
||||
expect((wrapper.get('#password-code').element as HTMLInputElement).value).toBe('')
|
||||
expect(document.activeElement?.id).toBe('password-code')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
// 다른 칸은 남긴다
|
||||
expect((wrapper.get('#password-current').element as HTMLInputElement).value).toBe(CURRENT)
|
||||
})
|
||||
|
||||
it('다른 탭에서 켜서 코드 없이 보냈으면(400 AUTH_007) 2단계 상태를 다시 받고 코드 칸을 보인다 — 알림 없음', async () => {
|
||||
let enabled = false
|
||||
const wrapper = await mountPage(
|
||||
() => fail(400, 'AUTH_007'),
|
||||
() => ok({ isEnabled: enabled, enabledAt: enabled ? '2026-10-31T00:12:00.000Z' : null }),
|
||||
)
|
||||
expect(wrapper.find('#password-code').exists()).toBe(false)
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
|
||||
enabled = true
|
||||
await submit(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
expect(passwordRequests().map((request) => request.data)).toEqual([{ currentPassword: CURRENT, newPassword: NEXT }])
|
||||
expect(requests.filter((request) => request.url === '/two-factor')).toHaveLength(2)
|
||||
expect(wrapper.find('#password-code').exists()).toBe(true)
|
||||
expect(errorOf(wrapper, 'password-code').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
|
||||
expect(document.activeElement?.id).toBe('password-code')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(wrapper.get('.settings-page__description').text()).toContain('2단계 인증이 켜져 있어')
|
||||
})
|
||||
|
||||
it('2단계 상태를 받지 못해도 비밀번호는 바꿀 수 있다 — 코드 칸 없이 보낸다', async () => {
|
||||
const wrapper = await mountPage(undefined, () => fail(503, 'MAIL_001'))
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(passwordRequests().map((request) => request.data)).toEqual([{ currentPassword: CURRENT, newPassword: NEXT }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호") — /settings/account/password · 설정의 첫 화면
|
||||
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣는 것
|
||||
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호" · 12단계 SettingsTwoFactor "비밀번호 — 2단계 인증을 켠 직원")
|
||||
// — /settings/account/password · 설정의 첫 화면
|
||||
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 앱 비밀번호도 모두 지워지는 것(12단계 — 모든 직원)
|
||||
// 2단계 인증 상태는 들어올 때마다 새로 받는다(two-factor.store) — 켠 직원이면 머리 설명이 바뀌고 양식 맨 아래에 「인증 앱 코드」 칸이 생긴다.
|
||||
// 못 받아도 그대로 쓸 수 있다 — 켰는데 코드 없이 보내면 서버가 AUTH_007 로 알려 와 양식이 상태를 다시 받고 칸을 보인다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import PasswordChangeForm from '@/components/settings/PasswordChangeForm.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
|
||||
const twoFactorStore = useTwoFactorStore()
|
||||
|
||||
const description = computed(() =>
|
||||
twoFactorStore.isEnabled ? PASSWORD_TEXT.descriptionTwoFactor : PASSWORD_TEXT.description,
|
||||
)
|
||||
|
||||
// 처음 그리기 전에 — 다른 탭에서 켰거나 껐을 수 있다
|
||||
void twoFactorStore.load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="password-page-title"
|
||||
:title="PASSWORD_TEXT.title"
|
||||
:description="PASSWORD_TEXT.description"
|
||||
:description="description"
|
||||
>
|
||||
<PasswordChangeForm />
|
||||
<div
|
||||
@@ -24,7 +39,7 @@ import { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
/>
|
||||
<div class="password-note__text">
|
||||
<span>{{ PASSWORD_TEXT.noteSessions }}</span>
|
||||
<span>{{ PASSWORD_TEXT.noteImap }}</span>
|
||||
<span>{{ PASSWORD_TEXT.noteAppPasswords }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPageFrame>
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { TWO_FACTOR_CHANGE_TIMEOUT_MS } from '@/shared/constants/account-security'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import scanSource from '@/components/settings/TwoFactorEnrollmentScan.vue?raw'
|
||||
import AccountTwoFactorPage from './AccountTwoFactorPage.vue'
|
||||
|
||||
// 설정 › 계정 › 2단계 인증 (12단계 — 디자인 원본 SettingsTwoFactor · 계약 5절 "2단계 인증")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
|
||||
const ON: TwoFactorStatus = { isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' }
|
||||
const PASSWORD = 'old-pass-1234'
|
||||
|
||||
function enrollment(seq: number): TwoFactorEnrollment {
|
||||
const secret = seq === 1 ? 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS' : 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'
|
||||
return {
|
||||
enrollmentId: `enroll-${seq}`,
|
||||
secret,
|
||||
otpauthUrl: `otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=${secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
|
||||
expiresAt: '2026-10-31T00:22:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
interface Server {
|
||||
status?: () => MockResult
|
||||
enroll?: (seq: number) => MockResult | Promise<MockResult>
|
||||
activate?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
deactivate?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('AccountTwoFactorPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
Reflect.deleteProperty(navigator, 'clipboard')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(server: Server = {}) {
|
||||
let enrollSeq = 0
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/two-factor') return server.status ? server.status() : ok(OFF)
|
||||
if (request.url === '/two-factor/enrollments') {
|
||||
enrollSeq += 1
|
||||
return server.enroll ? server.enroll(enrollSeq) : ok(enrollment(enrollSeq), 201)
|
||||
}
|
||||
if (request.url === '/two-factor/activation') return server.activate ? server.activate(request) : ok(ON)
|
||||
if (request.url === '/two-factor/deactivation') return server.deactivate ? server.deactivate(request) : ok(OFF)
|
||||
return ok(null)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/two-factor')
|
||||
await router.isReady()
|
||||
const wrapper = mount(AccountTwoFactorPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const calls = (url: string) => requests.filter((request) => request.url === url)
|
||||
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const enableDialog = () => document.querySelector<HTMLDialogElement>('dialog[aria-labelledby="two-factor-enable-title"]')
|
||||
const disableDialog = () => document.querySelector<HTMLDialogElement>('dialog[aria-labelledby="two-factor-disable-title"]')
|
||||
|
||||
async function openEnable(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-two-factor-enable]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function fillEnable(wrapper: VueWrapper, values: { code?: string; password?: string }) {
|
||||
if (values.code !== undefined) await wrapper.get('#two-factor-enable-code').setValue(values.code)
|
||||
if (values.password !== undefined) await wrapper.get('#two-factor-enable-password').setValue(values.password)
|
||||
}
|
||||
|
||||
async function submitEnable(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-enable-submit]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('꺼짐 — 「꺼짐」(글자로만) · [2단계 인증 켜기] · 갈색 경고(앱 비밀번호 링크) · 켜는 차례 1 · 2 · 3', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(calls('/two-factor').map((request) => request.method)).toEqual(['GET'])
|
||||
expect(wrapper.get('#two-factor-page-title').text()).toBe('2단계 인증')
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
|
||||
expect(wrapper.get('[data-two-factor-state]').text()).toContain('지금은 로그인할 때 비밀번호만 확인합니다.')
|
||||
expect(wrapper.get('[data-two-factor-enable]').text()).toBe('2단계 인증 켜기')
|
||||
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
|
||||
const warning = wrapper.get('[data-two-factor-warning]')
|
||||
expect(warning.text()).toContain('켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다')
|
||||
expect(warning.get('a').text()).toBe('앱 비밀번호')
|
||||
expect(warning.get('a').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
// 링크 앞뒤 글과 빈칸 없이 잇는다
|
||||
expect(warning.text()).toContain('켜기 전에 앱 비밀번호를 만들어 두면 편합니다.')
|
||||
expect(wrapper.findAll('.two-factor__step').map((step) => step.text().slice(0, 1))).toEqual(['1', '2', '3'])
|
||||
expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('켜짐 — 「켜짐 — 2026년 10월 31일부터」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON) })
|
||||
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
|
||||
expect(wrapper.get('[data-two-factor-label]').classes()).toContain('two-factor__state-label--on')
|
||||
expect(wrapper.get('[data-two-factor-disable]').text()).toBe('2단계 인증 끄기')
|
||||
expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
|
||||
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('메일 프로그램은 앱 비밀번호로 연결합니다.')
|
||||
|
||||
const fromEngine = await mountPage({ status: () => ok({ isEnabled: true, enabledAt: null }) })
|
||||
expect(fromEngine.get('[data-two-factor-label]').text()).toBe('켜짐')
|
||||
})
|
||||
|
||||
it('상태를 받지 못하면 사전 문구와 [다시 시도] — 누르면 다시 받는다', async () => {
|
||||
let reply: MockResult = fail(503, 'MAIL_001')
|
||||
const wrapper = await mountPage({ status: () => reply })
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(toastMessages()).toEqual([])
|
||||
reply = ok(OFF)
|
||||
await wrapper.get('.mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
|
||||
})
|
||||
|
||||
describe('켜기 창', () => {
|
||||
it('열 때마다 새 키 — QR(SVG) · 4자씩 띄운 키 · 저장될 이름, 처음 초점은 코드 칸', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openEnable(wrapper)
|
||||
|
||||
expect(enableDialog()?.hasAttribute('open')).toBe(true)
|
||||
expect(calls('/two-factor/enrollments').map((request) => request.method)).toEqual(['POST'])
|
||||
const qr = wrapper.get('[data-qr-code]')
|
||||
expect(qr.attributes()).toMatchObject({ role: 'img', 'aria-label': '인증 앱에 등록할 QR 코드' })
|
||||
expect(qr.findAll('.qr-code__module').length).toBeGreaterThan(100)
|
||||
expect(wrapper.findAll('.scan__key-group').map((group) => group.text())).toEqual([
|
||||
'K7QF',
|
||||
'2M4X',
|
||||
'HB6R',
|
||||
'TZ3N',
|
||||
'PW5C',
|
||||
'YL7D',
|
||||
'JQ2V',
|
||||
'MGAS',
|
||||
])
|
||||
// 띄어쓰기는 없다 — 골라 복사해도 · 읽어 줘도 키 그대로
|
||||
expect(wrapper.get('[data-enable-key]').text()).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
expect(wrapper.get('[data-enable-key]').element.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
// 일부러 두 줄 — 한 줄에 4묶음
|
||||
expect(
|
||||
wrapper.findAll('.scan__key-line').map((line) => line.findAll('.scan__key-group').map((group) => group.text())),
|
||||
).toEqual([
|
||||
['K7QF', '2M4X', 'HB6R', 'TZ3N'],
|
||||
['PW5C', 'YL7D', 'JQ2V', 'MGAS'],
|
||||
])
|
||||
expect(wrapper.text()).toContain('앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다.')
|
||||
expect(document.activeElement?.id).toBe('two-factor-enable-code')
|
||||
expect(wrapper.get('#two-factor-enable-code').attributes()).toMatchObject({
|
||||
inputmode: 'numeric',
|
||||
autocomplete: 'one-time-code',
|
||||
})
|
||||
})
|
||||
|
||||
it('키의 묶음은 가운데서 줄이 바뀌지 않는다 — 묶음 · 줄은 줄바꿈 없는 inline-block (block 이면 골라 복사한 키에 줄바꿈이 낀다)', () => {
|
||||
const css = scanSource.slice(scanSource.indexOf('<style'))
|
||||
const rule = (selector: string) => new RegExp(`${selector.replace(/[.-]/g, '\\$&')}\\s*\\{([^}]*)\\}`).exec(css)?.[1] ?? ''
|
||||
|
||||
expect(rule('.scan__key-group')).toMatch(/display:\s*inline-block/)
|
||||
expect(rule('.scan__key-group')).toMatch(/white-space:\s*nowrap/)
|
||||
expect(rule('.scan__key-line')).toMatch(/display:\s*inline-block/)
|
||||
expect(rule('.scan__key-line')).toMatch(/width:\s*100%/)
|
||||
// 글자 아무 데서나 끊는 규칙을 두지 않는다 (묶음 가운데서 "EL / WN" 으로 갈렸다)
|
||||
expect(rule('.scan__key-value')).not.toMatch(/overflow-wrap|word-break/)
|
||||
})
|
||||
|
||||
it('[복사]는 키를 띄어쓰기 없이 복사하고 「키를 복사했습니다.」 · 못 하면 직접 고르라고 알린다', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
const wrapper = await mountPage()
|
||||
await openEnable(wrapper)
|
||||
|
||||
await wrapper.get('[data-enable-copy]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(writeText).toHaveBeenCalledWith('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
expect(toastMessages()).toEqual(['키를 복사했습니다.'])
|
||||
|
||||
writeText.mockRejectedValueOnce(new Error('막힘'))
|
||||
await wrapper.get('[data-enable-copy]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.at(-1)).toMatchObject({ kind: 'error', message: '키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요.' })
|
||||
})
|
||||
|
||||
it('보내기 전에 본다 — 코드가 6자리가 아니면 코드 칸(AUTH_007 문구), 지금 비밀번호를 비우면 그 칸(VAL_001 문구)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openEnable(wrapper)
|
||||
|
||||
await fillEnable(wrapper, { code: '12' })
|
||||
await submitEnable(wrapper)
|
||||
expect(calls('/two-factor/activation')).toEqual([])
|
||||
expect(wrapper.get('#two-factor-enable-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
|
||||
expect(document.activeElement?.id).toBe('two-factor-enable-code')
|
||||
|
||||
await fillEnable(wrapper, { code: '482 913' })
|
||||
await submitEnable(wrapper)
|
||||
expect(calls('/two-factor/activation')).toEqual([])
|
||||
expect(wrapper.find('#two-factor-enable-code-error').exists()).toBe(false)
|
||||
expect(wrapper.get('#two-factor-enable-password-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(document.activeElement?.id).toBe('two-factor-enable-password')
|
||||
})
|
||||
|
||||
it('켜면 POST /two-factor/activation { enrollmentId, otpCode, currentPassword } — 창을 닫고 「켜짐」, 머리 아래 "2단계 인증을 켰습니다" 로 초점', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
|
||||
expect(calls('/two-factor/activation').map((request) => request.data)).toEqual([
|
||||
{ enrollmentId: 'enroll-1', otpCode: '482913', currentPassword: PASSWORD },
|
||||
])
|
||||
// 서버가 엔진을 여러 번 부른다 — 한도는 보통(10초)보다 긴 30초
|
||||
expect(calls('/two-factor/activation')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS)
|
||||
expect(enableDialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
|
||||
const done = wrapper.get('[data-two-factor-done]')
|
||||
expect(done.attributes('role')).toBe('status')
|
||||
expect(done.text()).toContain('2단계 인증을 켰습니다')
|
||||
expect(done.text()).toContain('메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.')
|
||||
expect(done.get('a').text()).toBe('앱 비밀번호 만들기')
|
||||
expect(done.get('a').attributes('href')).toBe('/settings/account/app-passwords')
|
||||
expect(document.activeElement).toBe(done.element)
|
||||
// 키는 창에서 지웠다
|
||||
expect(wrapper.find('[data-enable-key]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('켜는 동안 「켜는 중…」(aria-busy) · [켜기]는 aria-disabled — 다시 눌러도 요청은 하나', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const wrapper = await mountPage({
|
||||
activate: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
})
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
|
||||
const submit = wrapper.get('[data-enable-submit]')
|
||||
expect(submit.text()).toBe('켜는 중…')
|
||||
expect(submit.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
|
||||
await submitEnable(wrapper)
|
||||
expect(calls('/two-factor/activation')).toHaveLength(1)
|
||||
|
||||
release(ok(ON))
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
|
||||
})
|
||||
|
||||
it('코드가 틀리면(AUTH_006) 코드 칸 아래 · 칸을 비우고 초점 — QR · 키는 그대로 (새 키를 받지 않는다)', async () => {
|
||||
const wrapper = await mountPage({ activate: () => fail(400, 'AUTH_006') })
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '000111', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
|
||||
expect(wrapper.get('#two-factor-enable-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
|
||||
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('')
|
||||
expect(document.activeElement?.id).toBe('two-factor-enable-code')
|
||||
expect(wrapper.get('[data-enable-key]').text()).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(1)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('지금 비밀번호가 틀리면(AUTH_005) 비밀번호 칸 아래 · 그 칸에 초점 — 코드는 남긴다', async () => {
|
||||
const wrapper = await mountPage({ activate: () => fail(400, 'AUTH_005') })
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: 'wrong-pass' })
|
||||
await submitEnable(wrapper)
|
||||
|
||||
expect(wrapper.get('#two-factor-enable-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
|
||||
expect(document.activeElement?.id).toBe('two-factor-enable-password')
|
||||
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('482913')
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('AUTH_008(10분 지남 · 더 새 등록) — 처음부터 다시: 창 위에 사전 문구, 새 키로 다시 그리고 코드는 비운다', async () => {
|
||||
const wrapper = await mountPage({ activate: () => fail(409, 'AUTH_008') })
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
expect(calls('/two-factor').length).toBe(2)
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-enable-notice]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
|
||||
expect(wrapper.get('[data-enable-key]').text()).toBe('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP')
|
||||
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('')
|
||||
expect(enableDialog()?.hasAttribute('open')).toBe(true)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('AUTH_008 인데 그사이 다른 곳에서 켜졌으면 — 창을 닫고 사전 문구로 알린다, 화면은 「켜짐」', async () => {
|
||||
let enabledElsewhere = false
|
||||
const wrapper = await mountPage({
|
||||
status: () => ok(enabledElsewhere ? ON : OFF),
|
||||
activate: () => {
|
||||
enabledElsewhere = true
|
||||
return fail(409, 'AUTH_008')
|
||||
},
|
||||
})
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
expect(enableDialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_008])
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('키를 받지 못하면 창 안에 사전 문구와 [다시 시도] — [켜기]는 막는다', async () => {
|
||||
let reply: MockResult = fail(503, 'MAIL_001')
|
||||
const wrapper = await mountPage({ enroll: () => reply })
|
||||
await openEnable(wrapper)
|
||||
|
||||
expect(wrapper.get('[data-enable-error]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(wrapper.get('[data-enable-submit]').attributes('aria-disabled')).toBe('true')
|
||||
expect(toastMessages()).toEqual([])
|
||||
|
||||
reply = ok(enrollment(1), 201)
|
||||
await wrapper.get('.scan__retry').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-enable-error]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-qr-code]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-enable-submit]').attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('코드 · 비밀번호 칸에서 Enter 도 [켜기]와 같다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
|
||||
await wrapper.get('#two-factor-enable-password').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(calls('/two-factor/activation')).toHaveLength(1)
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
|
||||
})
|
||||
|
||||
it('닫았다 다시 연 창에는 앞 요청의 거절을 넣지 않는다 — 「켜는 중」도 처음으로, 처음부터 다시(AUTH_008)도 하지 않는다', async () => {
|
||||
const replies: ((result: MockResult) => void)[] = []
|
||||
const wrapper = await mountPage({
|
||||
activate: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
replies.push(resolve)
|
||||
}),
|
||||
})
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
await wrapper.get('[data-enable-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await openEnable(wrapper)
|
||||
expect(wrapper.get('[data-enable-submit]').text()).toBe('켜기')
|
||||
expect(wrapper.get('[data-enable-submit]').attributes('aria-busy')).toBeUndefined()
|
||||
|
||||
replies[0]?.(fail(400, 'AUTH_006'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#two-factor-enable-code-error').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-enable-submit]').text()).toBe('켜기')
|
||||
expect(toastMessages()).toEqual([])
|
||||
|
||||
// 다시 연 창에서 보낸 요청이 AUTH_008 이어도 — 그 창의 요청이므로 처음부터 다시 한다(앞 요청과 섞이지 않는다)
|
||||
await fillEnable(wrapper, { code: '135790', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
expect(calls('/two-factor/activation').map((request) => (request.data as { enrollmentId: string }).enrollmentId)).toEqual([
|
||||
'enroll-1',
|
||||
'enroll-2',
|
||||
])
|
||||
replies[1]?.(fail(409, 'AUTH_008'))
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(3)
|
||||
expect(wrapper.get('[data-enable-notice]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
|
||||
})
|
||||
|
||||
it('닫힌 뒤(다시 열지 않음) 온 거절은 칸 대신 알림으로 · 서버가 켰으면 창을 닫았어도 화면은 「켜짐」', async () => {
|
||||
const replies: ((result: MockResult) => void)[] = []
|
||||
const wrapper = await mountPage({
|
||||
activate: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
replies.push(resolve)
|
||||
}),
|
||||
})
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: 'wrong-pass' })
|
||||
await submitEnable(wrapper)
|
||||
await wrapper.get('[data-enable-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
replies[0]?.(fail(400, 'AUTH_005'))
|
||||
await flushPromises()
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_005])
|
||||
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
await wrapper.get('[data-enable-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
replies[1]?.(ok(ON))
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toContain('켜짐')
|
||||
expect(wrapper.find('[data-two-factor-done]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('켜지 않고 닫으면 키를 버린다 — 다시 열면 새 키를 받고, 닫은 뒤 늦게 온 키는 그리지 않는다', async () => {
|
||||
let releaseFirst: (result: MockResult) => void = () => undefined
|
||||
const wrapper = await mountPage({
|
||||
enroll: (seq) =>
|
||||
seq === 1
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
: ok(enrollment(seq), 201),
|
||||
})
|
||||
await openEnable(wrapper)
|
||||
await wrapper.get('[data-enable-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(enableDialog()?.hasAttribute('open')).toBe(false)
|
||||
|
||||
releaseFirst(ok(enrollment(1), 201))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-enable-key]').text()).toBe('')
|
||||
|
||||
await openEnable(wrapper)
|
||||
expect(calls('/two-factor/enrollments')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-enable-key]').text()).toBe('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP')
|
||||
})
|
||||
})
|
||||
|
||||
describe('끄기 창', () => {
|
||||
async function openDisable(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-two-factor-disable]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('alertdialog — "2단계 인증을 끌까요?" · 지금 비밀번호 · 인증 앱 코드, 처음 초점은 「취소」', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON) })
|
||||
await openDisable(wrapper)
|
||||
|
||||
const dialog = disableDialog()
|
||||
expect(dialog?.hasAttribute('open')).toBe(true)
|
||||
expect(dialog?.getAttribute('role')).toBe('alertdialog')
|
||||
expect(dialog?.textContent).toContain('끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다.')
|
||||
expect(document.activeElement?.hasAttribute('data-disable-cancel')).toBe(true)
|
||||
expect(wrapper.get('label[for="two-factor-disable-password"]').text()).toBe('지금 비밀번호')
|
||||
expect(wrapper.get('label[for="two-factor-disable-code"]').text()).toBe('인증 앱 코드')
|
||||
})
|
||||
|
||||
it('비밀번호부터 본다 — 둘 다 비었으면 비밀번호 칸에 초점 · 틀리면(AUTH_005) 그 칸 아래', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON), deactivate: () => fail(400, 'AUTH_005') })
|
||||
await openDisable(wrapper)
|
||||
|
||||
await wrapper.get('[data-disable-submit]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(calls('/two-factor/deactivation')).toEqual([])
|
||||
expect(document.activeElement?.id).toBe('two-factor-disable-password')
|
||||
|
||||
await wrapper.get('#two-factor-disable-password').setValue('wrong-pass')
|
||||
await wrapper.get('#two-factor-disable-code').setValue('482913')
|
||||
await wrapper.get('[data-disable-submit]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#two-factor-disable-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('코드가 틀리면(AUTH_006) 코드 칸 아래 · 칸을 비운다', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON), deactivate: () => fail(400, 'AUTH_006') })
|
||||
await openDisable(wrapper)
|
||||
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
|
||||
await wrapper.get('#two-factor-disable-code').setValue('000111')
|
||||
await wrapper.get('[data-disable-submit]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#two-factor-disable-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
|
||||
expect((wrapper.get('#two-factor-disable-code').element as HTMLInputElement).value).toBe('')
|
||||
expect(document.activeElement?.id).toBe('two-factor-disable-code')
|
||||
})
|
||||
|
||||
it('두 칸에서 Enter 도 [끄기]와 같다 · 닫았다 다시 연 창에는 앞 요청의 거절을 넣지 않는다', async () => {
|
||||
const replies: ((result: MockResult) => void)[] = []
|
||||
const wrapper = await mountPage({
|
||||
status: () => ok(ON),
|
||||
deactivate: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
replies.push(resolve)
|
||||
}),
|
||||
})
|
||||
await openDisable(wrapper)
|
||||
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
|
||||
await wrapper.get('#two-factor-disable-code').setValue('482913')
|
||||
await wrapper.get('#two-factor-disable-code').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
expect(calls('/two-factor/deactivation')).toHaveLength(1)
|
||||
|
||||
await wrapper.get('[data-disable-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
await openDisable(wrapper)
|
||||
expect(wrapper.get('[data-disable-submit]').text()).toBe('끄기')
|
||||
|
||||
replies[0]?.(fail(400, 'AUTH_006'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#two-factor-disable-code-error').exists()).toBe(false)
|
||||
expect(toastMessages()).toEqual([])
|
||||
expect(wrapper.get('[data-disable-submit]').text()).toBe('끄기')
|
||||
})
|
||||
|
||||
it('끄면 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」, 초점은 [2단계 인증 켜기]', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON) })
|
||||
await openDisable(wrapper)
|
||||
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
|
||||
await wrapper.get('#two-factor-disable-code').setValue('482913')
|
||||
await wrapper.get('[data-disable-submit]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls('/two-factor/deactivation').map((request) => request.data)).toEqual([
|
||||
{ currentPassword: PASSWORD, otpCode: '482913' },
|
||||
])
|
||||
expect(disableDialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(toastMessages()).toEqual(['2단계 인증을 껐습니다.'])
|
||||
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
|
||||
expect(document.activeElement?.hasAttribute('data-two-factor-enable')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('세션이 바뀌면(다른 곳에서 이 세션을 끊음 → 세션 만료) 켜기 창을 닫고 QR · 키 · 입력한 비밀번호를 지운다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
expect(wrapper.find('[data-qr-code]').exists()).toBe(true)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
|
||||
expect(enableDialog()?.hasAttribute('open')).toBe(false)
|
||||
expect(wrapper.find('[data-qr-code]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-enable-key]').text()).toBe('')
|
||||
expect((wrapper.get('#two-factor-enable-password').element as HTMLInputElement).value).toBe('')
|
||||
expect((wrapper.get('#two-factor-enable-code').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 끄기 창도 닫고 입력한 비밀번호 · 코드를 지운다', async () => {
|
||||
const wrapper = await mountPage({ status: () => ok(ON) })
|
||||
await wrapper.get('[data-two-factor-disable]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#two-factor-disable-password').setValue(PASSWORD)
|
||||
await wrapper.get('#two-factor-disable-code').setValue('482913')
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
|
||||
expect(disableDialog()?.hasAttribute('open')).toBe(false)
|
||||
expect((wrapper.get('#two-factor-disable-password').element as HTMLInputElement).value).toBe('')
|
||||
expect((wrapper.get('#two-factor-disable-code').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('켜는 사이 세션이 바뀌었으면(로그아웃) 늦게 온 결과를 쓰지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const wrapper = await mountPage({
|
||||
activate: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
})
|
||||
await openEnable(wrapper)
|
||||
await fillEnable(wrapper, { code: '482913', password: PASSWORD })
|
||||
await submitEnable(wrapper)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
release(ok(ON))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-two-factor-done]').exists()).toBe(false)
|
||||
// 다음 사람의 상태가 되지 않게 스토어에도 쓰지 않는다
|
||||
expect(useTwoFactorStore().twoFactor).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,459 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 2단계 인증 (디자인 원본 SettingsTwoFactor · README "12단계 › 설정 › 계정 › 2단계 인증" · 계약 5절 "2단계 인증")
|
||||
// — /settings/account/two-factor · 탭 제목 "2단계 인증"
|
||||
// 머리 + 설명 → 상태 상자(글자로만 — 뱃지 · 스위치 없음): 「꺼짐」(회색 굵게) + [2단계 인증 켜기] / 「켜짐 — 2026년 10월 31일부터」(초록 굵게 — 켠 때를 모르면 「켜짐」) + [2단계 인증 끄기]
|
||||
// - 꺼져 있을 때: 갈색 경고(켜면 메일 프로그램은 앱 비밀번호로 — 앱 비밀번호 링크) + 「켜는 차례」 1 · 2 · 3(청록 굵은 숫자)
|
||||
// - 켜져 있을 때: 안내 상자(휴대폰 바꾸기 · 잃어버림 · 메일 프로그램은 앱 비밀번호로)
|
||||
// - 켜기 창(TwoFactorEnableDialog — 620) · 끄기 창(TwoFactorDisableDialog — 400, 처음 초점 「취소」)
|
||||
// - 켠 직후: 머리 아래 안내 상자 "2단계 인증을 켰습니다" + [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다(화면 상태). 초점은 그 상자로
|
||||
// - 끈 뒤: 알림 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」, 초점은 [2단계 인증 켜기]로
|
||||
// - 들어올 때마다 상태를 새로 받는다(two-factor.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
|
||||
// - 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃]으로 이 세션이 끊김 · 로그아웃 · 만료) 창을 모두 닫는다 — 세션 만료 창 뒤에 QR · 키 · 입력한 비밀번호가 남지 않게
|
||||
// (창은 닫힐 때 키 · 칸을 지운다)
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LinkedText from '@/components/LinkedText.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import TwoFactorDisableDialog from '@/components/settings/TwoFactorDisableDialog.vue'
|
||||
import TwoFactorEnableDialog from '@/components/settings/TwoFactorEnableDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import { twoFactorStateLabel } from '@/shared/utils/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const twoFactorStore = useTwoFactorStore()
|
||||
const toast = useToast()
|
||||
|
||||
const enableRef = ref<HTMLButtonElement | null>(null)
|
||||
const noticeRef = ref<HTMLElement | null>(null)
|
||||
const enableOpen = ref<boolean>(false)
|
||||
const disableOpen = ref<boolean>(false)
|
||||
/** 이 화면에서 방금 켰다 — 머리 아래 안내 상자 (떠나면 사라진다) */
|
||||
const justEnabled = ref<boolean>(false)
|
||||
|
||||
const appPasswordsRoute = { name: SETTINGS_ROUTES.appPasswords }
|
||||
const ready = computed(() => twoFactorStore.status === 'ready' && twoFactorStore.twoFactor !== null)
|
||||
const enabled = computed(() => twoFactorStore.isEnabled)
|
||||
const stateLabel = computed(() => (twoFactorStore.twoFactor ? twoFactorStateLabel(twoFactorStore.twoFactor) : ''))
|
||||
const email = computed(() => authStore.user?.email ?? '')
|
||||
|
||||
async function onEnabled() {
|
||||
enableOpen.value = false
|
||||
justEnabled.value = true
|
||||
await nextTick()
|
||||
noticeRef.value?.focus()
|
||||
}
|
||||
|
||||
async function onDisabled() {
|
||||
disableOpen.value = false
|
||||
justEnabled.value = false
|
||||
toast.success(TWO_FACTOR_TEXT.disabled)
|
||||
await nextTick()
|
||||
enableRef.value?.focus()
|
||||
}
|
||||
|
||||
// 세션이 바뀌면 창을 모두 닫는다 (켠 직후 안내도 내린다)
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
enableOpen.value = false
|
||||
disableOpen.value = false
|
||||
justEnabled.value = false
|
||||
},
|
||||
)
|
||||
|
||||
// 처음 그리기 전에 — 다른 탭 · 엔진 관리 화면에서 바꿨을 수 있다
|
||||
void twoFactorStore.load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="two-factor-page-title"
|
||||
:title="TWO_FACTOR_TEXT.title"
|
||||
:description="TWO_FACTOR_TEXT.description"
|
||||
>
|
||||
<MailLoadError
|
||||
v-if="twoFactorStore.status === 'error' && twoFactorStore.error"
|
||||
:error="twoFactorStore.error"
|
||||
leave-label=""
|
||||
@retry="twoFactorStore.load()"
|
||||
/>
|
||||
|
||||
<template v-else-if="ready">
|
||||
<div
|
||||
v-if="justEnabled && enabled"
|
||||
ref="noticeRef"
|
||||
class="two-factor__done"
|
||||
role="status"
|
||||
tabindex="-1"
|
||||
data-two-factor-done
|
||||
>
|
||||
<AppIcon
|
||||
name="check"
|
||||
:stroke-width="2.2"
|
||||
class="two-factor__done-icon"
|
||||
/>
|
||||
<div class="two-factor__done-body">
|
||||
<div class="two-factor__done-text">
|
||||
<span class="two-factor__done-title">{{ TWO_FACTOR_TEXT.enabledTitle }}</span>
|
||||
<span>{{ TWO_FACTOR_TEXT.enabledText }}</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="appPasswordsRoute"
|
||||
class="two-factor__done-action"
|
||||
>
|
||||
{{ TWO_FACTOR_TEXT.enabledAction }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="two-factor__state"
|
||||
data-two-factor-state
|
||||
>
|
||||
<div class="two-factor__state-text">
|
||||
<span
|
||||
:class="['two-factor__state-label', { 'two-factor__state-label--on': enabled }]"
|
||||
data-two-factor-label
|
||||
>{{ stateLabel }}</span>
|
||||
<span class="two-factor__state-hint">{{ enabled ? TWO_FACTOR_TEXT.onHint : TWO_FACTOR_TEXT.offHint }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!enabled"
|
||||
ref="enableRef"
|
||||
type="button"
|
||||
class="two-factor__enable"
|
||||
data-two-factor-enable
|
||||
@click="enableOpen = true"
|
||||
>
|
||||
<AppIcon
|
||||
name="shield-check"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ TWO_FACTOR_TEXT.enable }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="two-factor__disable"
|
||||
data-two-factor-disable
|
||||
@click="disableOpen = true"
|
||||
>
|
||||
{{ TWO_FACTOR_TEXT.disable }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="!enabled">
|
||||
<div
|
||||
class="two-factor__warning"
|
||||
data-two-factor-warning
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:stroke-width="1.9"
|
||||
class="two-factor__warning-icon"
|
||||
/>
|
||||
<div class="two-factor__warning-text">
|
||||
<span class="two-factor__warning-title">{{ TWO_FACTOR_TEXT.warningTitle }}</span>
|
||||
<LinkedText
|
||||
:before="TWO_FACTOR_TEXT.warningBefore"
|
||||
:link="TWO_FACTOR_TEXT.warningLink"
|
||||
:after="TWO_FACTOR_TEXT.warningAfter"
|
||||
:to="appPasswordsRoute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="two-factor__steps"
|
||||
aria-labelledby="two-factor-steps-title"
|
||||
>
|
||||
<h2
|
||||
id="two-factor-steps-title"
|
||||
class="two-factor__steps-title"
|
||||
>
|
||||
{{ TWO_FACTOR_TEXT.stepsTitle }}
|
||||
</h2>
|
||||
<ol class="two-factor__step-list">
|
||||
<li
|
||||
v-for="(step, index) in TWO_FACTOR_TEXT.steps"
|
||||
:key="step"
|
||||
class="two-factor__step"
|
||||
>
|
||||
<span
|
||||
class="two-factor__step-number"
|
||||
aria-hidden="true"
|
||||
>{{ index + 1 }}</span>
|
||||
<span>{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="two-factor__notes"
|
||||
data-two-factor-notes
|
||||
>
|
||||
<p class="two-factor__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ TWO_FACTOR_TEXT.noteChangePhone }}</span>
|
||||
</p>
|
||||
<p class="two-factor__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ TWO_FACTOR_TEXT.noteLostPhone }}</span>
|
||||
</p>
|
||||
<p class="two-factor__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<LinkedText
|
||||
:before="TWO_FACTOR_TEXT.noteMailClientBefore"
|
||||
:link="TWO_FACTOR_TEXT.noteMailClientLink"
|
||||
:after="TWO_FACTOR_TEXT.noteMailClientAfter"
|
||||
:to="appPasswordsRoute"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="two-factor__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
|
||||
<!-- 창은 상태 묶음 밖에 둔다 — 창 안에서 상태를 다시 받는 동안에도 사라지지 않게 -->
|
||||
<TwoFactorEnableDialog
|
||||
:open="enableOpen"
|
||||
:email="email"
|
||||
@close="enableOpen = false"
|
||||
@enabled="onEnabled"
|
||||
/>
|
||||
<TwoFactorDisableDialog
|
||||
:open="disableOpen"
|
||||
@close="disableOpen = false"
|
||||
@disabled="onDisabled"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 켠 직후 안내 상자 — 바탕 #F3F8F8 · 초록 체크 · 제목 초록 굵게 · [앱 비밀번호 만들기] 36 (최대 700) */
|
||||
.two-factor__done {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
max-width: 43.75rem;
|
||||
padding: var(--space-12) var(--space-14);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.two-factor__done:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.two-factor__done:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--color-brand);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.two-factor__done-icon {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.two-factor__done-body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.two-factor__done-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.two-factor__done-title {
|
||||
font-weight: 600;
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
/* 36 — 밀도 예외 (--size-notice-action, 차단 풀기와 같다) */
|
||||
.two-factor__done-action {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.two-factor__done-action:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 상태 상자 — 테두리 · 안쪽 16 18 (최대 700) */
|
||||
.two-factor__state {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12) var(--space-20);
|
||||
max-width: 43.75rem;
|
||||
padding: var(--space-16) var(--space-18);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.two-factor__state-text {
|
||||
flex: 1 1 18.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.two-factor__state-label {
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.two-factor__state-label--on {
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.two-factor__state-hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.two-factor__enable {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-18);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.two-factor__enable:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 끄기 — 흰 바탕 · 빨간 글 · 테두리 */
|
||||
.two-factor__disable {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.two-factor__disable:hover {
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
/* 꺼져 있을 때의 갈색 경고 (최대 700) */
|
||||
.two-factor__warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
max-width: 43.75rem;
|
||||
padding: var(--space-12) var(--space-14);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.two-factor__warning-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.two-factor__warning-text {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.two-factor__warning-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.two-factor__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.two-factor__steps-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.two-factor__step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
max-width: 43.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 청록 굵은 숫자 — 동그라미 없음 (숫자 칸 20) */
|
||||
.two-factor__step {
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-20) minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.two-factor__step-number {
|
||||
font-weight: 700;
|
||||
color: var(--color-brand-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 켜져 있을 때의 안내 상자 — 바탕 #F3F8F8 (최대 700) */
|
||||
.two-factor__notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
max-width: 43.75rem;
|
||||
padding: var(--space-14) var(--space-16);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.two-factor__note {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.two-factor__placeholder {
|
||||
min-height: 16rem;
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,8 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
|
||||
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부")
|
||||
// 설정 메뉴 — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
|
||||
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" · "12단계 — 계정 보안")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
@@ -35,7 +35,7 @@ describe('SettingsLayout', () => {
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
it('묶음 둘 — 계정(비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
@@ -43,7 +43,12 @@ describe('SettingsLayout', () => {
|
||||
const lists = menu.findAll('ul')
|
||||
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
|
||||
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
|
||||
[['비밀번호', '/settings/account/password']],
|
||||
[
|
||||
['비밀번호', '/settings/account/password'],
|
||||
['2단계 인증', '/settings/account/two-factor'],
|
||||
['앱 비밀번호', '/settings/account/app-passwords'],
|
||||
['로그인 기록', '/settings/account/login-history'],
|
||||
],
|
||||
[
|
||||
['서명', '/settings/mail/signature'],
|
||||
['부재중 자동 응답', '/settings/mail/vacation'],
|
||||
@@ -106,6 +111,39 @@ describe('SettingsLayout', () => {
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '대용량 첨부', requiresAuth: true })
|
||||
})
|
||||
|
||||
it('설정 › 계정 › 2단계 인증 · 앱 비밀번호 · 로그인 기록 (12단계) — 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
|
||||
await router.push('/settings/account/two-factor')
|
||||
expect(router.currentRoute.value.name).toBe('settings-account-two-factor')
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '2단계 인증', requiresAuth: true })
|
||||
|
||||
await router.push('/settings/account/app-passwords')
|
||||
expect(router.currentRoute.value.name).toBe('settings-account-app-passwords')
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '앱 비밀번호', requiresAuth: true })
|
||||
|
||||
await router.push('/settings/account/login-history')
|
||||
expect(router.currentRoute.value.name).toBe('settings-account-login-history')
|
||||
expect(router.currentRoute.value.meta).toMatchObject({ title: '로그인 기록', requiresAuth: true })
|
||||
})
|
||||
|
||||
it('계정 보안 메뉴의 아이콘 — 방패(체크) · 열쇠 · 시계(되감기), 지금 화면이면 현재로 표시한다', async () => {
|
||||
const twoFactor = await mountAt('/settings/account/two-factor')
|
||||
const shield = twoFactor.wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
expect(shield.text()).toBe('2단계 인증')
|
||||
expect(shield.find('polyline').attributes('points')).toBe('9 12 11 14 15 10')
|
||||
|
||||
const appPasswords = await mountAt('/settings/account/app-passwords')
|
||||
const key = appPasswords.wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
expect(key.text()).toBe('앱 비밀번호')
|
||||
expect(key.findAll('path')).toHaveLength(1)
|
||||
|
||||
const history = await mountAt('/settings/account/login-history')
|
||||
const clock = history.wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
expect(clock.text()).toBe('로그인 기록')
|
||||
expect(clock.findAll('path').map((shape) => shape.attributes('d'))).toContain('M3 3v5h5')
|
||||
})
|
||||
|
||||
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/large-files')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
@@ -96,8 +96,8 @@ export const routes: RouteRecordRaw[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면.
|
||||
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 ·
|
||||
// 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면. /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
path: '/settings',
|
||||
component: () => import('@/pages/settings/SettingsLayout.vue'),
|
||||
meta: { requiresAuth: true, title: '설정' },
|
||||
@@ -109,6 +109,25 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/settings/AccountPasswordPage.vue'),
|
||||
meta: { title: '비밀번호' },
|
||||
},
|
||||
{
|
||||
// 2단계 인증 · 앱 비밀번호 · 로그인 기록 (12단계 — 디자인 README "12단계 — 계정 보안", 계약 5절)
|
||||
path: 'account/two-factor',
|
||||
name: SETTINGS_ROUTES.twoFactor,
|
||||
component: () => import('@/pages/settings/AccountTwoFactorPage.vue'),
|
||||
meta: { title: '2단계 인증' },
|
||||
},
|
||||
{
|
||||
path: 'account/app-passwords',
|
||||
name: SETTINGS_ROUTES.appPasswords,
|
||||
component: () => import('@/pages/settings/AccountAppPasswordsPage.vue'),
|
||||
meta: { title: '앱 비밀번호' },
|
||||
},
|
||||
{
|
||||
path: 'account/login-history',
|
||||
name: SETTINGS_ROUTES.loginHistory,
|
||||
component: () => import('@/pages/settings/AccountLoginHistoryPage.vue'),
|
||||
meta: { title: '로그인 기록' },
|
||||
},
|
||||
{
|
||||
path: 'mail/signature',
|
||||
name: SETTINGS_ROUTES.signature,
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// 계정 보안 상수 — 계약 5절 "계정 보안 — 12단계" · 디자인 README "12단계 — 계정 보안"
|
||||
// (로그인 코드 단계 LoginTwoFactor · 설정 › 계정 › 2단계 인증 SettingsTwoFactor · 앱 비밀번호 SettingsAppPasswords · 로그인 기록 SettingsLoginHistory)
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 서버가 거절한 까닭의 문구는 에러 코드 사전(AUTH_006 ~ AUTH_009)에서만 온다.
|
||||
|
||||
/** 인증 앱 코드의 자릿수 — 숫자 6자리 · 30초 (계약 5절 "한도 · 규칙") */
|
||||
export const OTP_CODE_LENGTH = 6
|
||||
|
||||
/**
|
||||
* 로그인 코드 단계에 머물 수 있는 시간 — 넘으면 화면 메모리의 비밀번호를 지우고, [확인] 때 「다시 로그인해 주세요」와 함께 첫 단계로 간다
|
||||
* (계약 5절 "로그인" 클라이언트 · 디자인 README "로그인 — 코드 단계")
|
||||
*/
|
||||
export const TWO_FACTOR_LOGIN_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 2단계 인증 켜기 · 끄기 요청 하나의 시간 한도 — 서버는 비밀번호 확인 · 대리 접근 쓰기 · 결과를 모르면 다시 읽기까지 엔진을 여러 번 부른다.
|
||||
* 기본 10초로 끊으면 화면은 실패로 보는데 서버는 이미 켰을 수 있다 (비밀번호 바꾸기의 PASSWORD_CHANGE_TIMEOUT_MS 와 같은 까닭)
|
||||
*/
|
||||
export const TWO_FACTOR_CHANGE_TIMEOUT_MS = 30 * 1000
|
||||
|
||||
/**
|
||||
* 앱 비밀번호 상한 — 엔진 기본값 5(x:Authentication.maxAppPasswords). 엔진 상한을 바꾸면 이 값과 AUTH_009 문구도 함께 바꾼다 (계약 5절)
|
||||
*/
|
||||
export const APP_PASSWORD_MAX = 5
|
||||
/** 앱 비밀번호 이름 — 앞뒤 공백을 지운 값으로 1~40자 (JS 문자열 길이) */
|
||||
export const APP_PASSWORD_NAME_MAX_LENGTH = 40
|
||||
|
||||
/**
|
||||
* 2단계 인증 키를 한 줄에 몇 묶음(4자씩)까지 그리는가 — 32자 키를 일부러 두 줄(4묶음 + 4묶음)로 그린다.
|
||||
* 620 창에서 QR 옆 키 상자는 32자 + 묶음 사이 여백을 한 줄에 담지 못해 묶음 가운데서 줄이 바뀌었다(2026-10-31 브라우저 점검)
|
||||
*/
|
||||
export const TWO_FACTOR_KEY_GROUPS_PER_LINE = 4
|
||||
|
||||
/** 로그인된 브라우저를 한 번에 받는 수 — 서버 최대(100). 직원 한 사람의 웹메일 세션은 이보다 적다 */
|
||||
export const AUTH_SESSION_LIST_LIMIT = 100
|
||||
|
||||
/**
|
||||
* 메일 프로그램 설정값 — 앱 비밀번호 화면의 설정값 상자 · 만든 결과 창 (디자인 README "앱 비밀번호 › 메일 프로그램 설정값 상자").
|
||||
* 호스트 · 포트는 이 상수 하나에 둔다 — 전 직원 전환 때 다시 본다
|
||||
*/
|
||||
export const MAIL_CLIENT_SETTINGS = {
|
||||
host: 'mx1.arasystem.it.kr',
|
||||
imapPort: 993,
|
||||
smtpPort: 465,
|
||||
security: 'SSL/TLS',
|
||||
} as const
|
||||
|
||||
/** 인증 앱 코드 칸 — 로그인 코드 단계 · 켜기 · 끄기 창 · 비밀번호 화면 */
|
||||
export const OTP_TEXT = {
|
||||
placeholder: '000000',
|
||||
} as const
|
||||
|
||||
/** 로그인 — 코드 단계 (디자인 원본 LoginTwoFactor) */
|
||||
export const LOGIN_TWO_FACTOR_TEXT = {
|
||||
title: '2단계 인증',
|
||||
subtitle: '비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.',
|
||||
otherAccount: '다른 계정으로 로그인',
|
||||
codeLabel: '인증 앱에 표시된 6자리 코드',
|
||||
submit: '확인',
|
||||
submitting: '확인하는 중…',
|
||||
help: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
|
||||
note: 'Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.',
|
||||
/** 코드 단계에 10분 넘게 머문 뒤 [확인] — 첫 단계의 위쪽 상자 */
|
||||
expired: '코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.',
|
||||
} as const
|
||||
|
||||
/** 설정 › 계정 › 2단계 인증 (디자인 원본 SettingsTwoFactor) */
|
||||
export const TWO_FACTOR_TEXT = {
|
||||
title: '2단계 인증',
|
||||
description:
|
||||
'로그인할 때 비밀번호와 함께 휴대폰 인증 앱의 코드를 확인합니다. 비밀번호가 새어 나가도 휴대폰이 없으면 로그인할 수 없습니다.',
|
||||
off: '꺼짐',
|
||||
offHint: '지금은 로그인할 때 비밀번호만 확인합니다.',
|
||||
on: '켜짐',
|
||||
onSince: (day: string) => `켜짐 — ${day}부터`,
|
||||
onHint: '로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다.',
|
||||
enable: '2단계 인증 켜기',
|
||||
disable: '2단계 인증 끄기',
|
||||
warningTitle: '켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다',
|
||||
/** 경고 글 — 가운데 「앱 비밀번호」는 앱 비밀번호 화면 링크 */
|
||||
warningBefore:
|
||||
'Outlook · 썬더버드 · 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 ',
|
||||
warningLink: '앱 비밀번호',
|
||||
warningAfter: '를 만들어 두면 편합니다.',
|
||||
stepsTitle: '켜는 차례',
|
||||
steps: [
|
||||
'휴대폰에 인증 앱을 설치합니다 — Google Authenticator, Microsoft Authenticator 처럼 6자리 코드를 만드는(TOTP) 앱이면 됩니다.',
|
||||
'[2단계 인증 켜기]를 누르고 화면의 QR 코드를 인증 앱으로 찍습니다.',
|
||||
'앱에 표시된 6자리 코드와 지금 비밀번호를 넣으면 켜집니다. 그다음부터 로그인할 때마다 코드를 묻습니다.',
|
||||
],
|
||||
noteChangePhone: '휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요 — 끌 때 지금 휴대폰의 코드가 필요합니다.',
|
||||
noteLostPhone: '휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.',
|
||||
noteMailClientBefore: '메일 프로그램은 ',
|
||||
noteMailClientLink: '앱 비밀번호',
|
||||
noteMailClientAfter: '로 연결합니다.',
|
||||
/** 켠 직후 머리 아래 안내 상자 — 이 화면을 떠나면 사라진다 */
|
||||
enabledTitle: '2단계 인증을 켰습니다',
|
||||
enabledText: '메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.',
|
||||
enabledAction: '앱 비밀번호 만들기',
|
||||
disabled: '2단계 인증을 껐습니다.',
|
||||
} as const
|
||||
|
||||
/** 2단계 인증 켜기 창 (모달 620) */
|
||||
export const TWO_FACTOR_ENABLE_TEXT = {
|
||||
title: '2단계 인증 켜기',
|
||||
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
|
||||
scanHint: '인증 앱에서 [+] → 「QR 코드 스캔」을 고르고 이 그림을 찍습니다.',
|
||||
qrLabel: '인증 앱에 등록할 QR 코드',
|
||||
keyLabel: '찍을 수 없으면 이 키를 입력하세요',
|
||||
copy: '복사',
|
||||
copyLabel: '키 복사',
|
||||
copied: '키를 복사했습니다.',
|
||||
copyFailed: '키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요.',
|
||||
/** 앱에 저장될 이름 — 서버의 otpauthUrl 과 같은 이름(계약 5절 "정한 것" 1) */
|
||||
savedAs: (email: string) => `앱에는 「ARA SYSTEM (${email})」로 저장됩니다.`,
|
||||
/** 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다 (README "켜기 창") */
|
||||
discardNote: '켜지 않고 닫으면 이 키는 버립니다 — 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
|
||||
loading: 'QR 코드를 만드는 중…',
|
||||
codeLabel: '앱에 표시된 6자리 코드',
|
||||
passwordLabel: '지금 비밀번호',
|
||||
showPassword: '지금 비밀번호 보기',
|
||||
submit: '켜기',
|
||||
submitting: '켜는 중…',
|
||||
cancel: '취소',
|
||||
retry: '다시 시도',
|
||||
} as const
|
||||
|
||||
/** 2단계 인증 끄기 창 (alertdialog 400 · 처음 초점 「취소」) */
|
||||
export const TWO_FACTOR_DISABLE_TEXT = {
|
||||
title: '2단계 인증을 끌까요?',
|
||||
message: '끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다.',
|
||||
passwordLabel: '지금 비밀번호',
|
||||
showPassword: '지금 비밀번호 보기',
|
||||
codeLabel: '인증 앱 코드',
|
||||
submit: '끄기',
|
||||
submitting: '끄는 중…',
|
||||
cancel: '취소',
|
||||
} as const
|
||||
|
||||
/** 설정 › 계정 › 앱 비밀번호 (디자인 원본 SettingsAppPasswords) */
|
||||
export const APP_PASSWORD_TEXT = {
|
||||
title: '앱 비밀번호',
|
||||
description:
|
||||
'Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다.',
|
||||
count: (count: string, max: string) => `${count} / ${max}개`,
|
||||
create: '앱 비밀번호 만들기',
|
||||
/** 5개를 모두 만들었을 때 머리 아래 한 줄 — [만들기]를 눌러도 이 글을 읽어 준다 */
|
||||
full: (max: string) => `${max}개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.`,
|
||||
listLabel: '앱 비밀번호 — 최근에 만든 차례',
|
||||
columnName: '이름',
|
||||
columnCreated: '만든 날',
|
||||
columnLastUsed: '마지막 사용',
|
||||
columnActions: '지우기',
|
||||
neverUsed: '아직 쓰지 않음',
|
||||
delete: '지우기',
|
||||
deleteLabel: (name: string) => `‘${name}’ 앱 비밀번호 지우기`,
|
||||
emptyTitle: '만든 앱 비밀번호가 없습니다',
|
||||
emptyHint:
|
||||
'Outlook · 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결합니다.',
|
||||
settingsTitle: '메일 프로그램 설정값',
|
||||
incoming: '받는 메일 (IMAP)',
|
||||
outgoing: '보내는 메일 (SMTP)',
|
||||
username: '사용자 이름',
|
||||
usernameNote: ' — 메일 주소 전체',
|
||||
password: '비밀번호',
|
||||
passwordNote: '여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
|
||||
port: (port: number) => `포트 ${port}`,
|
||||
notes: [
|
||||
'앱 비밀번호는 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰입니다. 웹메일 로그인에는 쓸 수 없습니다.',
|
||||
'2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다.',
|
||||
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.',
|
||||
'휴대폰이나 PC 를 잃어버렸으면 그 기기의 앱 비밀번호를 바로 지워 주세요.',
|
||||
],
|
||||
deleted: (name: string) => `‘${name}’ 앱 비밀번호를 지웠습니다.`,
|
||||
} as const
|
||||
|
||||
/** 앱 비밀번호 만들기 창 (모달 400 · 처음 초점 이름 칸) */
|
||||
export const APP_PASSWORD_CREATE_TEXT = {
|
||||
title: '앱 비밀번호 만들기',
|
||||
nameLabel: '이름',
|
||||
namePlaceholder: '예: 회사 PC 아웃룩',
|
||||
nameHelp: '어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.',
|
||||
empty: '이름을 넣어 주세요.',
|
||||
duplicate: '같은 이름의 앱 비밀번호가 있습니다.',
|
||||
submit: '만들기',
|
||||
cancel: '취소',
|
||||
} as const
|
||||
|
||||
/** 만든 결과 창 (모달 620 · 처음 초점 [복사]) — 값은 이 창에서 한 번만 */
|
||||
export const APP_PASSWORD_RESULT_TEXT = {
|
||||
title: '앱 비밀번호를 만들었습니다',
|
||||
message: (name: string) => `‘${name}’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
|
||||
copy: '복사',
|
||||
copyLabel: '앱 비밀번호 복사',
|
||||
lengthNote: (length: string) => `${length}자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.`,
|
||||
warningStrong: '이 창을 닫으면 다시 볼 수 없습니다.',
|
||||
warningAfter: ' 잊어버리면 지우고 새로 만들어 주세요.',
|
||||
copied: '앱 비밀번호를 복사했습니다.',
|
||||
copyFailed: '앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.',
|
||||
close: '닫기',
|
||||
} as const
|
||||
|
||||
/** 앱 비밀번호 지우기 확인 (alertdialog 400 · 처음 초점 「취소」) */
|
||||
export const APP_PASSWORD_DELETE_TEXT = {
|
||||
title: (name: string) => `‘${name}’ 앱 비밀번호를 지울까요?`,
|
||||
message: '이 비밀번호로 연결한 메일 프로그램은 다음 연결부터 로그인하지 못합니다. 지운 비밀번호는 되돌릴 수 없습니다.',
|
||||
confirm: '지우기',
|
||||
cancel: '취소',
|
||||
} as const
|
||||
|
||||
/** 설정 › 계정 › 로그인 기록 (디자인 원본 SettingsLoginHistory) */
|
||||
export const LOGIN_HISTORY_TEXT = {
|
||||
title: '로그인 기록',
|
||||
/** 머리 설명 — 「비밀번호를 바꾸고」 · 「2단계 인증」은 링크 */
|
||||
descriptionBefore: '최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 ',
|
||||
descriptionPasswordLink: '비밀번호를 바꾸고',
|
||||
descriptionMiddle: ', ',
|
||||
descriptionTwoFactorLink: '2단계 인증',
|
||||
descriptionAfter: '이 꺼져 있으면 켜 주세요.',
|
||||
/** 화면 틀의 description(글만) — 링크를 그릴 수 없는 곳에서 */
|
||||
description:
|
||||
'최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요.',
|
||||
sessionsTitle: '로그인된 브라우저',
|
||||
sessionsCount: (count: string) => `웹메일 ${count}곳`,
|
||||
endOthers: '다른 곳 모두 로그아웃',
|
||||
columnDevice: '브라우저 · 운영체제',
|
||||
columnIp: 'IP',
|
||||
columnLastActive: '마지막 활동',
|
||||
columnKind: '기기',
|
||||
/** 줄 앞 아이콘의 이름 — PC · 휴대폰 */
|
||||
kindPc: 'PC',
|
||||
kindMobile: '휴대폰',
|
||||
columnActions: '로그아웃',
|
||||
current: '지금 이 브라우저',
|
||||
endSession: '로그아웃',
|
||||
endSessionLabel: (device: string) => `‘${device}’ 로그아웃`,
|
||||
unknownBrowser: '알 수 없는 브라우저',
|
||||
unknownValue: '알 수 없음',
|
||||
noOthers: '다른 곳에 로그인된 브라우저가 없습니다. ',
|
||||
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램은 ',
|
||||
sessionsNoteLink: '앱 비밀번호',
|
||||
sessionsNoteAfter: '를 지워 끊습니다.',
|
||||
sessionEnded: (device: string) => `‘${device}’의 로그인을 끊었습니다.`,
|
||||
othersEnded: (count: string) => `다른 곳의 로그인 ${count}곳을 끊었습니다.`,
|
||||
/** 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음) — 이 브라우저까지 끊었다 */
|
||||
allEnded: (count: string) => `이 브라우저를 포함한 ${count}곳의 로그인을 끊었습니다.`,
|
||||
eventsTitle: '최근 로그인',
|
||||
eventsHintAll: '최근 90일 · 웹메일과 메일 프로그램',
|
||||
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
|
||||
eventsHintFailuresUnknown: '최근 90일 · 실패만',
|
||||
filterLabel: '보일 기록',
|
||||
filterAll: '전체',
|
||||
filterFailures: '실패만',
|
||||
columnTime: '시각',
|
||||
columnMethod: '방식',
|
||||
columnResult: '결과',
|
||||
methodWebmail: '웹메일',
|
||||
methodImap: '메일 프로그램 · IMAP',
|
||||
methodSmtp: '메일 프로그램 · SMTP',
|
||||
appPassword: (name: string) => `앱 비밀번호 ‘${name}’`,
|
||||
success: '성공',
|
||||
successTwoFactor: '성공 · 2단계 인증',
|
||||
failure: '실패',
|
||||
failureWrongPassword: '실패 — 비밀번호 틀림',
|
||||
failureWrongCode: '실패 — 코드 틀림',
|
||||
failureAppPasswordRequired: '실패 — 앱 비밀번호 필요',
|
||||
more: '더 보기',
|
||||
moreHint: '최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.',
|
||||
emptyFailuresTitle: '최근 90일 동안 실패한 로그인이 없습니다',
|
||||
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 봅니다.',
|
||||
emptyAllTitle: '최근 90일 동안 로그인 기록이 없습니다',
|
||||
emptyAllHint: '기록은 이 기능을 배포한 날부터 쌓입니다.',
|
||||
} as const
|
||||
|
||||
/** [다른 곳 모두 로그아웃] 확인 (alertdialog 400 · 처음 초점 「취소」) */
|
||||
export const END_OTHER_SESSIONS_TEXT = {
|
||||
title: '다른 곳의 로그인을 모두 끊을까요?',
|
||||
/** "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …" — 이름은 세 곳까지, 넘으면 "외 n곳" */
|
||||
message: (count: string, names: string) =>
|
||||
`이 브라우저를 뺀 ${count}곳(${names})의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.`,
|
||||
/**
|
||||
* 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음 — 서버는 이 요청의 세션을 몰라 모두 끊는다, 계약 5절) —
|
||||
* 이 브라우저도 로그아웃된다고 알린다
|
||||
*/
|
||||
titleAll: '이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?',
|
||||
messageAll: (count: string, names: string) =>
|
||||
`이 브라우저를 알아보지 못해 이 브라우저를 포함한 ${count}곳(${names})의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
|
||||
moreNames: (count: string) => `외 ${count}곳`,
|
||||
note: '메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.',
|
||||
confirm: '모두 로그아웃',
|
||||
cancel: '취소',
|
||||
} as const
|
||||
|
||||
/** 확인 창 글에 이름을 몇 곳까지 늘어놓는가 */
|
||||
export const END_OTHER_SESSIONS_NAME_LIMIT = 3
|
||||
@@ -15,6 +15,8 @@ export const PASSWORD_CHANGE_TIMEOUT_MS = 30 * 1000
|
||||
export const PASSWORD_TEXT = {
|
||||
title: '비밀번호',
|
||||
description: '웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
|
||||
/** 2단계 인증을 켠 직원의 머리 설명 (12단계 — 디자인 원본 SettingsTwoFactor "비밀번호 — 2단계 인증을 켠 직원") */
|
||||
descriptionTwoFactor: '웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다.',
|
||||
current: '지금 비밀번호',
|
||||
next: '새 비밀번호',
|
||||
confirm: '새 비밀번호 확인',
|
||||
@@ -24,8 +26,12 @@ export const PASSWORD_TEXT = {
|
||||
/** 새 비밀번호 아래 안내 — 규칙에 맞지 않으면 이 글이 빨간 오류로 바뀐다 */
|
||||
hint: '8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
mismatch: '새 비밀번호가 서로 다릅니다.',
|
||||
/** 2단계 인증을 켠 직원만 — 양식 맨 아래 (코드가 30초마다 바뀌어 먼저 넣으면 다른 칸을 쓰는 동안 지날 수 있다) */
|
||||
code: '인증 앱 코드',
|
||||
codeHint: '코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.',
|
||||
submit: '비밀번호 바꾸기',
|
||||
done: '비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.',
|
||||
noteSessions: '바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
|
||||
noteImap: '휴대폰이나 PC 메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.',
|
||||
/** 모든 직원 (12단계) — 비밀번호를 바꾸면 서버가 그 직원의 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") */
|
||||
noteAppPasswords: '앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
|
||||
} as const
|
||||
|
||||
@@ -307,6 +307,22 @@ export const ICONS = {
|
||||
line(12, 9, 12, 13),
|
||||
line(12, 17, 12.01, 17),
|
||||
],
|
||||
|
||||
// ── 12단계: 계정 보안 (디자인 원본 SettingsTwoFactor · SettingsAppPasswords · SettingsLoginHistory) ──
|
||||
/** 설정 메뉴의 2단계 인증 · [2단계 인증 켜기] (방패 + 체크) */
|
||||
'shield-check': [path('M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'), polyline('9 12 11 14 15 10')],
|
||||
/** 설정 메뉴의 앱 비밀번호 · 앱 비밀번호가 없을 때 (열쇠) */
|
||||
key: [
|
||||
path(
|
||||
'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4',
|
||||
),
|
||||
],
|
||||
/** 설정 메뉴의 로그인 기록 (되감는 시계) */
|
||||
history: [path('M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'), path('M3 3v5h5'), path('M12 7v5l4 2')],
|
||||
/** 로그인된 브라우저 — PC */
|
||||
monitor: [rect(2, 3, 20, 14, 2), line(8, 21, 16, 21), line(12, 17, 12, 21)],
|
||||
/** 로그인된 브라우저 — 휴대폰 */
|
||||
smartphone: [rect(5, 2, 14, 20, 2), line(12, 18, 12.01, 18)],
|
||||
} as const satisfies Record<string, readonly IconShape[]>
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
|
||||
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래)
|
||||
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래 ·
|
||||
// "12단계 — 계정 보안" — 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록)
|
||||
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
|
||||
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」)가 이 이름을 함께 쓴다.
|
||||
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 · 계정 보안 화면끼리의 링크)가 이 이름을 함께 쓴다.
|
||||
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
export const SETTINGS_ROUTES = {
|
||||
/** 설정의 첫 화면 — /settings 는 이 화면으로 바꿔 끼운다 (앱 레일의 "설정") */
|
||||
password: 'settings-account-password',
|
||||
/** 2단계 인증 · 앱 비밀번호 · 로그인 기록 (12단계) */
|
||||
twoFactor: 'settings-account-two-factor',
|
||||
appPasswords: 'settings-account-app-passwords',
|
||||
loginHistory: 'settings-account-login-history',
|
||||
signature: 'settings-mail-signature',
|
||||
vacation: 'settings-mail-vacation',
|
||||
filters: 'settings-mail-filters',
|
||||
@@ -37,7 +42,12 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
|
||||
{
|
||||
id: 'account',
|
||||
label: '계정',
|
||||
items: [{ routeName: SETTINGS_ROUTES.password, label: '비밀번호', icon: 'lock' }],
|
||||
items: [
|
||||
{ routeName: SETTINGS_ROUTES.password, label: '비밀번호', icon: 'lock' },
|
||||
{ routeName: SETTINGS_ROUTES.twoFactor, label: '2단계 인증', icon: 'shield-check' },
|
||||
{ routeName: SETTINGS_ROUTES.appPasswords, label: '앱 비밀번호', icon: 'key' },
|
||||
{ routeName: SETTINGS_ROUTES.loginHistory, label: '로그인 기록', icon: 'history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mail',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 계정 보안 API 공유 타입 — docs/mail/api-contract.md 5절 "계정 보안 — 12단계" 와 같은 구조
|
||||
// (2단계 인증 · 앱 비밀번호 · 로그인된 브라우저 · 로그인 기록). 로그인 · 비밀번호 바꾸기에 더한 칸은 shared/types/auth.ts
|
||||
|
||||
/** GET /two-factor · 켜기 · 끄기의 응답 — 엔진이 기준이다 */
|
||||
export interface TwoFactorStatus {
|
||||
isEnabled: boolean
|
||||
/** 웹메일에서 켠 때 — 엔진 관리 화면에서 켰으면 null */
|
||||
enabledAt: string | null
|
||||
}
|
||||
|
||||
/** POST /two-factor/enrollments (201 · no-store) — 비밀은 이 응답에만, 창을 닫으면 버린다 */
|
||||
export interface TwoFactorEnrollment {
|
||||
enrollmentId: string
|
||||
/** base32(채움 없음) 32자 */
|
||||
secret: string
|
||||
/** QR 로 그린다 (otpauth://totp/…) */
|
||||
otpauthUrl: string
|
||||
/** 이때까지 켜야 한다 (등록을 시작하고 10분) */
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
/** POST /two-factor/activation 본문 */
|
||||
export interface TwoFactorActivationPayload {
|
||||
enrollmentId: string
|
||||
otpCode: string
|
||||
currentPassword: string
|
||||
}
|
||||
|
||||
/** POST /two-factor/deactivation 본문 */
|
||||
export interface TwoFactorDeactivationPayload {
|
||||
currentPassword: string
|
||||
otpCode: string
|
||||
}
|
||||
|
||||
/** 앱 비밀번호 · 로그인 기록의 메일 프로그램 방식 */
|
||||
export type MailClientProtocol = 'imap' | 'smtp'
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — 엔진 웹훅의 로그인 성공으로 서버가 만든다 */
|
||||
export interface AppPasswordLastUsed {
|
||||
at: string
|
||||
method: MailClientProtocol
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
/** GET /app-passwords 의 한 줄 — 최근에 만든 것이 위 */
|
||||
export interface AppPassword {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
/** 쓰지 않았으면 null */
|
||||
lastUsed: AppPasswordLastUsed | null
|
||||
}
|
||||
|
||||
/** POST /app-passwords (201 · no-store) — 값(password)은 이 응답에만 온다. 화면 메모리에만 두고 창을 닫으면 지운다 */
|
||||
export interface CreatedAppPassword extends AppPassword {
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 서버가 User-Agent 를 짧은 이름으로 바꾼 것 — 모르면 null */
|
||||
export interface LoginDevice {
|
||||
browser: string | null
|
||||
os: string | null
|
||||
}
|
||||
|
||||
/** GET /auth/sessions 의 한 줄 — 지금 세션이 맨 앞, 그다음 최근 활동 차례 */
|
||||
export interface AuthSession {
|
||||
id: string
|
||||
createdAt: string
|
||||
/** 마지막 갱신(회전) 때 — 없으면 로그인 때 */
|
||||
lastActiveAt: string
|
||||
/** 12단계 전에 연 세션은 null */
|
||||
ip: string | null
|
||||
device: LoginDevice | null
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
/** DELETE /auth/sessions — 이 요청의 세션만 남기고 끊은 수 */
|
||||
export interface EndedSessions {
|
||||
ended: number
|
||||
}
|
||||
|
||||
export type LoginMethod = 'webmail' | MailClientProtocol
|
||||
|
||||
/** 실패한 까닭 — wrongPassword(웹메일에 넣은 앱 비밀번호 포함) · wrongCode(다시 쓴 코드 포함) · appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로) */
|
||||
export type LoginFailureReason = 'wrongPassword' | 'wrongCode' | 'appPasswordRequired'
|
||||
|
||||
/** GET /login-events 의 한 줄 — 최근 차례, 90일 보존 */
|
||||
export interface LoginEvent {
|
||||
id: string
|
||||
occurredAt: string
|
||||
method: LoginMethod
|
||||
isSuccess: boolean
|
||||
/** 웹메일 성공이 코드까지 거쳤는지 */
|
||||
isTwoFactor: boolean
|
||||
failureReason: LoginFailureReason | null
|
||||
ip: string | null
|
||||
/** 웹메일만 — 메일 프로그램은 null */
|
||||
device: LoginDevice | null
|
||||
/** 메일 프로그램이 앱 비밀번호로 들어온 경우 — name 은 그때의 이름(지운 뒤에도 남는다) */
|
||||
appPassword: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
/** GET /login-events 쿼리 */
|
||||
export interface LoginEventQuery {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
/** false 면 실패만 */
|
||||
isSuccess?: false
|
||||
}
|
||||
@@ -11,14 +11,35 @@ export interface AuthUser {
|
||||
export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
/**
|
||||
* 2단계 인증 코드 (12단계 — 계약 5절) — 코드 단계에서만 싣는다. 서버는 비밀번호를 들고 있지 않아 코드 단계에서도 비밀번호를 한 번 더 싣는다.
|
||||
* 숫자 6자리가 아니면 400 VAL_001, 틀리거나 2분 안에 다시 쓴 코드면 400 AUTH_006
|
||||
*/
|
||||
otpCode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/login 응답 (12단계 — 계약 5절). 2단계 인증을 켠 직원이고 비밀번호가 맞으면 200 twoFactorRequired — 쿠키가 없고 email · name 은 null.
|
||||
* status 가 없는 응답(12단계 전 서버)은 signedIn 으로 읽는다
|
||||
*/
|
||||
export type LoginResponse =
|
||||
| { status?: 'signedIn'; email: string; name: string }
|
||||
| { status: 'twoFactorRequired'; email: null; name: null }
|
||||
|
||||
/** 로그인의 결과 — 로그인했거나, 코드 단계로 가야 한다 */
|
||||
export type LoginOutcome = { status: 'signedIn'; user: AuthUser } | { status: 'twoFactorRequired' }
|
||||
|
||||
/**
|
||||
* PUT /auth/password 요청 본문 (4단계) — 성공은 200 data: null, 지금 비밀번호가 틀리면 400 AUTH_005.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊고, 앱 비밀번호도 모두 지운다(12단계)
|
||||
*/
|
||||
export interface ChangePasswordPayload {
|
||||
currentPassword: string
|
||||
/** 8~128자 · 지금 비밀번호와 달라야 한다 (넘으면 400 VAL_001) */
|
||||
newPassword: string
|
||||
/**
|
||||
* 2단계 인증 코드 (12단계) — 2단계 인증을 켠 직원만 필수. 없으면 400 AUTH_007(다른 탭에서 켠 경우 — 화면이 코드 칸을 보인다),
|
||||
* 틀리거나 다시 쓴 코드면 400 AUTH_006
|
||||
*/
|
||||
otpCode?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AppPassword, AuthSession, LoginEvent } from '@/shared/types/account-security'
|
||||
import { formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import {
|
||||
appPasswordCountText,
|
||||
appPasswordLastUsedText,
|
||||
appPasswordNameProblem,
|
||||
chunkItems,
|
||||
deviceLabel,
|
||||
endOtherSessionsMessage,
|
||||
groupCharacters,
|
||||
isCompleteOtp,
|
||||
isMobileDevice,
|
||||
loginAppPasswordLabel,
|
||||
loginDeviceLabel,
|
||||
loginMethodLabel,
|
||||
loginResultLabel,
|
||||
normalizeOtpInput,
|
||||
otherSessions,
|
||||
twoFactorStateLabel,
|
||||
} from './account-security'
|
||||
|
||||
// 계정 보안 화면의 순수 함수 (계약 5절 · 디자인 README "12단계 — 계정 보안")
|
||||
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z')
|
||||
|
||||
function event(changes: Partial<LoginEvent>): LoginEvent {
|
||||
return {
|
||||
id: 'e1',
|
||||
occurredAt: '2026-10-31T00:30:00Z',
|
||||
method: 'webmail',
|
||||
isSuccess: true,
|
||||
isTwoFactor: false,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Chrome', os: 'Windows' },
|
||||
appPassword: null,
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
function session(changes: Partial<AuthSession>): AuthSession {
|
||||
return {
|
||||
id: 's1',
|
||||
createdAt: '2026-10-30T00:00:00Z',
|
||||
lastActiveAt: '2026-10-31T00:05:00Z',
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Edge', os: 'Windows' },
|
||||
isCurrent: false,
|
||||
...changes,
|
||||
}
|
||||
}
|
||||
|
||||
describe('인증 앱 코드 칸', () => {
|
||||
it('숫자만 남기고 6자리에서 자른다 — 붙여 넣은 띄어쓰기 · 하이픈 · 전각 숫자', () => {
|
||||
expect(normalizeOtpInput('123 456')).toBe('123456')
|
||||
expect(normalizeOtpInput('123-456')).toBe('123456')
|
||||
expect(normalizeOtpInput(' 12a3 ')).toBe('123')
|
||||
expect(normalizeOtpInput('123456')).toBe('123456')
|
||||
expect(normalizeOtpInput('1234567890')).toBe('123456')
|
||||
expect(normalizeOtpInput('코드')).toBe('')
|
||||
})
|
||||
|
||||
it('숫자 6자리만 다 넣은 코드다', () => {
|
||||
expect(isCompleteOtp('123456')).toBe(true)
|
||||
expect(isCompleteOtp('12345')).toBe(false)
|
||||
expect(isCompleteOtp('1234567')).toBe(false)
|
||||
expect(isCompleteOtp('12345a')).toBe(false)
|
||||
expect(isCompleteOtp('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4자씩 끊어 보이기', () => {
|
||||
it('띄어쓰기 없이 조각으로만 나눈다 — 이어 붙이면 원래 값이다', () => {
|
||||
const secret = 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'
|
||||
const groups = groupCharacters(secret)
|
||||
expect(groups).toEqual(['K7QF', '2M4X', 'HB6R', 'TZ3N', 'PW5C', 'YL7D', 'JQ2V', 'MGAS'])
|
||||
expect(groups.join('')).toBe(secret)
|
||||
expect(groupCharacters('app_7qm2xk')).toEqual(['app_', '7qm2', 'xk'])
|
||||
expect(groupCharacters('')).toEqual([])
|
||||
})
|
||||
|
||||
it('조각을 줄로 묶는다 — 32자 키는 한 줄에 4묶음씩 두 줄', () => {
|
||||
const groups = groupCharacters('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
|
||||
expect(chunkItems(groups, 4)).toEqual([
|
||||
['K7QF', '2M4X', 'HB6R', 'TZ3N'],
|
||||
['PW5C', 'YL7D', 'JQ2V', 'MGAS'],
|
||||
])
|
||||
expect(chunkItems(['a', 'b', 'c'], 2)).toEqual([['a', 'b'], ['c']])
|
||||
expect(chunkItems([], 4)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('기기 이름', () => {
|
||||
it('"브라우저 · 운영체제" — 모르면 "알 수 없는 브라우저"', () => {
|
||||
expect(deviceLabel({ browser: 'Edge', os: 'Windows' })).toBe('Edge · Windows')
|
||||
expect(deviceLabel({ browser: 'Safari', os: null })).toBe('Safari')
|
||||
expect(deviceLabel({ browser: null, os: 'Android' })).toBe('알 수 없는 브라우저 · Android')
|
||||
expect(deviceLabel(null)).toBe('알 수 없는 브라우저')
|
||||
})
|
||||
|
||||
it('휴대폰 아이콘은 iOS · Android — 모르면 PC', () => {
|
||||
expect(isMobileDevice({ browser: 'Safari', os: 'iOS' })).toBe(true)
|
||||
expect(isMobileDevice({ browser: 'Samsung Internet', os: 'Android' })).toBe(true)
|
||||
expect(isMobileDevice({ browser: 'Chrome', os: 'macOS' })).toBe(false)
|
||||
expect(isMobileDevice(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2단계 인증 상태 글', () => {
|
||||
it('꺼짐 · 켜짐 — 웹메일에서 켠 때가 있으면 "2026년 10월 31일부터"', () => {
|
||||
expect(twoFactorStateLabel({ isEnabled: false, enabledAt: null })).toBe('꺼짐')
|
||||
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: null })).toBe('켜짐')
|
||||
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: '2026-10-31T03:00:00Z' })).toBe('켜짐 — 2026년 10월 31일부터')
|
||||
})
|
||||
})
|
||||
|
||||
describe('앱 비밀번호 이름', () => {
|
||||
const existing: AppPassword[] = [
|
||||
{ id: 'a', name: '회사 PC 아웃룩', createdAt: '2026-10-27T00:00:00Z', lastUsed: null },
|
||||
]
|
||||
|
||||
it('앞뒤 공백을 지운 값으로 — 비었는지 · 40자를 넘는지 · 이미 있는 이름인지', () => {
|
||||
expect(appPasswordNameProblem(' ', existing)).toBe('empty')
|
||||
expect(appPasswordNameProblem('가'.repeat(41), existing)).toBe('tooLong')
|
||||
expect(appPasswordNameProblem(` ${'가'.repeat(40)} `, existing)).toBeNull()
|
||||
expect(appPasswordNameProblem(' 회사 PC 아웃룩 ', existing)).toBe('duplicate')
|
||||
expect(appPasswordNameProblem('휴대폰 메일 앱', existing)).toBeNull()
|
||||
})
|
||||
|
||||
it('개수 글 "3 / 5개"', () => {
|
||||
expect(appPasswordCountText(3, 5)).toBe('3 / 5개')
|
||||
})
|
||||
|
||||
it('마지막 사용 — "오늘 오전 9:12 · IMAP · 211.186.95.252", 쓰지 않았으면 null', () => {
|
||||
const at = '2026-10-31T00:12:00Z'
|
||||
expect(appPasswordLastUsedText({ at, method: 'imap', ip: '211.186.95.252' }, NOW)).toBe(
|
||||
`${formatRelativeDayTime(at, new Date(NOW))} · IMAP · 211.186.95.252`,
|
||||
)
|
||||
expect(appPasswordLastUsedText({ at, method: 'smtp', ip: null }, NOW)).toBe(
|
||||
`${formatRelativeDayTime(at, new Date(NOW))} · SMTP`,
|
||||
)
|
||||
expect(appPasswordLastUsedText(null, NOW)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('로그인 기록', () => {
|
||||
it('방식 — 웹메일 · 메일 프로그램 · IMAP · 메일 프로그램 · SMTP', () => {
|
||||
expect(loginMethodLabel('webmail')).toBe('웹메일')
|
||||
expect(loginMethodLabel('imap')).toBe('메일 프로그램 · IMAP')
|
||||
expect(loginMethodLabel('smtp')).toBe('메일 프로그램 · SMTP')
|
||||
})
|
||||
|
||||
it('앱 비밀번호로 들어왔으면 방식 아래 줄에 그 이름 (지운 뒤에도 그 이름)', () => {
|
||||
expect(loginAppPasswordLabel(event({ method: 'imap', appPassword: { id: 'b', name: '회사 PC 아웃룩' } }))).toBe(
|
||||
'앱 비밀번호 ‘회사 PC 아웃룩’',
|
||||
)
|
||||
expect(loginAppPasswordLabel(event({ method: 'imap' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('기기 — 웹메일은 브라우저 · 운영체제, 메일 프로그램은 모른다(null)', () => {
|
||||
expect(loginDeviceLabel(event({}))).toBe('Chrome · Windows')
|
||||
expect(loginDeviceLabel(event({ device: null }))).toBe('알 수 없는 브라우저')
|
||||
expect(loginDeviceLabel(event({ method: 'smtp', device: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('결과 글 다섯 가지 — 실패는 isFailure', () => {
|
||||
expect(loginResultLabel(event({}))).toEqual({ label: '성공', isFailure: false })
|
||||
expect(loginResultLabel(event({ isTwoFactor: true }))).toEqual({ label: '성공 · 2단계 인증', isFailure: false })
|
||||
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'wrongPassword' }))).toEqual({
|
||||
label: '실패 — 비밀번호 틀림',
|
||||
isFailure: true,
|
||||
})
|
||||
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'wrongCode' }))).toEqual({
|
||||
label: '실패 — 코드 틀림',
|
||||
isFailure: true,
|
||||
})
|
||||
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'appPasswordRequired' }))).toEqual({
|
||||
label: '실패 — 앱 비밀번호 필요',
|
||||
isFailure: true,
|
||||
})
|
||||
// 모르는 까닭 — 「실패」만
|
||||
expect(loginResultLabel(event({ isSuccess: false, failureReason: null }))).toEqual({ label: '실패', isFailure: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('다른 곳 모두 로그아웃', () => {
|
||||
it('지금 이 브라우저를 뺀 세션 · 확인 창 글은 이름을 세 곳까지, 넘으면 "외 n곳"', () => {
|
||||
const sessions = [
|
||||
session({ id: 'now', isCurrent: true, device: { browser: 'Chrome', os: 'Windows' } }),
|
||||
session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }),
|
||||
session({ id: 'b', device: { browser: 'Safari', os: 'iOS' } }),
|
||||
]
|
||||
const others = otherSessions(sessions)
|
||||
expect(others.map((item) => item.id)).toEqual(['a', 'b'])
|
||||
expect(endOtherSessionsMessage(others)).toBe(
|
||||
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
|
||||
const many = [
|
||||
...others,
|
||||
session({ id: 'c', device: { browser: 'Firefox', os: 'Linux' } }),
|
||||
session({ id: 'd', device: null }),
|
||||
session({ id: 'e', device: null }),
|
||||
]
|
||||
expect(endOtherSessionsMessage(many)).toBe(
|
||||
'이 브라우저를 뺀 5곳(Edge · Windows, Safari · iOS, Firefox · Linux 외 2곳)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 이 브라우저도 로그아웃된다는 글', () => {
|
||||
const unknown = [session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }), session({ id: 'b', device: null })]
|
||||
|
||||
expect(endOtherSessionsMessage(unknown, true)).toBe(
|
||||
'이 브라우저를 알아보지 못해 이 브라우저를 포함한 2곳(Edge · Windows, 알 수 없는 브라우저)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
// 계정 보안 화면의 순수 함수 — 계약 5절 "계정 보안 — 12단계" · 디자인 README "12단계 — 계정 보안"
|
||||
// 인증 앱 코드 칸 다듬기 · 4자씩 끊어 보이기 · 기기 이름 · 앱 비밀번호 이름 검사 · 로그인 기록의 방식 · 결과 글
|
||||
|
||||
import {
|
||||
APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
APP_PASSWORD_TEXT,
|
||||
END_OTHER_SESSIONS_NAME_LIMIT,
|
||||
END_OTHER_SESSIONS_TEXT,
|
||||
LOGIN_HISTORY_TEXT,
|
||||
OTP_CODE_LENGTH,
|
||||
TWO_FACTOR_TEXT,
|
||||
} from '@/shared/constants/account-security'
|
||||
import type {
|
||||
AppPassword,
|
||||
AppPasswordLastUsed,
|
||||
AuthSession,
|
||||
LoginDevice,
|
||||
LoginEvent,
|
||||
LoginMethod,
|
||||
MailClientProtocol,
|
||||
TwoFactorStatus,
|
||||
} from '@/shared/types/account-security'
|
||||
import { formatCount, formatLongDate, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
|
||||
const NON_DIGITS = /\D/g
|
||||
const COMPLETE_OTP = new RegExp(`^\\d{${OTP_CODE_LENGTH}}$`)
|
||||
|
||||
/**
|
||||
* 인증 앱 코드 칸에 들어온 글을 다듬는다 — 숫자만 남기고 6자리에서 자른다.
|
||||
* 붙여 넣은 "123 456" · "123-456" 과 전각 숫자(NFKC 로 반각)도 받는다 (계약 5절 "로그인" 클라이언트).
|
||||
* 칸에 maxlength 를 걸지 않는 까닭: 브라우저가 붙여 넣은 글을 먼저 잘라 "123 45" 만 들어온다
|
||||
*/
|
||||
export function normalizeOtpInput(raw: string): string {
|
||||
return raw.normalize('NFKC').replace(NON_DIGITS, '').slice(0, OTP_CODE_LENGTH)
|
||||
}
|
||||
|
||||
/** 숫자 6자리를 다 넣었는가 — 서버도 같은 모양만 받는다(아니면 400 VAL_001) */
|
||||
export function isCompleteOtp(code: string): boolean {
|
||||
return COMPLETE_OTP.test(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* 글을 size 글자씩 끊는다 — 2단계 인증 키 · 앱 비밀번호를 4자씩 떨어뜨려 보인다.
|
||||
* 띄어쓰기를 넣지 않고 조각으로만 나눈다 — 화면은 조각 사이를 여백으로 벌리므로, 골라 복사해도 원래 값 그대로다
|
||||
*/
|
||||
export function groupCharacters(value: string, size = 4): string[] {
|
||||
const groups: string[] = []
|
||||
for (let start = 0; start < value.length; start += size) groups.push(value.slice(start, start + size))
|
||||
return groups
|
||||
}
|
||||
|
||||
/** 조각을 size 개씩 줄로 묶는다 — 2단계 인증 키를 일부러 두 줄(한 줄에 4묶음)로 그린다 */
|
||||
export function chunkItems<T>(items: readonly T[], size: number): T[][] {
|
||||
const lines: T[][] = []
|
||||
for (let start = 0; start < items.length; start += size) lines.push(items.slice(start, start + size))
|
||||
return lines
|
||||
}
|
||||
|
||||
/** 브라우저 · 운영체제 — "Edge · Windows". 서버가 모르면(null) "알 수 없는 브라우저" */
|
||||
export function deviceLabel(device: LoginDevice | null): string {
|
||||
const browser = device?.browser?.trim() || LOGIN_HISTORY_TEXT.unknownBrowser
|
||||
const os = device?.os?.trim()
|
||||
return os ? `${browser} · ${os}` : browser
|
||||
}
|
||||
|
||||
/** 휴대폰 아이콘을 쓸 운영체제 — 서버의 짧은 이름 (계약 5절 "로그인된 브라우저") */
|
||||
const MOBILE_OS = new Set(['iOS', 'Android'])
|
||||
|
||||
/** 로그인된 브라우저 줄의 아이콘 — 휴대폰 · PC (모르면 PC) */
|
||||
export function isMobileDevice(device: LoginDevice | null): boolean {
|
||||
return MOBILE_OS.has(device?.os ?? '')
|
||||
}
|
||||
|
||||
/** 2단계 인증 상태 글 — 「꺼짐」 · 「켜짐 — 2026년 10월 31일부터」(켠 때를 모르면 「켜짐」) */
|
||||
export function twoFactorStateLabel(status: TwoFactorStatus): string {
|
||||
if (!status.isEnabled) return TWO_FACTOR_TEXT.off
|
||||
const day = status.enabledAt ? formatLongDate(status.enabledAt) : ''
|
||||
return day ? TWO_FACTOR_TEXT.onSince(day) : TWO_FACTOR_TEXT.on
|
||||
}
|
||||
|
||||
export type AppPasswordNameProblem = 'empty' | 'tooLong' | 'duplicate'
|
||||
|
||||
/**
|
||||
* 앱 비밀번호 이름 — 앞뒤 공백을 지운 값으로 1~40자(JS 문자열 길이), 이미 있는 이름이 아니어야 한다.
|
||||
* 서버도 같은 규칙으로 400 VAL_001 — 화면이 먼저 보고 칸 아래에 알린다 (계약 5절 "앱 비밀번호")
|
||||
*/
|
||||
export function appPasswordNameProblem(name: string, existing: readonly AppPassword[]): AppPasswordNameProblem | null {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '') return 'empty'
|
||||
if (trimmed.length > APP_PASSWORD_NAME_MAX_LENGTH) return 'tooLong'
|
||||
if (existing.some((item) => item.name.trim() === trimmed)) return 'duplicate'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 메일 프로그램 방식 — "IMAP" · "SMTP" */
|
||||
export function protocolLabel(protocol: MailClientProtocol): string {
|
||||
return protocol.toUpperCase()
|
||||
}
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — "오늘 오전 9:12 · IMAP · 211.186.95.252". 쓰지 않았으면 null(화면은 「아직 쓰지 않음」) */
|
||||
export function appPasswordLastUsedText(lastUsed: AppPasswordLastUsed | null, now: number): string | null {
|
||||
if (!lastUsed) return null
|
||||
const parts = [formatRelativeDayTime(lastUsed.at, new Date(now)), protocolLabel(lastUsed.method)]
|
||||
if (lastUsed.ip) parts.push(lastUsed.ip)
|
||||
return parts.filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
const METHOD_LABEL: Readonly<Record<LoginMethod, string>> = {
|
||||
webmail: LOGIN_HISTORY_TEXT.methodWebmail,
|
||||
imap: LOGIN_HISTORY_TEXT.methodImap,
|
||||
smtp: LOGIN_HISTORY_TEXT.methodSmtp,
|
||||
}
|
||||
|
||||
/** 로그인 기록의 방식 — "웹메일" · "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" */
|
||||
export function loginMethodLabel(method: LoginMethod): string {
|
||||
return METHOD_LABEL[method] ?? method
|
||||
}
|
||||
|
||||
/** 로그인 기록의 방식 아래 줄 — 앱 비밀번호로 들어왔으면 "앱 비밀번호 ‘회사 PC 아웃룩’" (지운 앱 비밀번호도 그 이름 그대로) */
|
||||
export function loginAppPasswordLabel(event: LoginEvent): string | null {
|
||||
return event.appPassword ? LOGIN_HISTORY_TEXT.appPassword(event.appPassword.name) : null
|
||||
}
|
||||
|
||||
/** 로그인 기록의 기기 — 웹메일은 "Chrome · Windows", 메일 프로그램은 기기를 몰라 null(화면은 「—」) */
|
||||
export function loginDeviceLabel(event: LoginEvent): string | null {
|
||||
return event.method === 'webmail' ? deviceLabel(event.device) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 기록의 결과 글 — 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」.
|
||||
* 실패는 화면이 빨간 굵은 글자로만 보인다 (뱃지 없음)
|
||||
*/
|
||||
export function loginResultLabel(event: LoginEvent): { label: string; isFailure: boolean } {
|
||||
if (event.isSuccess) {
|
||||
return { label: event.isTwoFactor ? LOGIN_HISTORY_TEXT.successTwoFactor : LOGIN_HISTORY_TEXT.success, isFailure: false }
|
||||
}
|
||||
switch (event.failureReason) {
|
||||
case 'wrongPassword':
|
||||
return { label: LOGIN_HISTORY_TEXT.failureWrongPassword, isFailure: true }
|
||||
case 'wrongCode':
|
||||
return { label: LOGIN_HISTORY_TEXT.failureWrongCode, isFailure: true }
|
||||
case 'appPasswordRequired':
|
||||
return { label: LOGIN_HISTORY_TEXT.failureAppPasswordRequired, isFailure: true }
|
||||
default:
|
||||
return { label: LOGIN_HISTORY_TEXT.failure, isFailure: true }
|
||||
}
|
||||
}
|
||||
|
||||
/** [다른 곳 모두 로그아웃]이 끊을 곳 — 지금 이 브라우저를 뺀 세션 */
|
||||
export function otherSessions(sessions: readonly AuthSession[]): AuthSession[] {
|
||||
return sessions.filter((session) => !session.isCurrent)
|
||||
}
|
||||
|
||||
/**
|
||||
* [다른 곳 모두 로그아웃] 확인 창 글 — "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …".
|
||||
* 이름은 세 곳까지 늘어놓고 넘으면 "외 n곳".
|
||||
* 지금 이 브라우저 줄이 없으면(includesThisBrowser — 서버가 이 세션을 알아보지 못해 모두 끊는다) 이 브라우저도 로그아웃된다는 글
|
||||
*/
|
||||
export function endOtherSessionsMessage(others: readonly AuthSession[], includesThisBrowser = false): string {
|
||||
const count = others.length
|
||||
const names = others.slice(0, END_OTHER_SESSIONS_NAME_LIMIT).map((session) => deviceLabel(session.device))
|
||||
const rest = count - names.length
|
||||
const listed = rest > 0 ? `${names.join(', ')} ${END_OTHER_SESSIONS_TEXT.moreNames(formatCount(rest))}` : names.join(', ')
|
||||
return includesThisBrowser
|
||||
? END_OTHER_SESSIONS_TEXT.messageAll(formatCount(count), listed)
|
||||
: END_OTHER_SESSIONS_TEXT.message(formatCount(count), listed)
|
||||
}
|
||||
|
||||
/** 앱 비밀번호 개수 글 — "3 / 5개" */
|
||||
export function appPasswordCountText(count: number, max: number): string {
|
||||
return APP_PASSWORD_TEXT.count(formatCount(count), formatCount(max))
|
||||
}
|
||||
@@ -313,6 +313,13 @@ export function formatBlockedDay(value: string, now: Date = new Date(), timeZone
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/** 연도까지 쓴 날 (디자인 원본 SettingsTwoFactor: "켜짐 — 2026년 10월 31일부터") */
|
||||
export function formatLongDate(value: string, timeZone?: string): string {
|
||||
const date = parseDate(value)
|
||||
if (!date) return ''
|
||||
return new Intl.DateTimeFormat('ko-KR', { timeZone, year: 'numeric', month: 'long', day: 'numeric' }).format(date)
|
||||
}
|
||||
|
||||
/** 문의 코드로 보여 줄 traceId 앞자리 수 (디자인 원본 States: "문의 코드 3f1c0a2e") */
|
||||
export const TRACE_CODE_LENGTH = 8
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encode } from 'uqr'
|
||||
|
||||
import { qrModuleRuns, type QrModules } from './qr-code'
|
||||
|
||||
// QR 코드 칸 — 2단계 인증 켜기 창 (계약 5절 "2단계 인증" — QR 은 화면이 otpauthUrl 로 그린다)
|
||||
|
||||
const OTPAUTH_URL =
|
||||
'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30'
|
||||
|
||||
/** 이어진 덩이를 다시 칸 표로 */
|
||||
function matrixOf({ size, runs }: QrModules): boolean[][] {
|
||||
const matrix = Array.from({ length: size }, () => Array.from({ length: size }, () => false))
|
||||
for (const run of runs) {
|
||||
for (let x = run.x; x < run.x + run.width; x += 1) {
|
||||
const row = matrix[run.y]
|
||||
if (row) row[x] = true
|
||||
}
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
/** (left, top) 에서 시작하는 7×7 찾기 무늬 — 바깥 테 어둡고 · 그 안 한 줄 밝고 · 가운데 3×3 어둡다 */
|
||||
function hasFinderPattern(matrix: boolean[][], left: number, top: number): boolean {
|
||||
for (let dy = 0; dy < 7; dy += 1) {
|
||||
for (let dx = 0; dx < 7; dx += 1) {
|
||||
const ring = Math.max(Math.abs(dx - 3), Math.abs(dy - 3))
|
||||
const expected = ring !== 2
|
||||
if (matrix[top + dy]?.[left + dx] !== expected) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
describe('qrModuleRuns', () => {
|
||||
it('부호기(uqr)의 칸 표와 똑같다 — 어두운 칸을 줄마다 이어진 덩이로 옮겼을 뿐이다', () => {
|
||||
const modules = qrModuleRuns(OTPAUTH_URL)
|
||||
const reference = encode(OTPAUTH_URL, { ecc: 'M', border: 0 })
|
||||
|
||||
expect(modules.size).toBe(reference.size)
|
||||
expect(matrixOf(modules)).toEqual(reference.data)
|
||||
})
|
||||
|
||||
it('한 변은 17 + 4 × 판이고 세 귀퉁이에 찾기 무늬가 있다 (테두리 없이 — 여백은 그리는 쪽이 둔다)', () => {
|
||||
const modules = qrModuleRuns(OTPAUTH_URL)
|
||||
const matrix = matrixOf(modules)
|
||||
const far = modules.size - 7
|
||||
|
||||
expect((modules.size - 17) % 4).toBe(0)
|
||||
// otpauth 주소 150자 남짓 — 오류 정정 M 이면 8판(49칸)
|
||||
expect(modules.size).toBe(49)
|
||||
expect(hasFinderPattern(matrix, 0, 0)).toBe(true)
|
||||
expect(hasFinderPattern(matrix, far, 0)).toBe(true)
|
||||
expect(hasFinderPattern(matrix, 0, far)).toBe(true)
|
||||
expect(hasFinderPattern(matrix, far, far)).toBe(false)
|
||||
})
|
||||
|
||||
it('덩이는 칸 안에 있고 한 줄에서 서로 붙지 않는다 (붙을 수 있으면 한 덩이로 이었다)', () => {
|
||||
const { size, runs } = qrModuleRuns(OTPAUTH_URL)
|
||||
const byRow = new Map<number, { x: number; width: number }[]>()
|
||||
for (const run of runs) {
|
||||
expect(run.width).toBeGreaterThan(0)
|
||||
expect(run.x).toBeGreaterThanOrEqual(0)
|
||||
expect(run.x + run.width).toBeLessThanOrEqual(size)
|
||||
byRow.set(run.y, [...(byRow.get(run.y) ?? []), run])
|
||||
}
|
||||
for (const row of byRow.values()) {
|
||||
for (let index = 1; index < row.length; index += 1) {
|
||||
const previous = row[index - 1]
|
||||
const current = row[index]
|
||||
if (previous && current) expect(current.x).toBeGreaterThan(previous.x + previous.width)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('같은 글은 늘 같은 그림이다', () => {
|
||||
expect(qrModuleRuns(OTPAUTH_URL)).toEqual(qrModuleRuns(OTPAUTH_URL))
|
||||
expect(qrModuleRuns(OTPAUTH_URL)).not.toEqual(qrModuleRuns(OTPAUTH_URL.replace('JBSWY3DP', 'KRSXG5CT')))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// QR 코드 칸 — 2단계 인증 켜기 창의 QR (계약 5절 "2단계 인증" — QR 은 화면이 otpauthUrl 로 그린다)
|
||||
// 부호화는 uqr(MIT · 의존성 없음 — Project Nayuki 의 QR 생성기를 옮긴 것)에 맡기고, 여기서는 그릴 칸만 고른다.
|
||||
// 화면(QrCodeImage.vue)은 이 칸을 SVG <rect> 로 v-for 해 그린다 — v-html · 남이 만든 SVG 글을 쓰지 않는다.
|
||||
// 비밀이 든 글(otpauthUrl)을 받으므로 로그 · 저장 없이 계산만 한다.
|
||||
import { encode } from 'uqr'
|
||||
|
||||
/** 오류 정정 단계 — M(약 15% 복구). 화면에 비친 빛 · 모니터 줄무늬에도 읽히고, L 보다 한 판 커질 뿐이다 (otpauth 주소 150자 남짓 — 49칸) */
|
||||
const QR_ERROR_CORRECTION = 'M'
|
||||
|
||||
/** 한 줄에서 이어진 어두운 칸 — 칸마다 <rect> 를 두지 않고 가로로 이어 그린다 (그릴 요소 수가 절반 아래로) */
|
||||
export interface QrRun {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
}
|
||||
|
||||
export interface QrModules {
|
||||
/** 한 변의 칸 수 (테두리 없이 — 17 + 4 × 판) */
|
||||
size: number
|
||||
runs: QrRun[]
|
||||
}
|
||||
|
||||
/** 글을 QR 로 부호화해 어두운 칸을 줄마다 이어진 덩이로 돌려준다 — 바깥 여백(quiet zone)은 그리는 쪽이 둔다 */
|
||||
export function qrModuleRuns(text: string): QrModules {
|
||||
const { size, data } = encode(text, { ecc: QR_ERROR_CORRECTION, border: 0 })
|
||||
const runs: QrRun[] = []
|
||||
data.forEach((row, y) => {
|
||||
let x = 0
|
||||
while (x < size) {
|
||||
if (!row[x]) {
|
||||
x += 1
|
||||
continue
|
||||
}
|
||||
const start = x
|
||||
while (x < size && row[x]) x += 1
|
||||
runs.push({ x: start, y, width: x - start })
|
||||
}
|
||||
})
|
||||
return { size, runs }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import type { AppPassword } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
|
||||
import { useAppPasswordsStore } from './app-passwords.store'
|
||||
|
||||
// 앱 비밀번호 스토어 — 값은 받지도 두지도 않는다 · 늦게 온 응답 · 세션 세대 (계약 5절 "앱 비밀번호")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const EXISTING: AppPassword = { id: 'a', name: '회사 PC 아웃룩', createdAt: '2026-10-27T00:00:00Z', lastUsed: null }
|
||||
const ADDED: AppPassword = { id: 'b', name: '휴대폰 메일 앱', createdAt: '2026-10-31T00:00:00Z', lastUsed: null }
|
||||
|
||||
const pageOf = (items: AppPassword[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
|
||||
|
||||
describe('app-passwords.store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('만들기 요청은 보내지 않는다 — 값을 뺀 줄만 add 로 받아 맨 위에 넣는다', async () => {
|
||||
const requests = installMockApi(() => ok(pageOf([EXISTING])))
|
||||
const store = useAppPasswordsStore()
|
||||
await store.load()
|
||||
|
||||
store.add(ADDED)
|
||||
|
||||
expect(store.items.map((item) => item.id)).toEqual(['b', 'a'])
|
||||
expect(requests.map((request) => request.method)).toEqual(['GET'])
|
||||
expect('create' in store).toBe(false)
|
||||
})
|
||||
|
||||
it('받는 중인 불러오기는 add 보다 옛것이라 버린다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
const store = useAppPasswordsStore()
|
||||
const loading = store.load()
|
||||
await flushPromises()
|
||||
|
||||
store.add(ADDED)
|
||||
release(ok(pageOf([EXISTING])))
|
||||
await loading
|
||||
|
||||
expect(store.items.map((item) => item.id)).toEqual(['b'])
|
||||
expect(store.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('지우는 사이 세션 세대가 바뀌었으면 목록에 쓰지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
installMockApi((request) =>
|
||||
request.method === 'GET'
|
||||
? ok(pageOf([EXISTING]))
|
||||
: new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
const store = useAppPasswordsStore()
|
||||
await store.load()
|
||||
|
||||
const removing = store.remove('a')
|
||||
await flushPromises()
|
||||
useAuthStore().startSession(USER)
|
||||
release(ok(null))
|
||||
await removing
|
||||
|
||||
expect(store.items.map((item) => item.id)).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import { APP_PASSWORD_MAX } from '@/shared/constants/account-security'
|
||||
import type { AppPassword } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
// 앱 비밀번호 스토어 — 계약 5절 "앱 비밀번호" (12단계 — 엔진 상한 5개)
|
||||
// - 설정 › 앱 비밀번호에 들어올 때마다 새로 받는다(load) — 다른 탭에서 만들었거나, 비밀번호를 바꿔 모두 지워졌을 수 있다. 오류는 화면이 직접 그린다
|
||||
// - 만들기 요청은 여기서 보내지 않는다 — 값(password)이 든 응답이 개발 도구(Pinia 타임라인)에 남지 않게, 화면이 보내고 값을 뺀 줄만 add 로 넣는다
|
||||
// (값은 결과 창의 화면 메모리에만 — 창을 닫으면 지운다)
|
||||
// - 지우기는 응답을 받은 뒤 줄을 뺀다(이미 없어도 200). 실패는 예외가 그대로 올라간다(알림은 인터셉터)
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq), 지우기는 세션 세대(authStore.generation).
|
||||
// add 는 부르는 쪽이 세션 세대를 본 뒤에 부른다
|
||||
export const useAppPasswordsStore = defineStore('app-passwords', () => {
|
||||
const api = useAccountSecurity()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const items = ref<AppPassword[]>([])
|
||||
const status = ref<LoadStatus>('idle')
|
||||
const error = ref<ApiError | null>(null)
|
||||
/** 상한까지 만들었다 — [만들기]를 막는다 */
|
||||
const isFull = computed<boolean>(() => items.value.length >= APP_PASSWORD_MAX)
|
||||
|
||||
let seq = 0
|
||||
|
||||
/** 새로 받는다 — 실패하면 알리지 않고 상태에 담는다 (화면이 사전 문구 + [다시 시도]) */
|
||||
async function load(): Promise<void> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
status.value = 'loading'
|
||||
error.value = null
|
||||
try {
|
||||
const page = await api.listAppPasswords({ silent: true })
|
||||
if (current !== seq) return
|
||||
items.value = page.items
|
||||
status.value = 'ready'
|
||||
} catch (caught) {
|
||||
if (current !== seq) return
|
||||
status.value = 'error'
|
||||
error.value = toApiError(caught)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면을 그대로 둔 채 다시 받는다 — 만들기가 상한(409 AUTH_009 — 그사이 다른 창에서 채웠다)에 걸렸을 때 개수를 맞춘다.
|
||||
* 실패하면 알리지 않고 받아 둔 목록을 둔다
|
||||
*/
|
||||
async function refresh(): Promise<void> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
try {
|
||||
const page = await api.listAppPasswords({ silent: true })
|
||||
if (current !== seq) return
|
||||
items.value = page.items
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
} catch {
|
||||
// 받아 둔 목록을 둔다 — 다음에 들어올 때 새로 받는다
|
||||
}
|
||||
}
|
||||
|
||||
/** 만든 줄(값을 뺀 것)을 맨 위에 넣는다 — 받는 중인 불러오기는 이것보다 옛것일 수 있어 버린다 */
|
||||
function add(item: AppPassword): void {
|
||||
seq += 1
|
||||
items.value = [item, ...items.value.filter((existing) => existing.id !== item.id)]
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
}
|
||||
|
||||
/** 지우기 — 응답을 받은 뒤 줄을 뺀다. 기다리는 사이 세션이 바뀌었으면 목록에는 쓰지 않는다 */
|
||||
async function remove(id: string): Promise<void> {
|
||||
const session = authStore.generation
|
||||
await api.deleteAppPassword(id)
|
||||
if (authStore.generation !== session) return
|
||||
seq += 1
|
||||
items.value = items.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
seq += 1
|
||||
items.value = []
|
||||
status.value = 'idle'
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 목록이 남지 않게
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
() => reset(),
|
||||
)
|
||||
|
||||
return { items, status, error, isFull, load, refresh, add, remove, reset }
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import type { AuthSession } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
|
||||
import { useLoginHistoryStore } from './login-history.store'
|
||||
|
||||
// 로그인 기록 스토어 — 늦게 온 응답 · 세션 세대 (계약 5절 "로그인된 브라우저" · "로그인 기록")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
|
||||
function session(id: string, isCurrent = false): AuthSession {
|
||||
return { id, createdAt: '2026-10-31T00:00:00Z', lastActiveAt: '2026-10-31T00:00:00Z', ip: null, device: null, isCurrent }
|
||||
}
|
||||
|
||||
const pageOf = <T>(items: T[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
|
||||
|
||||
describe('login-history.store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('세션을 끊는 사이 세션 세대가 바뀌었으면(같은 직원이 다시 로그인) 목록에 쓰지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
installMockApi((request) =>
|
||||
request.method === 'GET'
|
||||
? ok(pageOf([session('now', true), session('other')]))
|
||||
: new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
const store = useLoginHistoryStore()
|
||||
await store.loadSessions()
|
||||
|
||||
const ending = store.endSession('other')
|
||||
await flushPromises()
|
||||
useAuthStore().startSession(USER)
|
||||
release(ok(null))
|
||||
await ending
|
||||
|
||||
expect(store.sessions.map((item) => item.id)).toEqual(['now', 'other'])
|
||||
})
|
||||
|
||||
it('끊기의 실패(장애)는 그대로 올라간다 — 이미 끝난 세션(404)만 끊긴 것으로 본다', async () => {
|
||||
let reply: MockResult = fail(503, 'SYS_001')
|
||||
installMockApi((request) => (request.method === 'GET' ? ok(pageOf([session('now', true), session('other')])) : reply))
|
||||
const store = useLoginHistoryStore()
|
||||
await store.loadSessions()
|
||||
|
||||
await expect(store.endSession('other')).rejects.toMatchObject({ code: 'SYS_001' })
|
||||
expect(store.sessions).toHaveLength(2)
|
||||
|
||||
reply = fail(404, 'RES_001')
|
||||
await store.endSession('other')
|
||||
expect(store.sessions.map((item) => item.id)).toEqual(['now'])
|
||||
// 404 는 알리지 않는다 (장애는 인터셉터가 한 번 알렸다)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('로그아웃 · 다른 직원이면 받아 둔 기록을 지운다', async () => {
|
||||
installMockApi(() => ok(pageOf([session('now', true)])))
|
||||
const store = useLoginHistoryStore()
|
||||
await store.loadSessions()
|
||||
await store.loadEvents()
|
||||
store.setFilter('failures')
|
||||
await flushPromises()
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await flushPromises()
|
||||
|
||||
expect(store.sessions).toEqual([])
|
||||
expect(store.sessionsStatus).toBe('idle')
|
||||
expect(store.events).toEqual([])
|
||||
expect(store.eventsFilter).toBe('all')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import type { AuthSession, LoginEvent, LoginEventQuery } from '@/shared/types/account-security'
|
||||
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
/** 최근 로그인의 보기 — 전체 · 실패만 */
|
||||
export type LoginEventFilter = 'all' | 'failures'
|
||||
|
||||
// 로그인 기록 스토어 — 계약 5절 "로그인된 브라우저" · "로그인 기록" (12단계)
|
||||
// - 설정 › 로그인 기록에 들어올 때마다 둘 다 새로 받는다(loadSessions · loadEvents). 오류는 묶음마다 화면이 직접 그린다(사전 문구 + [다시 시도])
|
||||
// - 로그인된 브라우저: 줄의 [로그아웃]은 묻지 않고 바로 끊고(이미 끝난 세션 404 RES_001 도 끊긴 것과 같다) 줄을 뺀다.
|
||||
// [다른 곳 모두 로그아웃]은 이 요청의 세션만 남긴다 — 지금 세션 줄만 남긴다
|
||||
// - 최근 로그인: 20개씩 [더 보기](커서). [전체 · 실패만]을 바꾸면 그 보기로 처음부터 다시 받는다 — 받는 동안은 지난 목록을 그대로 두고(busy)
|
||||
// 다 받으면 바꾼다. 보이는 목록의 보기는 loadedFilter(머리 글 "실패 n번" · 빈 상태가 이것을 본다)
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq), 끊기는 세션 세대(authStore.generation)
|
||||
export const useLoginHistoryStore = defineStore('login-history', () => {
|
||||
const api = useAccountSecurity()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── 로그인된 브라우저 ─────────────
|
||||
const sessions = ref<AuthSession[]>([])
|
||||
const sessionsStatus = ref<LoadStatus>('idle')
|
||||
const sessionsError = ref<ApiError | null>(null)
|
||||
let sessionsSeq = 0
|
||||
|
||||
async function loadSessions(): Promise<void> {
|
||||
sessionsSeq += 1
|
||||
const current = sessionsSeq
|
||||
sessionsStatus.value = 'loading'
|
||||
sessionsError.value = null
|
||||
try {
|
||||
const page = await api.listAuthSessions({ silent: true })
|
||||
if (current !== sessionsSeq) return
|
||||
sessions.value = page.items
|
||||
sessionsStatus.value = 'ready'
|
||||
} catch (caught) {
|
||||
if (current !== sessionsSeq) return
|
||||
sessionsStatus.value = 'error'
|
||||
sessionsError.value = toApiError(caught)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 하나 끊기 — 이미 끝났거나 없는 세션(404 RES_001)도 끊긴 것과 같다(알리지 않는다).
|
||||
* 그 밖의 실패는 예외가 그대로 올라간다(알림은 인터셉터). 기다리는 사이 세션이 바뀌었으면 목록에는 쓰지 않는다
|
||||
*/
|
||||
async function endSession(id: string): Promise<void> {
|
||||
const session = authStore.generation
|
||||
try {
|
||||
await api.endAuthSession(id, { silent: ['RES_001'] })
|
||||
} catch (caught) {
|
||||
if (toApiError(caught).code !== 'RES_001') throw caught
|
||||
}
|
||||
if (authStore.generation !== session) return
|
||||
sessionsSeq += 1
|
||||
sessions.value = sessions.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 곳 모두 로그아웃 — 끊은 수와, 이 브라우저까지 끊겼을 수 있는지를 돌려준다.
|
||||
* - 지금 세션 줄이 있으면 그 줄만 남긴다.
|
||||
* - 없으면(서버가 이 요청의 세션을 몰라 모두 끊는다 — 계약 5절) 빈 목록을 두지 않고 다시 받는다 — 이 브라우저도 끊겼으면
|
||||
* 그 요청이 401 → 갱신 실패 → 세션 만료 창으로 이어지고, 끊기지 않았으면 남은 줄을 그린다
|
||||
*/
|
||||
async function endOtherSessions(): Promise<{ ended: number; includedThisBrowser: boolean }> {
|
||||
const session = authStore.generation
|
||||
const hadCurrent = sessions.value.some((item) => item.isCurrent)
|
||||
const result = await api.endOtherAuthSessions()
|
||||
const outcome = { ended: result.ended, includedThisBrowser: !hadCurrent }
|
||||
if (authStore.generation !== session) return outcome
|
||||
if (hadCurrent) {
|
||||
sessionsSeq += 1
|
||||
sessions.value = sessions.value.filter((item) => item.isCurrent)
|
||||
} else {
|
||||
void loadSessions()
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
// ── 최근 로그인 ─────────────
|
||||
const events = ref<LoginEvent[]>([])
|
||||
const eventsPageInfo = shallowRef<PageInfo | null>(null)
|
||||
/** 고른 보기 — 전환 단추가 보인다 */
|
||||
const eventsFilter = ref<LoginEventFilter>('all')
|
||||
/** 지금 보이는 목록의 보기 — 받는 동안은 고른 보기와 다를 수 있다 */
|
||||
const loadedFilter = ref<LoginEventFilter>('all')
|
||||
const eventsStatus = ref<LoadStatus>('idle')
|
||||
const eventsError = ref<ApiError | null>(null)
|
||||
/** 보기를 바꿔 다시 받는 중 — 지난 목록은 그대로 둔다 */
|
||||
const eventsBusy = ref<boolean>(false)
|
||||
const loadingMore = ref<boolean>(false)
|
||||
let eventsSeq = 0
|
||||
|
||||
function eventQuery(filter: LoginEventFilter, cursor?: string): LoginEventQuery {
|
||||
return {
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(filter === 'failures' ? { isSuccess: false } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 고른 보기로 처음부터 받는다 — 목록이 있으면 그대로 둔 채(busy), 없으면 자리만 */
|
||||
async function loadEvents(): Promise<void> {
|
||||
eventsSeq += 1
|
||||
const current = eventsSeq
|
||||
const filter = eventsFilter.value
|
||||
loadingMore.value = false
|
||||
if (eventsStatus.value === 'ready') eventsBusy.value = true
|
||||
else eventsStatus.value = 'loading'
|
||||
eventsError.value = null
|
||||
try {
|
||||
const page = await api.listLoginEvents(eventQuery(filter), { silent: true })
|
||||
if (current !== eventsSeq) return
|
||||
events.value = page.items
|
||||
eventsPageInfo.value = page.pageInfo
|
||||
loadedFilter.value = filter
|
||||
eventsStatus.value = 'ready'
|
||||
} catch (caught) {
|
||||
if (current !== eventsSeq) return
|
||||
eventsStatus.value = 'error'
|
||||
eventsError.value = toApiError(caught)
|
||||
} finally {
|
||||
if (current === eventsSeq) eventsBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** [전체 · 실패만] — 바뀌었을 때만 다시 받는다 */
|
||||
function setFilter(filter: LoginEventFilter): void {
|
||||
if (eventsFilter.value === filter) return
|
||||
eventsFilter.value = filter
|
||||
void loadEvents()
|
||||
}
|
||||
|
||||
/** [더 보기] — 보이는 목록의 보기로 이어 받는다. 실패는 인터셉터가 알린다(받아 둔 목록은 그대로) */
|
||||
async function loadMoreEvents(): Promise<void> {
|
||||
const cursor = eventsPageInfo.value?.nextCursor
|
||||
if (loadingMore.value || eventsBusy.value || !cursor) return
|
||||
const current = eventsSeq
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const page = await api.listLoginEvents(eventQuery(loadedFilter.value, cursor))
|
||||
if (current !== eventsSeq) return
|
||||
const known = new Set(events.value.map((event) => event.id))
|
||||
events.value = [...events.value, ...page.items.filter((event) => !known.has(event.id))]
|
||||
eventsPageInfo.value = page.pageInfo
|
||||
} catch {
|
||||
// 인터셉터가 알렸다
|
||||
} finally {
|
||||
if (current === eventsSeq) loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasMoreEvents = computed<boolean>(() => eventsPageInfo.value?.hasNext === true)
|
||||
|
||||
/** 화면에 들어올 때 — 지난번 목록을 그리지 않고 둘 다 새로 받는다. 보기는 「전체」부터 */
|
||||
function loadPage(): void {
|
||||
eventsFilter.value = 'all'
|
||||
eventsStatus.value = 'idle'
|
||||
eventsBusy.value = false
|
||||
void loadSessions()
|
||||
void loadEvents()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
sessionsSeq += 1
|
||||
eventsSeq += 1
|
||||
sessions.value = []
|
||||
sessionsStatus.value = 'idle'
|
||||
sessionsError.value = null
|
||||
events.value = []
|
||||
eventsPageInfo.value = null
|
||||
eventsFilter.value = 'all'
|
||||
loadedFilter.value = 'all'
|
||||
eventsStatus.value = 'idle'
|
||||
eventsError.value = null
|
||||
eventsBusy.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
|
||||
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 기록이 남지 않게
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
() => reset(),
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
sessionsStatus,
|
||||
sessionsError,
|
||||
loadSessions,
|
||||
endSession,
|
||||
endOtherSessions,
|
||||
events,
|
||||
eventsPageInfo,
|
||||
eventsFilter,
|
||||
loadedFilter,
|
||||
eventsStatus,
|
||||
eventsError,
|
||||
eventsBusy,
|
||||
loadingMore,
|
||||
hasMoreEvents,
|
||||
loadEvents,
|
||||
setFilter,
|
||||
loadMoreEvents,
|
||||
loadPage,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import type { TwoFactorStatus } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
|
||||
import { useTwoFactorStore } from './two-factor.store'
|
||||
|
||||
// 2단계 인증 상태 스토어 — 늦게 온 응답 · 화면을 그대로 둔 다시 받기 (계약 5절 "2단계 인증")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
|
||||
const ON: TwoFactorStatus = { isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' }
|
||||
|
||||
describe('two-factor.store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('켠 결과(apply)가 먼저 오면 그 전에 시작한 불러오기(꺼짐)는 버린다', async () => {
|
||||
let releaseLoad: (result: MockResult) => void = () => undefined
|
||||
installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
releaseLoad = resolve
|
||||
}),
|
||||
)
|
||||
const store = useTwoFactorStore()
|
||||
|
||||
const loading = store.load()
|
||||
await flushPromises()
|
||||
store.apply(ON)
|
||||
releaseLoad(ok(OFF))
|
||||
await loading
|
||||
|
||||
expect(store.twoFactor).toEqual(ON)
|
||||
expect(store.isEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('켜기 · 끄기 요청은 스토어가 보내지 않는다 — 지금 비밀번호 · 코드가 액션 인자로 남지 않게 (창이 보내고 결과만 apply)', () => {
|
||||
const store = useTwoFactorStore()
|
||||
|
||||
expect('activate' in store).toBe(false)
|
||||
expect('deactivate' in store).toBe(false)
|
||||
})
|
||||
|
||||
it('refresh 는 화면을 그대로 둔다 — 받는 동안 status 를 바꾸지 않고, 실패하면 받아 둔 상태를 둔다', async () => {
|
||||
let reply: MockResult | Promise<MockResult> = ok(OFF)
|
||||
installMockApi(() => reply)
|
||||
const store = useTwoFactorStore()
|
||||
await store.load()
|
||||
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
reply = new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const refreshing = store.refresh()
|
||||
await flushPromises()
|
||||
expect(store.status).toBe('ready')
|
||||
release(fail(503, 'MAIL_001'))
|
||||
await expect(refreshing).resolves.toBeNull()
|
||||
expect(store.twoFactor).toEqual(OFF)
|
||||
|
||||
reply = ok(ON)
|
||||
await expect(store.refresh()).resolves.toEqual(ON)
|
||||
expect(store.isEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import type { TwoFactorStatus } from '@/shared/types/account-security'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
// 2단계 인증 상태 스토어 — 계약 5절 "2단계 인증" (12단계)
|
||||
// - 설정 › 2단계 인증 · 비밀번호 화면이 들어올 때마다 새로 받는다(load) — 다른 탭 · 엔진 관리 화면에서 바꿨을 수 있다. 오류는 화면이 직접 그린다
|
||||
// (비밀번호 화면은 못 받아도 그대로 쓸 수 있다 — 2단계를 켰는데 코드 없이 보내면 서버가 AUTH_007 로 알려 와 다시 받는다)
|
||||
// - 켜기 · 끄기 요청은 여기서 보내지 않는다 — 지금 비밀번호 · 코드가 든 액션 인자가 개발 도구(Pinia 타임라인)에 남지 않게, 창이 보내고
|
||||
// 비밀 없는 결과(GET 과 같은 모양)만 apply 로 넘긴다. 등록(키 · otpauth 주소)도 여기 두지 않는다 — 켜기 창의 화면 메모리에만
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq), 켜기 · 끄기 결과는 창이 세션 세대(authStore.generation)로 가린다
|
||||
export const useTwoFactorStore = defineStore('two-factor', () => {
|
||||
const api = useAccountSecurity()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const twoFactor = shallowRef<TwoFactorStatus | null>(null)
|
||||
const status = ref<LoadStatus>('idle')
|
||||
const error = ref<ApiError | null>(null)
|
||||
/** 켜져 있다 — 모르면 false */
|
||||
const isEnabled = computed<boolean>(() => twoFactor.value?.isEnabled === true)
|
||||
|
||||
let seq = 0
|
||||
|
||||
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 지난 값은 둔다) */
|
||||
async function load(): Promise<TwoFactorStatus | null> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
status.value = 'loading'
|
||||
error.value = null
|
||||
try {
|
||||
const result = await api.getTwoFactor({ silent: true })
|
||||
if (current !== seq) return twoFactor.value
|
||||
twoFactor.value = result
|
||||
status.value = 'ready'
|
||||
return result
|
||||
} catch (caught) {
|
||||
if (current !== seq) return twoFactor.value
|
||||
status.value = 'error'
|
||||
error.value = toApiError(caught)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면을 그대로 둔 채 다시 받는다 — 켜기 창의 처음부터 다시(AUTH_008 — 그사이 다른 곳에서 켰는지) · 비밀번호 화면의 AUTH_007.
|
||||
* 실패하면 알리지 않고 null (받아 둔 상태는 그대로)
|
||||
*/
|
||||
async function refresh(): Promise<TwoFactorStatus | null> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
try {
|
||||
const result = await api.getTwoFactor({ silent: true })
|
||||
if (current !== seq) return twoFactor.value
|
||||
twoFactor.value = result
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버가 알려 준 상태(켜기 · 끄기의 응답)를 그대로 둔다 — 받는 중인 불러오기는 이것보다 옛것일 수 있어 버린다 */
|
||||
function apply(result: TwoFactorStatus) {
|
||||
seq += 1
|
||||
twoFactor.value = result
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
seq += 1
|
||||
twoFactor.value = null
|
||||
status.value = 'idle'
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 상태가 남지 않게
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
() => reset(),
|
||||
)
|
||||
|
||||
return { twoFactor, status, error, isEnabled, load, refresh, apply, reset }
|
||||
})
|
||||
@@ -51,6 +51,25 @@ export function createTestRouter(): Router {
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
// 12단계 — 계정 보안 (router/routes.ts 와 같은 이름 · 모양)
|
||||
{
|
||||
path: '/settings/account/two-factor',
|
||||
name: 'settings-account-two-factor',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/account/app-passwords',
|
||||
name: 'settings-account-app-passwords',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/account/login-history',
|
||||
name: 'settings-account-login-history',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/mail/signature',
|
||||
name: 'settings-mail-signature',
|
||||
|
||||
Reference in New Issue
Block a user