diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2683bc5..60a723f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,9 +10,12 @@ import { useLargeFileAlerts } from '@/composables/useLargeFileAlerts' import { useLogoutSync } from '@/composables/useLogoutBroadcast' import { useMailRealtime } from '@/composables/useMailRealtime' import { useNewMailNotifier } from '@/composables/useNewMailNotifier' +import { useSessionRestriction } from '@/composables/useSessionRestriction' // 다른 탭에서 로그아웃하면 이 탭도 로그인 화면으로 간다 (계약 1절 "클라이언트 처리 규칙") useLogoutSync() +// 세션에 제한(새 비밀번호 · 2단계 켜기 — 13단계)이 생기면 보호 화면을 떠나 로그인 틀의 그 단계로 간다 +useSessionRestriction() // 실시간 새 메일 — 로그인한 동안 앱 전체에서 돈다(설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게). // 메일 화면은 이 소식을 받아 목록 · 개수를 맞춘다 (계약 2절 "실시간 알림" — MailLayout 의 useMailStatePolling). diff --git a/frontend/src/components/QrCodeImage.vue b/frontend/src/components/QrCodeImage.vue index 314bacb..6e212cc 100644 --- a/frontend/src/components/QrCodeImage.vue +++ b/frontend/src/components/QrCodeImage.vue @@ -12,9 +12,13 @@ interface Props { value: string /** 그림의 이름 (스크린리더) */ label: string + /** 148 — 로그인 틀의 2단계 인증 켜기(양식 칸 360 안에 키를 옆에 둔다 — 13단계). 기본은 160 */ + compact?: boolean } -const props = defineProps() +const props = withDefaults(defineProps(), { + compact: false, +}) /** 바깥 여백 칸 수 */ const QUIET_ZONE = 2 @@ -26,7 +30,7 @@ const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`) + + diff --git a/frontend/src/components/auth/LoginCodeStep.vue b/frontend/src/components/auth/LoginCodeStep.vue index 2698faf..ae08347 100644 --- a/frontend/src/components/auth/LoginCodeStep.vue +++ b/frontend/src/components/auth/LoginCodeStep.vue @@ -6,11 +6,14 @@ // - 코드 틀림(AUTH_006)은 칸 아래 빨간 글(부모가 칸을 비우고 초점을 돌려준다), 시도 과다(REQ_001)는 위쪽 상자 — 로그인 오류와 같은 자리 · 같은 모양 // - 확인 중: 「확인하는 중…」(로그인 단추와 같은 모양 · aria-busy), 칸은 읽기만, [다른 계정으로 로그인]도 누를 수 없다(aria-disabled). // [확인]은 disabled 가 아니라 aria-disabled — 키보드 초점을 잃지 않게(시도 과다 · 장애 뒤에도 그 자리에 남는다). 바쁜 동안의 누름은 부모가 무시한다 +// - 도움말은 문의 줄(13단계 — LoginSupportLine): 연락처가 있으면 "인증 앱을 쓸 수 없나요?" 다음 줄에 연락처, 없으면 지금 글 // 상태는 부모(LoginPage)가 가진다 — 비밀번호를 다시 실어 보내는 일이 부모 몫이라서. 고정 id: #login-code · #login-code-error · #login-code-alert import { ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' +import LoginSupportLine from '@/components/auth/LoginSupportLine.vue' import { LOGIN_TWO_FACTOR_TEXT, OTP_TEXT } from '@/shared/constants/account-security' +import { SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction' import { normalizeOtpInput } from '@/shared/utils/account-security' interface Props { @@ -23,6 +26,8 @@ interface Props { codeError: string | null /** 위쪽 상자 — 사전 문구(REQ_001) */ alert: string | null + /** 문의 연락처 — 없으면 지금 글 (13단계) */ + supportContact?: string | null } interface Emits { @@ -31,7 +36,9 @@ interface Emits { (e: 'other-account'): void } -const props = defineProps() +const props = withDefaults(defineProps(), { + supportContact: null, +}) const emit = defineEmits() const inputRef = ref(null) @@ -147,9 +154,11 @@ defineExpose({ -

- {{ LOGIN_TWO_FACTOR_TEXT.help }} -

+

{{ LOGIN_TWO_FACTOR_TEXT.note }}

