From 1881ec7a420ec053bb6ce2a848215a3fbb5fb7e9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 15:37:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=2013=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=A4=80=EB=B9=84=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20=EC=83=88=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=C2=B7=202=EB=8B=A8=EA=B3=84=20=EC=BC=9C=EA=B8=B0=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=C2=B7=20=EC=9C=A0=EC=98=88=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=C2=B7=2030=EC=9D=BC=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=C2=B7=20=EB=AC=B8=EC=9D=98=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제한된 세션: restriction 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로, AUTH_010 은 알림 없이 me 를 다시 받아 옮긴다. 제한된 동안은 나가기 확인을 묻지 않고(확인에서 이동을 취소하면 메일 화면에 갇혔다 — 리뷰) 실시간 알림을 열지 않는다. - 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분 · 세션이 끝나면 지운다. 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다. - 유예 노란 줄(시작일 전날까지), 설정 › 2단계 인증의 필수 안내, 설정 › 메일 프로그램 연결(펼침 줄 · 새 Outlook 은 권하지 않음), 휴지통 · 스팸메일함 30일 줄, 로그인 · 코드 단계의 문의 줄. 검증: lint · type-check · 단위 2,354 · 실사용 시험 89/89. Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.vue | 3 + frontend/src/components/QrCodeImage.vue | 14 +- .../src/components/auth/LoginAccountRow.vue | 97 +++ .../src/components/auth/LoginCodeStep.vue | 23 +- .../auth/LoginPasswordChangeStep.vue | 488 ++++++++++++++ .../src/components/auth/LoginSupportLine.vue | 47 ++ .../components/auth/LoginTwoFactorScan.vue | 274 ++++++++ .../auth/LoginTwoFactorSetupStep.vue | 530 +++++++++++++++ .../src/components/mail/MailListFrame.spec.ts | 72 ++ .../src/components/mail/MailListFrame.vue | 51 +- .../mail/TwoFactorGraceNotice.spec.ts | 92 +++ .../components/mail/TwoFactorGraceNotice.vue | 113 ++++ .../mail/editor/signature/SignatureForm.vue | 6 +- .../settings/MailClientGuideList.vue | 199 ++++++ .../settings/MailClientSettings.vue | 23 +- .../src/components/settings/OtpCodeField.vue | 28 +- .../src/components/settings/PasswordField.vue | 38 +- .../src/components/settings/VacationForm.vue | 5 +- frontend/src/components/shell/AppShell.vue | 2 + frontend/src/composables/useApi.spec.ts | 177 ++++- frontend/src/composables/useApi.ts | 117 +++- frontend/src/composables/useAuth.spec.ts | 56 +- frontend/src/composables/useAuth.ts | 42 +- .../src/composables/useDialogLeaveConfirm.ts | 6 +- frontend/src/composables/useLoginSecret.ts | 70 ++ .../src/composables/useMailRealtime.spec.ts | 35 + frontend/src/composables/useMailRealtime.ts | 12 +- .../composables/useSessionRestriction.spec.ts | 92 +++ .../src/composables/useSessionRestriction.ts | 36 + .../src/pages/LoginPage.restricted.spec.ts | 619 ++++++++++++++++++ frontend/src/pages/LoginPage.spec.ts | 17 +- frontend/src/pages/LoginPage.vue | 268 +++++++- frontend/src/pages/mail/ComposePage.spec.ts | 14 + frontend/src/pages/mail/ComposePage.vue | 10 +- frontend/src/pages/mail/MailLayout.vue | 5 + .../settings/AccountAppPasswordsPage.spec.ts | 9 + .../settings/AccountAppPasswordsPage.vue | 24 +- .../settings/AccountMailClientsPage.spec.ts | 141 ++++ .../pages/settings/AccountMailClientsPage.vue | 257 ++++++++ .../settings/AccountTwoFactorPage.spec.ts | 66 ++ .../pages/settings/AccountTwoFactorPage.vue | 48 +- .../pages/settings/MailSignaturePage.spec.ts | 15 + .../src/pages/settings/SettingsLayout.spec.ts | 3 +- frontend/src/router/index.spec.ts | 26 +- frontend/src/router/index.ts | 7 +- frontend/src/router/routes.ts | 9 +- .../src/shared/constants/account-security.ts | 1 - frontend/src/shared/constants/icons.ts | 7 + frontend/src/shared/constants/mail-clients.ts | 132 ++++ frontend/src/shared/constants/mail.ts | 14 +- .../shared/constants/session-restriction.ts | 89 +++ .../src/shared/constants/settings-menu.ts | 5 +- frontend/src/shared/types/account-security.ts | 4 + frontend/src/shared/types/auth.ts | 37 +- .../src/shared/utils/auth-session.spec.ts | 70 ++ frontend/src/shared/utils/auth-session.ts | 49 ++ .../src/shared/utils/support-contact.spec.ts | 21 + frontend/src/shared/utils/support-contact.ts | 12 + frontend/src/stores/auth.store.ts | 55 +- .../src/stores/support-contact.store.spec.ts | 45 ++ frontend/src/stores/support-contact.store.ts | 39 ++ frontend/src/test-utils/router.ts | 7 + 62 files changed, 4773 insertions(+), 100 deletions(-) create mode 100644 frontend/src/components/auth/LoginAccountRow.vue create mode 100644 frontend/src/components/auth/LoginPasswordChangeStep.vue create mode 100644 frontend/src/components/auth/LoginSupportLine.vue create mode 100644 frontend/src/components/auth/LoginTwoFactorScan.vue create mode 100644 frontend/src/components/auth/LoginTwoFactorSetupStep.vue create mode 100644 frontend/src/components/mail/TwoFactorGraceNotice.spec.ts create mode 100644 frontend/src/components/mail/TwoFactorGraceNotice.vue create mode 100644 frontend/src/components/settings/MailClientGuideList.vue create mode 100644 frontend/src/composables/useLoginSecret.ts create mode 100644 frontend/src/composables/useSessionRestriction.spec.ts create mode 100644 frontend/src/composables/useSessionRestriction.ts create mode 100644 frontend/src/pages/LoginPage.restricted.spec.ts create mode 100644 frontend/src/pages/settings/AccountMailClientsPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountMailClientsPage.vue create mode 100644 frontend/src/shared/constants/mail-clients.ts create mode 100644 frontend/src/shared/constants/session-restriction.ts create mode 100644 frontend/src/shared/utils/auth-session.spec.ts create mode 100644 frontend/src/shared/utils/auth-session.ts create mode 100644 frontend/src/shared/utils/support-contact.spec.ts create mode 100644 frontend/src/shared/utils/support-contact.ts create mode 100644 frontend/src/stores/support-contact.store.spec.ts create mode 100644 frontend/src/stores/support-contact.store.ts 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({