= { ...record }
diff --git a/frontend/src/components/feedback/ToastHost.vue b/frontend/src/components/feedback/ToastHost.vue
index 66845ad..3b052ce 100644
--- a/frontend/src/components/feedback/ToastHost.vue
+++ b/frontend/src/components/feedback/ToastHost.vue
@@ -8,6 +8,7 @@
// 초점이 든 알림이 닫히면(시간이 다 됨 — 마감이 있는 알림 포함 · 단추 · 넘침 · 세션이 바뀜) 초점을 알림에 들어오기 전 자리로 돌려준다 —
// 그 요소가 사라졌으면 화면의 main 으로 (키보드 사용자가 자리를 잃고 body 로 빠지지 않게 — 2026-10-29 리뷰)
// 단추는 한 번만 움직인다 — 닫히는 동안(퇴장 전환) 다시 눌러도 할 일을 또 하지 않는다 (toast.store 의 takeAction)
+// ✕로 닫을 때까지 남는 알림(sticky — 16단계 주소록 가져오기 결과)은 성공 알림에도 ✕ 를 단다(브랜드 바탕용 색 — 디자인 원본 Contacts16 "4")
import { ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -150,10 +151,11 @@ watch(
{{ toast.action.label }}
{
>
임시 저장
+
+
+// 쓰기 화면 [서명 ▾] (디자인 원본 Signature16 6 · README "16단계 — 여러 서명") — 아래 단추 줄의 [임시 저장] 다음.
+// 단추는 [임시 저장]과 같은 글자 단추(44)에 ▾. 화면 낭독은 툴바의 글꼴 단추처럼 지금 본문에 든 서명까지 읽는다("서명: 대외" · "서명: 넣지 않음") —
+// 서명 자리가 어느 서명과도 같지 않으면(그 메일에서 고쳤거나 지운 서명) "서명"만.
+// 메뉴는 편집기 메뉴(EditorMenu — 항목 40 · 폭 220): 서명(만든 차례 — 지금 본문에 든 서명에 ✓, "기본" 표시 없음) · 넣지 않음 · 가는 선 · 서명 관리.
+// 서명이 없으면 넣지 않음과 서명 관리만. 키보드는 [보내기 ▾]와 같다 — Enter · Space · 아래 화살표로 열면 첫 항목에 초점, 마우스로 열면 메뉴 자체에,
+// Esc 는 메뉴만 닫고 [서명 ▾]로 돌아간다. 아래 자리가 모자라면 위로 연다(usePopupPlacement — 아래 단추 줄에서는 보통 위).
+// 고르면 select(서명 id · 넣지 않음은 null), 서명 관리는 manage — 본문의 서명 자리를 바꾸는 일과 낭독 글(announcement)은 쓰기 화면이 정한다.
+// 고정 id · 이름 — 단추 #compose-signature · 메뉴 .compose-signature__menu · 낭독 [data-signature-status]
+import { computed, ref, watch } from 'vue'
+
+import AppIcon from '@/components/AppIcon.vue'
+import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
+import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
+import { usePopupPlacement } from '@/composables/usePopupPlacement'
+import { COMPOSE_SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
+import type { ComposeSignatureState } from '@/shared/types/mail-signature'
+
+interface SignatureChoice {
+ id: string
+ name: string
+}
+
+interface Props {
+ /** 내 서명 — 만든 차례 */
+ signatures: readonly SignatureChoice[]
+ /** 지금 본문에 든 서명 */
+ state: ComposeSignatureState
+ /** 누를 수 없다 — 편집기를 불러오는 중 · 보내는 중 등 */
+ disabled?: boolean
+ /** 서명을 바꾼 뒤 화면 낭독기에 읽어 줄 글 (role=status) */
+ announcement?: string
+}
+
+interface Emits {
+ /** 서명을 골랐다 — 넣지 않음은 null */
+ (e: 'select', id: string | null): void
+ /** 서명 관리 — 설정 › 메일 › 서명으로 */
+ (e: 'manage'): void
+ /** 메뉴를 열었다 — 서명을 아직 받지 못했으면 다시 받아 본다 */
+ (e: 'open'): void
+}
+
+const props = withDefaults(defineProps(), {
+ disabled: false,
+ announcement: '',
+})
+const emit = defineEmits()
+
+/** 메뉴 항목 id — 서명 id(서버가 만든 값)와 겹치지 않게 앞에 붙인다 */
+const SIGNATURE_ITEM_PREFIX = 'signature:'
+const NONE_ITEM = 'none'
+const MANAGE_ITEM = 'manage'
+/** 키보드로 열면 첫 항목부터 — 켜진(✓) 항목이 있어도 */
+const FIRST_ITEM = '[role^="menuitem"]'
+
+const buttonRef = ref(null)
+const menuRef = ref | null>(null)
+const menuOpen = ref(false)
+/** 키보드로 열었다 — 첫 항목에 초점 */
+const byKeyboard = ref(false)
+/** 위로 연다 — 단추 아래가 모자랄 때 (메뉴를 그린 직후 · 초점을 주기 전에 잰다) */
+const { upward } = usePopupPlacement({
+ open: menuOpen,
+ trigger: () => buttonRef.value,
+ popup: () => {
+ const menu: unknown = menuRef.value?.$el
+ return menu instanceof HTMLElement ? menu : null
+ },
+})
+
+const trigger = computed(() => buttonRef.value)
+/** 단추의 화면 낭독 이름 — "서명: 대외" · "서명: 넣지 않음" · 고친 서명이면 "서명" */
+const buttonLabel = computed(() => {
+ const { state } = props
+ if (state.kind === 'none') return COMPOSE_SIGNATURE_TEXT.buttonLabel(COMPOSE_SIGNATURE_TEXT.none)
+ if (state.kind === 'signature') {
+ const current = props.signatures.find((signature) => signature.id === state.id)
+ if (current) return COMPOSE_SIGNATURE_TEXT.buttonLabel(current.name)
+ }
+ return COMPOSE_SIGNATURE_TEXT.button
+})
+const items = computed(() => {
+ const { state } = props
+ return [
+ ...props.signatures.map((signature) => ({
+ id: `${SIGNATURE_ITEM_PREFIX}${signature.id}`,
+ role: 'menuitemradio' as const,
+ label: signature.name,
+ checked: state.kind === 'signature' && state.id === signature.id,
+ })),
+ {
+ id: NONE_ITEM,
+ role: 'menuitemradio' as const,
+ label: COMPOSE_SIGNATURE_TEXT.none,
+ checked: state.kind === 'none',
+ },
+ menuSeparator('separator'),
+ { id: MANAGE_ITEM, label: COMPOSE_SIGNATURE_TEXT.manage },
+ ]
+})
+
+function openMenu(keyboard: boolean) {
+ if (props.disabled) return
+ byKeyboard.value = keyboard
+ menuOpen.value = true
+ emit('open')
+}
+
+function onClick(event: MouseEvent) {
+ if (menuOpen.value) {
+ menuOpen.value = false
+ return
+ }
+ // detail 0 — Enter · Space 로 눌렀다
+ openMenu(event.detail === 0)
+}
+
+function onKeydown(event: KeyboardEvent) {
+ if (event.key !== 'ArrowDown' || menuOpen.value) return
+ event.preventDefault()
+ openMenu(true)
+}
+
+function onMenuClose(restoreFocus: boolean) {
+ menuOpen.value = false
+ if (restoreFocus) buttonRef.value?.focus()
+}
+
+function onSelect(id: string) {
+ menuOpen.value = false
+ buttonRef.value?.focus()
+ if (id === MANAGE_ITEM) emit('manage')
+ else if (id === NONE_ITEM) emit('select', null)
+ else if (id.startsWith(SIGNATURE_ITEM_PREFIX)) emit('select', id.slice(SIGNATURE_ITEM_PREFIX.length))
+}
+
+// 보내는 중 · 양식을 새로 채움 등으로 꺼지면 메뉴도 닫는다
+watch(
+ () => props.disabled,
+ (off) => {
+ if (off) menuOpen.value = false
+ },
+)
+
+
+
+
+
+ {{ COMPOSE_SIGNATURE_TEXT.button }}
+
+
+
+
{{ announcement }}
+
+
+
+
diff --git a/frontend/src/components/mail/editor/EditorMenu.vue b/frontend/src/components/mail/editor/EditorMenu.vue
index a8e35a4..c8e0da3 100644
--- a/frontend/src/components/mail/editor/EditorMenu.vue
+++ b/frontend/src/components/mail/editor/EditorMenu.vue
@@ -19,6 +19,11 @@ interface Props {
align?: 'start' | 'end'
/** 마우스로 열었다 — 항목 대신 메뉴 자체에 초점 (EditorPopup) */
focusContent?: boolean
+ /**
+ * 키보드로 열 때 초점을 줄 항목 (선택자) — 주지 않으면 켜진 항목, 그다음 첫 항목 (EditorPopup).
+ * 메뉴 단추(쓰기 화면 [서명 ▾] — 16단계)는 켜진 항목이 있어도 첫 항목에서 시작한다
+ */
+ initialFocus?: string
}
interface Emits {
@@ -30,6 +35,7 @@ interface Emits {
withDefaults(defineProps(), {
align: 'start',
focusContent: false,
+ initialFocus: '',
})
const emit = defineEmits()
@@ -66,6 +72,7 @@ function select(item: EditorMenuItem, event: MouseEvent) {
:trigger="trigger"
:align="align"
:focus-content="focusContent"
+ :initial-focus="initialFocus"
@close="(restore) => emit('close', restore)"
>
diff --git a/frontend/src/components/mail/editor/mail-editor-handle.ts b/frontend/src/components/mail/editor/mail-editor-handle.ts
index 539f70b..b00d84f 100644
--- a/frontend/src/components/mail/editor/mail-editor-handle.ts
+++ b/frontend/src/components/mail/editor/mail-editor-handle.ts
@@ -59,6 +59,18 @@ export interface MailEditorHandle {
captureForSave: () => MailEditorSnapshot | null
/** 저장이 끝났다 — 그때의 본문을 "바꾸지 않은" 기준으로 삼는다 (isChanged 가 이것과 견준다) */
markSaved: (savePoint: EditorSavePoint) => void
+ /**
+ * 본문 안 서명 자리의 비교 값 — 서명 자리가 없으면 null (16단계 [서명 ▾] — 어느 서명이 들었는지 signatureFingerprint 와 견준다).
+ * 글(공백을 하나로)과 그림으로 만든 값이라 서명 안을 고쳤으면 어느 서명과도 같지 않다
+ */
+ signaturePlace: () => string | null
+ /** 서명 HTML 의 비교 값 — signaturePlace 와 같은 규칙(편집기 스키마로 읽은 모양) */
+ signatureFingerprint: (html: string) => string
+ /**
+ * 서명 자리만 바꾼다 — 쓴 글 · 인용은 그대로이고 실행 취소로 되돌린다. html 이 null 이면 서명 자리와 그 앞의 빈 줄을 뺀다.
+ * 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다
+ */
+ replaceSignature: (html: string | null) => void
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
diff --git a/frontend/src/components/mail/editor/mail-extensions.ts b/frontend/src/components/mail/editor/mail-extensions.ts
index 8480d1e..cb7ecf6 100644
--- a/frontend/src/components/mail/editor/mail-extensions.ts
+++ b/frontend/src/components/mail/editor/mail-extensions.ts
@@ -3,6 +3,7 @@
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크, 서명 그림.
// 제목 스타일·코드·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 그림은 서명 그림(/api/mail-signature/images/{id})만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
+// 서명 자리(16단계 — [서명 ▾] 가 바꾸는 자리)는 블록 노드 하나다 — 빈 줄 뒤의 속성 없는 div 로 나가고 들어온다 (mail-signature-block.ts).
// 붙여 넣은 글 · 원문 인용의 서식 값은 서버 형식에 맞는 것만 읽는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TableKit } from '@tiptap/extension-table'
@@ -23,6 +24,7 @@ import {
mailOrderedList,
starterKitPart,
} from '@/components/mail/editor/mail-format-extensions'
+import { MailSignatureBlock } from '@/components/mail/editor/mail-signature-block'
import { SignatureImage } from '@/components/mail/editor/signature-image'
import {
EDITOR_INDENT_STEP_PX,
@@ -266,6 +268,7 @@ export function createMailEditorExtensions(hooks: Partial = {})
MailTableCell,
MailTableHeader,
SignatureImage,
+ MailSignatureBlock,
Placeholder.configure({ placeholder: EDITOR_PLACEHOLDER }),
MailEditorBehavior.configure({
openLink: hooks.openLink ?? null,
diff --git a/frontend/src/components/mail/editor/mail-signature-block.spec.ts b/frontend/src/components/mail/editor/mail-signature-block.spec.ts
new file mode 100644
index 0000000..2004251
--- /dev/null
+++ b/frontend/src/components/mail/editor/mail-signature-block.spec.ts
@@ -0,0 +1,266 @@
+import { Editor, createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
+import { EditorState, TextSelection } from '@tiptap/pm/state'
+import { afterEach, beforeAll, describe, expect, it } from 'vitest'
+
+import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
+import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
+import {
+ MAIL_SIGNATURE_NODE,
+ findSignatureBlock,
+ replaceSignatureTransaction,
+ signatureFingerprintOf,
+ signatureNodeOf,
+ withSignatureBoundary,
+} from '@/components/mail/editor/mail-signature-block'
+import { buildQuoteHtml } from '@/shared/utils/mail-quote'
+import { newMailHtmlWithSignature, signatureBlockHtml } from '@/shared/utils/mail-signature'
+import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
+
+// 쓰기 본문의 서명 자리 (16단계 — 디자인 원본 Signature16 6 · 계약 2절 "앱이 만드는 모양")
+
+const schema = getSchema(createMailEditorExtensions())
+const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
+/** 서버가 정화해 준 서명 HTML 모양 — 글꼴 div 로 감쌌다 */
+const OUTSIDE =
+ '' +
+ '
김아라 · 인프라팀
' +
+ `
ARA SYSTEM ·
`
+const INSIDE = '김아라 · 인프라팀
내선 1234
'
+/** 보낸 본문의 감싸개 (toMailHtml · 앱 kMailBodyWrapperStyle) */
+const WRAPPER_STYLE = "font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75"
+
+function doc(html: string): ProseMirrorNode {
+ return createDocument(normalizeIncomingHtml(html), schema)
+}
+
+/** 맨 위 단계 블록의 모양 — 서명 자리는 [서명:글], 원문 인용은 [인용], 빈 문단은 '' */
+function blocks(node: ProseMirrorNode): string[] {
+ const out: string[] = []
+ node.forEach((child) => {
+ if (child.type.name === MAIL_SIGNATURE_NODE) out.push(`[서명:${child.textContent}]`)
+ else if (child.type.name === 'blockquote') out.push('[인용]')
+ else out.push(child.textContent)
+ })
+ return out
+}
+
+/** 보내는 모양 — 쓰기 화면과 같은 길 (getHTML → toMailHtml) */
+function mailHtml(node: ProseMirrorNode): string {
+ return toMailHtml(getHTMLFromFragment(node.content, schema))
+}
+
+/** 서버의 보내는 정화가 하는 일 중 경계에 닿는 것 — data-* 를 지운다 (sanitize-outgoing-html.ts) */
+function sanitizeLikeServer(html: string): string {
+ return html.replace(/ data-[a-z-]+="[^"]*"/g, '')
+}
+
+function apply(node: ProseMirrorNode, signatureHtml: string | null): ProseMirrorNode {
+ const state = EditorState.create({ doc: node, schema })
+ const signature = signatureHtml === null ? null : signatureNodeOf(schema, signatureHtml)
+ const tr = replaceSignatureTransaction(state, signature)
+ return tr ? state.apply(tr).doc : node
+}
+
+describe('서명 자리 — 읽기', () => {
+ it('새 메일은 쓸 줄 · 빈 줄 하나 · 서명 자리 — 서명 안의 글꼴 div 는 풀고 문단만 둔다', () => {
+ const node = doc(newMailHtmlWithSignature(OUTSIDE))
+ expect(blocks(node)).toEqual(['', '', '[서명:김아라 · 인프라팀ARA SYSTEM · ]'])
+ expect(findSignatureBlock(node)?.index).toBe(2)
+ })
+
+ it('답장 · 전달은 인용 위 — 쓸 줄 · 빈 줄 · 서명 자리 · 인용', () => {
+ const html = buildQuoteHtml([{ label: '제목', value: '일정' }], '원문
', signatureBlockHtml(INSIDE))
+ expect(blocks(doc(html))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
+ })
+
+ it('보낸(저장한) 본문은 빈 문단 뒤 속성 없는 div 로 나가고, 서버가 정화한 뒤 다시 열어도 그 자리를 찾는다', () => {
+ const node = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '원문
', signatureBlockHtml(INSIDE)))
+ const sent = mailHtml(node)
+ // 앱과 같은 경계 — 빈 문단( ) 바로 뒤의 속성 없는 div, 안의 문단은 margin: 0
+ expect(sent).toContain(
+ '
',
+ )
+ const reopened = doc(sanitizeLikeServer(sent))
+ expect(blocks(reopened)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
+ const before = findSignatureBlock(node)
+ const after = findSignatureBlock(reopened)
+ expect(before && after && signatureFingerprintOf(after.node)).toBe(before && signatureFingerprintOf(before.node))
+ })
+
+ it('앱이 만든 본문(compose_body.dart)도 서명 자리로 읽는다 — 감싸개 · 빈 문단 · 서명 div · 원문 인용', () => {
+ const app =
+ `` +
+ '
확인했습니다.
' +
+ '
' +
+ '
원본 메일
' +
+ '원문
'
+ expect(blocks(doc(app))).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀]', '[인용]'])
+ })
+
+ it.each([
+ ['빈 문단 뒤가 아니다', '글
'],
+ ['속성이 있다', '글
'],
+ ['글도 그림도 없다(인용 경계 표시가 남은 빈 div)', '글
다음
'],
+ ['원문 인용 안이다', '글
원본 메일
'],
+ ])('속성 없는 div 라도 서명 자리가 아니다 — %s', (_label, html) => {
+ expect(findSignatureBlock(doc(html))).toBeNull()
+ })
+})
+
+describe('서명 자리 — 바꾸기 (replaceSignatureTransaction)', () => {
+ const NEW_MAIL = newMailHtmlWithSignature(OUTSIDE)
+
+ it('다른 서명으로 — 그 자리만 바뀌고 쓴 글 · 인용은 그대로', () => {
+ const reply = buildQuoteHtml([{ label: '제목', value: '일정' }], '원문
', signatureBlockHtml(OUTSIDE))
+ const written = doc(reply.replace('
', '보내 주신 견적서 잘 받았습니다.
'))
+ const changed = apply(written, INSIDE)
+ expect(blocks(changed)).toEqual(['보내 주신 견적서 잘 받았습니다.', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
+ // 텍스트 대체 본문 — 내 글 · 빈 줄 · 서명 · 인용 줄 (서명 뒤에는 빈 줄이 없다 — 앱과 같다)
+ expect(docToText(changed)).toBe(
+ '보내 주신 견적서 잘 받았습니다.\n\n김아라 · 인프라팀\n내선 1234\n> 원본 메일\n> 제목: 일정\n>\n> 원문',
+ )
+ })
+
+ it('넣지 않음 — 서명 자리와 그 앞의 빈 줄을 뺀다', () => {
+ expect(blocks(apply(doc(NEW_MAIL.replace('
', '안녕하세요.
')), null))).toEqual(['안녕하세요.'])
+ })
+
+ it('서명 자리가 없으면 처음 넣는 자리에 — 새 메일은 쓴 글 끝에 빈 줄 하나 뒤로 (있는 빈 줄을 쓴다)', () => {
+ expect(blocks(apply(doc('안녕하세요.
'), INSIDE))).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]'])
+ expect(blocks(apply(doc('안녕하세요.
'), INSIDE))).toEqual([
+ '안녕하세요.',
+ '',
+ '[서명:김아라 · 인프라팀내선 1234]',
+ '',
+ ])
+ // 아무것도 쓰지 않았다 — 쓸 줄 · 빈 줄 · 서명 (처음 넣는 모양과 같다)
+ expect(blocks(apply(doc('
'), INSIDE))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]'])
+ })
+
+ it('서명 자리가 없으면 처음 넣는 자리에 — 답장 · 전달은 인용 위', () => {
+ const reply = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '원문
'))
+ expect(blocks(apply(reply, INSIDE))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
+ const written = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '원문
').replace('
', '네.
'))
+ expect(blocks(apply(written, INSIDE))).toEqual(['네.', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
+ })
+
+ it('서명 자리도 없고 넣지 않음이면 바꿀 것이 없다', () => {
+ const state = EditorState.create({ doc: doc('글
'), schema })
+ expect(replaceSignatureTransaction(state, null)).toBeNull()
+ })
+})
+
+describe('어느 서명이 들었나 (signatureFingerprintOf)', () => {
+ it('같은 서명은 같은 값 — 서명 안을 고치면 달라진다, 공백 · nbsp 는 하나로 본다', () => {
+ const inserted = findSignatureBlock(doc(newMailHtmlWithSignature(INSIDE)))
+ const fresh = signatureNodeOf(schema, INSIDE)
+ expect(inserted && fresh && signatureFingerprintOf(inserted.node)).toBe(fresh && signatureFingerprintOf(fresh))
+
+ const spaced = signatureNodeOf(schema, '김아라 \u00a0· 인프라팀
')
+ const plain = signatureNodeOf(schema, '김아라 · 인프라팀
')
+ expect(spaced && signatureFingerprintOf(spaced)).toBe(plain && signatureFingerprintOf(plain))
+
+ const edited = signatureNodeOf(schema, INSIDE.replace('1234', '5678'))
+ expect(edited && signatureFingerprintOf(edited)).not.toBe(fresh && signatureFingerprintOf(fresh))
+ })
+
+ it('그림도 견준다 — 글이 같아도 그림이 다르면 다른 서명', () => {
+ const withLogo = signatureNodeOf(schema, OUTSIDE)
+ const withoutLogo = signatureNodeOf(schema, OUTSIDE.replace(/ ]*>/, ''))
+ expect(withLogo && signatureFingerprintOf(withLogo)).not.toBe(withoutLogo && signatureFingerprintOf(withoutLogo))
+ })
+})
+
+describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-11-10 리뷰 M3)', () => {
+ const editors: Editor[] = []
+
+ beforeAll(installProseMirrorDom)
+
+ afterEach(() => {
+ for (const editor of editors.splice(0)) editor.destroy()
+ })
+
+ /** 쓰기 화면의 편집기와 같게 — 플러그인(서명 자리 지키기)은 화면에 붙일 때 켜진다 */
+ function mountedEditor(content: string): Editor {
+ const editor = new Editor({ element: null, extensions: createMailEditorExtensions(), content })
+ editors.push(editor)
+ editor.mount(document.createElement('div'))
+ return editor
+ }
+
+ /** 서명 앞 빈 줄 안에 커서 — 맨 위 단계 index 번째 문단의 처음 */
+ function cursorInto(editor: Editor, index: number) {
+ let pos = 0
+ for (let at = 0; at < index; at += 1) pos += editor.state.doc.child(at).nodeSize
+ editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 1)))
+ }
+
+ /** 저장하고 다시 연 모양 — 보내는 모양(빈 줄을 지킨 문서 → toMailHtml)에서 서버 정화처럼 data-* 를 지우고 다시 읽는다 */
+ function reopened(editor: Editor): ProseMirrorNode {
+ const html = toMailHtml(getHTMLFromFragment(withSignatureBoundary(editor.state.doc).content, schema))
+ return doc(sanitizeLikeServer(html))
+ }
+
+ const WRITTEN = newMailHtmlWithSignature(INSIDE).replace('
', '안녕하세요.
')
+ const SIGNED = '[서명:김아라 · 인프라팀내선 1234]'
+
+ it('서명 자리가 하나 더 들어오면(서명 안에서 복사해 붙여 넣기 등) 처음부터 있던 것만 두고 나머지는 문단으로 푼다', () => {
+ const editor = mountedEditor(newMailHtmlWithSignature(INSIDE).replace('
', '첫 줄
'))
+ editor.commands.insertContentAt(0, signatureBlockHtml('복사한 서명
'))
+
+ const signatures: string[] = []
+ editor.state.doc.forEach((child) => {
+ if (child.type.name === MAIL_SIGNATURE_NODE) signatures.push(child.textContent)
+ })
+ expect(signatures).toEqual(['김아라 · 인프라팀내선 1234'])
+ expect(editor.state.doc.textContent).toContain('복사한 서명')
+ })
+
+ it('서명 위 빈 줄에 글을 쓰면 그 아래 · 서명 위에 빈 줄을 다시 둔다 — 커서는 쓰던 자리, 실행 취소는 입력과 함께, 다시 열어도 서명 자리', () => {
+ const editor = mountedEditor(WRITTEN)
+ cursorInto(editor, 1)
+ editor.commands.insertContent('감사합니다.')
+
+ expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감사합니다.', '', SIGNED])
+ expect(editor.state.selection.$from.parent.textContent).toBe('감사합니다.')
+ expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '감사합니다.', '', SIGNED])
+
+ editor.commands.undo()
+ expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
+ })
+
+ it('Backspace 로 서명 위 빈 줄을 지워도 빈 줄을 다시 둔다 — 쓴 글은 그대로, 커서는 윗줄 끝, 다시 열어도 서명 자리', () => {
+ const editor = mountedEditor(WRITTEN)
+ cursorInto(editor, 1)
+ editor.commands.keyboardShortcut('Backspace')
+
+ expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
+ expect(editor.state.selection.$from.parent.textContent).toBe('안녕하세요.')
+ expect(editor.state.selection.$from.parentOffset).toBe('안녕하세요.'.length)
+ expect(blocks(reopened(editor)).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
+ })
+
+ it('한글을 조합하는 중에는 미룬다 — 보내고 저장하는 모양(withSignatureBoundary)이 채우고, 조합이 끝난 다음 입력에서 편집기도 채운다', () => {
+ const editor = mountedEditor(WRITTEN)
+ cursorInto(editor, 1)
+ editor.view.dispatch(editor.state.tr.insertText('감').setMeta('composition', 1))
+
+ expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '감', SIGNED])
+ expect(blocks(withSignatureBoundary(editor.state.doc)).slice(0, 4)).toEqual(['안녕하세요.', '감', '', SIGNED])
+ expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '감', '', SIGNED])
+
+ editor.commands.insertContent(' ')
+ expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감 ', '', SIGNED])
+ })
+
+ it('빈 줄이 있으면 그대로 — 앱이 만든 저장본 모양도 바꾸지 않는다', () => {
+ const app = doc(
+ `',
+ )
+ expect(withSignatureBoundary(app)).toBe(app)
+ expect(mailHtml(app)).toContain('
')
+ })
+})
diff --git a/frontend/src/components/mail/editor/mail-signature-block.ts b/frontend/src/components/mail/editor/mail-signature-block.ts
new file mode 100644
index 0000000..1892b0d
--- /dev/null
+++ b/frontend/src/components/mail/editor/mail-signature-block.ts
@@ -0,0 +1,269 @@
+// 쓰기 본문의 서명 자리 — 16단계 [서명 ▾] (디자인 원본 Signature16 6 · 계약 2절 "앱이 만드는 모양")
+// 서명 자리는 빈 줄 하나 뒤의 서명 덩어리다. 편집기 안에서는 블록 노드 하나(mailSignature — 안은 서명의 문단들)이고,
+// 보낼 때는 속성 없는 로 나간다 — 앱과 같은 경계라 임시 저장본을 다시 열어도(웹 · 앱 모두) 그 자리를 다시 찾는다.
+// - 읽기: 쓰기 화면이 처음 넣는 서명은 표시를 단 div(data-mail-signature — mail-signature.ts 의 signatureBlockHtml),
+// 저장본 · 보내기 취소로 되돌린 메일은 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"(서버 정화가 data-* 를 지운다).
+// 맨 위 단계 = 문서 바로 아래 · 또는 문서에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래. 원문 인용 안의 div 는 서명 자리가 아니다.
+// - 서명 자리는 하나다 — 붙여 넣기 · 끌어 놓기로 하나 더 생기면 처음부터 있던 것만 두고 나머지는 문단으로 푼다(appendTransaction).
+// - 서명 자리 바로 앞에는 늘 빈 줄 — 그 빈 줄에 글을 쓰거나 Backspace 로 지우면 빈 줄을 다시 넣는다(appendTransaction — 한글 조합 중에는 미룬다),
+// 보내고 저장하는 모양도 한 번 더 지킨다(withSignatureBoundary — MailEditor 의 contentOf). 빈 줄이 빠지면 다시 열 때 경계를 찾지 못한다(2026-11-10 리뷰 M3).
+// - 바꾸기(replaceSignatureTransaction): 그 자리만 새 서명으로 — 쓴 글 · 인용 · 첨부는 그대로이고 실행 취소로 되돌린다.
+// 넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다.
+// - 어느 서명이 들었는지(signatureFingerprintOf)는 글(공백을 하나로 줄여)과 그림 주소로 견준다 — 고쳤으면 어느 서명과도 같지 않다.
+import { Node, createDocument } from '@tiptap/core'
+import { Fragment, type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
+import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
+
+import { blockLines, normalizeIncomingHtml } from '@/components/mail/editor/mail-serialize'
+import { SIGNATURE_IMAGE_NODE } from '@/components/mail/editor/signature-image'
+import { MAIL_SIGNATURE_MARK_ATTRIBUTE } from '@/shared/constants/mail-signature'
+import { signatureBlockHtml } from '@/shared/utils/mail-signature'
+import { normalizeSignatureImageSrc } from '@/shared/utils/outgoing-html-rules'
+
+export const MAIL_SIGNATURE_NODE = 'mailSignature'
+
+/** 빈 줄로 보는 공백 — JS 의 \s 는 nbsp(U+00A0)도 포함한다 */
+const BLANK = /^\s*$/
+
+/** 글도 그림도 없는 문단인가 (DOM) —
·
· 공백뿐 */
+function isEmptyParagraphElement(element: Element | null): boolean {
+ return (
+ element !== null &&
+ element.tagName === 'P' &&
+ BLANK.test(element.textContent ?? '') &&
+ element.querySelector('img') === null
+ )
+}
+
+/** 맨 위 단계인가 — 문서(body) 바로 아래, 또는 body 에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래 */
+function isTopLevelElement(element: HTMLElement): boolean {
+ const parent = element.parentElement
+ if (!parent) return false
+ if (parent.tagName === 'BODY') return true
+ const grand = parent.parentElement
+ return (
+ parent.tagName === 'DIV' &&
+ !parent.hasAttribute(MAIL_SIGNATURE_MARK_ATTRIBUTE) &&
+ grand?.tagName === 'BODY' &&
+ grand.children.length === 1
+ )
+}
+
+/**
+ * 저장본의 서명 경계인가 — 맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다 (계약 2절 "앱이 만드는 모양").
+ * 인용 경계 표시(대용량 첨부)가 남은 빈 div 는 서명이 아니다
+ */
+export function isSignatureBoundaryElement(element: HTMLElement): boolean {
+ if (element.tagName !== 'DIV' || element.attributes.length > 0) return false
+ if (!isTopLevelElement(element)) return false
+ if (!isEmptyParagraphElement(element.previousElementSibling)) return false
+ return !BLANK.test(element.textContent ?? '') || element.querySelector('img') !== null
+}
+
+/** 글도 그림도 없는 문단인가 (편집기 문서) */
+export function isEmptyParagraphNode(node: ProseMirrorNode): boolean {
+ if (node.type.name !== 'paragraph') return false
+ let empty = BLANK.test(node.textContent)
+ if (empty) {
+ node.descendants((child) => {
+ if (child.type.name === SIGNATURE_IMAGE_NODE) empty = false
+ return empty
+ })
+ }
+ return empty
+}
+
+/** 문서 맨 위 단계의 서명 자리 — 없으면 null */
+export function findSignatureBlock(doc: ProseMirrorNode): { node: ProseMirrorNode; pos: number; index: number } | null {
+ let pos = 0
+ for (let index = 0; index < doc.childCount; index += 1) {
+ const child = doc.child(index)
+ if (child.type.name === MAIL_SIGNATURE_NODE) return { node: child, pos, index }
+ pos += child.nodeSize
+ }
+ return null
+}
+
+/** 서명 비교 값 — 글(공백을 하나로)과 그림 주소. 같은 서명을 넣은 자리 · 저장본에서 되찾은 자리가 같은 값이 된다 */
+export function signatureFingerprintOf(node: ProseMirrorNode): string {
+ const text = blockLines(node).join('\n').replace(/\s+/g, ' ').trim()
+ const images: string[] = []
+ node.descendants((child) => {
+ if (child.type.name === SIGNATURE_IMAGE_NODE) images.push(normalizeSignatureImageSrc(child.attrs.src) ?? '')
+ return true
+ })
+ return `${text}\n${images.join(' ')}`
+}
+
+/** 서명 HTML → 서명 자리 노드 (편집기 스키마로 읽는다 — 스키마에 없는 것은 빠진다). 읽을 것이 없으면 null */
+export function signatureNodeOf(schema: Schema, html: string): ProseMirrorNode | null {
+ const doc = createDocument(signatureBlockHtml(normalizeIncomingHtml(html)), schema)
+ const first = doc.firstChild
+ return first?.type.name === MAIL_SIGNATURE_NODE ? first : null
+}
+
+/** 맨 위 단계의 첫 원문 인용 자리 — 없으면 null */
+function mailQuoteIndex(doc: ProseMirrorNode): number | null {
+ for (let index = 0; index < doc.childCount; index += 1) {
+ const child = doc.child(index)
+ if (child.type.name === 'blockquote' && child.attrs.mailQuote) return index
+ }
+ return null
+}
+
+/** 맨 위 단계 index 번째 블록이 시작하는 자리 */
+function positionOfIndex(doc: ProseMirrorNode, index: number): number {
+ let pos = 0
+ for (let at = 0; at < index && at < doc.childCount; at += 1) pos += doc.child(at).nodeSize
+ return pos
+}
+
+/**
+ * 서명 자리가 없을 때 넣을 자리와 넣을 것 — 처음 넣는 자리와 같다: 새 메일은 본문 끝, 답장 · 전달은 원문 인용 위.
+ * 쓴 글의 마지막 블록 뒤에 빈 줄 하나 · 서명 (그 자리에 이미 빈 줄이 있으면 그것을 쓴다). 쓴 글이 없으면 쓸 줄 · 빈 줄 · 서명
+ */
+function insertionOf(doc: ProseMirrorNode): { index: number; blankLines: number } {
+ const limit = mailQuoteIndex(doc) ?? doc.childCount
+ let last = -1
+ for (let index = 0; index < limit; index += 1) {
+ if (!isEmptyParagraphNode(doc.child(index))) last = index
+ }
+ if (last < 0) {
+ // 쓴 글이 없다 — 쓸 줄과 빈 줄 하나를 둔다 (있는 빈 줄을 먼저 쓴다)
+ const present = Math.min(limit, 2)
+ return { index: present, blankLines: 2 - present }
+ }
+ const after = last + 1
+ const reuse = after < limit && isEmptyParagraphNode(doc.child(after))
+ return reuse ? { index: after + 1, blankLines: 0 } : { index: after, blankLines: 1 }
+}
+
+/**
+ * 서명 자리를 바꾸는 거래 — signature 가 null 이면 서명 자리와 그 앞의 빈 줄을 뺀다(없으면 바꿀 것이 없다 — null).
+ * 서명 자리가 없으면 처음 넣는 자리에 넣는다
+ */
+export function replaceSignatureTransaction(state: EditorState, signature: ProseMirrorNode | null): Transaction | null {
+ const { doc, schema } = state
+ const found = findSignatureBlock(doc)
+ const tr = state.tr
+ if (found && signature) {
+ tr.replaceWith(found.pos, found.pos + found.node.nodeSize, signature)
+ return tr
+ }
+ if (found) {
+ const before = found.index > 0 ? doc.child(found.index - 1) : null
+ const start = before && isEmptyParagraphNode(before) ? found.pos - before.nodeSize : found.pos
+ tr.delete(start, found.pos + found.node.nodeSize)
+ return tr
+ }
+ const paragraph = schema.nodes.paragraph
+ if (!signature || !paragraph) return null
+ const { index, blankLines } = insertionOf(doc)
+ const blanks = Array.from({ length: blankLines }, () => paragraph.create())
+ tr.insert(positionOfIndex(doc, index), Fragment.fromArray([...blanks, signature]))
+ return tr
+}
+
+/** 서명 자리 바로 앞이 빈 줄이 아니면 그 서명 자리 — 그 앞에 빈 줄을 넣어야 경계가 선다. 서명 자리가 없거나 이미 빈 줄이면 null */
+function blockMissingBlankLine(doc: ProseMirrorNode): { pos: number; index: number } | null {
+ const found = findSignatureBlock(doc)
+ if (!found) return null
+ const before = found.index > 0 ? doc.child(found.index - 1) : null
+ return before && isEmptyParagraphNode(before) ? null : found
+}
+
+/**
+ * 보내고 저장할 문서 — 서명 자리 바로 앞에 빈 줄이 없으면 하나 넣은 문서 (있으면 그대로).
+ * 편집기는 늘 빈 줄을 지키지만(아래 appendTransaction) 한글 조합 중에는 미루므로, 보내는 · 저장하는 모양에서 한 번 더 지킨다 —
+ * 빈 줄이 빠지면 다시 열 때 서명 자리를 찾지 못한다(계약 2절 "앱이 만드는 모양"의 경계). 쓴 글은 지우지 않는다
+ */
+export function withSignatureBoundary(doc: ProseMirrorNode): ProseMirrorNode {
+ const missing = blockMissingBlankLine(doc)
+ const paragraph = doc.type.schema.nodes.paragraph
+ if (!missing || !paragraph) return doc
+ const children: ProseMirrorNode[] = []
+ doc.forEach((child, _offset, index) => {
+ if (index === missing.index) children.push(paragraph.create())
+ children.push(child)
+ })
+ return doc.copy(Fragment.fromArray(children))
+}
+
+const signatureBlockKey = new PluginKey('mailSignatureBlock')
+
+/**
+ * 서명 자리는 하나 — 붙여 넣기(서명 안에서 복사한 글은 서명 자리째 붙는다) 등으로 둘 이상이 되면 처음부터 있던 것만 두고 나머지는 문단으로 푼다.
+ * 처음부터 있던 것을 모르면(없었거나 지워졌다) 첫 것을 둔다
+ */
+function unwrapExtraSignatures(
+ transactions: readonly Transaction[],
+ oldState: EditorState,
+ newState: EditorState,
+ tr: Transaction,
+) {
+ const blocks: { node: ProseMirrorNode; pos: number }[] = []
+ newState.doc.forEach((child, offset) => {
+ if (child.type.name === MAIL_SIGNATURE_NODE) blocks.push({ node: child, pos: offset })
+ })
+ if (blocks.length < 2) return
+ const previous = findSignatureBlock(oldState.doc)
+ let kept = 0
+ if (previous) {
+ let mapped: number | null = previous.pos
+ for (const transaction of transactions) {
+ const result = transaction.mapping.mapResult(mapped)
+ mapped = result.deleted ? null : result.pos
+ if (mapped === null) break
+ }
+ const index = blocks.findIndex((block) => block.pos === mapped)
+ if (index >= 0) kept = index
+ }
+ // 뒤에서부터 풀어 앞 블록의 자리가 바뀌지 않게
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
+ if (index === kept) continue
+ const block = blocks[index]
+ if (block) tr.replaceWith(block.pos, block.pos + block.node.nodeSize, block.node.content)
+ }
+}
+
+/**
+ * 서명 자리 지키기 — 문서가 바뀐 거래 뒤에 (1) 서명 자리를 하나로 (2) 서명 자리 바로 앞에 빈 줄을 둔다.
+ * (2)는 그 빈 줄에 글을 쓰거나(Backspace · Delete 로) 빈 줄을 지웠을 때 빈 줄을 다시 넣는 것이다 — 쓴 글은 그대로 두고 그 아래 · 서명 위에 새 빈 줄,
+ * 커서는 쓰던 자리 그대로, 실행 취소는 그 입력과 한 번에 되돌린다(덧붙인 거래는 입력과 같은 기록 묶음).
+ * 한글을 조합하는 중(거래의 composition 표시)에는 미룬다 — 조합 중인 글 옆의 화면을 바꾸지 않게. 다음 거래 · 보내고 저장하는 모양(withSignatureBoundary)이 채운다
+ */
+function guardSignatureBlock(transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) {
+ if (!transactions.some((transaction) => transaction.docChanged)) return null
+ const tr = newState.tr
+ unwrapExtraSignatures(transactions, oldState, newState, tr)
+ const composing = transactions.some((transaction) => Boolean(transaction.getMeta('composition')))
+ const paragraph = newState.schema.nodes.paragraph
+ const missing = composing ? null : blockMissingBlankLine(tr.doc)
+ if (missing && paragraph) tr.insert(missing.pos, paragraph.create())
+ return tr.docChanged ? tr : null
+}
+
+/** 서명 자리 노드 — 쓰기 본문 스키마(mail-extensions.ts)에 든다. 서명 칸(SignatureEditor) 스키마에는 없다 */
+export const MailSignatureBlock = Node.create({
+ name: MAIL_SIGNATURE_NODE,
+ group: 'block',
+ content: 'block+',
+
+ parseHTML() {
+ return [
+ // 쓰기 화면이 처음 넣는 서명 — 표시로 안다 (속성 없는 div 규칙보다 먼저)
+ { tag: `div[${MAIL_SIGNATURE_MARK_ATTRIBUTE}]`, priority: 60 },
+ // 저장본 · 앱이 만든 본문 — 빈 문단 뒤의 속성 없는 div
+ { tag: 'div', getAttrs: (element: HTMLElement) => (isSignatureBoundaryElement(element) ? null : false) },
+ ]
+ },
+
+ // 보낼 때 · 저장할 때 속성 없는 div — 앱과 같은 경계 (표시는 서버 정화가 어차피 지운다)
+ renderHTML() {
+ return ['div', 0]
+ },
+
+ addProseMirrorPlugins() {
+ return [new Plugin({ key: signatureBlockKey, appendTransaction: guardSignatureBlock })]
+ },
+})
diff --git a/frontend/src/components/mail/editor/signature/SignatureDefaultsField.vue b/frontend/src/components/mail/editor/signature/SignatureDefaultsField.vue
new file mode 100644
index 0000000..b70342d
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignatureDefaultsField.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+ {{ SIGNATURE_DEFAULTS_TEXT.legend }}
+
+
+
{{ field.label }}
+
choose(field.use, event)"
+ >
+
+ {{ signature.name }}
+
+
+ {{ SIGNATURE_DEFAULTS_TEXT.none }}
+
+
+
+
+
+ {{ SIGNATURE_DEFAULTS_TEXT.saved }}
+
+
+
+
+ {{ SIGNATURE_DEFAULTS_TEXT.hint }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignatureDeleteDialog.vue b/frontend/src/components/mail/editor/signature/SignatureDeleteDialog.vue
new file mode 100644
index 0000000..7248363
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignatureDeleteDialog.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+ {{ SIGNATURE_DELETE_TEXT.confirm }}
+
+
+
+ {{ SIGNATURE_DELETE_TEXT.cancel }}
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignatureEditor.spec.ts b/frontend/src/components/mail/editor/signature/SignatureEditor.spec.ts
new file mode 100644
index 0000000..2193661
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignatureEditor.spec.ts
@@ -0,0 +1,99 @@
+import { afterEach, beforeAll, describe, expect, it } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+
+import { editorOf, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
+import SignatureEditor from './SignatureEditor.vue'
+
+// 공용 편집기 — 서명 칸 · 부재중 자동 응답 내용 칸(16단계)이 함께 쓴다 (prop 은 phase16 shared-editor.md)
+
+const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
+
+describe('SignatureEditor — 공용 편집기 (16단계)', () => {
+ const mounted: VueWrapper[] = []
+
+ beforeAll(installProseMirrorDom)
+
+ afterEach(() => {
+ for (const wrapper of mounted.splice(0)) wrapper.unmount()
+ document.body.innerHTML = ''
+ })
+
+ function mountEditor(props: Record
= {}) {
+ const wrapper = mount(SignatureEditor, {
+ attachTo: document.body,
+ props: { labelledBy: 'field-label', ...props },
+ })
+ mounted.push(wrapper)
+ return wrapper
+ }
+
+ const toolNames = (wrapper: VueWrapper) =>
+ wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
+
+ it('기본값은 서명 칸 그대로 — 편집 영역 #signature-body · 툴바 "서명 서식" · 그림 단추', () => {
+ const wrapper = mountEditor()
+ expect(wrapper.find('#signature-body').exists()).toBe(true)
+ expect(wrapper.get('[role="toolbar"]').attributes()).toMatchObject({
+ 'aria-label': '서명 서식',
+ 'aria-controls': 'signature-body',
+ })
+ expect(toolNames(wrapper)).toEqual(['굵게', '기울임', '밑줄', '글자 색: 기본', '링크', '그림 넣기'])
+ })
+
+ it('부재중 내용 칸처럼 — id · 툴바 이름을 바꾸고 그림을 끄면 그림 단추 · 그림 노드가 없고 파일을 받지 않는다', async () => {
+ const wrapper = mountEditor({
+ bodyId: 'vacation-body',
+ toolbarLabel: '자동 응답 서식',
+ allowImages: false,
+ initialHtml: `휴가 중입니다.
`,
+ })
+ const body = wrapper.get('#vacation-body')
+ expect(body.attributes('aria-labelledby')).toBe('field-label')
+ expect(wrapper.get('[role="toolbar"]').attributes()).toMatchObject({
+ 'aria-label': '자동 응답 서식',
+ 'aria-controls': 'vacation-body',
+ })
+ expect(toolNames(wrapper)).toEqual(['굵게', '기울임', '밑줄', '글자 색: 기본', '링크'])
+ // 저장한 글에 그림이 섞여 와도 스키마가 받지 않아 빠진다
+ expect(body.find('img').exists()).toBe(false)
+
+ body.element.dispatchEvent(pasteEvent({ files: [new File(['x'], '사진.png', { type: 'image/png' })] }))
+ expect(wrapper.emitted('files')).toBeUndefined()
+ })
+
+ it('getText — 서식을 뺀 글(글자 수를 셀 때), getContent — 쓰기 본문과 같은 서식 본문 · 글', async () => {
+ const wrapper = mountEditor({ allowImages: false, initialHtml: '안녕하세요 , 김아라입니다.
복귀는 11월 23일입니다.
' })
+ const exposed = wrapper.vm as unknown as {
+ getText: () => string
+ getContent: () => { text: string; html: string | null }
+ }
+ expect(exposed.getText()).toBe('안녕하세요, 김아라입니다.\n복귀는 11월 23일입니다.')
+ const content = exposed.getContent()
+ expect(content.text).toBe(exposed.getText())
+ expect(content.html).toMatch(/^/)
+
+ editorOf(wrapper.element).commands.clearContent(true)
+ expect(exposed.getText()).toBe('')
+ expect(exposed.getContent()).toEqual({ text: '', html: null, quoteStart: null })
+ })
+
+ it('invalid — 편집 영역 aria-invalid · 빨간 테두리, disabled — 읽기만 · 툴바 단추를 끈다(바꿈으로 알리지 않는다)', async () => {
+ const wrapper = mountEditor({ allowImages: false, initialHtml: '내용
' })
+ const body = () => wrapper.get('#signature-body')
+
+ await wrapper.setProps({ invalid: true, describedBy: 'field-error' })
+ expect(body().attributes()).toMatchObject({ 'aria-invalid': 'true', 'aria-describedby': 'field-error' })
+ expect(wrapper.classes()).toContain('signature-editor--invalid')
+
+ await wrapper.setProps({ invalid: false, disabled: true })
+ expect(body().attributes('aria-invalid')).toBeUndefined()
+ expect(body().attributes('contenteditable')).toBe('false')
+ expect(body().attributes('aria-disabled')).toBe('true')
+ expect(wrapper.findAll('[role="toolbar"] button').every((button) => button.attributes('disabled') !== undefined)).toBe(true)
+ expect(wrapper.classes()).toContain('signature-editor--disabled')
+
+ await wrapper.setProps({ disabled: false })
+ expect(body().attributes('contenteditable')).toBe('true')
+ expect(wrapper.emitted('change')).toBeUndefined()
+ })
+})
diff --git a/frontend/src/components/mail/editor/signature/SignatureEditor.vue b/frontend/src/components/mail/editor/signature/SignatureEditor.vue
index 0006015..9af2b4e 100644
--- a/frontend/src/components/mail/editor/signature/SignatureEditor.vue
+++ b/frontend/src/components/mail/editor/signature/SignatureEditor.vue
@@ -6,10 +6,14 @@
// - 내용이 바뀌면 change 로 문서를 넘긴다 — 미리 보기가 그 문서를 그린다
// - 바꿨는가(isChanged)는 처음 불러온 서명, 저장한 뒤에는 저장한 서명(markSaved)과 견준다 — 서명 화면의 나가기 확인
// TipTap 은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 서명 화면이 지연 로딩한다.
+// 16단계 — 공용 편집기: 부재중 자동 응답의 내용 칸도 이것을 쓴다(쓰는 법은 prop 머리말). 기본값은 서명 칸 그대로다.
+// - bodyId: 편집 영역 id(툴바의 aria-controls) · toolbarLabel: 툴바 이름 · allowImages: false 면 그림 단추 · 그림 노드가 없고
+// 붙여 넣거나 끌어다 놓은 파일은 받지 않는다(무시) · invalid: aria-invalid + 빨간 테두리 · disabled: 고칠 수 없게(흐리게, 툴바 끔)
+// - 편집 영역의 최소 높이는 CSS 변수 --signature-editor-min-height(기본 8.25rem = 132) — 쓰는 쪽 감싸개에서 바꾼다
import { Editor, getHTMLFromFragment } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
-import { computed, markRaw, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, markRaw, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { EditorSavePoint, MailEditorContent } from '@/components/mail/editor/mail-editor-handle'
@@ -21,7 +25,12 @@ import {
} from '@/components/mail/editor/signature-image'
import { createSignatureEditorExtensions } from '@/components/mail/editor/signature/signature-extensions'
import SignatureToolbar from '@/components/mail/editor/signature/SignatureToolbar.vue'
-import { SIGNATURE_TEXT, type SignatureImageSize } from '@/shared/constants/mail-signature'
+import {
+ SIGNATURE_BODY_ID,
+ SIGNATURE_TEXT,
+ SIGNATURE_TOOLBAR_LABEL,
+ type SignatureImageSize,
+} from '@/shared/constants/mail-signature'
import type { SignatureImage } from '@/shared/types/mail'
import { pastedFiles } from '@/shared/utils/editor-files'
import { imageSizeOf, imageWidthFor, insertedImageWidth } from '@/shared/utils/mail-signature'
@@ -34,19 +43,29 @@ interface Props {
labelledBy: string
/** 아래 안내 · 오류 id (aria-describedby) */
describedBy?: string | null
- /** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림) */
- images: ReadonlyMap
+ /** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림). 그림을 쓰지 않는 칸은 주지 않는다 */
+ images?: ReadonlyMap
/** 그림을 더 넣을 수 없다 (올리는 중) */
imageDisabled?: boolean
/** 파일을 끌고 있다 — 칸 위에 덮개를 보인다 */
dragging?: boolean
+ /** 편집 영역의 id — 툴바가 aria-controls 로 가리킨다. 한 화면에 칸이 둘이면 다르게 준다 (기본 "signature-body") */
+ bodyId?: string
+ /** 툴바의 화면 낭독 이름 (기본 "서명 서식") */
+ toolbarLabel?: string
+ /** 그림을 쓴다 — false 면 그림 단추가 없고, 붙여 넣은 그림은 빠지며, 붙여 넣거나 끌어다 놓은 파일은 받지 않는다 */
+ allowImages?: boolean
+ /** 잘못된 입력 — 편집 영역이 aria-invalid 이고 테두리가 빨갛다 (오류 글은 describedBy 로 잇는다) */
+ invalid?: boolean
+ /** 고칠 수 없다 — 편집 영역을 읽기만 하고(흐리게) 툴바 단추를 끈다 */
+ disabled?: boolean
}
interface Emits {
(e: 'ready', doc: ProseMirrorNode): void
/** 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
(e: 'change', doc: ProseMirrorNode): void
- /** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 */
+ /** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 (allowImages 가 false 면 보내지 않는다) */
(e: 'files', files: File[]): void
/** 그림 단추 — 파일 고르기를 연다 */
(e: 'pick-image'): void
@@ -55,14 +74,20 @@ interface Emits {
const props = withDefaults(defineProps(), {
initialHtml: '',
describedBy: null,
+ images: () => new Map(),
imageDisabled: false,
dragging: false,
+ bodyId: SIGNATURE_BODY_ID,
+ toolbarLabel: SIGNATURE_TOOLBAR_LABEL,
+ allowImages: true,
+ invalid: false,
+ disabled: false,
})
const emit = defineEmits()
-/** 화면에 하나뿐인 서명 칸 */
-const SIGNATURE_BODY_ID = 'signature-body'
const IMAGE_SIZES: readonly SignatureImageSize[] = ['small', 'medium', 'original']
+/** 그림 크기 줄의 이름표 id — 한 화면에 칸이 여럿이어도 겹치지 않게 */
+const imageSizeLabelId = `${useId()}-image-size-label`
const bodyRef = ref(null)
const toolbarRef = ref | null>(null)
@@ -74,36 +99,44 @@ let mounted = false
*/
let baseline: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
-const extensions = createSignatureEditorExtensions({
- openLink: () => toolbarRef.value?.openLink(),
- focusToolbar: () => toolbarRef.value?.focusToolbar(),
-})
+const extensions = createSignatureEditorExtensions(
+ {
+ openLink: () => toolbarRef.value?.openLink(),
+ focusToolbar: () => toolbarRef.value?.focusToolbar(),
+ },
+ { images: props.allowImages },
+)
+
+/**
+ * 붙여 넣거나 끌어다 놓은 파일 — 그림을 쓰는 칸이면 files 로 넘기고, 아니면 받지 않는다(무시 — 글이 함께 오면 글은 그대로 붙는다).
+ * 넘길 파일이 없으면 false (편집기가 글을 붙인다)
+ */
+function takeFiles(files: File[]): boolean {
+ if (files.length === 0) return false
+ if (props.allowImages) emit('files', files)
+ return true
+}
const editor = markRaw(
new Editor({
element: null,
extensions,
+ editable: !props.disabled,
content: props.initialHtml ? normalizeIncomingHtml(props.initialHtml) : '',
editorProps: {
attributes: () => ({
- id: SIGNATURE_BODY_ID,
+ id: props.bodyId,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': props.labelledBy,
...(props.describedBy ? { 'aria-describedby': props.describedBy } : {}),
+ ...(props.invalid ? { 'aria-invalid': 'true' } : {}),
+ ...(props.disabled ? { 'aria-disabled': 'true' } : {}),
}),
- handlePaste: (_view, event) => {
- const files = pastedFiles(event.clipboardData)
- if (files.length === 0) return false
- emit('files', files)
- return true
- },
+ handlePaste: (_view, event) => takeFiles(pastedFiles(event.clipboardData)),
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
- const files = Array.from(event.dataTransfer?.files ?? [])
- if (files.length === 0) return false
- emit('files', files)
- return true
+ return takeFiles(Array.from(event.dataTransfer?.files ?? []))
},
transformPastedHTML: normalizeIncomingHtml,
},
@@ -180,6 +213,11 @@ function getContent(): MailEditorContent {
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)), quoteStart: null }
}
+/** 보이는 글 — 서식을 뺀 텍스트(getContent 의 text 와 같다, 끝의 빈 줄은 뺀다). 글자 수를 셀 때 */
+function getText(): string {
+ return docToText(editor.state.doc)
+}
+
/** 서명이 쓰는 그림 id */
function imageIds(): Set {
return signatureImageIdsOf(editor.state.doc)
@@ -224,23 +262,39 @@ onBeforeUnmount(() => {
// 오류 안내가 생기거나 사라지면 서명 칸의 aria 속성을 다시 그린다
watch(
- () => props.describedBy,
+ () => [props.describedBy, props.invalid],
() => {
if (mounted && !editor.isDestroyed) editor.view.updateState(editor.state)
},
)
-defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
+// 고칠 수 없게 되면 읽기만 — 바꿈(change)으로 알리지 않는다
+watch(
+ () => props.disabled,
+ (disabled) => {
+ if (!editor.isDestroyed) editor.setEditable(!disabled, false)
+ },
+)
+
+defineExpose({ getContent, getText, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
-
+
@@ -257,16 +311,16 @@ defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSaveP
{{ SIGNATURE_TEXT.imageSizeLabel }}
+// 서명이 없을 때 (디자인 원본 Signature16 2) — 점선 상자 안에 펜 아이콘 · "아직 서명이 없습니다" · 안내 · [새 서명](채운 단추).
+// 메일에 넣을 서명 칸과 목록은 그리지 않는다(고를 서명이 없다 — 서명 화면이 정한다).
+// 고정 id — [새 서명] #signature-empty-new
+import AppIcon from '@/components/AppIcon.vue'
+import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
+
+interface Emits {
+ (e: 'create'): void
+}
+
+const emit = defineEmits()
+
+
+
+
+
+
+ {{ SIGNATURE_TEXT.emptyTitle }}
+
+
+ {{ SIGNATURE_TEXT.emptyBody }}
+
+
+
+ {{ SIGNATURE_TEXT.newSignature }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignatureForm.vue b/frontend/src/components/mail/editor/signature/SignatureForm.vue
index 21ae404..605afb2 100644
--- a/frontend/src/components/mail/editor/signature/SignatureForm.vue
+++ b/frontend/src/components/mail/editor/signature/SignatureForm.vue
@@ -1,109 +1,129 @@
@@ -348,6 +427,20 @@ watch([forNew, forReply], markChanged)
gap: var(--space-22);
}
+.signature-form__name {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+/* 이름 칸 — 폭 320, 칸이 좁으면 칸 폭 (디자인 원본 Signature16) */
+.signature-form__name-input {
+ width: 20rem;
+ max-width: 100%;
+ box-sizing: border-box;
+ font: inherit;
+}
+
.signature-form__field {
display: flex;
flex-direction: column;
@@ -357,6 +450,7 @@ watch([forNew, forReply], markChanged)
.signature-form__label {
margin: 0;
font-weight: 600;
+ color: var(--color-text);
}
.signature-form__alert {
@@ -383,46 +477,11 @@ watch([forNew, forReply], markChanged)
color: var(--color-text-muted);
}
-.signature-form__usage {
- display: flex;
- flex-direction: column;
- margin: 0;
- padding: 0;
- border: 0;
-}
-
-.signature-form__legend {
- margin: 0 0 var(--space-4);
- padding: 0;
- font-weight: 600;
- color: var(--color-text);
-}
-
-.signature-form__option {
- display: flex;
- align-items: center;
- gap: var(--space-10);
- min-height: var(--size-touch);
- cursor: pointer;
-}
-
-.signature-form__checkbox {
- width: 1.125rem;
- height: 1.125rem;
- margin: 0;
- accent-color: var(--color-brand);
- cursor: pointer;
-}
-
-.signature-form__option-note {
- color: var(--color-text-muted);
-}
-
.signature-form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
- gap: var(--space-12);
+ gap: var(--space-8) var(--space-12);
}
.signature-form__save {
@@ -442,7 +501,9 @@ watch([forNew, forReply], markChanged)
background: var(--color-brand-strong);
}
-.signature-form__save:disabled {
+.signature-form__save:disabled,
+.signature-form__delete:disabled,
+.signature-form__cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -455,6 +516,42 @@ watch([forNew, forReply], markChanged)
font-weight: 500;
}
+/* [이 서명 지우기] — [저장]과 떨어뜨려 오른쪽 끝 (디자인 원본 Signature16 1) */
+.signature-form__delete {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-6);
+ min-height: var(--size-touch);
+ padding: 0 var(--space-12);
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--color-danger);
+ font-weight: 600;
+}
+
+.signature-form__delete:hover:not(:disabled) {
+ background: var(--color-danger-surface);
+ color: var(--color-danger-strong);
+}
+
+/* 새 서명의 [취소] — 보조 단추 (디자인 원본 Signature16 3) */
+.signature-form__cancel {
+ min-height: var(--size-touch);
+ padding: 0 var(--space-16);
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--color-text-strong);
+ font-weight: 500;
+}
+
+.signature-form__cancel:hover:not(:disabled) {
+ background: var(--color-surface-muted);
+ color: var(--color-text);
+}
+
.signature-form__rule {
width: 100%;
height: var(--border-width);
diff --git a/frontend/src/components/mail/editor/signature/SignatureList.vue b/frontend/src/components/mail/editor/signature/SignatureList.vue
new file mode 100644
index 0000000..d6d1a01
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignatureList.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+ {{ SIGNATURE_TEXT.listLabel }}
+
+
+
+
+ {{ row.name }}
+ {{ row.sub }}
+
+
+
+
+
+ {{ SIGNATURE_TEXT.newSignature }}
+
+
+ {{ full ? SIGNATURE_TEXT.full : SIGNATURE_TEXT.count(count) }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignaturePreview.vue b/frontend/src/components/mail/editor/signature/SignaturePreview.vue
index 7f75120..d1c97bc 100644
--- a/frontend/src/components/mail/editor/signature/SignaturePreview.vue
+++ b/frontend/src/components/mail/editor/signature/SignaturePreview.vue
@@ -1,8 +1,8 @@
+
+
+
+
+
+
+
+
+
+ (formDirty = dirty)"
+ @saving="(saving) => (formSaving = saving)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignatureToolbar.vue b/frontend/src/components/mail/editor/signature/SignatureToolbar.vue
index 7ba6263..70b7423 100644
--- a/frontend/src/components/mail/editor/signature/SignatureToolbar.vue
+++ b/frontend/src/components/mail/editor/signature/SignatureToolbar.vue
@@ -4,6 +4,7 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). Esc → 서명 칸, 서명 칸에서 Alt+F10 → 툴바
// - 키보드로 누른 단추는 초점을 툴바에 두고, 마우스로 누른 단추와 팝오버에서 고른 값은 서명 칸으로 돌아간다
// 그림 단추는 파일 고르기를 연다 — 올리기는 서명 화면(SignatureForm)이 한다 (pick-image).
+// 16단계 — 부재중 내용 칸도 쓴다: 이름(label — "자동 응답 서식" 등) · 그림 단추 없이(allowImages: false) · 칸을 고칠 수 없을 때 모두 끔(disabled).
import type { ChainedCommands, Editor } from '@tiptap/core'
import { computed, nextTick, ref } from 'vue'
@@ -16,6 +17,7 @@ import { applyLink, readLinkTarget, removeLink, type LinkTarget } from '@/compon
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
import { readToolbarStatus } from '@/components/mail/editor/editor-state'
import { EDITOR_SHORTCUTS, EDITOR_TEXT_COLORS, type EditorColor } from '@/shared/constants/mail-editor'
+import { SIGNATURE_TOOLBAR_LABEL } from '@/shared/constants/mail-signature'
interface Props {
editor: Editor
@@ -23,8 +25,14 @@ interface Props {
revision: number
/** 서명 칸 id — aria-controls */
controls: string
+ /** 툴바의 화면 낭독 이름 — 주지 않으면 "서명 서식" */
+ label?: string
+ /** 그림 단추를 둔다 — false 면 없다 (부재중 내용 칸) */
+ allowImages?: boolean
/** 그림을 더 넣을 수 없다 (올리는 중 등) */
imageDisabled?: boolean
+ /** 칸을 고칠 수 없다 — 모든 단추를 끈다 */
+ disabled?: boolean
}
interface Emits {
@@ -33,7 +41,10 @@ interface Emits {
}
const props = withDefaults(defineProps(), {
+ label: SIGNATURE_TOOLBAR_LABEL,
+ allowImages: true,
imageDisabled: false,
+ disabled: false,
})
const emit = defineEmits()
@@ -60,7 +71,9 @@ const marks = computed(() => [
{ id: 'underline' as const, label: '밑줄', icon: 'underline' as const, pressed: status.value.underline, run: (chain: ChainedCommands) => chain.toggleUnderline() },
])
-const focusableTools = computed(() => TOOL_ORDER.filter((id) => id !== 'image' || !props.imageDisabled))
+const focusableTools = computed(() =>
+ TOOL_ORDER.filter((id) => id !== 'image' || (props.allowImages && !props.imageDisabled)),
+)
const rovingTool = computed(() =>
focusableTools.value.includes(activeTool.value) ? activeTool.value : 'bold',
)
@@ -179,8 +192,9 @@ defineExpose({
ref="rootRef"
class="signature-toolbar"
role="toolbar"
- aria-label="서명 서식"
+ :aria-label="label"
:aria-controls="controls"
+ :aria-disabled="disabled ? 'true' : undefined"
@keydown="onKeydown"
@focusin="onFocusIn"
>
@@ -191,6 +205,7 @@ defineExpose({
:label="item.label"
:shortcut="EDITOR_SHORTCUTS[item.id]"
:pressed="item.pressed"
+ :disabled="disabled"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
@@ -208,6 +223,7 @@ defineExpose({
title="글자 색"
haspopup="dialog"
:expanded="popup?.id === 'textColor'"
+ :disabled="disabled"
:focusable="rovingTool === 'textColor'"
@press="togglePopup('textColor')"
>
@@ -235,6 +251,7 @@ defineExpose({
:shortcut="EDITOR_SHORTCUTS.link"
haspopup="dialog"
:expanded="popup?.id === 'link'"
+ :disabled="disabled"
:focusable="rovingTool === 'link'"
@press="togglePopup('link')"
>
@@ -254,10 +271,11 @@ defineExpose({
diff --git a/frontend/src/components/mail/editor/signature/signature-extensions.ts b/frontend/src/components/mail/editor/signature/signature-extensions.ts
index 35177c1..b1ed878 100644
--- a/frontend/src/components/mail/editor/signature/signature-extensions.ts
+++ b/frontend/src/components/mail/editor/signature/signature-extensions.ts
@@ -1,7 +1,8 @@
-// 서명 칸(TipTap) 확장 구성 — 디자인 README "2단계 › 서명" (디자인 원본 Settings)
+// 서명 칸(TipTap) 확장 구성 — 디자인 README "2단계 › 서명" (디자인 원본 Settings · Signature16)
// 쓸 수 있는 것: 문단 · 줄바꿈, 굵게 · 기울임 · 밑줄, 글자 색, 링크, 서명 그림. 그 밖(목록 · 표 · 글꼴 · 글자 크기 등)은
// 스키마에 없어 붙여 넣어도 글자만 남는다. 값 형식은 쓰기 본문과 같다 (서버 정화 허용 목록 — mail-format-extensions.ts).
// 서명은 쓰기 화면을 열 때 본문에 그대로 들어가므로, 이 스키마는 쓰기 본문 스키마(mail-extensions.ts)의 일부여야 한다.
+// 16단계 — 부재중 자동 응답의 내용 칸도 이 편집기를 쓴다(그림 없이 — images: false 면 스키마에 그림이 없어 붙여 넣은 그림도 빠진다).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TextStyle } from '@tiptap/extension-text-style'
import { StarterKit } from '@tiptap/starter-kit'
@@ -53,13 +54,21 @@ const STARTER_KIT_OPTIONS = {
link: false,
} as const
-export function createSignatureEditorExtensions(hooks: Partial = {}): AnyExtension[] {
+export interface SignatureEditorSchemaOptions {
+ /** 서명 그림을 받는다 — 주지 않으면 받는다 (부재중 내용 칸은 false) */
+ images?: boolean
+}
+
+export function createSignatureEditorExtensions(
+ hooks: Partial = {},
+ options: SignatureEditorSchemaOptions = {},
+): AnyExtension[] {
return [
StarterKit.configure(STARTER_KIT_OPTIONS),
createMailLinkExtension(),
TextStyle,
MailColor,
- SignatureImage,
+ ...(options.images === false ? [] : [SignatureImage]),
SignatureBehavior.configure({
openLink: hooks.openLink ?? null,
focusToolbar: hooks.focusToolbar ?? null,
diff --git a/frontend/src/components/settings/TwoFactorEnrollmentScan.vue b/frontend/src/components/settings/TwoFactorEnrollmentScan.vue
index 821a061..e8a7bc8 100644
--- a/frontend/src/components/settings/TwoFactorEnrollmentScan.vue
+++ b/frontend/src/components/settings/TwoFactorEnrollmentScan.vue
@@ -1,5 +1,7 @@
+
+
+
+
+
+ {{ message }}
+
+
+
+ {{ notice }}
+
+
+
+
+ 1
+
+
+
+
+
+ 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ busy === 'verifying' ? TWO_FACTOR_REPLACE_TEXT.verifying : TWO_FACTOR_REPLACE_TEXT.next }}
+
+
+
+
+
+ {{ TWO_FACTOR_REPLACE_TEXT.back }}
+
+
+ {{ busy === 'replacing' ? TWO_FACTOR_REPLACE_TEXT.submitting : TWO_FACTOR_REPLACE_TEXT.submit }}
+
+
+
+ {{ TWO_FACTOR_REPLACE_TEXT.cancel }}
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/TwoFactorReplaceSteps.vue b/frontend/src/components/settings/TwoFactorReplaceSteps.vue
new file mode 100644
index 0000000..eed8ed4
--- /dev/null
+++ b/frontend/src/components/settings/TwoFactorReplaceSteps.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+ {{ TWO_FACTOR_REPLACE_TEXT.stepNew }}
+
+
+
+
+
+ {{ TWO_FACTOR_REPLACE_TEXT.stepCurrent }}
+
+
+
+
+
diff --git a/frontend/src/components/settings/VacationExternalFields.vue b/frontend/src/components/settings/VacationExternalFields.vue
new file mode 100644
index 0000000..94b0c71
--- /dev/null
+++ b/frontend/src/components/settings/VacationExternalFields.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+ {{ VACATION_TEXT.externalLegend }}
+
+
+ {{ VACATION_TEXT.externalHint(domain) }}
+
+
+
+
+ {{ VACATION_TEXT.externalOptions[option].label }}
+ {{ VACATION_TEXT.externalOptions[option].hint }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/VacationForm.vue b/frontend/src/components/settings/VacationForm.vue
index b4993d4..e646503 100644
--- a/frontend/src/components/settings/VacationForm.vue
+++ b/frontend/src/components/settings/VacationForm.vue
@@ -1,35 +1,53 @@
@@ -44,11 +120,11 @@ defineExpose({
-
-
{{ VACATION_TEXT.body }}
-
@@ -134,10 +218,28 @@ defineExpose({
max-width: var(--form-wide-max-width);
}
+/* 내용 칸 이름 줄 — 오른쪽에 글자 수 (주소록 메모와 같은 자리 · 12px) */
+.vacation-message__label-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: var(--space-8);
+}
+
+.vacation-message__count {
+ font-size: var(--font-size-small);
+ color: var(--color-text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+/* 켜 둔 채 600자를 넘음 — 빨갛게 굵게 (디자인 원본 Settings16 4) */
+.vacation-message__count.is-over {
+ font-weight: 600;
+ color: var(--color-danger);
+}
+
+/* 편집 영역 높이 150 (디자인 원본 Settings16 — 서명 칸은 132) */
.vacation-message__body {
- width: 100%;
- padding: var(--space-10) var(--space-12);
- line-height: var(--line-height-body);
- resize: vertical;
+ --signature-editor-min-height: 9.375rem;
}
diff --git a/frontend/src/components/settings/VacationPreview.vue b/frontend/src/components/settings/VacationPreview.vue
index 28683a1..d470a59 100644
--- a/frontend/src/components/settings/VacationPreview.vue
+++ b/frontend/src/components/settings/VacationPreview.vue
@@ -1,26 +1,69 @@
@@ -29,13 +72,42 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
aria-labelledby="vacation-preview-title"
data-vacation-preview
>
-
+
+ {{ VACATION_TEXT.previewTitle }}
+
+
+
+ {{ option.label }}
+
+
+
+
+ {{ VACATION_TEXT.previewNone }}
+
+
- {{ VACATION_TEXT.previewTitle }}
-
-
{{ VACATION_TEXT.previewFrom }}
@@ -53,18 +125,18 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
{{ shownSubject }}
-
- {{ enabled ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteOff }}
+ {{ note }}
@@ -76,12 +148,24 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
gap: var(--space-12);
}
+/* 머리 — 제목 + 전환, 좁으면 줄을 바꾼다 (사이 8 14) */
+.vacation-preview__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-8) var(--space-14);
+}
+
.vacation-preview__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
+.vacation-preview__side {
+ padding: 0 var(--space-14);
+}
+
.vacation-preview__card {
display: flex;
flex-direction: column;
@@ -110,7 +194,7 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
overflow-wrap: anywhere;
}
-/* 받는 쪽이 보는 모양 — 글자 그대로(줄바꿈 · 공백), 메일 본문 글꼴 */
+/* 받는 쪽이 보는 모양 — 메일 본문 글꼴 · 문단 사이 없음 · 여러 칸 띄어쓰기는 그대로(보내는 본문이 nbsp 로 바꾼다) */
.vacation-preview__body {
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -120,6 +204,28 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
color: var(--color-text-body);
}
+.vacation-preview__body :deep(p) {
+ margin: 0;
+}
+
+.vacation-preview__body :deep(a) {
+ color: var(--color-brand);
+}
+
+/* 보내지 않기의 회사 밖 쪽 — 답장 자리에 회색 상자 (최소 높이 120) */
+.vacation-preview__none {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 7.5rem;
+ padding: var(--space-20);
+ border: var(--border-width) solid var(--color-border);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-panel);
+ color: var(--color-text-secondary);
+ text-align: center;
+}
+
.vacation-preview__note {
margin: 0;
font-size: var(--font-size-label);
diff --git a/frontend/src/components/shell/AppShell.spec.ts b/frontend/src/components/shell/AppShell.spec.ts
index fc8f2a5..5608ae3 100644
--- a/frontend/src/components/shell/AppShell.spec.ts
+++ b/frontend/src/components/shell/AppShell.spec.ts
@@ -5,7 +5,8 @@ import { defineComponent, h } from 'vue'
import MailSignaturePage from '@/pages/settings/MailSignaturePage.vue'
import SettingsLayout from '@/pages/settings/SettingsLayout.vue'
-import type { MailSignature, SignatureImage } from '@/shared/types/mail'
+import type { SignatureImage } from '@/shared/types/mail'
+import type { MailSignature } from '@/shared/types/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
@@ -21,10 +22,10 @@ const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const SIGNATURE: MailSignature = {
+ id: 'sig-1',
+ name: '기본 서명',
bodyHtml: '
',
body: '김아라 · 인프라팀',
- isForNew: true,
- isForReply: true,
images: [],
updatedAt: '2026-10-27T05:00:00Z',
}
@@ -44,7 +45,11 @@ const UPLOADED: SignatureImage = {
const PasswordScreen = defineComponent({ name: 'PasswordScreen', render: () => h('h1', '비밀번호') })
const server: MockHandler = ({ method, url }) => {
- if (method === 'GET' && url === '/mail-signature') return ok(SIGNATURE)
+ // 서명 설정 (16단계 — 여러 서명): 목록 · 기본 둘
+ if (method === 'GET' && url === '/mail-signatures') {
+ return ok({ items: [SIGNATURE], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
+ }
+ if (method === 'GET' && url === '/mail-signature-defaults') return ok({ newMessageId: SIGNATURE.id, replyId: SIGNATURE.id })
if (method === 'POST' && url === '/mail-signature/images') return ok(UPLOADED, 201)
return ok(null)
}
@@ -55,7 +60,7 @@ describe('AppShell — 잘못 놓은 파일 (15단계)', () => {
beforeAll(installProseMirrorDom)
beforeAll(async () => {
- await import('@/components/mail/editor/signature/SignatureForm.vue')
+ await import('@/components/mail/editor/signature/SignatureSettings.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
diff --git a/frontend/src/composables/useAccountSecurity.ts b/frontend/src/composables/useAccountSecurity.ts
index 59db9f9..70e0b97 100644
--- a/frontend/src/composables/useAccountSecurity.ts
+++ b/frontend/src/composables/useAccountSecurity.ts
@@ -12,7 +12,10 @@ import type {
TwoFactorActivationPayload,
TwoFactorDeactivationPayload,
TwoFactorEnrollment,
+ TwoFactorReplacementPayload,
TwoFactorStatus,
+ TwoFactorVerification,
+ TwoFactorVerificationPayload,
} from '@/shared/types/account-security'
import type { PaginatedResult } from '@/shared/types/pagination'
@@ -35,13 +38,44 @@ export function useAccountSecurity() {
}
/**
- * 등록 시작 (꺼져 있을 때만) — 새 키 · otpauth 주소 · 이때까지 켜야 하는 시각(10분).
- * 직원마다 기다리는 등록은 하나다 — 새로 시작하면 앞의 것은 버린다. 이미 켜져 있으면 409 AUTH_008
+ * 등록 시작 — 새 키 · otpauth 주소 · 이때까지 켜거나 바꿔야 하는 시각(10분). 꺼져 있으면 켜기용, 켜져 있으면 휴대폰 바꾸기용이다(16단계).
+ * 직원마다 기다리는 등록은 하나다 — 새로 시작하면 앞의 것은 버린다
*/
function startTwoFactorEnrollment(options?: AccountSecurityRequestOptions): Promise
{
return api.post('/two-factor/enrollments', null, options)
}
+ /**
+ * 휴대폰 바꾸기 ① (16단계) — 새 휴대폰의 코드를 그 등록의 키로 먼저 확인한다(엔진은 등록 때 코드를 보지 않는다). 맞으면 서버가 등록에 "확인됨"을 둔다.
+ * 코드 틀림 · 다시 쓴 코드 400 AUTH_006 · 등록이 없음 · 10분 지남 · 더 새 등록 409 AUTH_008
+ */
+ function verifyTwoFactorEnrollment(
+ enrollmentId: string,
+ payload: TwoFactorVerificationPayload,
+ options?: AccountSecurityRequestOptions,
+ ): Promise {
+ return api.post(
+ `/two-factor/enrollments/${segment(enrollmentId)}/verification`,
+ payload,
+ options,
+ )
+ }
+
+ /**
+ * 휴대폰 바꾸기 ② (16단계) — 지금 비밀번호를 먼저 보고(AUTH_005) 지금 휴대폰의 코드로(AUTH_006) 새 키로 바꾼다. 2단계 인증이 꺼지는 틈이 없다.
+ * 등록이 확인되지 않음 · 10분 지남 · 그사이 꺼짐 409 AUTH_008. 결과를 모르면 503 MAIL_001 — 다시 읽어도 가를 수 없다.
+ * 서버가 엔진을 여러 번 부르므로 한도를 길게 둔다. 응답은 GET 과 같은 모양(켠 때는 그대로)
+ */
+ function replaceTwoFactor(
+ payload: TwoFactorReplacementPayload,
+ options?: AccountSecurityRequestOptions,
+ ): Promise {
+ return api.post('/two-factor/replacements', payload, {
+ timeout: TWO_FACTOR_CHANGE_TIMEOUT_MS,
+ ...options,
+ })
+ }
+
/**
* 켜기 — 코드를 등록의 키로 먼저 확인한 뒤 엔진에 쓴다. 코드 틀림 400 AUTH_006 · 지금 비밀번호 틀림 400 AUTH_005 ·
* 등록이 없음 · 10분 지남 · 그사이 바뀜 409 AUTH_008. 서버가 엔진을 여러 번 부르므로 한도를 길게 둔다
@@ -116,6 +150,8 @@ export function useAccountSecurity() {
return {
getTwoFactor,
startTwoFactorEnrollment,
+ verifyTwoFactorEnrollment,
+ replaceTwoFactor,
activateTwoFactor,
deactivateTwoFactor,
listAppPasswords,
diff --git a/frontend/src/composables/useContactImport.ts b/frontend/src/composables/useContactImport.ts
new file mode 100644
index 0000000..aa2de32
--- /dev/null
+++ b/frontend/src/composables/useContactImport.ts
@@ -0,0 +1,430 @@
+import { notifyApiError, toApiError, type ApiError } from './useApi'
+import { useContacts } from './useContacts'
+import { useToast } from './useToast'
+import {
+ CONTACT_IMPORT_BATCH_MAX_BYTES,
+ CONTACT_IMPORT_BATCH_MAX_VALUES,
+ CONTACT_IMPORT_BATCH_SIZE,
+ CONTACT_IMPORT_TOAST_TEXT,
+ CONTACT_IMPORT_UNDO_BATCH_SIZE,
+} from '@/shared/constants/contacts'
+import type { ContactImportChecked, ContactImportCheckStatus, ContactImported, ContactImportItem } from '@/shared/types/contacts'
+import { envelopeOf, requestBatchSize, type RequestBatchLimits } from '@/shared/utils/contact-import/batches'
+import { readContactFile } from '@/shared/utils/contact-import/read-file'
+import {
+ checkItemOf,
+ contactGroupKey,
+ prepareImport,
+ previewOf,
+ type ContactImportPlan,
+ type ContactImportPlanEntry,
+ type ImportPreview,
+} from '@/shared/utils/contact-import/records'
+import type { ContactFileProblem, ContactFileReadResult, ImportedFileKind } from '@/shared/utils/contact-import/types'
+import { formatCount } from '@/shared/utils/format'
+import { createUuid } from '@/shared/utils/uuid'
+import { useAuthStore } from '@/stores/auth.store'
+import { useContactImportStore } from '@/stores/contact-import.store'
+import { useContactsStore } from '@/stores/contacts.store'
+import type { ToastAction } from '@/stores/toast.store'
+
+// 주소록 가져오기 — 파일 읽기 · 미리 보기 확인 · 넣기 · [되돌리기] (16단계 — 디자인 원본 Contacts16 "2" ~ "4" · 계약 3절 "가져오기")
+// 읽기(read): 파일은 이 브라우저에서 읽고(shared/utils/contact-import), 넣을 수 있는 연락처만 묶음(아래)으로 서버에 이미 있는지 묻는다
+// (POST /contact-import-checks — 만들지 않는다). 남은 자리(remaining)를 다 채우면 더 묻지 않는다 — 넘는 연락처는 어차피 넣지 않는다
+// (요청 수 한도 — 경로 · IP 마다 1분 100회 — 를 아끼려고. 그 연락처 가운데 이미 있는 것은 미리 보기의 수에 "넣을 연락처"로 세진다).
+// 실패는 인터셉터가 사전 문구로 알린다 — 창은 파일 고르기로 돌아간다
+// 넣기(start): 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명" — 새 그룹을 먼저 만들고(같은 이름이 생겼으면 그 그룹, 100개면 그 그룹 없이.
+// 만들기 응답만 잃은 뒤 [다시 시도]에서 같은 이름이 돌아오면 그 그룹도 이번에 만든 것으로 센다)
+// 묶음마다 POST /contact-imports. 끝나면 같은 자리에서 결과 "연락처 330명을 넣었습니다. …" + [되돌리기] + ✕(✕로 닫을 때까지 남는다).
+// 멈추면 오류 알림 + [다시 시도](남은 것부터 — 같은 uid 라 두 번 들어가지 않는다, 이 알림도 ✕까지 남는다). 멈춘 알림에는 [되돌리기]가 없다.
+// - 409 CONTACT_002: 그사이 주소록이 찼다 — 남은 자리를 다시 물어 그만큼만 넣고 나머지는 "넣지 못함"(크기 한도면 멈춘다)
+// - 404 RES_001: 넣을 그룹이 그사이 지워졌다 — 그룹을 다시 받아 없는 그룹은 빼고 한 번 더
+// [되돌리기]: "가져온 연락처를 지우는 중 · 100 / 330명" — 이번에 넣은 연락처를 묶음마다 지우고(POST /contact-deletions) 이번에 새로 만든 그룹을 지운다
+// 확인 · 넣기 · 되돌리기 묶음은 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 끊는다(batches.ts — 서버 본문 한도 값 10,000 · 1MB 를 넘지 않게,
+// 칸을 한도까지 채운 연락처가 몰리면 100명보다 적게 보낸다)
+// 넣기 · 되돌리기 · [다시 시도]는 이 탭에서 하나씩 돈다(contact-import.store 의 enqueue — 도는 동안 누른 것은 끝난 뒤에, 그사이 세션이 바뀌었으면 하지 않는다).
+// 다른 화면으로 가도 이어 한다. 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 다음 요청을 보내지 않고 알림을 닫는다(진행 · 단추 알림은 toast.store 가 닫는다).
+// 끝나거나 멈추면 연락처 수 · 그룹 · 보는 목록을 다시 받는다 (contacts.store 의 afterBulkChange)
+
+/** 파일을 읽은 결과 — ready 면 미리 보기, failed 면 칸 아래 글의 까닭, error 면 서버 확인이 실패(알림은 인터셉터가 띄웠다) */
+export type ContactImportReadOutcome =
+ | { status: 'ready'; preview: ImportPreview }
+ | { status: 'failed'; problem: ContactFileProblem; kind: ImportedFileKind | null }
+ | { status: 'error' }
+ | { status: 'canceled' }
+
+/** 넣는 일의 자리 — [다시 시도]가 이어 간다 */
+interface ImportRun {
+ plan: ContactImportPlan
+ /** 그룹 열쇠 → id (null 이면 만들지 못함 — 그 그룹 없이 넣는다) */
+ groupIds: Map
+ /** 이번에 새로 만든 그룹 — [되돌리기]가 지운다 */
+ createdGroupIds: string[]
+ /**
+ * 만들기를 보냈지만 결과를 모르는 그룹(열쇠 — 응답 없음 · 5xx). 서버에서는 만들어졌을 수 있다 —
+ * [다시 시도]에서 같은 이름(CONTACT_001)이 돌아오면 이번에 만든 그룹으로 센다(되돌리면 지운다)
+ */
+ unsureGroupKeys: Set
+ /** 다음에 보낼 자리 · 끝(한도에 걸리면 줄어든다) */
+ next: number
+ end: number
+ insertedIds: string[]
+ /** 메일 서버가 받지 않은 연락처 수 */
+ missed: number
+ /** 그룹에 넣지 못한 연락처(uid) — 그룹 100개 · 구성원 500명 */
+ groupLess: Set
+ overLimit: number
+}
+
+interface UndoRun {
+ ids: string[]
+ groupIds: string[]
+ next: number
+ groupNext: number
+ groupsDeleted: number
+}
+
+type RunOutcome = 'done' | 'failed' | 'canceled'
+
+/** 확인 · 넣기 묶음 — 100명 · 값 8,000개 · 700KB 가운데 먼저 닿는 데서 (batches.ts) */
+const IMPORT_BATCH_LIMITS: RequestBatchLimits = {
+ maxItems: CONTACT_IMPORT_BATCH_SIZE,
+ maxValues: CONTACT_IMPORT_BATCH_MAX_VALUES,
+ maxBytes: CONTACT_IMPORT_BATCH_MAX_BYTES,
+}
+/** 되돌리기 묶음 — 100명 · 값 · 바이트 (id 뿐이라 보통 사람 수에서 끊긴다) */
+const UNDO_BATCH_LIMITS: RequestBatchLimits = { ...IMPORT_BATCH_LIMITS, maxItems: CONTACT_IMPORT_UNDO_BATCH_SIZE }
+/** 본문의 겉 — `{"contacts":[…]}` · `{"ids":[…]}` */
+const CONTACTS_ENVELOPE = envelopeOf('contacts')
+const IDS_ENVELOPE = envelopeOf('ids')
+
+/** 서버가 받았는지 모르는 실패 — 응답이 없거나(시간 초과 · 연결 끊김) 5xx. 4xx 는 서버가 거절한 것이다 */
+function outcomeUnknown(error: ApiError): boolean {
+ return error.status === undefined || error.status >= 500
+}
+
+/** 화면이 만드는 uid — urn:uuid: + 소문자 UUID (계약 3절) */
+export function newContactUid(): string {
+ return `urn:uuid:${createUuid().toLowerCase()}`
+}
+
+export function useContactImport() {
+ const contactsApi = useContacts()
+ const contactsStore = useContactsStore()
+ const importStore = useContactImportStore()
+ const authStore = useAuthStore()
+ const toast = useToast()
+
+ // ── 읽기 ─────────────────────────────────────────
+
+ /** 파일을 읽고 이미 있는 연락처를 서버에 묻는다 — signal 을 끊으면(창을 닫음 · 다른 파일) canceled */
+ async function read(file: File, signal: AbortSignal): Promise {
+ let result: ContactFileReadResult
+ try {
+ result = await readContactFile(file)
+ } catch {
+ // 읽다가 뜻밖에 멈췄다(깨진 파일 등) — 창이 "읽는 중"에 머물지 않게 열지 못한 파일로 알린다
+ result = { ok: false, problem: 'unreadable', kind: null }
+ }
+ if (signal.aborted) return { status: 'canceled' }
+ if (!result.ok) return { status: 'failed', problem: result.problem, kind: result.kind }
+ const prepared = prepareImport(result.kind, result.records, newContactUid)
+ const items = prepared.candidates.map(checkItemOf)
+ const statuses = new Map()
+ let remaining: number | null = null
+ let fresh = 0
+ for (let start = 0; start < items.length; ) {
+ if (remaining !== null && fresh >= remaining) break
+ const window = items.slice(start, start + CONTACT_IMPORT_BATCH_SIZE)
+ const batch = window.slice(0, requestBatchSize(window, IMPORT_BATCH_LIMITS, CONTACTS_ENVELOPE))
+ start += batch.length
+ let checked: ContactImportChecked
+ try {
+ checked = await contactsApi.checkContactImport(batch, { signal })
+ } catch (caught) {
+ return signal.aborted || toApiError(caught).canceled ? { status: 'canceled' } : { status: 'error' }
+ }
+ if (signal.aborted) return { status: 'canceled' }
+ remaining = checked.remaining
+ for (const item of checked.items) {
+ statuses.set(item.uid, item.status)
+ if (item.status === 'new') fresh += 1
+ }
+ }
+ return { status: 'ready', preview: previewOf(prepared, statuses, remaining) }
+ }
+
+ // ── 넣기 ─────────────────────────────────────────
+
+ function count(value: number): string {
+ return formatCount(value)
+ }
+
+ function progressText(run: ImportRun): string {
+ return CONTACT_IMPORT_TOAST_TEXT.progress(count(run.insertedIds.length), count(run.end))
+ }
+
+ /** 끝 알림 — 넣은 수 + 건너뛴 수 · 한도에 걸린 수를 한 문장씩 */
+ function resultText(run: ImportRun): string {
+ const parts: string[] = [CONTACT_IMPORT_TOAST_TEXT.done(count(run.insertedIds.length))]
+ if (run.plan.skipped > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.skipped(count(run.plan.skipped)))
+ if (run.overLimit > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.full(count(run.overLimit)))
+ if (run.groupLess.size > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.groupLimit(count(run.groupLess.size)))
+ if (run.missed > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.missed(count(run.missed)))
+ return parts.join(' ')
+ }
+
+ /** 그룹 열쇠들 → 이번 줄의 groupIds (만들지 못한 그룹은 뺀다) */
+ function itemOf(entry: ContactImportPlanEntry, run: ImportRun): ContactImportItem {
+ const ids = entry.groupKeys
+ .map((key) => run.groupIds.get(key) ?? null)
+ .filter((id): id is string => id !== null)
+ return { ...entry.item, groupIds: [...new Set(ids)] }
+ }
+
+ /** 같은 이름의 그룹이 그사이 생겼다 — 그룹을 다시 받아 그 그룹의 id (없으면 null) */
+ async function existingGroupId(key: string): Promise {
+ await contactsStore.loadGroups()
+ return contactsStore.groups.find((group) => contactGroupKey(group.name) === key)?.id ?? null
+ }
+
+ /**
+ * 새 그룹을 만든다(이미 만든 것은 건너뛴다 — [다시 시도]). 다 됐으면 null, 멈춰야 하면 그 결과
+ * (같은 이름 CONTACT_001 은 그 그룹, 100개 CONTACT_002 는 그 그룹 없이 — 둘은 알리지 않는다. 그 밖의 실패는 인터셉터가 알렸다)
+ */
+ async function resolveGroups(run: ImportRun, alive: () => boolean): Promise {
+ for (const group of run.plan.groups) {
+ if (run.groupIds.has(group.key)) continue
+ if (group.id !== null) {
+ run.groupIds.set(group.key, group.id)
+ continue
+ }
+ try {
+ const created = await contactsApi.createContactGroup(
+ { name: group.name },
+ { silent: ['CONTACT_001', 'CONTACT_002'] },
+ )
+ if (!alive()) return 'canceled'
+ run.groupIds.set(group.key, created.id)
+ run.createdGroupIds.push(created.id)
+ } catch (caught) {
+ if (!alive()) return 'canceled'
+ const error = toApiError(caught)
+ if (error.code === 'CONTACT_001') {
+ const id = await existingGroupId(group.key)
+ run.groupIds.set(group.key, id)
+ // 앞선 만들기의 응답만 잃었다 — 그 그룹은 이번에 만든 것이다(되돌리면 지운다)
+ if (id !== null && run.unsureGroupKeys.has(group.key) && !run.createdGroupIds.includes(id)) {
+ run.createdGroupIds.push(id)
+ }
+ } else if (error.code === 'CONTACT_002') {
+ run.groupIds.set(group.key, null)
+ } else {
+ if (outcomeUnknown(error)) run.unsureGroupKeys.add(group.key)
+ return 'failed'
+ }
+ if (!alive()) return 'canceled'
+ }
+ }
+ return null
+ }
+
+ /** 남은 자리 — 409 CONTACT_002 뒤에 다시 묻는다. 모르면 null */
+ async function roomFor(items: readonly ContactImportItem[]): Promise {
+ try {
+ const checked = await contactsApi.checkContactImport(items, { silent: true })
+ return Math.max(0, checked.remaining)
+ } catch {
+ return null
+ }
+ }
+
+ /** 그룹이 그사이 지워졌다 — 그룹을 다시 받아 없는 그룹은 뺀다 */
+ async function forgetRemovedGroups(run: ImportRun): Promise {
+ await contactsStore.loadGroups()
+ const present = new Set(contactsStore.groups.map((group) => group.id))
+ for (const [key, id] of run.groupIds) {
+ if (id !== null && !present.has(id)) run.groupIds.set(key, null)
+ }
+ }
+
+ function noteResult(run: ImportRun, slice: readonly ContactImportPlanEntry[], result: ContactImported) {
+ const byUid = new Map(result.items.map((item) => [item.uid, item]))
+ for (const entry of slice) {
+ const created = byUid.get(entry.item.uid)
+ if (!created) {
+ run.missed += 1
+ continue
+ }
+ run.insertedIds.push(created.id)
+ const groupLess = entry.groupKeys.some((key) => (run.groupIds.get(key) ?? null) === null)
+ if (groupLess || created.skippedGroupIds.length > 0) run.groupLess.add(entry.item.uid)
+ }
+ }
+
+ async function runImport(run: ImportRun, alive: () => boolean, onProgress: () => void): Promise {
+ const grouped = await resolveGroups(run, alive)
+ if (grouped !== null) return grouped
+ let retriedGroups = false
+ while (run.next < run.end) {
+ const window = run.plan.entries.slice(run.next, Math.min(run.end, run.next + CONTACT_IMPORT_BATCH_SIZE))
+ const windowItems = window.map((entry) => itemOf(entry, run))
+ const size = requestBatchSize(windowItems, IMPORT_BATCH_LIMITS, CONTACTS_ENVELOPE)
+ const slice = window.slice(0, size)
+ const items = windowItems.slice(0, size)
+ let result: ContactImported
+ try {
+ result = await contactsApi.importContacts(items, { silent: ['CONTACT_002', 'RES_001'] })
+ } catch (caught) {
+ if (!alive()) return 'canceled'
+ const error = toApiError(caught)
+ if (error.code === 'CONTACT_002') {
+ const room = await roomFor(items)
+ if (!alive()) return 'canceled'
+ if (room !== null && room < items.length) {
+ const end = run.next + room
+ run.overLimit += run.end - end
+ run.end = end
+ continue
+ }
+ } else if (error.code === 'RES_001' && !retriedGroups) {
+ retriedGroups = true
+ await forgetRemovedGroups(run)
+ if (!alive()) return 'canceled'
+ continue
+ }
+ if (error.code === 'CONTACT_002' || error.code === 'RES_001') notifyApiError(error)
+ return 'failed'
+ }
+ if (!alive()) return 'canceled'
+ retriedGroups = false
+ noteResult(run, slice, result)
+ run.next += slice.length
+ onProgress()
+ }
+ return 'done'
+ }
+
+ function undoAction(run: ImportRun): ToastAction | undefined {
+ if (run.insertedIds.length === 0 && run.createdGroupIds.length === 0) return undefined
+ const undo: UndoRun = {
+ ids: [...run.insertedIds],
+ groupIds: [...run.createdGroupIds],
+ next: 0,
+ groupNext: 0,
+ groupsDeleted: 0,
+ }
+ return {
+ label: CONTACT_IMPORT_TOAST_TEXT.undo,
+ run: () => void importStore.enqueue('undo', () => executeUndo(undo)),
+ }
+ }
+
+ /** 넣는다 — 차례(contact-import.store 의 enqueue) 안에서 부른다 */
+ async function execute(run: ImportRun): Promise {
+ const session = authStore.generation
+ const alive = () => authStore.generation === session
+ const progress = toast.progress(progressText(run))
+ const outcome = await runImport(run, alive, () => progress.update(progressText(run)))
+ if (outcome === 'canceled' || !alive()) {
+ progress.dismiss()
+ return
+ }
+ contactsStore.afterBulkChange()
+ if (outcome === 'done') {
+ progress.succeed(resultText(run), undoAction(run), { sticky: true })
+ return
+ }
+ const retry: ToastAction = {
+ label: CONTACT_IMPORT_TOAST_TEXT.retry,
+ run: () => void importStore.enqueue('import', () => execute(run)),
+ }
+ const stopped =
+ run.insertedIds.length > 0
+ ? CONTACT_IMPORT_TOAST_TEXT.stopped(count(run.end), count(run.insertedIds.length))
+ : CONTACT_IMPORT_TOAST_TEXT.notStarted
+ progress.fail(stopped, retry, { sticky: true })
+ }
+
+ /**
+ * [연락처 N명 넣기] — 창이 만든 계획대로 넣는다(창은 이미 닫혔다). 이 탭에서 가져오기 · 되돌리기가 도는 중이면 끝난 뒤에 한다
+ * ([가져오기]가 그동안 열리지 않으니 보통은 바로 시작한다)
+ */
+ function start(plan: ContactImportPlan): Promise {
+ const run: ImportRun = {
+ plan,
+ groupIds: new Map(),
+ createdGroupIds: [],
+ unsureGroupKeys: new Set(),
+ next: 0,
+ end: plan.entries.length,
+ insertedIds: [],
+ missed: 0,
+ groupLess: new Set(),
+ overLimit: plan.overLimit,
+ }
+ return importStore.enqueue('import', () => execute(run))
+ }
+
+ // ── 되돌리기 ────────────────────────────────────────
+
+ async function runUndo(undo: UndoRun, alive: () => boolean, onProgress: () => void): Promise {
+ while (undo.next < undo.ids.length) {
+ const window = undo.ids.slice(undo.next, undo.next + CONTACT_IMPORT_UNDO_BATCH_SIZE)
+ const batch = window.slice(0, requestBatchSize(window, UNDO_BATCH_LIMITS, IDS_ENVELOPE))
+ try {
+ await contactsApi.deleteContacts(batch)
+ } catch {
+ return alive() ? 'failed' : 'canceled'
+ }
+ if (!alive()) return 'canceled'
+ undo.next += batch.length
+ onProgress()
+ }
+ while (undo.groupNext < undo.groupIds.length) {
+ const id = undo.groupIds[undo.groupNext] ?? ''
+ try {
+ await contactsApi.deleteContactGroup(id, { silent: ['RES_001'] })
+ undo.groupsDeleted += 1
+ } catch (caught) {
+ if (!alive()) return 'canceled'
+ // 그사이 다른 곳에서 지운 그룹은 지운 것으로 본다(세지는 않는다)
+ if (toApiError(caught).code !== 'RES_001') return 'failed'
+ }
+ if (!alive()) return 'canceled'
+ undo.groupNext += 1
+ }
+ return 'done'
+ }
+
+ /** 되돌린다 — 차례(contact-import.store 의 enqueue) 안에서 부른다 */
+ async function executeUndo(undo: UndoRun): Promise {
+ const session = authStore.generation
+ const alive = () => authStore.generation === session
+ const total = count(undo.ids.length)
+ const progress = toast.progress(CONTACT_IMPORT_TOAST_TEXT.undoProgress(count(undo.next), total))
+ const outcome = await runUndo(undo, alive, () =>
+ progress.update(CONTACT_IMPORT_TOAST_TEXT.undoProgress(count(undo.next), total)),
+ )
+ if (outcome === 'canceled' || !alive()) {
+ progress.dismiss()
+ return
+ }
+ contactsStore.afterBulkChange(undo.ids.slice(0, undo.next))
+ if (outcome === 'done') {
+ progress.succeed(
+ undo.groupsDeleted > 0
+ ? CONTACT_IMPORT_TOAST_TEXT.undoneWithGroups(total, count(undo.groupsDeleted))
+ : CONTACT_IMPORT_TOAST_TEXT.undone(total),
+ )
+ return
+ }
+ const retry: ToastAction = {
+ label: CONTACT_IMPORT_TOAST_TEXT.retry,
+ run: () => void importStore.enqueue('undo', () => executeUndo(undo)),
+ }
+ progress.fail(CONTACT_IMPORT_TOAST_TEXT.undoStopped(total, count(undo.next)), retry, { sticky: true })
+ }
+
+ return { read, start }
+}
diff --git a/frontend/src/composables/useContacts.ts b/frontend/src/composables/useContacts.ts
index cd3016c..f972250 100644
--- a/frontend/src/composables/useContacts.ts
+++ b/frontend/src/composables/useContacts.ts
@@ -1,10 +1,14 @@
import type { AxiosRequestConfig } from 'axios'
-import { useApi } from './useApi'
+import { buildApiUrl, confirmSession, useApi } from './useApi'
+import { startBrowserDownload } from './useMail'
import { CONTACT_GROUP_MEMBER_PAGES_MAX, CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
import type {
Contact,
ContactDeleted,
+ ContactDeletions,
+ ContactDeletionsPayload,
+ ContactExportQuery,
ContactGroup,
ContactGroupCreatePayload,
ContactGroupDeleted,
@@ -12,6 +16,10 @@ import type {
ContactGroupMembersAdded,
ContactGroupMembersPayload,
ContactGroupRenamePayload,
+ ContactImportChecked,
+ ContactImported,
+ ContactImportItem,
+ ContactImportPayload,
ContactInput,
ContactListQuery,
ContactPatch,
@@ -21,6 +29,15 @@ import type {
} from '@/shared/types/contacts'
import type { PaginatedResult } from '@/shared/types/pagination'
+const CONTACT_EXPORTS_PATH = '/contact-exports'
+
+/** 내보내기 파일 주소 — axios baseURL 과 같은 규칙으로 만들어야 쿠키(Path=/api)가 함께 간다 */
+export function contactExportUrl(query: ContactExportQuery): string {
+ const params = new URLSearchParams({ format: query.format })
+ if (query.groupId) params.set('groupId', query.groupId)
+ return `${buildApiUrl(CONTACT_EXPORTS_PATH)}?${params.toString()}`
+}
+
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type ContactsRequestOptions = Pick
@@ -141,6 +158,47 @@ export function useContacts() {
return members
}
+ // ── 가져오기 · 내보내기 (16단계 — 계약 3절 "가져오기 · 내보내기") ─────────────
+
+ /**
+ * 가져올 연락처가 이미 있는가 — 만들지 않는다. 1~100명. remaining 은 더 둘 수 있는 연락처 수(3,000 − 지금 수).
+ * 같은 파일 안의 겹침은 화면이 본다(같은 규칙)
+ */
+ function checkContactImport(
+ contacts: readonly ContactImportItem[],
+ options?: ContactsRequestOptions,
+ ): Promise {
+ const payload: ContactImportPayload = { contacts: [...contacts] }
+ return api.post('/contact-import-checks', payload, options)
+ }
+
+ /**
+ * 가져온 연락처를 만든다 — 1~100명, 엔진 요청 한 번. 같은 uid 를 다시 보내면 두 번 만들지 않는다.
+ * 한도(연락처 3,000 · 크기)를 넘는 묶음은 409 CONTACT_002(하나도 만들지 않음), 없는 그룹은 404 RES_001, 검사에 걸리면 400 VAL_001.
+ * 엔진이 일부만 받으면 받은 것만 온다
+ */
+ function importContacts(contacts: readonly ContactImportItem[], options?: ContactsRequestOptions): Promise {
+ const payload: ContactImportPayload = { contacts: [...contacts] }
+ return api.post('/contact-imports', payload, options)
+ }
+
+ /** 여러 연락처를 지운다 — 1~500개(겹치지 않게). 되돌리기 표는 남기지 않는다 (가져오기의 [되돌리기]) */
+ function deleteContacts(ids: readonly string[], options?: ContactsRequestOptions): Promise {
+ const payload: ContactDeletionsPayload = { ids: [...ids] }
+ return api.post('/contact-deletions', payload, options)
+ }
+
+ /**
+ * 내 주소록(또는 그룹 하나)을 파일로 내려받는다 — 첨부처럼 이동하기 전에 세션을 확인한다 (confirmSession).
+ * 이동은 axios 의 401 갱신 규칙을 타지 않아, 액세스 토큰이 만료된 채 이동하면 오류 JSON 이 파일로 받아진다.
+ * @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자 · 알림)는 이미 끝났다
+ */
+ async function downloadContactExport(query: ContactExportQuery): Promise {
+ if (!(await confirmSession())) return false
+ startBrowserDownload(contactExportUrl(query))
+ return true
+ }
+
return {
listContacts,
getContact,
@@ -156,5 +214,9 @@ export function useContacts() {
listDirectoryEntries,
getDirectoryEntry,
listGroupMembers,
+ checkContactImport,
+ importContacts,
+ deleteContacts,
+ downloadContactExport,
}
}
diff --git a/frontend/src/composables/useContactsDialogs.ts b/frontend/src/composables/useContactsDialogs.ts
index 293b168..fb68297 100644
--- a/frontend/src/composables/useContactsDialogs.ts
+++ b/frontend/src/composables/useContactsDialogs.ts
@@ -14,6 +14,9 @@ export type ContactsDialogRequest =
| { kind: 'group-name'; group: ContactGroup | null }
/** 구성원 넣기 */
| { kind: 'members'; group: ContactGroup }
+ /** 연락처 가져오기 · 내보내기 (16단계 — 「전체 연락처」 머리 · 빈 주소록) */
+ | { kind: 'import' }
+ | { kind: 'export' }
export interface ContactsDialogsHandle {
/** 지금 연 창 — 없으면 null (읽기만) */
@@ -21,6 +24,8 @@ export interface ContactsDialogsHandle {
openContact: (contact: Contact | null, prefill?: ContactPrefill | null) => void
openGroupName: (group: ContactGroup | null) => void
openMembers: (group: ContactGroup) => void
+ openImport: () => void
+ openExport: () => void
close: () => void
}
@@ -39,6 +44,12 @@ export function createContactsDialogs(): ContactsDialogsHandle {
openMembers: (group) => {
request.value = { kind: 'members', group }
},
+ openImport: () => {
+ request.value = { kind: 'import' }
+ },
+ openExport: () => {
+ request.value = { kind: 'export' }
+ },
close: () => {
request.value = null
},
diff --git a/frontend/src/composables/useMail.ts b/frontend/src/composables/useMail.ts
index 5ee4631..7fcdcda 100644
--- a/frontend/src/composables/useMail.ts
+++ b/frontend/src/composables/useMail.ts
@@ -27,7 +27,6 @@ import type {
MailFilterSet,
MailFilterSetPayload,
MailQuota,
- MailSignature,
MailState,
MailVacation,
MailVacationState,
@@ -48,18 +47,17 @@ import type {
MessagesPatched,
RecipientSuggestion,
RecipientSuggestionQuery,
- SaveSignaturePayload,
ScheduledMessageCancel,
ReleasedSend,
SearchIndexState,
SendMessagePayload,
SentMessage,
- SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { conversationPageOf } from '@/shared/utils/mail-conversation'
import { conversationListPageOf } from '@/shared/utils/mail-conversation-list'
+import { vacationExternalOf } from '@/shared/utils/mail-vacation'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
@@ -103,15 +101,28 @@ type SentMessageReply = Omit
*/
type ScheduledReleaseReply = { id?: unknown; rejectedRecipients?: unknown; pendingSend?: unknown } | null
-/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
-type MailVacationReply = MailVacation & { isActive?: unknown }
+/**
+ * GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가, 16단계 앞의 서버는 htmlBody · external 이 없다
+ * (vacationStateOf 가 맞춘다)
+ */
+type MailVacationReply = Omit & {
+ isActive?: unknown
+ htmlBody?: unknown
+ external?: unknown
+}
/**
* 부재중 응답 → 화면의 값. isActive 가 없거나 불리언이 아니면(옛 서버 · 응답을 바꾼 게이트웨이) 동작하는 것으로 본다 —
- * "지금은 동작하지 않습니다" 줄을 잘못 띄우지 않게 (계약 2절 "부재중 자동 응답"의 MailVacationState)
+ * "지금은 동작하지 않습니다" 줄을 잘못 띄우지 않게 (계약 2절 "부재중 자동 응답"의 MailVacationState).
+ * 서식 본문 · 회사 밖 응답(16단계)이 없거나 모양이 틀리면 글만 · 사내와 같은 응답으로 — 옛 화면이 저장한 것과 같게 읽는다
*/
function vacationStateOf(reply: MailVacationReply): MailVacationState {
- return { ...reply, isActive: typeof reply.isActive === 'boolean' ? reply.isActive : true }
+ return {
+ ...reply,
+ htmlBody: typeof reply.htmlBody === 'string' ? reply.htmlBody : null,
+ external: vacationExternalOf(reply.external),
+ isActive: typeof reply.isActive === 'boolean' ? reply.isActive : true,
+ }
}
export interface UploadOptions {
@@ -217,7 +228,8 @@ export function useMail() {
/**
* 전체 교체 — 꺼도 제목 · 내용은 남긴다. 응답은 저장한 값 + isActive (저장하면 엔진에서 다시 켜진다).
- * 본문은 다섯 칸만 골라 보낸다 — 받은 응답(isActive 가 든 MailVacationState)을 그대로 넘겨도 isActive 가 실리지 않게 (서버가 400 VAL_001)
+ * 본문은 일곱 칸만 골라 보낸다 — 받은 응답(isActive 가 든 MailVacationState)을 그대로 넘겨도 isActive 가 실리지 않게 (서버가 400 VAL_001).
+ * 서식 본문 · 회사 밖 응답(16단계)은 늘 보낸다 — 주지 않았으면 글만 · 사내와 같은 응답 (서버가 새 칸이 없는 옛 요청을 그렇게 저장하는 것과 같다)
*/
async function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise {
const body: MailVacation = {
@@ -226,6 +238,9 @@ export function useMail() {
toDate: payload.toDate,
subject: payload.subject,
textBody: payload.textBody,
+ htmlBody: payload.htmlBody ?? null,
+ // 네 칸만 새로 만든다 (모르는 칸이 실리지 않게)
+ external: vacationExternalOf(payload.external),
}
return vacationStateOf(await api.put('/mail-vacation', body, options))
}
@@ -409,24 +424,6 @@ export function useMail() {
})
}
- // ── 서명 (계약 2절 "서명") ─────────────────────────────
-
- /** 저장한 적 없으면 기본값(둘 다 넣기 · 빈 서명)이 온다 */
- function getSignature(options?: MailRequestOptions): Promise {
- return api.get('/mail-signature', options)
- }
-
- function saveSignature(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise {
- return api.put('/mail-signature', payload, options)
- }
-
- /** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하, 한 사람 3장까지 */
- function uploadSignatureImage(file: File, options?: MailRequestOptions): Promise {
- const form = new FormData()
- form.append('file', file)
- return api.post('/mail-signature/images', form, options)
- }
-
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
/**
@@ -600,9 +597,6 @@ export function useMail() {
getDraft,
createDraft,
updateDraft,
- getSignature,
- saveSignature,
- uploadSignatureImage,
listRecipientSuggestions,
}
}
diff --git a/frontend/src/composables/useMailSignatures.ts b/frontend/src/composables/useMailSignatures.ts
new file mode 100644
index 0000000..3cb8b9d
--- /dev/null
+++ b/frontend/src/composables/useMailSignatures.ts
@@ -0,0 +1,133 @@
+import type { AxiosRequestConfig } from 'axios'
+
+import { getErrorMessage, notifyApiError, useApi, type ApiError } from './useApi'
+import type { SignatureImage } from '@/shared/types/mail'
+import type { MailSignature, MailSignatureDefaults, MailSignaturePayload } from '@/shared/types/mail-signature'
+
+/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
+export type MailSignatureRequestOptions = Pick
+
+/** 경로에 넣는 값은 늘 인코딩한다 */
+const segment = (value: string) => encodeURIComponent(value)
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function textOrNull(value: unknown): string | null {
+ return typeof value === 'string' ? value : null
+}
+
+/** 서명 하나 — id · 이름이 없으면 null (응답을 바꾼 게이트웨이 등 — 그런 줄은 쓰지 않는다) */
+function signatureOf(value: unknown): MailSignature | null {
+ if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') return null
+ return {
+ id: value.id,
+ name: value.name,
+ bodyHtml: textOrNull(value.bodyHtml),
+ body: textOrNull(value.body),
+ images: Array.isArray(value.images) ? (value.images as SignatureImage[]) : [],
+ updatedAt: textOrNull(value.updatedAt),
+ }
+}
+
+/** 목록 응답 { items, pageInfo } 의 서명 — 모양이 틀린 줄은 뺀다 */
+function signatureListOf(reply: unknown): MailSignature[] {
+ const items = isRecord(reply) && Array.isArray(reply.items) ? reply.items : []
+ return items.flatMap((item: unknown) => {
+ const signature = signatureOf(item)
+ return signature ? [signature] : []
+ })
+}
+
+/** 기본 둘 — 글이 아닌 값은 넣지 않음(null)으로 본다 */
+function defaultsOf(reply: unknown): MailSignatureDefaults {
+ const value = isRecord(reply) ? reply : {}
+ return { newMessageId: textOrNull(value.newMessageId), replyId: textOrNull(value.replyId) }
+}
+
+/**
+ * 만들기 · 고치기 응답 — 모양이 틀리면 보낸 값으로 메운다. 만든 서명의 id 를 모르면 실패로 알린다(SYS_001 — 인터셉터는 성공으로 보고 알리지 않았다).
+ * 그 서명은 서버에 있을 수 있다 — 화면에 다시 들어오면 목록에 보인다
+ */
+function savedSignatureOf(reply: unknown, sent: MailSignaturePayload, id?: string): MailSignature {
+ const signature = signatureOf(reply)
+ if (signature) return signature
+ if (!id) {
+ const error: ApiError = { code: 'SYS_001', message: getErrorMessage('SYS_001') }
+ notifyApiError(error)
+ throw error
+ }
+ return { id, ...sent, images: [], updatedAt: null }
+}
+
+// 여러 서명 API Composable — docs/mail/api-contract.md 2절 "서명" (16단계, 모두 로그인 필요)
+// 인터셉터가 { success, data } 를 언래핑한다. 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-signature.store)를 거친다 —
+// 서명 그림 올리기만 서명 화면(SignatureForm)이 부른다 (올린 그림은 서명을 저장할 때 붙는다)
+export function useMailSignatures() {
+ const api = useApi()
+
+ /** 내 서명 — 만든 차례, 10개까지 (pageInfo.hasNext 는 늘 false) */
+ async function listSignatures(options?: MailSignatureRequestOptions): Promise {
+ return signatureListOf(await api.get('/mail-signatures', options))
+ }
+
+ /**
+ * 새 서명 — 201. 서명이 하나도 없던 직원이면 서버가 기본 둘을 이 서명으로 정한다.
+ * 이름이 비었거나 30자 초과 · 같은 이름 · 11번째 · 본문 한도(서식 10,000 · 글 2,000 · 그림 3장)는 400 VAL_001
+ */
+ async function createSignature(
+ payload: MailSignaturePayload,
+ options?: MailSignatureRequestOptions,
+ ): Promise {
+ return savedSignatureOf(await api.post('/mail-signatures', payload, options), payload)
+ }
+
+ /** 서명 고치기 — 이름 · 본문 전체를 바꾼다. 없는 서명은 404 RES_001, 검사는 만들기와 같다 */
+ async function updateSignature(
+ id: string,
+ payload: MailSignaturePayload,
+ options?: MailSignatureRequestOptions,
+ ): Promise {
+ return savedSignatureOf(
+ await api.put(`/mail-signatures/${segment(id)}`, payload, options),
+ payload,
+ id,
+ )
+ }
+
+ /** 서명 지우기 — 204. 이 서명을 기본으로 넣던 곳은 넣지 않음이 되고, 이 서명에만 쓰던 그림은 서버가 지운다 */
+ async function deleteSignature(id: string, options?: MailSignatureRequestOptions): Promise {
+ await api.delete(`/mail-signatures/${segment(id)}`, options)
+ }
+
+ /** 새 메일 · 답장과 전달에 넣을 서명 */
+ async function getDefaults(options?: MailSignatureRequestOptions): Promise {
+ return defaultsOf(await api.get('/mail-signature-defaults', options))
+ }
+
+ /** 기본 둘을 바꾼다 — 둘 다 보낸다. 없는 서명 id 는 404 RES_001 */
+ async function saveDefaults(
+ defaults: MailSignatureDefaults,
+ options?: MailSignatureRequestOptions,
+ ): Promise {
+ return defaultsOf(await api.put('/mail-signature-defaults', defaults, options))
+ }
+
+ /** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하. 한 사람이 보관하는 그림은 20장까지(넘으면 400 VAL_001) */
+ function uploadSignatureImage(file: File, options?: MailSignatureRequestOptions): Promise {
+ const form = new FormData()
+ form.append('file', file)
+ return api.post('/mail-signature/images', form, options)
+ }
+
+ return {
+ listSignatures,
+ createSignature,
+ updateSignature,
+ deleteSignature,
+ getDefaults,
+ saveDefaults,
+ uploadSignatureImage,
+ }
+}
diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts
index ff84274..79a512b 100644
--- a/frontend/src/composables/useToast.ts
+++ b/frontend/src/composables/useToast.ts
@@ -9,9 +9,9 @@ export interface ProgressToast {
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
update: (message: string) => void
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [거래처 보기] */
- succeed: (message: string, action?: ToastAction) => void
+ succeed: (message: string, action?: ToastAction, options?: ToastResultOptions) => void
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [다시 시도] */
- fail: (message: string, action?: ToastAction) => void
+ fail: (message: string, action?: ToastAction, options?: ToastResultOptions) => void
/** 알리지 않고 닫는다 */
dismiss: () => void
}
@@ -19,6 +19,13 @@ export interface ProgressToast {
/** 알림 옵션 — expiresAt(ms — Date.now)에 닫는다. 붙잡아도 멈추지 않고 남은 시간 막대가 보인다 (9단계 [보내기 취소]) */
export interface ToastOptions {
expiresAt?: number
+ /** ✕로 닫을 때까지 남긴다 (16단계 주소록 가져오기의 결과 — toast.store 머리말) */
+ sticky?: boolean
+}
+
+/** 진행 알림의 결과 옵션 — ✕로 닫을 때까지 남긴다 */
+export interface ToastResultOptions {
+ sticky?: boolean
}
/**
@@ -49,6 +56,7 @@ export function useToast() {
message,
action: action ? sessionBound(action) : undefined,
expiresAt: options.expiresAt ?? null,
+ sticky: options.sticky === true,
})
}
@@ -63,6 +71,7 @@ export function useToast() {
message,
action: action ? sessionBound(action) : undefined,
expiresAt: options.expiresAt ?? null,
+ sticky: options.sticky === true,
})
}
@@ -72,18 +81,21 @@ export function useToast() {
let id = store.show({ kind: 'progress', message })
const sameSession = () => authStore.generation === session
- function settle(kind: TimedToastKind, text: string, action?: ToastAction) {
+ function settle(kind: TimedToastKind, text: string, action?: ToastAction, options: ToastResultOptions = {}) {
if (!sameSession()) return
const bound = action ? sessionBound(action) : null
- if (!store.update(id, { kind, message: text, action: bound })) id = store.show({ kind, message: text, action: bound })
+ const sticky = options.sticky === true
+ if (!store.update(id, { kind, message: text, action: bound, sticky })) {
+ id = store.show({ kind, message: text, action: bound, sticky })
+ }
}
return {
update: (text) => {
if (sameSession()) store.update(id, { message: text })
},
- succeed: (text, action) => settle('success', text, action),
- fail: (text, action) => settle('error', text, action),
+ succeed: (text, action, options) => settle('success', text, action, options),
+ fail: (text, action, options) => settle('error', text, action, options),
dismiss: () => store.dismiss(id),
}
}
diff --git a/frontend/src/pages/contacts/ContactsTransfer.spec.ts b/frontend/src/pages/contacts/ContactsTransfer.spec.ts
new file mode 100644
index 0000000..b50346a
--- /dev/null
+++ b/frontend/src/pages/contacts/ContactsTransfer.spec.ts
@@ -0,0 +1,780 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia, type Pinia } from 'pinia'
+import { h, type FunctionalComponent } from 'vue'
+import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
+
+import ToastHost from '@/components/feedback/ToastHost.vue'
+import { CLOCK } from '@/composables/useClock'
+import { CONTACT_IMPORT_ERROR_TEXT, CONTACT_IMPORT_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
+import type { Contact, ContactGroup, ContactImportItem } from '@/shared/types/contacts'
+import type { PaginatedResult } from '@/shared/types/pagination'
+import { formatDate } from '@/shared/utils/format'
+import { useAuthStore } from '@/stores/auth.store'
+import { useContactImportStore } from '@/stores/contact-import.store'
+import { useToastStore } from '@/stores/toast.store'
+import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
+import { createManualClock } from '@/test-utils/manual-clock'
+import { buildXlsx } from '@/test-utils/zip'
+import {
+ fail,
+ flushPromises,
+ installMockApi,
+ ok,
+ restoreApiAdapter,
+ type MockRequest,
+ type MockResult,
+} from '@/test-utils/mock-api'
+import ContactsLayout from './ContactsLayout.vue'
+import ContactsPage from './ContactsPage.vue'
+
+// 주소록 가져오기 · 내보내기 (16단계 — 디자인 원본 Contacts16 · 계약 3절 "가져오기 · 내보내기")
+// 서버는 흉내다 — 확인(이메일이 주소록에 있으면 exists) · 넣기(uid 마다 하나, 같은 uid 는 다시 만들지 않는다) · 여러 명 지우기 · 그룹
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+const Empty: FunctionalComponent = () => h('div')
+Empty.displayName = 'Empty'
+/** 손 시계의 지금 — 2026-11-10 (이 PC 의 날짜 — 내보낼 파일 이름) */
+const NOW = new Date(2026, 10, 10, 9, 30).getTime()
+
+function contact(id: string, name: string, email: string | null, patch: Partial = {}): Contact {
+ return {
+ id,
+ name,
+ emails: email ? [{ address: email, label: 'work' }] : [],
+ phones: [],
+ company: null,
+ department: null,
+ title: null,
+ note: null,
+ groupIds: [],
+ ...patch,
+ }
+}
+
+interface ServerState {
+ contacts: Contact[]
+ groups: ContactGroup[]
+ /** GET /contacts?limit=1 의 total — 한도 시험 */
+ total?: number
+ /** 확인이 주는 남은 자리 — 주지 않으면 3,000 − 연락처 수 */
+ remaining?: number
+ /** 넣기 요청마다 — 주면 그 결과를 쓴다(undefined 면 보통대로) */
+ importReply?: (request: MockRequest, index: number) => MockResult | undefined
+ /** 그룹을 만든 뒤 — 주면 그 결과를 쓴다(만들고 응답만 잃은 경우) */
+ groupCreateReply?: (created: ContactGroup) => MockResult | undefined
+}
+
+function pageOf(items: T[], total: number = items.length): PaginatedResult {
+ return { items, pageInfo: { hasNext: false, nextCursor: null, total } }
+}
+
+function groupsOf(state: ServerState): ContactGroup[] {
+ return state.groups.map((group) => ({
+ ...group,
+ memberCount: state.contacts.filter((item) => item.groupIds.includes(group.id)).length,
+ }))
+}
+
+function serve(state: ServerState, request: MockRequest, imports: { count: number }, uids: Map): MockResult {
+ const { method, url, params } = request
+ const id = decodeURIComponent(url.split('/')[2] ?? '')
+ if (url === '/auth/me') return ok({ user: USER, restriction: null, twoFactorDeadline: null })
+ if (url === '/contact-groups' && method === 'GET') return ok(pageOf(groupsOf(state)))
+ if (url === '/contact-groups' && method === 'POST') {
+ const body = request.data as { name: string }
+ if (state.groups.some((group) => group.name.toLowerCase() === body.name.toLowerCase())) return fail(409, 'CONTACT_001')
+ const created: ContactGroup = { id: `ng${state.groups.length + 1}`, name: body.name, memberCount: 0 }
+ state.groups.push(created)
+ return state.groupCreateReply?.(created) ?? ok(created, 201)
+ }
+ if (url.startsWith('/contact-groups/') && method === 'DELETE') {
+ const group = state.groups.find((item) => item.id === id)
+ if (!group) return fail(404, 'RES_001')
+ state.groups = state.groups.filter((item) => item.id !== id)
+ return ok({ id, name: group.name, memberIds: [], restoreToken: null })
+ }
+ if (url === '/contact-import-checks') {
+ const items = (request.data as { contacts: ContactImportItem[] }).contacts
+ const known = new Set(state.contacts.flatMap((item) => item.emails.map((email) => email.address.toLowerCase())))
+ return ok({
+ items: items.map((item) => ({
+ uid: item.uid,
+ status: item.emails.some((email) => known.has(email.address.toLowerCase())) ? 'exists' : 'new',
+ })),
+ remaining: state.remaining ?? 3000 - state.contacts.length,
+ })
+ }
+ if (url === '/contact-imports') {
+ imports.count += 1
+ const custom = state.importReply?.(request, imports.count)
+ if (custom) return custom
+ const items = (request.data as { contacts: ContactImportItem[] }).contacts
+ const created = items.map((item) => {
+ const existing = uids.get(item.uid)
+ if (existing) return { uid: item.uid, id: existing, skippedGroupIds: [] }
+ const newId = `c${state.contacts.length + 100}`
+ uids.set(item.uid, newId)
+ state.contacts.push({
+ id: newId,
+ name: item.name,
+ emails: item.emails,
+ phones: item.phones,
+ company: item.company,
+ department: item.department,
+ title: item.title,
+ note: item.note,
+ groupIds: item.groupIds,
+ })
+ return { uid: item.uid, id: newId, skippedGroupIds: [] }
+ })
+ return ok({ items: created }, 201)
+ }
+ if (url === '/contact-deletions') {
+ const ids = (request.data as { ids: string[] }).ids
+ const deletedIds = ids.filter((item) => state.contacts.some((existing) => existing.id === item))
+ state.contacts = state.contacts.filter((item) => !ids.includes(item.id))
+ return ok({ deletedIds, notFoundIds: ids.filter((item) => !deletedIds.includes(item)) })
+ }
+ if (url === '/contacts' && method === 'GET') {
+ if (params.limit === 1 && params.groupId === undefined && params.q === undefined) {
+ return ok(pageOf([], state.total ?? state.contacts.length))
+ }
+ const items = state.contacts.filter((item) => params.groupId === undefined || item.groupIds.includes(String(params.groupId)))
+ return ok(pageOf(items, state.total ?? items.length))
+ }
+ if (url === '/directory-entries') return ok(pageOf([]))
+ return ok(null)
+}
+
+function testRouter(): Router {
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: Empty },
+ { path: '/login', name: 'login', component: Empty },
+ { path: '/mail', name: 'mail', component: Empty },
+ { path: '/mail/compose', name: 'mail-compose', component: Empty },
+ { path: '/settings', name: 'settings', component: Empty },
+ {
+ path: '/contacts',
+ component: ContactsLayout,
+ children: [
+ { path: '', name: 'contacts', component: ContactsPage },
+ { path: 'all/:contactId', name: 'contacts-contact', component: ContactsPage },
+ { path: 'groups/:groupId', name: 'contacts-group', component: ContactsPage },
+ { path: 'groups/:groupId/:contactId', name: 'contacts-group-contact', component: ContactsPage },
+ { path: 'directory/:kind(people|shared)', name: 'contacts-directory', component: ContactsPage },
+ { path: 'directory/:kind(people|shared)/:entryId', name: 'contacts-directory-entry', component: ContactsPage },
+ ],
+ },
+ ],
+ })
+}
+
+/** 네이버 꼴 CSV — 값마다 앞에 ' , UTF-8 BOM */
+function naverCsv(rows: string[][]): File {
+ const header = ['성', '이름', '휴대폰번호', '이메일', '그룹명', '회사·소속명', '부서명', '직책', '회사주소', '메모']
+ const lines = [header, ...rows].map((cells, index) => (index === 0 ? cells : cells.map((cell) => `'${cell}`)).join(','))
+ return new File([`\uFEFF${lines.join('\n')}\n`], 'NAVER_Contacts.csv', { type: 'text/csv' })
+}
+
+describe('주소록 가져오기 · 내보내기 (16단계)', () => {
+ let pinia: Pinia
+ let requests: MockRequest[]
+ let state: ServerState
+ let imports: { count: number }
+ let uids: Map
+ let downloads: string[]
+ const mounted: VueWrapper[] = []
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ useAuthStore().startSession(USER)
+ imports = { count: 0 }
+ uids = new Map()
+ downloads = []
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
+ downloads.push(this.getAttribute('href') ?? '')
+ })
+ state = {
+ contacts: [
+ contact('c1', '박지훈', 'jihoon.park@example.com', { groupIds: ['g1'] }),
+ contact('c2', '오세훈', 'sehun.oh@example.net'),
+ ],
+ groups: [
+ { id: 'g1', name: '거래처', memberCount: 0 },
+ { id: 'g2', name: '세무', memberCount: 0 },
+ { id: 'g3', name: '협력사 담당', memberCount: 0 },
+ ],
+ }
+ })
+
+ afterEach(() => {
+ for (const wrapper of mounted.splice(0)) {
+ if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
+ }
+ vi.restoreAllMocks()
+ useToastStore().clear()
+ restoreApiAdapter()
+ document.body.innerHTML = ''
+ })
+
+ async function mountApp(path = '/contacts') {
+ requests = installMockApi((request) => serve(state, request, imports, uids))
+ const router = testRouter()
+ await router.push(path)
+ await router.isReady()
+ const clock = createManualClock(NOW)
+ const app = mount(
+ { render: () => [h(RouterView), h(ToastHost)] },
+ { attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
+ )
+ mounted.push(app)
+ await flushPromises()
+ return { app, router }
+ }
+
+ /** 조건이 될 때까지 기다린다 — 파일 읽기 · 압축 풀기 · 요청이 여러 번 양보한다 */
+ async function until(condition: () => boolean, tries = 60) {
+ for (let index = 0; index < tries && !condition(); index += 1) await flushPromises()
+ if (!condition()) throw new Error('기다린 조건이 되지 않았다')
+ }
+
+ async function click(target: Pick, 'element' | 'trigger'>) {
+ ;(target.element as HTMLElement).focus()
+ await target.trigger('click')
+ await flushPromises()
+ }
+
+ async function chooseFile(app: VueWrapper, file: File) {
+ const input = app.get('[data-import-file]')
+ Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
+ await input.trigger('change')
+ }
+
+ const toasts = () => useToastStore().toasts
+ const toastMessages = () => toasts().map((toast) => toast.message)
+ const isOpen = (app: VueWrapper, selector: string) => app.find(`${selector}[open]`).exists()
+
+ async function openImport(app: VueWrapper) {
+ await click(app.get('[data-contacts-import]'))
+ expect(isOpen(app, '[data-contact-import]')).toBe(true)
+ }
+
+ async function readPreview(app: VueWrapper, file: File) {
+ await chooseFile(app, file)
+ await until(() => app.find('[data-import-found]').exists())
+ }
+
+ describe('「전체 연락처」 머리 · 빈 주소록', () => {
+ it('전체 연락처 머리에만 [가져오기] · [내보내기] — 그룹 머리는 지금 그대로', async () => {
+ const { app, router } = await mountApp()
+ expect(app.get('[data-contacts-import]').text()).toBe('가져오기')
+ expect(app.get('[data-contacts-export]').text()).toBe('내보내기')
+ expect(app.get('[data-contacts-export]').attributes('aria-disabled')).toBeUndefined()
+
+ await router.push('/contacts/groups/g1')
+ await flushPromises()
+ expect(app.find('[data-contacts-import]').exists()).toBe(false)
+ expect(app.find('[data-contacts-add-members]').exists()).toBe(true)
+ })
+
+ it('연락처가 하나도 없으면 [내보내기]를 끄고 까닭, 빈 칸 안내에 테두리 [가져오기]와 안내 한 줄', async () => {
+ state.contacts = []
+ const { app } = await mountApp()
+ const exportButton = app.get('[data-contacts-export]')
+ expect(exportButton.attributes('aria-disabled')).toBe('true')
+ expect(exportButton.attributes('title')).toBe('내보낼 연락처가 없습니다.')
+ expect(app.get(`#${exportButton.attributes('aria-describedby')}`).text()).toBe('내보낼 연락처가 없습니다.')
+ await click(exportButton)
+ expect(isOpen(app, '[data-contact-export]')).toBe(false)
+
+ const empty = app.get('[data-contacts-empty="first"]')
+ expect(empty.get('[data-contacts-first-import]').text()).toBe('가져오기')
+ expect(empty.findAll('.contacts-list__first-note').map((note) => note.text())).toEqual([
+ '다른 메일이나 휴대폰에서 내보낸 연락처 파일이 있으면 [가져오기]로 한 번에 넣을 수 있습니다.',
+ '메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.',
+ ])
+ await click(empty.get('[data-contacts-first-import]'))
+ expect(isOpen(app, '[data-contact-import]')).toBe(true)
+ })
+
+ it('연락처가 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로, 가져오는 동안은 바쁜 표시', async () => {
+ state.total = 3000
+ const { app } = await mountApp()
+ const importButton = app.get('[data-contacts-import]')
+ expect(importButton.attributes('aria-disabled')).toBe('true')
+ expect(importButton.attributes('aria-describedby')).toBe('contacts-full-note')
+ expect(importButton.attributes('title')).toBe(CONTACTS_PANEL_TEXT.fullNote)
+ await click(importButton)
+ expect(isOpen(app, '[data-contact-import]')).toBe(false)
+
+ state.total = 10
+ useContactImportStore().busy = 'import'
+ const busy = await mountApp()
+ expect(busy.app.get('[data-contacts-import]').attributes('aria-busy')).toBe('true')
+ await click(busy.app.get('[data-contacts-import]'))
+ expect(isOpen(busy.app, '[data-contact-import]')).toBe(false)
+ })
+ })
+
+ describe('가져오기 창 ① 파일 고르기', () => {
+ it('처음 — 글 · [파일 고르기](처음 초점) · 형식 안내 · [취소]만', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ const dialog = app.get('[data-contact-import]')
+ expect(dialog.get('#contact-import-title').text()).toBe('연락처 가져오기')
+ expect(dialog.get('#contact-import-message').text()).toBe(
+ '네이버, Google, Outlook 같은 메일이나 휴대폰에서 내보낸 연락처 파일을 그대로 넣을 수 있습니다.',
+ )
+ expect(document.activeElement).toBe(dialog.get('[data-import-pick]').element)
+ expect(dialog.get('.import-picker__formats').text()).toBe(
+ 'vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 넣을 수 있습니다. 파일 하나는 20MB까지입니다.',
+ )
+ expect(dialog.get('[data-import-file]').attributes('accept')).toBe('.vcf,.csv,.xlsx')
+ expect(dialog.find('[data-import-submit]').exists()).toBe(false)
+ })
+
+ it('읽는 동안의 상태 영역은 늘 있고 글만 바뀐다 — 영역이 글과 함께 생기면 화면 낭독기가 읽지 않기도 한다', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ const status = app.get('[data-import-status]')
+ expect(status.attributes('role')).toBe('status')
+ expect(status.text()).toBe('')
+ await chooseFile(app, naverCsv([['강', '민지', '', 'minji.kang@example.com', '', '', '', '', '', '']]))
+ expect(app.get('[data-import-status]').element).toBe(status.element)
+ expect(status.text()).toBe(CONTACT_IMPORT_TEXT.reading)
+ expect(app.get('.import-picker__reading').attributes('aria-hidden')).toBe('true')
+ await until(() => app.find('[data-import-found]').exists())
+ })
+
+ it('읽다가 뜻밖에 멈추면 "읽는 중"에 머물지 않고 열지 못한 파일로 칸 아래에 알린다', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ const file = new File(['이름,이메일'], '연락처.csv')
+ Object.defineProperty(file, 'size', {
+ get: () => {
+ throw new Error('읽기 실패')
+ },
+ })
+ await chooseFile(app, file)
+ await until(() => app.find('[data-import-error]').exists())
+ expect(app.get('[data-import-error]').text()).toBe(CONTACT_IMPORT_ERROR_TEXT.unreadable)
+ expect(app.find('[data-import-pick]').exists()).toBe(true)
+ })
+
+ it('끌어 오는 동안 놓는 자리가 바뀌고, 여러 파일을 놓으면 칸 아래 글(알림 없음)', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ const zone = app.get('[data-import-zone]')
+ const transfer = fakeDataTransfer([new File(['a'], 'a.csv'), new File(['b'], 'b.csv')])
+ zone.element.dispatchEvent(dragEvent('dragenter', transfer))
+ await flushPromises()
+ expect(zone.classes()).toContain('is-dragging')
+ expect(zone.text()).toBe('놓으면 이 파일에서 연락처를 찾습니다')
+
+ const drop = dragEvent('drop', transfer)
+ zone.element.dispatchEvent(drop)
+ await flushPromises()
+ expect(drop.defaultPrevented).toBe(true)
+ expect(app.get('[data-import-error]').text()).toBe('파일은 한 번에 하나만 넣을 수 있습니다.')
+ expect(app.get('[data-import-error]').attributes('role')).toBe('alert')
+ expect(zone.classes()).not.toContain('is-dragging')
+ expect(toasts()).toEqual([])
+ })
+
+ it.each([
+ ['list.xls', 'xls 내용', '옛 엑셀 파일(.xls)은 넣을 수 없습니다. 엑셀에서 [다른 이름으로 저장]을 눌러 .xlsx나 CSV 파일로 저장한 뒤 넣어 주세요.'],
+ ['photo.png', '\u0000PNG', '넣을 수 없는 파일입니다. vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 골라 주세요.'],
+ ['empty.csv', '날짜,금액\n2026-11-10,3000\n', '이 파일에서 연락처를 찾지 못했습니다. 이름이나 이메일이 든 열이 있는지 확인해 주세요.'],
+ ['empty.vcf', 'VERSION:3.0\n', '이 파일에서 연락처를 찾지 못했습니다.'],
+ ])('읽지 못하면 칸 아래 빨간 글 — %s', async (name, content, message) => {
+ const { app } = await mountApp()
+ await openImport(app)
+ await chooseFile(app, new File([content], name))
+ await until(() => app.find('[data-import-error]').exists())
+ expect(app.get('[data-import-error]').text()).toBe(message)
+ expect(document.activeElement).toBe(app.get('[data-import-pick]').element)
+ expect(requests.some((request) => request.url === '/contact-import-checks')).toBe(false)
+ })
+ })
+
+ describe('가져오기 창 ② 미리 보기 · 넣기 · 되돌리기', () => {
+ const rows = [
+ ['강', '민지', '01041203387', 'minji.kang@example.com', '거래처', '한빛상사', '구매팀', '과장', '서울 중구 세종대로 110', '견적 문의는 월말 전에 보내기.'],
+ ['박', '지훈', '', 'jihoon.park@example.com', '', '한빛상사', '영업팀', '', '', ''],
+ ['김', '도현', '01055210934', 'dohyun.kim@example.net', '거래처', '그린클라우드', '', '', '', ''],
+ ['', '', '01090346621', '', '', '', '', '', '', ''],
+ ['문', '가은', '', 'gaeun.moon@example.net', '동호회', '', '', '', '', ''],
+ ['강', '민지', '', 'MINJI.KANG@example.com', '', '', '', '', '', ''],
+ ]
+
+ it('세 묶음 · 그룹 글 · 메모 견본 — 처음 초점은 [연락처 N명 넣기]', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv(rows))
+ const dialog = app.get('[data-contact-import]')
+
+ expect(dialog.get('[data-import-file-name]').text()).toBe('NAVER_Contacts.csv')
+ expect(dialog.get('[data-import-found]').text()).toBe('연락처 6명을 찾았습니다.')
+ expect(dialog.get('[data-import-insert-count]').text()).toBe('3명')
+ expect(dialog.findAll('[data-import-row]').map((row) => row.findAll('[role="cell"]').map((cell) => cell.text()))).toEqual([
+ ['강민지', 'minji.kang@example.com', '010-4120-3387', '한빛상사', '거래처'],
+ ['김도현', 'dohyun.kim@example.net', '010-5521-0934', '그린클라우드', '거래처'],
+ ['문가은', 'gaeun.moon@example.net', '', '', '동호회'],
+ ])
+ expect(document.activeElement).toBe(dialog.get('[data-import-submit]').element)
+ expect(dialog.get('[data-import-submit]').text()).toBe('연락처 3명 넣기')
+
+ const toggle = dialog.get('[data-import-duplicates-toggle]')
+ expect(toggle.text()).toContain('이미 있는 연락처는 건너뜁니다')
+ expect(toggle.text()).toContain('2명')
+ expect(toggle.attributes('aria-expanded')).toBe('false')
+ await click(toggle)
+ expect(toggle.attributes('aria-expanded')).toBe('true')
+ expect(
+ dialog.findAll('[data-import-duplicates] li').map((item) => item.findAll('span').map((span) => span.text())),
+ ).toEqual([
+ ['3번째 줄', '박지훈jihoon.park@example.com', '박지훈', '주소록에 있음'],
+ ['7번째 줄', '강민지MINJI.KANG@example.com', '강민지', '2번째 줄과 같음'],
+ ])
+
+ await click(dialog.get('[data-import-problems-toggle]'))
+ expect(dialog.get('[data-import-problems-toggle]').text()).toContain('넣지 못하는 줄')
+ expect(dialog.get('[data-import-problems] li').text()).toContain('5번째 줄')
+ expect(dialog.get('[data-import-problems] li').text()).toContain('010-9034-6621')
+ expect(dialog.get('[data-import-problems] li').text()).toContain('이름과 이메일이 모두 없습니다.')
+
+ expect(dialog.get('[data-import-groups-text]').text()).toBe(
+ '파일에 적힌 그룹 2개 가운데 거래처는 이미 있는 그룹에 넣고 동호회는 새로 만듭니다.',
+ )
+ expect(dialog.get('[data-import-info]').element).toHaveProperty('checked', true)
+ expect(dialog.get('[data-import-info-sample]').text()).toContain('예: 강민지의 메모')
+ expect(dialog.get('[data-import-info-sample]').text()).toContain('회사 주소: 서울 중구 세종대로 110')
+ expect(requests.filter((request) => request.url === '/contact-import-checks')).toHaveLength(1)
+ })
+
+ it('[연락처 N명 넣기] — 새 그룹을 먼저 만들고 100명씩 넣는다, 결과는 ✕까지 남고 [되돌리기]가 넣은 연락처 · 새 그룹을 지운다', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv(rows))
+ const dialog = app.get('[data-contact-import]')
+
+ await dialog.get('[data-import-group-mode="new"]').setValue(true)
+ await click(dialog.get('[data-import-submit]'))
+ expect(dialog.get('[data-import-group-name]').attributes('aria-invalid')).toBe('true')
+ expect(dialog.get('.import-groups .form-message--error').text()).toBe('그룹 이름을 넣어 주세요.')
+ expect(document.activeElement).toBe(dialog.get('[data-import-group-name]').element)
+ expect(isOpen(app, '[data-contact-import]')).toBe(true)
+
+ await dialog.get('[data-import-group-name]').setValue('네이버 주소록')
+ await click(dialog.get('[data-import-submit]'))
+ await until(() => toasts().some((toast) => toast.kind === 'success'))
+ expect(isOpen(app, '[data-contact-import]')).toBe(false)
+
+ const groupCreates = requests.filter((request) => request.url === '/contact-groups' && request.method === 'POST')
+ expect(groupCreates.map((request) => (request.data as { name: string }).name)).toEqual(['네이버 주소록', '동호회'])
+ const importRequests = requests.filter((request) => request.url === '/contact-imports')
+ expect(importRequests).toHaveLength(1)
+ expect(requests.indexOf(importRequests[0]!)).toBeGreaterThan(requests.indexOf(groupCreates[1]!))
+ const sent = (importRequests[0]?.data as { contacts: ContactImportItem[] }).contacts
+ expect(sent.map((item) => [item.name, item.groupIds])).toEqual([
+ ['강민지', ['g1', 'ng4']],
+ ['김도현', ['g1', 'ng4']],
+ ['문가은', ['ng5', 'ng4']],
+ ])
+ expect(sent[0]?.uid).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
+ expect(sent[0]?.note).toBe('견적 문의는 월말 전에 보내기.\n\n가져온 정보\n회사 주소: 서울 중구 세종대로 110')
+ expect(sent[0]?.phones).toEqual([{ number: '010-4120-3387', label: 'mobile' }])
+
+ const [result] = toasts()
+ expect(result?.message).toBe('연락처 3명을 넣었습니다. 이미 있던 2명은 건너뛰었습니다.')
+ expect(result?.sticky).toBe(true)
+ expect(app.get('[data-toast-close]').attributes('aria-label')).toBe('알림 닫기')
+ // 끝나면 연락처 수 · 그룹 · 보는 목록을 다시 받는다
+ await until(() => app.get('[data-contacts-count]').text() === '5명')
+
+ await click(app.get('.toast__action'))
+ await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 3명')))
+ expect(toastMessages()).toEqual(['가져온 연락처 3명과 새로 만든 그룹 2개를 지웠습니다.'])
+ const deletions = requests.filter((request) => request.url === '/contact-deletions')
+ expect(deletions.map((request) => (request.data as { ids: string[] }).ids)).toEqual([['c102', 'c103', 'c104']])
+ expect(
+ requests.filter((request) => request.method === 'DELETE').map((request) => request.url),
+ ).toEqual(['/contact-groups/ng4', '/contact-groups/ng5'])
+ expect(state.contacts.map((item) => item.id)).toEqual(['c1', 'c2'])
+ })
+
+ it('주소록이 차면 남은 자리만큼 — 한도 안내 · 단추의 수, 결과에 넣지 못한 수', async () => {
+ state.remaining = 1
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv(rows))
+ const dialog = app.get('[data-contact-import]')
+ expect(dialog.get('[data-import-limit]').text()).toBe(
+ '연락처는 3,000개까지 둘 수 있어 앞에서부터 1명만 넣습니다. 나머지 2명은 넣지 않습니다.',
+ )
+ expect(dialog.get('[data-import-submit]').text()).toBe('연락처 1명 넣기')
+
+ await click(dialog.get('[data-import-submit]'))
+ await until(() => toasts().some((toast) => toast.kind === 'success'))
+ expect(toastMessages()).toEqual([
+ '연락처 1명을 넣었습니다. 이미 있던 2명은 건너뛰었습니다. 연락처는 3,000개까지 둘 수 있어 나머지 2명은 넣지 못했습니다.',
+ ])
+ })
+
+ it('남은 자리가 하나도 없으면(그사이 주소록이 참) 한도 안내는 패널의 3,000개 안내 · [연락처 넣기]는 꺼진다', async () => {
+ state.remaining = 0
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv(rows))
+ expect(app.get('[data-import-limit]').text()).toBe(CONTACTS_PANEL_TEXT.fullNote)
+ expect(app.get('[data-import-submit]').attributes('aria-disabled')).toBe('true')
+ expect(app.find('[data-import-insert-count]').exists()).toBe(false)
+ })
+
+ it('넣을 연락처가 없으면 [연락처 넣기]를 끄고(aria-disabled) 까닭을 단추 옆에', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv([rows[1]!]))
+ const submit = app.get('[data-import-submit]')
+ expect(submit.text()).toBe('연락처 넣기')
+ expect(submit.attributes('aria-disabled')).toBe('true')
+ expect(app.get(`#${submit.attributes('aria-describedby')}`).text()).toBe('넣을 연락처가 없습니다.')
+ await click(submit)
+ expect(isOpen(app, '[data-contact-import]')).toBe(true)
+ expect(requests.some((request) => request.url === '/contact-imports')).toBe(false)
+ })
+
+ it('도중에 멈추면 오류 알림 + [다시 시도](✕까지 남는다) — 남은 연락처부터 같은 uid 로 이어 넣는다', async () => {
+ const many = Array.from({ length: 150 }, (_, index) => ['이', `사람${index}`, '', `p${index}@example.org`, '', '', '', '', '', ''])
+ state.importReply = (_request, index) => (index === 2 ? 'network' : undefined)
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv(many))
+ await click(app.get('[data-import-submit]'))
+ await until(() => toasts().some((toast) => toast.kind === 'error' && toast.action !== null))
+
+ const stopped = toasts().find((toast) => toast.action !== null)
+ expect(stopped?.message).toBe('연락처 150명 가운데 100명을 넣고 멈췄습니다. 다시 시도해 주세요.')
+ expect(stopped?.sticky).toBe(true)
+ expect(stopped?.action?.label).toBe('다시 시도')
+ const firstBatches = requests.filter((request) => request.url === '/contact-imports')
+ const secondUids = (firstBatches[1]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.uid)
+ expect(secondUids).toHaveLength(50)
+
+ state.importReply = undefined
+ const retry = app.findAll('.toast').find((toast) => toast.text().includes('멈췄습니다'))
+ await click(retry!.get('.toast__action'))
+ await until(() => toasts().some((toast) => toast.kind === 'success'))
+ const batches = requests.filter((request) => request.url === '/contact-imports')
+ expect(batches).toHaveLength(3)
+ expect((batches[2]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.uid)).toEqual(secondUids)
+ expect(toastMessages()).toContain('연락처 150명을 넣었습니다.')
+ expect(state.contacts).toHaveLength(152)
+ })
+
+ it('칸을 한도까지 채운 연락처 100명 — 확인 · 넣기 묶음이 값 8,000개에서 나뉜다 (서버 본문 한도 값 10,000 · 1MB 를 넘지 않게)', async () => {
+ /** 서버(json-shape.ts)와 같은 셈 — 맨 바깥 1 + 객체 · 배열마다 항목 수 */
+ const serverValueCount = (body: unknown): number => {
+ let values = 1
+ const pending: unknown[] = [body]
+ while (pending.length > 0) {
+ const next = pending.pop()
+ if (typeof next !== 'object' || next === null) continue
+ const children = Array.isArray(next) ? next : Object.values(next)
+ values += children.length
+ pending.push(...children)
+ }
+ return values
+ }
+ const lines = ['이름,이메일,휴대폰']
+ for (let index = 0; index < 100; index += 1) {
+ const emails = Array.from({ length: 20 }, (_, k) => `p${index}.${k}@example.org`).join('; ')
+ const phones = Array.from({ length: 20 }, (_, k) => `010-${1000 + index}-${2000 + k}`).join('; ')
+ lines.push(`사람${index},"${emails}","${phones}"`)
+ }
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, new File([lines.join('\n')], '큰 연락처.csv'))
+ expect(app.get('[data-import-submit]').text()).toBe('연락처 100명 넣기')
+ await click(app.get('[data-import-submit]'))
+ await until(() => toasts().some((toast) => toast.kind === 'success'))
+
+ for (const url of ['/contact-import-checks', '/contact-imports']) {
+ const sent = requests.filter((request) => request.url === url)
+ expect(sent.map((request) => (request.data as { contacts: unknown[] }).contacts.length)).toEqual([61, 39])
+ for (const request of sent) {
+ expect(serverValueCount(request.data)).toBeLessThanOrEqual(8000)
+ expect(new TextEncoder().encode(JSON.stringify(request.data)).length).toBeLessThanOrEqual(700 * 1024)
+ }
+ }
+ expect(toastMessages()).toContain('연락처 100명을 넣었습니다.')
+ expect(state.contacts).toHaveLength(102)
+ })
+
+ it('새 그룹 만들기의 응답만 잃은 뒤 [다시 시도] — 같은 이름(CONTACT_001)이 돌아오면 이번에 만든 그룹으로 보고 [되돌리기]가 지운다', async () => {
+ state.groupCreateReply = () => 'network'
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv([rows[4]!]))
+ await click(app.get('[data-import-submit]'))
+ await until(() => toasts().some((toast) => toast.action !== null))
+ expect(toasts().find((toast) => toast.action !== null)?.message).toBe('연락처를 넣지 못했습니다. 다시 시도해 주세요.')
+ // 서버에는 만들어졌다
+ expect(state.groups.map((group) => group.name)).toContain('동호회')
+
+ state.groupCreateReply = undefined
+ const retry = app.findAll('.toast').find((toast) => toast.text().includes('넣지 못했습니다'))
+ await click(retry!.get('.toast__action'))
+ await until(() => toasts().some((toast) => toast.kind === 'success'))
+ const sent = requests.filter((request) => request.url === '/contact-imports')
+ expect((sent[0]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.groupIds)).toEqual([['ng4']])
+
+ const result = app.findAll('.toast').find((toast) => toast.text().includes('넣었습니다'))
+ await click(result!.get('.toast__action'))
+ await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 1명')))
+ expect(toastMessages()).toContain('가져온 연락처 1명과 새로 만든 그룹 1개를 지웠습니다.')
+ expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
+ '/contact-groups/ng4',
+ ])
+ expect(state.groups.map((group) => group.name)).toEqual(['거래처', '세무', '협력사 담당'])
+ })
+
+ it('그사이 다른 곳에서 같은 이름의 그룹을 만들었으면(CONTACT_001) 그 그룹에 넣고 [되돌리기]는 그 그룹을 지우지 않는다', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv([rows[4]!]))
+ state.groups.push({ id: 'other', name: '동호회', memberCount: 0 })
+ await click(app.get('[data-import-submit]'))
+ await until(() => toasts().some((toast) => toast.kind === 'success'))
+ const sent = requests.filter((request) => request.url === '/contact-imports')
+ expect((sent[0]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.groupIds)).toEqual([['other']])
+
+ await click(app.get('.toast__action'))
+ await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 1명')))
+ expect(toastMessages()).toEqual(['가져온 연락처 1명을 지웠습니다.'])
+ expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
+ expect(state.groups.map((group) => group.id)).toContain('other')
+ })
+
+ it('처음부터 넣지 못하면 "연락처를 넣지 못했습니다." + [다시 시도]', async () => {
+ state.importReply = () => fail(503, 'MAIL_001')
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, naverCsv([rows[0]!]))
+ await click(app.get('[data-import-submit]'))
+ await until(() => toasts().some((toast) => toast.action !== null))
+ expect(toasts().find((toast) => toast.action !== null)?.message).toBe('연락처를 넣지 못했습니다. 다시 시도해 주세요.')
+ })
+
+ it('엑셀(.xlsx — 네이버 내보내기처럼 칸 안 문자열)도 같은 미리 보기', async () => {
+ const bytes = await buildXlsx([
+ ['성', '이름', '휴대폰번호', '이메일', '그룹명', '회사·소속명', '부서명'],
+ ['남궁', '서연', '01077142093', 'seoyeon.namgung@example.com', '거래처', '서린디자인', ''],
+ ['류', '지환', '', 'jihwan.ryu@example.com', '세무', '바른회계법인', ''],
+ ])
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, new File([bytes.slice()], 'NAVER_Contacts.xlsx'))
+ expect(app.get('[data-import-found]').text()).toBe('연락처 2명을 찾았습니다.')
+ expect(app.findAll('[data-import-row]').map((row) => row.get('[role="cell"]').text())).toEqual(['남궁서연', '류지환'])
+ expect(app.get('[data-import-groups-text]').text()).toBe('파일에 적힌 그룹 거래처, 세무는 이미 있는 그룹에 넣습니다.')
+ // 칸 없는 정보가 없으면 메모 체크 칸을 두지 않는다
+ expect(app.find('[data-import-info]').exists()).toBe(false)
+ })
+
+ it('vCard — 줄 대신 "N번째 연락처", 묶음 이름은 "넣지 못하는 연락처"', async () => {
+ const vcf = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'FN:신예린',
+ 'EMAIL;type=INTERNET;type=HOME:yerin.shin@example.com',
+ 'END:VCARD',
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'TEL;type=CELL:010-3192-5540',
+ 'END:VCARD',
+ ].join('\r\n')
+ const { app } = await mountApp()
+ await openImport(app)
+ await readPreview(app, new File([vcf], '연락처.vcf'))
+ const toggle = app.get('[data-import-problems-toggle]')
+ expect(toggle.text()).toContain('넣지 못하는 연락처')
+ expect(toggle.text()).toContain('1명')
+ await click(toggle)
+ expect(app.get('[data-import-problems] li').text()).toContain('2번째 연락처')
+ })
+
+ it('[취소] · Esc 는 묻지 않고 닫고, 읽는 중이면 읽기를 멈춘다', async () => {
+ const { app } = await mountApp()
+ await openImport(app)
+ await chooseFile(app, naverCsv(rows))
+ await click(app.get('[data-import-cancel]'))
+ expect(isOpen(app, '[data-contact-import]')).toBe(false)
+ await flushPromises()
+ await flushPromises()
+ await openImport(app)
+ expect(app.find('[data-import-found]').exists()).toBe(false)
+ expect(app.find('[data-import-pick]').exists()).toBe(true)
+ })
+ })
+
+ describe('내보내기 창', () => {
+ it('처음 — 내 주소록 전체 · vCard, 파일 이름은 주소록-날짜. [내보내기]는 세션을 확인한 뒤 바로 내려받는다', async () => {
+ const { app } = await mountApp()
+ await click(app.get('[data-contacts-export]'))
+ const dialog = app.get('[data-contact-export]')
+ expect(isOpen(app, '[data-contact-export]')).toBe(true)
+ expect(dialog.get('#contact-export-title').text()).toBe('연락처 내보내기')
+ expect(dialog.get('[data-export-scope="all"]').element).toHaveProperty('checked', true)
+ expect(dialog.get('[data-export-format="vcard"]').element).toHaveProperty('checked', true)
+ expect(dialog.text()).toContain('연락처 2명')
+ expect(dialog.text()).toContain('내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다.')
+ expect(dialog.get('[data-export-file-name]').text()).toBe(`주소록-${formatDate(new Date(NOW))}.vcf`)
+
+ await click(dialog.get('[data-export-submit]'))
+ expect(isOpen(app, '[data-contact-export]')).toBe(false)
+ await until(() => downloads.length > 0)
+ expect(requests.some((request) => request.url === '/auth/me')).toBe(true)
+ expect(downloads).toEqual(['/api/contact-exports?format=vcard'])
+ })
+
+ it('그룹 하나를 CSV 로 — 첫 그룹이 골라지고 파일 이름에 그룹 이름', async () => {
+ const { app } = await mountApp()
+ await click(app.get('[data-contacts-export]'))
+ const dialog = app.get('[data-contact-export]')
+ await dialog.get('[data-export-scope="group"]').setValue(true)
+ await dialog.get('[data-export-format="csv"]').setValue(true)
+ expect((dialog.get('[data-export-group]').element as HTMLSelectElement).value).toBe('g1')
+ expect(dialog.get('[data-export-group]').findAll('option').map((option) => option.text())).toEqual([
+ '거래처 · 1명',
+ '세무 · 0명',
+ '협력사 담당 · 0명',
+ ])
+ expect(dialog.get('[data-export-file-name]').text()).toBe(`주소록-거래처-${formatDate(new Date(NOW))}.csv`)
+ await click(dialog.get('[data-export-submit]'))
+ await until(() => downloads.length > 0)
+ expect(downloads).toEqual(['/api/contact-exports?format=csv&groupId=g1'])
+ })
+
+ it('구성원이 없는 그룹이면 [내보내기]를 끄고 까닭을 그룹 칸 아래에 — 다른 그룹을 고르면 돌아온다', async () => {
+ const { app } = await mountApp()
+ await click(app.get('[data-contacts-export]'))
+ const dialog = app.get('[data-contact-export]')
+ await dialog.get('[data-export-scope="group"]').setValue(true)
+ await dialog.get('[data-export-group]').setValue('g3')
+ const submit = dialog.get('[data-export-submit]')
+ expect(submit.attributes('aria-disabled')).toBe('true')
+ expect(dialog.get('#contact-export-empty').text()).toBe('‘협력사 담당’ 그룹에는 아직 아무도 없어 내보낼 연락처가 없습니다.')
+ expect(dialog.find('[data-export-file-name]').exists()).toBe(false)
+ await click(submit)
+ expect(downloads).toEqual([])
+
+ await dialog.get('[data-export-group]').setValue('g1')
+ expect(submit.attributes('aria-disabled')).toBeUndefined()
+ })
+ })
+})
diff --git a/frontend/src/pages/mail/ComposePage.drafts.spec.ts b/frontend/src/pages/mail/ComposePage.drafts.spec.ts
index 32c894e..7b99583 100644
--- a/frontend/src/pages/mail/ComposePage.drafts.spec.ts
+++ b/frontend/src/pages/mail/ComposePage.drafts.spec.ts
@@ -7,7 +7,8 @@ import ToastHost from '@/components/feedback/ToastHost.vue'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_MAX_LENGTH } from '@/shared/constants/mail'
-import type { Draft, MailSignature, Mailbox, MessageDetail } from '@/shared/types/mail'
+import type { Draft, Mailbox, MessageDetail } from '@/shared/types/mail'
+import type { MailSignature, MailSignatureDefaults } from '@/shared/types/mail-signature'
import { formatDraftSavedTime } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -68,32 +69,30 @@ const MAILBOXES: Mailbox[] = [
{ id: 'trash', name: '휴지통', role: 'trash', parentId: null, sortOrder: 5, totalCount: 0, unreadCount: 0 },
]
-const NO_SIGNATURE: MailSignature = {
- bodyHtml: null,
- body: null,
- isForNew: true,
- isForReply: true,
- images: [],
- updatedAt: null,
-}
-
+/** 서명 — 새 메일 · 답장과 전달 기본 (16단계 — 여러 서명, 계약 2절 "서명") */
const SIGNATURE: MailSignature = {
+ id: 'sig-out',
+ name: '대외',
bodyHtml:
'' +
'
김아라 · 인프라팀
' +
`
`,
body: '김아라 · 인프라팀',
- isForNew: true,
- isForReply: true,
images: [{ id: IMAGE_ID, type: 'image/png', size: 1000, width: 480, height: 120 }],
updatedAt: SAVED_AT,
}
+/** 서명이 없는 직원의 기본 둘 */
+const NO_DEFAULTS: MailSignatureDefaults = { newMessageId: null, replyId: null }
+
/** 먼저 보는 응답 — undefined 면 기본 서버가 답한다 */
type ExtraHandler = (request: MockRequest) => MockResult | Promise | undefined
interface ServerOptions {
- signature?: MailSignature
+ /** 내 서명 — 비면 서명 없음 */
+ signatures?: MailSignature[]
+ /** 기본 둘 — 비면 첫 서명을 두 곳 모두에, 서명이 없으면 넣지 않음 */
+ defaults?: MailSignatureDefaults
/** POST · PUT /drafts 의 응답 — 비면 d1, d2 … 로 성공 */
draftResults?: MockResult[]
/** GET /drafts/{id} */
@@ -122,7 +121,14 @@ function server(options: ServerOptions = {}): MockHandler {
if (method === 'GET' && url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
}
- if (method === 'GET' && url === '/mail-signature') return ok(options.signature ?? NO_SIGNATURE)
+ if (method === 'GET' && url === '/mail-signatures') {
+ const signatures = options.signatures ?? []
+ return ok({ items: signatures, pageInfo: { hasNext: false, nextCursor: null, total: signatures.length } })
+ }
+ if (method === 'GET' && url === '/mail-signature-defaults') {
+ const first = options.signatures?.[0]?.id ?? null
+ return ok(options.defaults ?? (first ? { newMessageId: first, replyId: first } : NO_DEFAULTS))
+ }
if (method === 'GET' && url.startsWith('/drafts/')) {
const draft = options.draft ?? drafts.get(url.slice('/drafts/'.length))
if (!draft) return fail(404, 'RES_001')
@@ -1045,7 +1051,7 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
it('저장본으로 채우고 답장으로 연다 — 서명은 다시 넣지 않고, 고치면 그 id 로 PUT (inReplyTo 그대로)', async () => {
const { wrapper, requests } = await mountCompose(
'/mail/compose?draft=d7',
- server({ draft: DRAFT, signature: SIGNATURE }),
+ server({ draft: DRAFT, signatures: [SIGNATURE] }),
)
expect(wrapper.get('h1').text()).toBe('답장')
@@ -1143,23 +1149,63 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
})
})
- describe('서명 (계약 2절 "서명")', () => {
- it('새 메일은 본문 끝 — 쓸 줄 · 빈 줄 하나 뒤에 서명(그림 포함). 넣어 둔 서명은 "바꿈"이 아니다', async () => {
- const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
- const paragraphs = wrapper.findAll('#compose-body p')
+ describe('서명 (계약 2절 "서명" — 16단계 여러 서명)', () => {
+ /** 둘째 서명 — 사내 (그림 없음) */
+ const INSIDE: MailSignature = {
+ id: 'sig-in',
+ name: '사내',
+ bodyHtml: '김아라 · 인프라팀
내선 1234
',
+ body: '김아라 · 인프라팀\n내선 1234',
+ images: [],
+ updatedAt: SAVED_AT,
+ }
- expect(paragraphs.map((paragraph) => paragraph.text())).toEqual(['', '', '김아라 · 인프라팀', ''])
+ /** 맨 위 단계 블록 — 서명 자리는 [서명:글], 원문 인용은 [인용] */
+ function blocks(wrapper: VueWrapper): string[] {
+ const out: string[] = []
+ editorOf(wrapper.element).state.doc.forEach((node) => {
+ if (node.type.name === 'mailSignature') out.push(`[서명:${node.textContent}]`)
+ else out.push(node.type.name === 'blockquote' ? '[인용]' : node.textContent)
+ })
+ return out
+ }
+
+ function menuItems(wrapper: VueWrapper) {
+ return wrapper.findAll('.compose-signature__menu [role^="menuitem"]')
+ }
+
+ /** [서명 ▾]를 열고 이름이 같은 항목을 누른다 */
+ async function pickSignature(wrapper: VueWrapper, label: string) {
+ await wrapper.get('#compose-signature').trigger('click')
+ await flushPromises()
+ const item = menuItems(wrapper).find((entry) => entry.text() === label)
+ if (!item) throw new Error(`메뉴에 ${label} 이 없다`)
+ await item.trigger('click')
+ await flushPromises()
+ await flushPromises()
+ }
+
+ function signatureStatus(wrapper: VueWrapper): string {
+ return wrapper.get('[data-signature-status]').text()
+ }
+
+ it('새 메일은 본문 끝 — 쓸 줄 · 빈 줄 하나 뒤에 새 메일 기본 서명(그림 포함). 넣어 둔 서명은 "바꿈"이 아니다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server({ signatures: [SIGNATURE] }))
+
+ // 끝의 빈 줄은 편집기가 첫 거래 때 스스로 붙인다 — 연 직후에는 서명 자리가 끝이다
+ expect(blocks(wrapper)).toEqual(['', '', '[서명:김아라 · 인프라팀]'])
expect(wrapper.get('#compose-body img[src]').attributes()).toMatchObject({
src: `/api/mail-signature/images/${IMAGE_ID}`,
width: '144',
})
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
await wait(30_000)
expect(draftCalls(requests)).toEqual([])
expect(blocksUnload()).toBe(false)
})
- it('보내면 서명 그림이 서식 본문에 남는다 — 텍스트 대체 본문에는 그림이 없다', async () => {
- const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
+ it('보내면 서명 자리는 빈 문단 뒤의 속성 없는 div(앱과 같은 경계) — 그림은 서식 본문에만', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server({ signatures: [SIGNATURE] }))
const input = wrapper.get('#compose-to')
await input.setValue('doyun.lee@arasystem.it.kr')
await input.trigger('blur')
@@ -1171,33 +1217,219 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
const send = requests.find((request) => request.method === 'POST' && request.url === '/messages')
const data = send?.data as { body: string; bodyHtml: string }
expect(data.body).toBe('\n\n김아라 · 인프라팀')
- expect(data.bodyHtml).toContain(` `)
- })
-
- it('답장 · 전달은 인용 위 — 쓸 줄 · 빈 줄 · 서명 · 인용', async () => {
- const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', server({ signature: SIGNATURE }))
- const doc = editorOf(wrapper.element).state.doc
- const blocks: string[] = []
- doc.forEach((node) => blocks.push(node.type.name === 'blockquote' ? '인용' : node.textContent))
- expect(blocks.slice(0, 5)).toEqual(['', '', '김아라 · 인프라팀', '', '인용'])
- })
-
- it('답장 · 전달에 넣지 않기로 했으면 넣지 않는다', async () => {
- const { wrapper } = await mountCompose(
- '/mail/compose?replyTo=m1',
- server({ signature: { ...SIGNATURE, isForReply: false } }),
+ expect(data.bodyHtml).toContain(
+ '
김아라 · 인프라팀
' +
+ `
`,
)
- expect(wrapper.find('#compose-body img[src]').exists()).toBe(false)
- expect(wrapper.get('#compose-body').text()).not.toContain('김아라 · 인프라팀')
})
- it('서명을 못 받아도 서명 없이 연다 — 알리지 않는다', async () => {
- const { wrapper } = await mountCompose(
+ it('답장 · 전달은 인용 위에 답장과 전달 기본 서명', async () => {
+ const reply = await mountCompose(
+ '/mail/compose?replyTo=m1',
+ server({ signatures: [SIGNATURE, INSIDE], defaults: { newMessageId: SIGNATURE.id, replyId: INSIDE.id } }),
+ )
+ expect(blocks(reply.wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
+ expect(reply.wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
+ })
+
+ it('답장과 전달 기본이 넣지 않음이면 넣지 않는다', async () => {
+ const none = await mountCompose(
+ '/mail/compose?replyTo=m1',
+ server({ signatures: [SIGNATURE], defaults: { newMessageId: SIGNATURE.id, replyId: null } }),
+ )
+ expect(none.wrapper.find('#compose-body img[src]').exists()).toBe(false)
+ expect(blocks(none.wrapper).slice(0, 2)).toEqual(['', '[인용]'])
+ expect(none.wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 넣지 않음')
+ })
+
+ it('서명을 못 받아도 서명 없이 연다 — 알리지 않고, [서명 ▾]를 열 때 다시 받아 본다', async () => {
+ let failing = true
+ const { wrapper, requests } = await mountCompose(
'/mail/compose',
- server({ extra: (request) => (request.url === '/mail-signature' ? fail(503, 'MAIL_001') : undefined) }),
+ server({
+ signatures: [SIGNATURE],
+ extra: (request) => (failing && request.url === '/mail-signatures' ? fail(503, 'MAIL_001') : undefined),
+ }),
)
expect(wrapper.get('#compose-body').text()).toBe('')
expect(useToastStore().toasts).toEqual([])
+
+ failing = false
+ await wrapper.get('#compose-signature').trigger('click')
+ await flushPromises()
+ expect(requests.filter((request) => request.url === '/mail-signatures')).toHaveLength(2)
+ expect(menuItems(wrapper).map((item) => item.text())).toEqual(['대외', '넣지 않음', '서명 관리'])
+ })
+
+ describe('[서명 ▾] — 쓰기 화면에서 서명 바꾸기 (디자인 원본 Signature16 6)', () => {
+ const BOTH = { signatures: [SIGNATURE, INSIDE], defaults: { newMessageId: SIGNATURE.id, replyId: INSIDE.id } }
+
+ it('메뉴 — 서명(만든 차례) · 넣지 않음 · 가는 선 · 서명 관리, 지금 든 서명에 ✓. 키보드로 열면 첫 항목, Esc 는 단추로', async () => {
+ const { wrapper } = await mountCompose('/mail/compose', server(BOTH), [], true)
+ const button = wrapper.get('#compose-signature')
+ expect(button.attributes('aria-haspopup')).toBe('menu')
+ expect(button.attributes('aria-expanded')).toBe('false')
+
+ ;(button.element as HTMLButtonElement).focus()
+ await button.trigger('keydown', { key: 'ArrowDown' })
+ await flushPromises()
+ expect(button.attributes('aria-expanded')).toBe('true')
+ expect(wrapper.get('.compose-signature__menu').attributes('aria-label')).toBe('서명')
+ expect(
+ menuItems(wrapper).map((item) => [item.text(), item.attributes('role'), item.attributes('aria-checked')]),
+ ).toEqual([
+ ['대외', 'menuitemradio', 'true'],
+ ['사내', 'menuitemradio', 'false'],
+ ['넣지 않음', 'menuitemradio', 'false'],
+ ['서명 관리', 'menuitem', undefined],
+ ])
+ expect(wrapper.find('.compose-signature__menu hr').exists()).toBe(true)
+ expect(document.activeElement?.textContent?.trim()).toBe('대외')
+
+ await wrapper.get('.compose-signature__menu').trigger('keydown', { key: 'Escape' })
+ await flushPromises()
+ expect(wrapper.find('.compose-signature__menu').exists()).toBe(false)
+ expect(document.activeElement?.id).toBe('compose-signature')
+ })
+
+ it('다른 서명을 고르면 서명 자리만 바뀐다 — 쓴 글은 그대로, 화면 낭독기에 알리고(알림 없음) 실행 취소로 되돌린다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server(BOTH))
+ editorOf(wrapper.element).commands.insertContentAt(1, '도윤님, 안녕하세요.')
+ await flushPromises()
+
+ await pickSignature(wrapper, '사내')
+ expect(blocks(wrapper)).toEqual(['도윤님, 안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]', ''])
+ expect(signatureStatus(wrapper)).toBe('서명을 ‘사내’로 바꿨습니다.')
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
+ expect(useToastStore().toasts).toEqual([])
+ // 바꾼 서명도 바꿈이다 — 임시 저장한다
+ await wait(3_000)
+ expect(draftCalls(requests)).toHaveLength(1)
+
+ editorOf(wrapper.element).commands.undo()
+ await flushPromises()
+ expect(blocks(wrapper)).toEqual(['도윤님, 안녕하세요.', '', '[서명:김아라 · 인프라팀]', ''])
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
+ })
+
+ it('넣지 않음 — 서명 자리와 그 앞의 빈 줄을 빼고, 다시 고르면 처음 넣는 자리(본문 끝)에 넣는다', async () => {
+ const { wrapper } = await mountCompose('/mail/compose', server(BOTH))
+ editorOf(wrapper.element).commands.insertContentAt(1, '안녕하세요.')
+ await flushPromises()
+
+ await pickSignature(wrapper, '넣지 않음')
+ expect(blocks(wrapper)).toEqual(['안녕하세요.', ''])
+ expect(signatureStatus(wrapper)).toBe('서명을 ‘넣지 않음’으로 바꿨습니다.')
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 넣지 않음')
+
+ await pickSignature(wrapper, '사내')
+ expect(blocks(wrapper)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]', ''])
+ })
+
+ it('답장은 인용 위의 서명 자리만 바뀐다', async () => {
+ const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', server(BOTH))
+ await pickSignature(wrapper, '대외')
+ expect(blocks(wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀]', '[인용]'])
+ await pickSignature(wrapper, '넣지 않음')
+ expect(blocks(wrapper).slice(0, 2)).toEqual(['', '[인용]'])
+ await pickSignature(wrapper, '사내')
+ expect(blocks(wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
+ })
+
+ it('서명 안을 고치면 어느 서명과도 같지 않다 — ✓ 없이, 단추는 "서명"만. 고르면 고친 것도 새 서명으로 바뀐다', async () => {
+ const { wrapper } = await mountCompose('/mail/compose', server(BOTH))
+ const editor = editorOf(wrapper.element)
+ let inside = -1
+ editor.state.doc.descendants((node, pos) => {
+ if (inside < 0 && node.isText && node.text === '김아라') inside = pos
+ })
+ editor.commands.insertContentAt(inside, '대표 ')
+ await flushPromises()
+
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명')
+ await wrapper.get('#compose-signature').trigger('click')
+ await flushPromises()
+ expect(menuItems(wrapper).map((item) => item.attributes('aria-checked'))).toEqual(['false', 'false', 'false', undefined])
+ await menuItems(wrapper)[0]?.trigger('click')
+ await flushPromises()
+ expect(blocks(wrapper)).toEqual(['', '', '[서명:김아라 · 인프라팀]', ''])
+ })
+
+ it('이어 쓰는 임시 저장본 — 서명 자리를 다시 찾아 같은 서명이면 ✓, 바꾸면 그 자리만 바뀐다 (서명을 다시 넣지 않는다)', async () => {
+ const saved: Draft = {
+ id: 'd9',
+ to: [],
+ cc: [],
+ bcc: [],
+ subject: '견적',
+ body: '확인했습니다.\n\n김아라 · 인프라팀\n내선 1234',
+ // 서버가 정화한 저장본 — 감싸개 · 빈 문단 뒤의 속성 없는 div (data-* 없음)
+ bodyHtml:
+ '',
+ attachments: [],
+ inReplyTo: null,
+ updatedAt: '2026-10-26T01:00:00Z',
+ }
+ const { wrapper } = await mountCompose('/mail/compose?draft=d9', server({ ...BOTH, draft: saved }))
+
+ expect(blocks(wrapper)).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀내선 1234]'])
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
+ await pickSignature(wrapper, '대외')
+ expect(blocks(wrapper)).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀]', ''])
+ })
+
+ it('서명 위 빈 줄에 글을 써도 빈 줄을 다시 두어 저장 · 다시 열기에서 서명 자리를 찾는다 (리뷰 M3)', async () => {
+ // 같은 서버 — 저장한 판을 기억했다가 다시 열 때 내준다
+ const handler = server(BOTH)
+ const first = await mountCompose('/mail/compose', handler)
+ const editor = editorOf(first.wrapper.element)
+ // 쓸 줄은 비우고 서명 바로 위 빈 줄(둘째 줄)에 쓴다
+ editor.commands.insertContentAt(editor.state.doc.child(0).nodeSize + 1, '감사합니다.')
+ await flushPromises()
+ expect(blocks(first.wrapper)).toEqual(['', '감사합니다.', '', '[서명:김아라 · 인프라팀]', ''])
+
+ await wait(3_000)
+ const saved = draftCalls(first.requests)[0]
+ expect(saved?.method).toBe('POST')
+ expect((saved?.data as { bodyHtml: string }).bodyHtml).toContain(
+ '감사합니다.
',
+ )
+ first.wrapper.unmount()
+
+ // 저장한 판을 다시 연다 — 서명 자리를 찾고 그 서명에 ✓
+ const { wrapper } = await mountCompose('/mail/compose?draft=d1', handler)
+ expect(blocks(wrapper).slice(0, 4)).toEqual(['', '감사합니다.', '', '[서명:김아라 · 인프라팀]'])
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
+ })
+
+ it('Backspace 로 서명 위 빈 줄을 지워도 빈 줄을 다시 두어 저장 · 다시 열기에서 서명 자리를 찾는다 (리뷰 M3)', async () => {
+ const handler = server(BOTH)
+ const first = await mountCompose('/mail/compose', handler)
+ const editor = editorOf(first.wrapper.element)
+ editor.commands.insertContentAt(1, '안녕하세요.')
+ // 서명 바로 위 빈 줄(둘째 줄)에서 Backspace
+ const blankLine = editor.state.doc.child(0).nodeSize + 1
+ editor.commands.setTextSelection(blankLine)
+ editor.commands.keyboardShortcut('Backspace')
+ await flushPromises()
+ expect(blocks(first.wrapper).slice(0, 3)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀]'])
+
+ await wait(3_000)
+ expect(draftCalls(first.requests)).toHaveLength(1)
+ first.wrapper.unmount()
+
+ const { wrapper } = await mountCompose('/mail/compose?draft=d1', handler)
+ expect(blocks(wrapper).slice(0, 3)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀]'])
+ expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
+ })
+
+ it('서명 관리 — 설정 › 메일 › 서명으로 간다 (바꾼 것이 없으면 묻지 않는다)', async () => {
+ const { wrapper, router } = await mountCompose('/mail/compose', server(BOTH))
+ await pickSignature(wrapper, '서명 관리')
+ expect(router.currentRoute.value.name).toBe('settings-mail-signature')
+ })
})
})
})
diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue
index 5d921e1..7145f9f 100644
--- a/frontend/src/pages/mail/ComposePage.vue
+++ b/frontend/src/pages/mail/ComposePage.vue
@@ -6,6 +6,11 @@
// 받는 사람을 채운 새 메일(8단계 — 주소록의 [메일 쓰기] · 주소 링크 · [그룹에 메일 쓰기]): 주소는 URL 이 아니라 라우터 상태로 온다(compose-prefill.ts).
// 채운 받는 사람은 "바꿈"이 아니다(답장처럼 — 손대지 않고 닫으면 저장하지 않는다). 초점은 제목으로 (받는 사람은 이미 있다)
// 서명: 열 때 설정의 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위 (디자인 README "2단계 › 서명").
+// 16단계(여러 서명 — 디자인 원본 Signature16 6): 넣는 서명은 "메일에 넣을 서명"의 새 메일 · 답장과 전달 기본이고, 서명 자리(빈 줄 뒤의 서명 덩어리 —
+// 앱과 같은 경계, mail-signature-block.ts)로 넣는다. 아래 단추 줄 [임시 저장] 다음의 [서명 ▾](ComposeSignatureMenu)로 그 자리만 다른 서명 · 넣지 않음으로
+// 바꾼다(쓴 글 · 인용 · 첨부는 그대로, 실행 취소로 되돌린다) — 바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."(알림 없음).
+// 어느 서명이 들었는지는 서명 자리의 글 · 그림을 저장한 서명과 견준다(이어 쓰는 저장본도 — 고쳤으면 ✓ 없이). 서명 관리는 설정 › 메일 › 서명으로(나가기 규칙대로).
+// 받는 사람에 따라 저절로 바꾸지 않는다.
// 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장", useDraftSaving):
// - 바꾼 내용이 있으면 입력이 3초 멈출 때(10초에 한 번까지 — 쉬지 않고 쓰면 처음 바꾼 뒤 30초에) · 「임시 저장」을 누를 때 ·
// 화면을 떠날 때 저장한다. 상태는 아래 단추 줄에.
@@ -71,6 +76,7 @@ import ComposeFooter from '@/components/mail/ComposeFooter.vue'
import ComposeHeader from '@/components/mail/ComposeHeader.vue'
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
import ComposeScheduleDialog from '@/components/mail/ComposeScheduleDialog.vue'
+import ComposeSignatureMenu from '@/components/mail/ComposeSignatureMenu.vue'
import RecipientField from '@/components/mail/RecipientField.vue'
import {
MAIL_EDITOR_RETRY,
@@ -106,8 +112,11 @@ import {
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import { SEND_LATER_TEXT, UNDO_SEND_DEADLINE_MARGIN_MS } from '@/shared/constants/mail-send-later'
import { UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
+import { COMPOSE_SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
+import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
+import type { ComposeSignatureState } from '@/shared/types/mail-signature'
import {
isSameComposeForm,
snapshotComposeForm,
@@ -131,8 +140,9 @@ import {
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { buildReplyDraft } from '@/shared/utils/mail-reply'
+import { directionParticle } from '@/shared/utils/korean'
import { partialSendMessage } from '@/shared/utils/mail-send-result'
-import { newMailHtmlWithSignature, signatureHtmlFor } from '@/shared/utils/mail-signature'
+import { newMailHtmlWithSignature, signatureBlockHtml, signatureHtmlOf } from '@/shared/utils/mail-signature'
import { MAIL_SCHEDULED_ROUTE } from '@/shared/utils/mail-list-source'
import { undoToastDeadline } from '@/shared/utils/pending-send'
import { rejectedScheduleProblem, sendAtOf, type ScheduleRejection } from '@/shared/utils/schedule-send'
@@ -239,6 +249,12 @@ let focusBodyWhenReady = false
const preparing = ref(false)
/** 새 메일에 넣을 서명을 기다리는 중 — 편집기를 서명과 함께 만든다 (받는 사람 · 제목은 그대로 쓸 수 있다) */
const bodyPending = ref(false)
+/** 본문에 든 서명 — [서명 ▾]의 ✓ · 단추의 화면 낭독 이름 (16단계 — 편집기가 준비되면 · 본문이 바뀔 때마다 다시 잰다) */
+const signatureState = ref({ kind: 'none' })
+/** 서명을 바꾼 뒤 화면 낭독기에 읽어 줄 글 — "서명을 ‘사내’로 바꿨습니다." */
+const signatureAnnouncement = ref('')
+/** 서명 HTML 마다의 비교 값 — 편집기 스키마로 한 번 읽어 둔다 (스키마는 편집기를 새로 만들어도 같다) */
+const signatureFingerprints = new Map()
/** 제목에 보이는 쓰기 종류 — 이어 쓰는 저장본은 불러온 뒤 답장 · 전달 · 새 메일 중 하나로 정한다 */
const composeKind = ref('new')
/** 발송 · 임시 저장의 inReplyTo — 답장할 메일 id (이어 쓰는 저장본은 저장본의 값) */
@@ -520,6 +536,8 @@ const recipientSuggestions = useRecipientSuggestions({
})
provide(RECIPIENT_SUGGESTIONS, recipientSuggestions)
recipientSuggestions.preload()
+// [서명 ▾]의 목록 · 이어 쓰는 저장본의 서명 견주기 — 메일 화면이 받아 두었으면 그대로 (새 메일 · 답장은 받은 뒤 서명을 넣는다)
+void signatureStore.ensureLoaded()
function commitRecipientInputs() {
toField.value?.commit()
@@ -637,33 +655,32 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
let prepareSeq = 0
-/** 새 메일 — 서명을 받은 뒤 편집기를 만든다 (받는 사람 · 제목은 그동안에도 쓸 수 있다) */
+/** 새 메일 — 서명을 받은 뒤 편집기를 만든다 (받는 사람 · 제목은 그동안에도 쓸 수 있다). 넣는 서명은 새 메일 기본 (못 받았으면 넣지 않는다) */
async function prepareNew() {
prepareSeq += 1
const seq = prepareSeq
bodyPending.value = true
- const signature = signatureHtmlFor(await signatureStore.ensureSignature(), 'new')
+ await signatureStore.ensureLoaded()
if (seq !== prepareSeq) return
+ const signature = signatureHtmlOf(signatureStore.defaultSignature('new'))
if (signature) draftHtml.value = newMailHtmlWithSignature(signature)
bodyPending.value = false
}
-/** 답장·전달 — 원본 메일과 서명을 불러와 양식을 채운다 */
+/** 답장·전달 — 원본 메일과 서명을 불러와 양식을 채운다. 넣는 서명은 답장과 전달 기본 */
async function prepareFrom(messageId: string, sourceMode: Exclude) {
prepareSeq += 1
const seq = prepareSeq
preparing.value = true
try {
- const [original, signature] = await Promise.all([
- getMessage(messageId),
- signatureStore.ensureSignature(),
- ])
+ const [original] = await Promise.all([getMessage(messageId), signatureStore.ensureLoaded()])
if (seq !== prepareSeq) return
- const signatureHtml = signatureHtmlFor(signature, 'reply')
+ const signatureHtml = signatureHtmlOf(signatureStore.defaultSignature('reply'))
+ const signatureBlock = signatureHtml ? signatureBlockHtml(signatureHtml) : null
/** 처음 채운 원본 첨부 — 불러오는 동안 사용자가 붙인 파일(창에 끌어다 놓기 등)은 여기 들지 않는다 ("바꿈"으로 남는다) */
let filledAttachments: readonly number[] = []
if (sourceMode === 'forward') {
- const draft = buildForwardDraft(original, undefined, signatureHtml)
+ const draft = buildForwardDraft(original, undefined, signatureBlock)
subject.value = draft.subject
draftHtml.value = draft.html
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
@@ -671,7 +688,7 @@ async function prepareFrom(messageId: string, sourceMode: Exclude item.status === 'done')
.map((item) => item.key)
} else {
- const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode, undefined, signatureHtml)
+ const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode, undefined, signatureBlock)
to.value = draft.to.map(tokenFromAddress)
cc.value = draft.cc.map(tokenFromAddress)
showCopies.value = cc.value.length > 0
@@ -761,6 +778,7 @@ function isFocusFree(): boolean {
function onEditorReady() {
editorReady.value = true
bodyChanged.value = false
+ refreshSignatureState()
if (!focusBodyWhenReady) return
focusBodyWhenReady = false
void nextTick(() => {
@@ -1094,13 +1112,72 @@ function clearLengthError(field: 'subject' | 'body') {
if (lengthError.value === field) lengthError.value = null
}
-/** 본문이 바뀌었다 — 길이 안내를 내리고, 저장한 때와 다른지 다시 잰 뒤 자동 저장 차례를 잡는다 */
+/** 본문이 바뀌었다 — 길이 안내를 내리고, 저장한 때와 다른지 다시 잰 뒤 자동 저장 차례를 잡는다. 든 서명도 다시 잰다 */
function onEditorChange() {
clearLengthError('body')
bodyChanged.value = editorRef.value?.isChanged() ?? false
+ refreshSignatureState()
drafts.touch()
}
+/** 서명 HTML 의 비교 값 — 서명마다 한 번 편집기 스키마로 읽는다 */
+function signatureFingerprintOf(editor: MailEditorHandle, html: string): string {
+ let fingerprint = signatureFingerprints.get(html)
+ if (fingerprint === undefined) {
+ fingerprint = editor.signatureFingerprint(html)
+ signatureFingerprints.set(html, fingerprint)
+ }
+ return fingerprint
+}
+
+/**
+ * 본문에 든 서명을 다시 잰다 — 서명 자리가 없으면 넣지 않음, 있으면 글 · 그림이 같은 서명(만든 차례로 먼저 맞는 것),
+ * 어느 서명과도 같지 않으면(그 메일에서 고쳤거나 지운 서명) ✓ 없이 (디자인 원본 Signature16 "서명 자리")
+ */
+function refreshSignatureState() {
+ const editor = editorRef.value
+ if (!editor || !editorReady.value) return
+ const place = editor.signaturePlace()
+ if (place === null) {
+ signatureState.value = { kind: 'none' }
+ return
+ }
+ const match = signatureStore.signatures.find((signature) => {
+ const html = signatureHtmlOf(signature)
+ return html !== null && signatureFingerprintOf(editor, html) === place
+ })
+ signatureState.value = match ? { kind: 'signature', id: match.id } : { kind: 'edited' }
+}
+
+/** [서명 ▾]를 열었다 — 서명을 받지 못했으면 다시 받아 본다 (받으면 목록이 채워진다) */
+function onSignatureMenuOpen() {
+ void signatureStore.ensureLoaded()
+}
+
+/**
+ * [서명 ▾]에서 골랐다 — 본문의 서명 자리만 그 서명으로(넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다).
+ * 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 인용 위)에. 바꾼 뒤 화면 낭독기에만 알린다
+ */
+async function onPickSignature(id: string | null) {
+ const editor = editorRef.value
+ if (!editor || !editorReady.value) return
+ const signature = signatureStore.signatureById(id)
+ // 그사이 다른 창에서 지운 서명이면 바꾸지 않는다
+ if (id !== null && !signature) return
+ editor.replaceSignature(signatureHtmlOf(signature))
+ refreshSignatureState()
+ const name = signature?.name ?? COMPOSE_SIGNATURE_TEXT.none
+ // 같은 글이어도 다시 읽게 비웠다가 넣는다
+ signatureAnnouncement.value = ''
+ await nextTick()
+ signatureAnnouncement.value = COMPOSE_SIGNATURE_TEXT.changed(name, directionParticle(name))
+}
+
+/** 서명 관리 — 설정 › 메일 › 서명으로. 쓰던 메일은 나가기 규칙대로 먼저 임시 저장한다 (저장하지 못했을 때만 묻는다) */
+function onManageSignatures() {
+ void router.push({ name: SETTINGS_ROUTES.signature })
+}
+
function pickFiles() {
fileInputRef.value?.click()
}
@@ -1173,6 +1250,12 @@ watch([sendable, () => authStore.generation], ([can], [, previousSession]) => {
watch(hasMissingLarge, (missing) => {
if (!missing) drafts.touch()
})
+
+// 서명을 (다시) 받았다 — [서명 ▾]의 ✓ 를 다시 잰다
+watch(
+ () => signatureStore.signatures,
+ () => refreshSignatureState(),
+)
@@ -1349,7 +1432,19 @@ watch(hasMissingLarge, (missing) => {
:sender="sender"
@save-draft="onSaveDraft"
@schedule="openSchedule"
- />
+ >
+
+
+
+
diff --git a/frontend/src/pages/mail/MailLayout.vue b/frontend/src/pages/mail/MailLayout.vue
index 3a77a8b..d5915c9 100644
--- a/frontend/src/pages/mail/MailLayout.vue
+++ b/frontend/src/pages/mail/MailLayout.vue
@@ -40,8 +40,8 @@ provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
void mailStore.ensureMailboxes()
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
void mailStore.ensureQuota()
-// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
-void useMailSignatureStore().ensureSignature()
+// 쓰기 화면이 열 때 넣을 서명 · [서명 ▾]의 목록 — 서명 목록과 기본 둘(16단계)을 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
+void useMailSignatureStore().ensureLoaded()
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
void useMailVacationStore().load()
// 패널의 「예약됨」 — 예약한 메일이 있을 때만 그린다
diff --git a/frontend/src/pages/settings/AccountTwoFactorPage.replace.spec.ts b/frontend/src/pages/settings/AccountTwoFactorPage.replace.spec.ts
new file mode 100644
index 0000000..e6f7d1a
--- /dev/null
+++ b/frontend/src/pages/settings/AccountTwoFactorPage.replace.spec.ts
@@ -0,0 +1,618 @@
+import { afterEach, beforeEach, describe, expect, it } 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 AccountTwoFactorPage from './AccountTwoFactorPage.vue'
+
+// 설정 › 계정 › 2단계 인증 — 휴대폰 바꾸기 (16단계 — 디자인 원본 Settings16 5~8 · 계약 5절 "2단계 인증")
+// ① 새 휴대폰 등록(QR · 키 · 새 휴대폰의 코드 → [다음]) ② 지금 휴대폰으로 확인(지금 휴대폰의 코드 · 지금 비밀번호 → [바꾸기])
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+const ON: TwoFactorStatus = { isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' }
+const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
+const PASSWORD = 'old-pass-1234'
+const DONE = '2단계 인증 휴대폰을 바꿨습니다. 이제 새 휴대폰의 코드로 로그인합니다.'
+const UNCONFIRMED =
+ '휴대폰을 바꿨는지 확인하지 못했습니다. 지금 휴대폰에 다음 코드가 뜨면 넣고 [바꾸기]를 다시 눌러 주세요. 코드가 맞지 않다고 나오면 이미 바뀌었을 수 있으니, 다음에 로그인할 때 새 휴대폰의 코드를 넣어 주세요.'
+const UNCONFIRMED_CLOSED =
+ '휴대폰을 바꿨는지 확인하지 못했습니다. 다음에 로그인할 때 새 휴대폰의 코드가 맞지 않으면 지금 휴대폰의 코드를 넣어 주세요.'
+const SUPPORT = '지금 휴대폰을 쓸 수 없나요? 관리자에게 2단계 인증을 풀어 달라고 요청한 뒤, 새 휴대폰으로 다시 켜 주세요.'
+
+function enrollment(seq: number): TwoFactorEnrollment {
+ const secret = seq === 1 ? 'R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6' : 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'
+ 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-11-10T00:22:00.000Z',
+ }
+}
+
+type Handler = (request: MockRequest) => MockResult | Promise
+
+interface Server {
+ status?: () => MockResult
+ verify?: Handler
+ replace?: Handler
+ supportContact?: () => MockResult
+}
+
+/** 응답을 손으로 풀어 주는 요청 — 보내는 동안의 모양을 본다 */
+function held() {
+ const replies: ((result: MockResult) => void)[] = []
+ const handler: Handler = () =>
+ new Promise((resolve) => {
+ replies.push(resolve)
+ })
+ return { handler, replies }
+}
+
+describe('AccountTwoFactorPage — 휴대폰 바꾸기 (16단계)', () => {
+ 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 = {}) {
+ let enrollSeq = 0
+ requests = installMockApi((request) => {
+ if (request.url === '/two-factor') return server.status ? server.status() : ok(ON)
+ if (request.url === '/two-factor/enrollments') {
+ enrollSeq += 1
+ return ok(enrollment(enrollSeq), 201)
+ }
+ if (/^\/two-factor\/enrollments\/[^/]+\/verification$/.test(request.url)) {
+ return server.verify ? server.verify(request) : ok({ verified: true })
+ }
+ if (request.url === '/two-factor/replacements') return server.replace ? server.replace(request) : ok(ON)
+ if (request.url === '/support-contact') return server.supportContact ? server.supportContact() : ok({ text: null })
+ 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 = (pattern: string | RegExp) =>
+ requests.filter((request) => (typeof pattern === 'string' ? request.url === pattern : pattern.test(request.url)))
+ const verifyCalls = () => calls(/\/verification$/)
+ const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
+ const dialog = () => document.querySelector('dialog[aria-labelledby="two-factor-replace-title"]')
+ const inDialog = (selector: string) => dialog()?.querySelector(selector) ?? null
+ const inputValue = (id: string) => (document.getElementById(id) as HTMLInputElement | null)?.value
+ const stepItems = () =>
+ Array.from(inDialog('[data-replace-steps]')?.querySelectorAll('li') ?? []).map((item) => ({
+ text: item.textContent?.trim() ?? '',
+ current: item.getAttribute('aria-current'),
+ done: item.querySelector('svg') !== null && !item.hasAttribute('aria-hidden'),
+ }))
+
+ async function openReplace(wrapper: VueWrapper) {
+ await wrapper.get('[data-two-factor-replace]').trigger('click')
+ await flushPromises()
+ }
+
+ async function next(wrapper: VueWrapper, code: string) {
+ await wrapper.get('#two-factor-replace-new-code').setValue(code)
+ await wrapper.get('[data-replace-next]').trigger('click')
+ await flushPromises()
+ }
+
+ async function fillCurrent(wrapper: VueWrapper, values: { code?: string; password?: string }) {
+ if (values.code !== undefined) await wrapper.get('#two-factor-replace-current-code').setValue(values.code)
+ if (values.password !== undefined) await wrapper.get('#two-factor-replace-password').setValue(values.password)
+ }
+
+ async function submit(wrapper: VueWrapper) {
+ await wrapper.get('[data-replace-submit]').trigger('click')
+ await flushPromises()
+ }
+
+ /** ②까지 — 새 휴대폰의 코드를 확인했다 */
+ async function toCurrentStep(wrapper: VueWrapper) {
+ await openReplace(wrapper)
+ await next(wrapper, '482913')
+ }
+
+ describe('① 새 휴대폰 등록', () => {
+ it('[휴대폰 바꾸기]를 누르면 창 — 켜진 채 새 키를 받고, 차례 · 글 · QR · 키는 바꾸기 글로, 처음 초점은 새 휴대폰의 코드 칸', async () => {
+ const wrapper = await mountPage()
+ await openReplace(wrapper)
+
+ expect(dialog()?.hasAttribute('open')).toBe(true)
+ expect(dialog()?.getAttribute('aria-describedby')).toBe('two-factor-replace-message')
+ expect(calls('/two-factor/enrollments').map((request) => request.method)).toEqual(['POST'])
+ expect(inDialog('#two-factor-replace-title')?.textContent?.trim()).toBe('2단계 인증 휴대폰 바꾸기')
+ expect(inDialog('[data-replace-steps]')?.getAttribute('aria-label')).toBe('휴대폰 바꾸기 차례')
+ expect(stepItems()).toEqual([
+ { text: '새 휴대폰 등록', current: 'step', done: false },
+ { text: '', current: null, done: false },
+ { text: '지금 휴대폰으로 확인', current: null, done: false },
+ ])
+ expect(inDialog('#two-factor-replace-message')?.textContent?.trim()).toBe(
+ '새 휴대폰을 등록하고 지금 휴대폰으로 확인하면 바뀝니다. 바꾸기를 마칠 때까지는 지금 휴대폰의 코드로 로그인합니다.',
+ )
+ const text = dialog()?.textContent ?? ''
+ expect(text).toContain('새 휴대폰의 인증 앱으로 QR 코드를 찍어 주세요')
+ expect(text).toContain('인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요.')
+ expect(text).toContain('앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다.')
+ expect(text).toContain('바꾸지 않고 닫으면 이 키는 더 쓸 수 없습니다. 새 휴대폰의 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요.')
+ expect(inDialog('[data-qr-code]')?.getAttribute('aria-label')).toBe('인증 앱에 등록할 QR 코드')
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
+ expect(document.querySelector('label[for="two-factor-replace-new-code"]')?.textContent).toBe('새 휴대폰의 앱에 표시된 6자리 코드')
+ expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
+ // 단추는 [다음] [취소]
+ expect(Array.from(dialog()?.querySelectorAll('.modal-dialog__actions button') ?? []).map((button) => button.textContent?.trim())).toEqual([
+ '다음',
+ '취소',
+ ])
+ })
+
+ it('키 이름 id 가 켜기 창과 겹치지 않는다 — [복사]는 바꾸기 창의 키 이름을 가리킨다', async () => {
+ const wrapper = await mountPage()
+ await openReplace(wrapper)
+
+ expect(document.querySelectorAll('#two-factor-key-label')).toHaveLength(1)
+ expect(document.querySelectorAll('#two-factor-replace-key-label')).toHaveLength(1)
+ expect(inDialog('[data-enable-copy]')?.getAttribute('aria-describedby')).toBe('two-factor-replace-key-label')
+ })
+
+ it('6자리가 아니면 보내지 않고 칸 아래에 AUTH_007 문구 · 그 칸에 초점', async () => {
+ const wrapper = await mountPage()
+ await openReplace(wrapper)
+ await next(wrapper, '12')
+
+ expect(verifyCalls()).toEqual([])
+ expect(wrapper.get('#two-factor-replace-new-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
+ expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
+ })
+
+ it('[다음]은 새 휴대폰의 코드를 먼저 확인한다 — 확인하는 동안 「확인하는 중…」(aria-busy · aria-disabled), 다시 눌러도 요청은 하나', async () => {
+ const { handler, replies } = held()
+ const wrapper = await mountPage({ verify: handler })
+ await openReplace(wrapper)
+ await next(wrapper, '482913')
+
+ expect(verifyCalls().map((request) => [request.method, request.url, request.data])).toEqual([
+ ['POST', '/two-factor/enrollments/enroll-1/verification', { otpCode: '482913' }],
+ ])
+ const button = wrapper.get('[data-replace-next]')
+ expect(button.text()).toBe('확인하는 중…')
+ expect(button.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
+ await button.trigger('click')
+ await flushPromises()
+ expect(verifyCalls()).toHaveLength(1)
+
+ replies[0]?.(ok({ verified: true }))
+ await flushPromises()
+ expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
+ })
+
+ it('새 휴대폰의 코드가 틀리면(AUTH_006) 칸 아래 · 칸을 비우고 초점 — QR · 키는 그대로, 알림 없음', async () => {
+ const wrapper = await mountPage({ verify: () => fail(400, 'AUTH_006') })
+ await openReplace(wrapper)
+ await next(wrapper, '000111')
+
+ expect(wrapper.get('#two-factor-replace-new-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
+ expect(inputValue('two-factor-replace-new-code')).toBe('')
+ expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
+ expect(calls('/two-factor/enrollments')).toHaveLength(1)
+ expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(false)
+ expect(toastMessages()).toEqual([])
+ })
+
+ it('AUTH_008(10분 지남 · 더 새 등록) — 창 위에 사전 문구, 새 키로 다시 그리고 코드는 비운다 · 처음 초점은 코드 칸', async () => {
+ const wrapper = await mountPage({ verify: () => fail(409, 'AUTH_008') })
+ await openReplace(wrapper)
+ await next(wrapper, '482913')
+ await flushPromises()
+
+ expect(calls('/two-factor')).toHaveLength(2)
+ expect(calls('/two-factor/enrollments')).toHaveLength(2)
+ const notice = wrapper.get('[data-replace-notice]')
+ expect(notice.text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
+ expect(notice.attributes('role')).toBe('alert')
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
+ expect(inputValue('two-factor-replace-new-code')).toBe('')
+ expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
+ expect(dialog()?.hasAttribute('open')).toBe(true)
+ expect(toastMessages()).toEqual([])
+ })
+
+ it('코드 칸에서 Enter 도 [다음]과 같다', async () => {
+ const wrapper = await mountPage()
+ await openReplace(wrapper)
+ await wrapper.get('#two-factor-replace-new-code').setValue('482913')
+ await wrapper.get('#two-factor-replace-new-code').trigger('keydown', { key: 'Enter' })
+ await flushPromises()
+
+ expect(verifyCalls()).toHaveLength(1)
+ expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
+ })
+ })
+
+ describe('② 지금 휴대폰으로 확인', () => {
+ it('새 휴대폰을 확인하면 ② — 차례(마친 걸음 체크 · 지금 걸음) · 글 · 칸 둘 · 문의 줄 · [이전] [바꾸기] [취소], 처음 초점은 지금 휴대폰의 코드 칸', async () => {
+ const wrapper = await mountPage()
+ await toCurrentStep(wrapper)
+
+ expect(stepItems()).toEqual([
+ { text: '새 휴대폰 등록', current: null, done: true },
+ { text: '', current: null, done: false },
+ { text: '지금 휴대폰으로 확인', current: 'step', done: false },
+ ])
+ expect(inDialog('#two-factor-replace-message')?.textContent?.trim()).toBe(
+ '새 휴대폰을 확인했습니다. 지금 휴대폰의 코드와 비밀번호를 넣으면 바뀌고, 그 뒤로는 새 휴대폰의 코드로만 로그인합니다.',
+ )
+ expect(document.querySelector('label[for="two-factor-replace-current-code"]')?.textContent).toBe('지금 휴대폰의 앱에 표시된 6자리 코드')
+ expect(document.querySelector('label[for="two-factor-replace-password"]')?.textContent).toBe('지금 비밀번호')
+ expect(wrapper.get('#two-factor-replace-current-code').attributes()).toMatchObject({
+ inputmode: 'numeric',
+ autocomplete: 'one-time-code',
+ })
+ expect(wrapper.get('#two-factor-replace-password').attributes('autocomplete')).toBe('current-password')
+ // 연락처가 없으면 글만
+ expect(inDialog('[data-support-line]')?.textContent?.trim()).toBe(SUPPORT)
+ expect(inDialog('[data-support-contact]')).toBeNull()
+ // ①의 칸 · QR 은 없다
+ expect(inDialog('[data-qr-code]')).toBeNull()
+ expect(document.getElementById('two-factor-replace-new-code')).toBeNull()
+ expect(Array.from(dialog()?.querySelectorAll('.modal-dialog__actions button') ?? []).map((button) => button.textContent?.trim())).toEqual([
+ '이전',
+ '바꾸기',
+ '취소',
+ ])
+ expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
+ })
+
+ it('문의 연락처가 있으면 물음 다음 줄에 연락처', async () => {
+ const wrapper = await mountPage({ supportContact: () => ok({ text: 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it' }) })
+ await toCurrentStep(wrapper)
+
+ expect(inDialog('[data-support-line]')?.textContent).toContain(SUPPORT)
+ expect(inDialog('[data-support-contact]')?.textContent).toBe('IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it')
+ expect(calls('/support-contact')).toHaveLength(1)
+ })
+
+ it('보내기 전에 본다 — 칸 차례(지금 코드 → 비밀번호): 코드가 6자리가 아니면 AUTH_007 문구, 비밀번호를 비우면 VAL_001 문구', async () => {
+ const wrapper = await mountPage()
+ await toCurrentStep(wrapper)
+
+ await submit(wrapper)
+ expect(calls('/two-factor/replacements')).toEqual([])
+ expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
+ expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
+
+ await fillCurrent(wrapper, { code: '135 790' })
+ await submit(wrapper)
+ expect(calls('/two-factor/replacements')).toEqual([])
+ expect(wrapper.find('#two-factor-replace-current-code-error').exists()).toBe(false)
+ expect(wrapper.get('#two-factor-replace-password-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
+ expect(document.activeElement?.id).toBe('two-factor-replace-password')
+ })
+
+ it('[바꾸기] — POST /two-factor/replacements { enrollmentId, currentOtpCode, currentPassword } · 30초, 창을 닫고 알림 · 화면은 그대로 · 초점은 [휴대폰 바꾸기]', async () => {
+ const wrapper = await mountPage()
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+
+ expect(calls('/two-factor/replacements').map((request) => request.data)).toEqual([
+ { enrollmentId: 'enroll-1', currentOtpCode: '135790', currentPassword: PASSWORD },
+ ])
+ expect(calls('/two-factor/replacements')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS)
+ expect(dialog()?.hasAttribute('open')).toBe(false)
+ expect(toastMessages()).toEqual([DONE])
+ expect(useToastStore().toasts[0]).toMatchObject({ kind: 'success' })
+ // 켠 때는 그대로
+ expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
+ expect(document.activeElement?.hasAttribute('data-two-factor-replace')).toBe(true)
+ // 키 · 칸은 지웠다 (닫힌 창은 ①의 빈 자리로 돌아간다)
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('')
+ expect(inDialog('[data-qr-code]')).toBeNull()
+ expect(inputValue('two-factor-replace-password')).toBeUndefined()
+ })
+
+ it('바꾸는 동안 「바꾸는 중…」 — [바꾸기] · [이전]은 aria-disabled, 다시 눌러도 요청은 하나 · [이전]도 움직이지 않는다', async () => {
+ const { handler, replies } = held()
+ const wrapper = await mountPage({ replace: handler })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+
+ const button = wrapper.get('[data-replace-submit]')
+ expect(button.text()).toBe('바꾸는 중…')
+ expect(button.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
+ expect(wrapper.get('[data-replace-back]').attributes('aria-disabled')).toBe('true')
+ await submit(wrapper)
+ await wrapper.get('[data-replace-back]').trigger('click')
+ await flushPromises()
+ expect(calls('/two-factor/replacements')).toHaveLength(1)
+ expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
+
+ replies[0]?.(ok(ON))
+ await flushPromises()
+ expect(toastMessages()).toEqual([DONE])
+ })
+
+ it('지금 비밀번호가 틀리면(AUTH_005) 비밀번호 칸 아래 · 그 칸에 초점 — 코드는 남긴다, 알림 없음', async () => {
+ const wrapper = await mountPage({ replace: () => fail(400, 'AUTH_005') })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: 'wrong-pass' })
+ await submit(wrapper)
+
+ expect(wrapper.get('#two-factor-replace-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
+ expect(document.activeElement?.id).toBe('two-factor-replace-password')
+ expect(inputValue('two-factor-replace-current-code')).toBe('135790')
+ expect(dialog()?.hasAttribute('open')).toBe(true)
+ expect(toastMessages()).toEqual([])
+ })
+
+ it('지금 휴대폰의 코드가 틀리면(AUTH_006) 코드 칸 아래 · 칸을 비우고 초점 — 비밀번호는 남긴다, 알림 없음', async () => {
+ const wrapper = await mountPage({ replace: () => fail(400, 'AUTH_006') })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '000111', password: PASSWORD })
+ await submit(wrapper)
+
+ expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
+ expect(inputValue('two-factor-replace-current-code')).toBe('')
+ expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
+ expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
+ expect(toastMessages()).toEqual([])
+ })
+
+ it('AUTH_008 · 아직 켜져 있음 — ①로 돌아와 창 위에 사전 문구, 새 키 · 새 휴대폰의 코드는 비운다', async () => {
+ const wrapper = await mountPage({ replace: () => fail(409, 'AUTH_008') })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+ await flushPromises()
+
+ expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
+ expect(stepItems()[0]).toMatchObject({ current: 'step', done: false })
+ expect(wrapper.get('[data-replace-notice]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
+ expect(calls('/two-factor/enrollments')).toHaveLength(2)
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
+ expect(inputValue('two-factor-replace-new-code')).toBe('')
+ expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
+ expect(toastMessages()).toEqual([])
+
+ // 새 키로 다시 확인하면 ②의 창 위 글은 내린다 — 그 등록으로 보낸다
+ await next(wrapper, '246802')
+ expect(wrapper.find('[data-replace-notice]').exists()).toBe(false)
+ expect(verifyCalls().at(-1)?.url).toBe('/two-factor/enrollments/enroll-2/verification')
+ })
+
+ it('AUTH_008 · 그사이 다른 곳에서 꺼짐 — 창을 닫고 사전 문구로 알린다, 화면은 「꺼짐」', async () => {
+ let disabledElsewhere = false
+ const wrapper = await mountPage({
+ status: () => ok(disabledElsewhere ? OFF : ON),
+ replace: () => {
+ disabledElsewhere = true
+ return fail(409, 'AUTH_008')
+ },
+ })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+ await flushPromises()
+
+ expect(dialog()?.hasAttribute('open')).toBe(false)
+ expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_008])
+ expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
+ expect(calls('/two-factor/enrollments')).toHaveLength(1)
+ })
+
+ it('결과를 모르면(503 MAIL_001) 바뀌었다고도 아니라고도 하지 않는다 — 창 위 상자 · ② 그대로 · 코드는 비우고 초점, 사전 알림 없음', async () => {
+ const wrapper = await mountPage({ replace: () => fail(503, 'MAIL_001') })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+
+ const notice = wrapper.get('[data-replace-notice]')
+ expect(notice.text()).toBe(UNCONFIRMED)
+ expect(notice.attributes('role')).toBe('alert')
+ expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
+ expect(inputValue('two-factor-replace-current-code')).toBe('')
+ expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
+ expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
+ expect(toastMessages()).toEqual([])
+ // 화면의 상태는 건드리지 않는다
+ expect(useTwoFactorStore().twoFactor).toEqual(ON)
+ })
+
+ it('결과를 모르는 채 다시 [바꾸기] — 코드가 맞지 않다고 나와도 창 위 상자는 남긴다', async () => {
+ let replies = 0
+ const wrapper = await mountPage({
+ replace: () => {
+ replies += 1
+ return replies === 1 ? 'network' : fail(400, 'AUTH_006')
+ },
+ })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+ expect(wrapper.get('[data-replace-notice]').text()).toBe(UNCONFIRMED)
+ // 응답 없음(SYS_001)도 사전 알림 없이 같은 상자
+ expect(toastMessages()).toEqual([])
+
+ await fillCurrent(wrapper, { code: '975310' })
+ await submit(wrapper)
+ expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
+ expect(wrapper.get('[data-replace-notice]').text()).toBe(UNCONFIRMED)
+ })
+
+ it('[이전] — ①로(같은 키 · 새 키를 받지 않음), 새 휴대폰의 코드는 비우고 초점, 다시 [다음]은 같은 등록으로', async () => {
+ const wrapper = await mountPage()
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+
+ await wrapper.get('[data-replace-back]').trigger('click')
+ await flushPromises()
+ expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
+ expect(stepItems()[0]).toMatchObject({ current: 'step', done: false })
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
+ expect(calls('/two-factor/enrollments')).toHaveLength(1)
+ expect(inputValue('two-factor-replace-new-code')).toBe('')
+ expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
+
+ await next(wrapper, '246802')
+ expect(verifyCalls().map((request) => request.url)).toEqual([
+ '/two-factor/enrollments/enroll-1/verification',
+ '/two-factor/enrollments/enroll-1/verification',
+ ])
+ // 지금 휴대폰의 코드는 시간이 지나 비웠고, 지금 비밀번호는 창을 닫을 때까지 남는다
+ expect(inputValue('two-factor-replace-current-code')).toBe('')
+ expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
+ })
+
+ it('두 칸에서 Enter 도 [바꾸기]와 같다', async () => {
+ const wrapper = await mountPage()
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await wrapper.get('#two-factor-replace-password').trigger('keydown', { key: 'Enter' })
+ await flushPromises()
+
+ expect(calls('/two-factor/replacements')).toHaveLength(1)
+ expect(toastMessages()).toEqual([DONE])
+ })
+ })
+
+ describe('닫은 뒤 · 세션', () => {
+ it('[취소]로 닫아도 서버가 바꿨으면 화면을 맞추고 알린다', async () => {
+ const { handler, replies } = held()
+ const wrapper = await mountPage({ replace: handler })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+ await wrapper.get('[data-replace-cancel]').trigger('click')
+ await flushPromises()
+ expect(dialog()?.hasAttribute('open')).toBe(false)
+
+ replies[0]?.(ok({ ...ON, enabledAt: '2026-11-01T00:00:00.000Z' }))
+ await flushPromises()
+ expect(toastMessages()).toEqual([DONE])
+ expect(useTwoFactorStore().twoFactor?.enabledAt).toBe('2026-11-01T00:00:00.000Z')
+ })
+
+ it('닫은 뒤 온 결과를 모르는 실패는 알림으로 · 칸 아래 거절은 사전 문구 알림으로', async () => {
+ const { handler, replies } = held()
+ const wrapper = await mountPage({ replace: handler })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+ await wrapper.get('[data-replace-cancel]').trigger('click')
+ await flushPromises()
+ replies[0]?.(fail(503, 'MAIL_001'))
+ await flushPromises()
+ expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([['error', UNCONFIRMED_CLOSED]])
+
+ useToastStore().clear()
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: 'wrong-pass' })
+ await submit(wrapper)
+ await wrapper.get('[data-replace-cancel]').trigger('click')
+ await flushPromises()
+ replies[1]?.(fail(400, 'AUTH_005'))
+ await flushPromises()
+ expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_005])
+ })
+
+ it('닫았다 다시 연 창에는 앞 요청의 거절을 넣지 않는다 — 새 키로 ①부터', async () => {
+ const { handler, replies } = held()
+ const wrapper = await mountPage({ replace: handler })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+ await wrapper.get('[data-replace-cancel]').trigger('click')
+ await flushPromises()
+
+ await openReplace(wrapper)
+ expect(calls('/two-factor/enrollments')).toHaveLength(2)
+ expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
+ expect(wrapper.get('[data-replace-next]').text()).toBe('다음')
+
+ replies[0]?.(fail(400, 'AUTH_006'))
+ await flushPromises()
+ expect(wrapper.find('#two-factor-replace-new-code-error').exists()).toBe(false)
+ expect(wrapper.find('[data-replace-notice]').exists()).toBe(false)
+ expect(toastMessages()).toEqual([])
+ })
+
+ it('닫으면 키 · 칸을 지운다 — 다시 열면 새 키, 닫은 뒤 늦게 온 확인은 ②로 넘기지 않는다', async () => {
+ const { handler, replies } = held()
+ const wrapper = await mountPage({ verify: handler })
+ await openReplace(wrapper)
+ await next(wrapper, '482913')
+ await wrapper.get('[data-replace-cancel]').trigger('click')
+ await flushPromises()
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('')
+
+ replies[0]?.(ok({ verified: true }))
+ await flushPromises()
+ expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(false)
+
+ await openReplace(wrapper)
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
+ expect(inputValue('two-factor-replace-new-code')).toBe('')
+ })
+
+ it('세션이 바뀌면(다른 곳에서 이 세션을 끊음 → 세션 만료) 창을 닫고 키 · 코드 · 비밀번호를 지운다', async () => {
+ const wrapper = await mountPage()
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+
+ useAuthStore().expireSession()
+ await flushPromises()
+
+ expect(dialog()?.hasAttribute('open')).toBe(false)
+ expect(inDialog('[data-qr-code]')).toBeNull()
+ expect(inDialog('[data-enable-key]')?.textContent).toBe('')
+ expect(document.getElementById('two-factor-replace-password')).toBeNull()
+ expect(document.getElementById('two-factor-replace-current-code')).toBeNull()
+ expect(inputValue('two-factor-replace-new-code')).toBe('')
+ })
+
+ it('바꾸는 사이 세션이 바뀌었으면(로그아웃) 늦게 온 결과를 쓰지 않는다 — 알림도 없다', async () => {
+ const { handler, replies } = held()
+ const wrapper = await mountPage({ replace: handler })
+ await toCurrentStep(wrapper)
+ await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
+ await submit(wrapper)
+
+ useAuthStore().clearAuth()
+ replies[0]?.(ok(ON))
+ await flushPromises()
+
+ expect(toastMessages()).toEqual([])
+ expect(useTwoFactorStore().twoFactor).toBeNull()
+ })
+ })
+})
diff --git a/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts b/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts
index 7bce367..540738b 100644
--- a/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts
+++ b/frontend/src/pages/settings/AccountTwoFactorPage.spec.ts
@@ -110,6 +110,8 @@ describe('AccountTwoFactorPage', () => {
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)
+ // 꺼져 있으면 [휴대폰 바꾸기]가 없다 — 켜기 창으로 새 휴대폰에서 켠다
+ expect(wrapper.find('[data-two-factor-replace]').exists()).toBe(false)
const warning = wrapper.get('[data-two-factor-warning]')
expect(warning.text()).toContain('켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다')
expect(warning.get('a').text()).toBe('앱 비밀번호')
@@ -120,12 +122,18 @@ describe('AccountTwoFactorPage', () => {
expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false)
})
- it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
+ 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단계 인증 끄기')
+ // [휴대폰 바꾸기]는 [2단계 인증 끄기] 바로 앞 (16단계)
+ const actions = wrapper.get('.two-factor__actions').element.children
+ expect(Array.from(actions).map((child) => child.textContent?.trim())).toEqual(['휴대폰 바꾸기', '2단계 인증 끄기'])
+ expect(wrapper.get('[data-two-factor-notes]').text()).toContain(
+ '휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다.',
+ )
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('메일 프로그램은 앱 비밀번호로 연결합니다.')
@@ -661,14 +669,19 @@ describe('AccountTwoFactorPage — 필수인 직원 (13단계)', () => {
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
})
- it('켜짐 — [2단계 인증 끄기] 대신 「회사 정책에 따라 끌 수 없습니다」, 휴대폰 바꾸기 안내는 관리자에게 해제 요청', async () => {
+ it('켜짐 — [2단계 인증 끄기] 대신 「회사 정책에 따라 끌 수 없습니다」, 그 앞에 [휴대폰 바꾸기] · 안내 첫 줄은 보통 직원과 같은 글 (16단계)', async () => {
const wrapper = await mountWith({ ...ON, isRequired: true, deadline: null })
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
expect(wrapper.get('[data-two-factor-locked]').text()).toBe('회사 정책에 따라 끌 수 없습니다')
+ expect(wrapper.get('[data-two-factor-replace]').text()).toBe('휴대폰 바꾸기')
+ // [휴대폰 바꾸기]가 끌 수 없다는 글 앞
+ const actions = wrapper.get('.two-factor__actions').element.children
+ expect(Array.from(actions).map((child) => child.textContent?.trim())).toEqual(['휴대폰 바꾸기', '회사 정책에 따라 끌 수 없습니다'])
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
const notes = wrapper.get('[data-two-factor-notes]').text()
- expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.')
+ expect(notes).toContain('휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다.')
+ expect(notes).not.toContain('관리자에게 2단계 인증을 풀어 달라고 요청해 주세요')
expect(notes).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
})
})
diff --git a/frontend/src/pages/settings/AccountTwoFactorPage.vue b/frontend/src/pages/settings/AccountTwoFactorPage.vue
index 6ce4145..6edf96c 100644
--- a/frontend/src/pages/settings/AccountTwoFactorPage.vue
+++ b/frontend/src/pages/settings/AccountTwoFactorPage.vue
@@ -1,18 +1,22 @@