@@ -327,12 +336,6 @@ defineExpose({ cursor: progress; } -.code-step__help { - margin: 0; - font-size: var(--font-size-label); - color: var(--color-text-muted); -} - .code-step__note { margin: 0; padding-top: var(--space-16); diff --git a/frontend/src/components/auth/LoginPasswordChangeStep.vue b/frontend/src/components/auth/LoginPasswordChangeStep.vue new file mode 100644 index 0000000..e147c99 --- /dev/null +++ b/frontend/src/components/auth/LoginPasswordChangeStep.vue @@ -0,0 +1,488 @@ + + + + + diff --git a/frontend/src/components/auth/LoginSupportLine.vue b/frontend/src/components/auth/LoginSupportLine.vue new file mode 100644 index 0000000..0a6362b --- /dev/null +++ b/frontend/src/components/auth/LoginSupportLine.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/components/auth/LoginTwoFactorScan.vue b/frontend/src/components/auth/LoginTwoFactorScan.vue new file mode 100644 index 0000000..3bb116e --- /dev/null +++ b/frontend/src/components/auth/LoginTwoFactorScan.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/components/auth/LoginTwoFactorSetupStep.vue b/frontend/src/components/auth/LoginTwoFactorSetupStep.vue new file mode 100644 index 0000000..e854de0 --- /dev/null +++ b/frontend/src/components/auth/LoginTwoFactorSetupStep.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/frontend/src/components/mail/MailListFrame.spec.ts b/frontend/src/components/mail/MailListFrame.spec.ts index 792bd97..7f841e9 100644 --- a/frontend/src/components/mail/MailListFrame.spec.ts +++ b/frontend/src/components/mail/MailListFrame.spec.ts @@ -182,3 +182,75 @@ describe('MailListFrame (목록 칸의 틀)', () => { expect(document.activeElement).toBe(section) }) }) + +// 휴지통 · 스팸메일함 — 30일 줄 (13단계 — 계약 6절 "휴지통 · 스팸 30일" · 디자인 원본 MailNoticesCutover) +describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계)', () => { + let pinia: Pinia + + const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 2 } + const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 1 } + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })) + }) + + afterEach(() => { + restoreApiAdapter() + document.body.innerHTML = '' + }) + + async function mountBox(mailbox: Mailbox, messages: MessageSummary[], component: 'list' | 'table' = 'list') { + const store = useMailStore() + store.mailboxes = [INBOX, TRASH, JUNK] + store.currentMailboxId = mailbox.id + store.messages = messages + store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length } + store.listStatus = 'ready' + const router = createTestRouter() + await router.push(`/mail/${mailbox.id}`) + await router.isReady() + const page = component === 'list' ? MessageList : (await import('./MessageTable.vue')).default + return mount(page, { global: { plugins: [pinia, router] }, attachTo: document.body }) + } + + it('휴지통 — 목록 머리 바로 아래 회색 줄 「휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.」(늘 보임 · 닫기 없음)', async () => { + const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })]) + + const notice = wrapper.get('[data-retention-notice]') + expect(notice.text()).toBe('휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.') + expect(notice.find('button').exists()).toBe(false) + }) + + it('스팸메일함도 같은 줄 — 목록 보기(표)에서는 표 위', async () => { + const wrapper = await mountBox(JUNK, [summary('j1', { mailboxIds: ['junk'] })], 'table') + + const notice = wrapper.get('[data-retention-notice]') + expect(notice.text()).toBe('스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.') + // 표 머리보다 앞 + const table = wrapper.get('table').element + expect(notice.element.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('메일이 없으면 줄을 빼고 빈 상태 글에 같은 뜻을 붙인다', async () => { + const wrapper = await mountBox(TRASH, []) + + expect(wrapper.find('[data-retention-notice]').exists()).toBe(false) + expect(wrapper.text()).toContain('휴지통이 비어 있습니다') + expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.') + }) + + it('다른 메일함 · 검색 결과에는 없다', async () => { + const inbox = await mountBox(INBOX, [summary('m1')]) + expect(inbox.find('[data-retention-notice]').exists()).toBe(false) + inbox.unmount() + + const store = useMailStore() + const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })]) + store.listSource = { kind: 'search', q: '견적', scope: 'trash' } + await flushPromises() + expect(wrapper.find('[data-retention-notice]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/components/mail/MailListFrame.vue b/frontend/src/components/mail/MailListFrame.vue index be777ef..9f8595f 100644 --- a/frontend/src/components/mail/MailListFrame.vue +++ b/frontend/src/components/mail/MailListFrame.vue @@ -1,12 +1,14 @@ + + + + diff --git a/frontend/src/components/mail/editor/signature/SignatureForm.vue b/frontend/src/components/mail/editor/signature/SignatureForm.vue index 09363b6..21ae404 100644 --- a/frontend/src/components/mail/editor/signature/SignatureForm.vue +++ b/frontend/src/components/mail/editor/signature/SignatureForm.vue @@ -92,11 +92,11 @@ const hasUnsavedChanges = computed( /** * 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창. - * 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났으면 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다. + * 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다. */ const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({ - active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.isLoggedIn, - unloadActive: () => hasUnsavedChanges.value && authStore.isLoggedIn, + active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.canUseApp, + unloadActive: () => hasUnsavedChanges.value && authStore.canUseApp, }) /** 무엇이든 바꾸면 "저장했습니다"를 내린다 */ diff --git a/frontend/src/components/settings/MailClientGuideList.vue b/frontend/src/components/settings/MailClientGuideList.vue new file mode 100644 index 0000000..d65dd9d --- /dev/null +++ b/frontend/src/components/settings/MailClientGuideList.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/frontend/src/components/settings/MailClientSettings.vue b/frontend/src/components/settings/MailClientSettings.vue index 8f0ba55..841d7a5 100644 --- a/frontend/src/components/settings/MailClientSettings.vue +++ b/frontend/src/components/settings/MailClientSettings.vue @@ -3,6 +3,7 @@ // 받는 메일(IMAP) 호스트 · 포트 993 · SSL/TLS / 보내는 메일(SMTP) 호스트 · 포트 465 · SSL/TLS / 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호. // 호스트 · 포트는 상수 하나(MAIL_CLIENT_SETTINGS)에 둔다 — 전 직원 전환 때 다시 본다. // compact: 결과 창의 요약 — 비밀번호 줄 없이, 12.5px · 위 선 하나 (화면의 상자는 줄마다 선이 있는 테두리 상자) +// passwordNote: 비밀번호 줄의 글 — 메일 프로그램 연결 화면(13단계)은 2단계 인증 상태에 따라 바꾼다. 좁은 폭(600 이하)에서는 이름(12px 회색) 위 · 값 아래 한 칸 import { computed } from 'vue' import { APP_PASSWORD_TEXT, MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security' @@ -11,10 +12,13 @@ interface Props { /** 직원 주소 — 사용자 이름 */ email: string compact?: boolean + /** 비밀번호 줄의 글 — 없으면 앱 비밀번호 화면의 글 */ + passwordNote?: string } const props = withDefaults(defineProps(), { compact: false, + passwordNote: APP_PASSWORD_TEXT.passwordNote, }) const { host, imapPort, smtpPort, security } = MAIL_CLIENT_SETTINGS @@ -26,7 +30,7 @@ const rows = computed(() => [ { term: APP_PASSWORD_TEXT.incoming, strong: host, rest: ` · ${serverLine(imapPort)}` }, { term: APP_PASSWORD_TEXT.outgoing, strong: host, rest: ` · ${serverLine(smtpPort)}` }, { term: APP_PASSWORD_TEXT.username, strong: props.email, rest: props.compact ? '' : APP_PASSWORD_TEXT.usernameNote }, - ...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: APP_PASSWORD_TEXT.passwordNote }]), + ...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: props.passwordNote }]), ]) @@ -86,6 +90,23 @@ const rows = computed(() => [ font-weight: 600; } +/* 좁은 폭 — 이름(12px 회색) 위 · 값 아래 한 칸 (디자인 원본 SettingsMailClients "좁은 폭") */ +@media (max-width: 37.5rem) { + .client-settings:not(.client-settings--compact) { + grid-template-columns: minmax(0, 1fr); + } + + .client-settings:not(.client-settings--compact) .client-settings__term { + padding-bottom: 0; + border-bottom: 0; + font-size: var(--font-size-small); + } + + .client-settings:not(.client-settings--compact) .client-settings__value { + padding-top: var(--space-2); + } +} + /* 결과 창의 요약 — 이름 칸 124 · 12.5px · 위 선 하나 */ .client-settings--compact { grid-template-columns: 7.75rem minmax(0, 1fr); diff --git a/frontend/src/components/settings/OtpCodeField.vue b/frontend/src/components/settings/OtpCodeField.vue index 89d8626..e957b6d 100644 --- a/frontend/src/components/settings/OtpCodeField.vue +++ b/frontend/src/components/settings/OtpCodeField.vue @@ -4,6 +4,7 @@ // - 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code" // maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다 // - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. 칸의 설명(aria-describedby)은 안내 → 오류 차례 (PasswordField 와 같다) +// - variant="login": 로그인 코드 칸 모양(폭 전체 · 테두리 #8A94A3 · 18px · 글자 사이 0.3em · 이름 12.5px 굵게) — 13단계 새 비밀번호 · 2단계 켜기 단계 import { computed, ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' @@ -23,6 +24,8 @@ interface Props { compactLabel?: boolean /** 대화상자를 열면 이 칸에 초점 (켜기 창 — 처음 초점은 코드 칸) */ autofocus?: boolean + /** 칸 모양 — 설정 화면(기본) · 로그인 틀 */ + variant?: 'settings' | 'login' } interface Emits { @@ -35,6 +38,7 @@ const props = withDefaults(defineProps(), { readonly: false, compactLabel: false, autofocus: false, + variant: 'settings', }) const emit = defineEmits() @@ -61,7 +65,7 @@ defineExpose({