From c31442e639a7acbe7a29d062a690a5f85362afc3 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 17:25:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=2012=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=B3=B4=EC=95=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=C2=B7=202=EB=8B=A8=EA=B3=84=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=C2=B7=20=EC=95=B1=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=C2=B7=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인은 비밀번호가 맞고 2단계를 켠 직원만 코드 단계로 간다. 설정 › 계정에 2단계 인증 · 앱 비밀번호 · 로그인 기록을 더하고, 비밀번호 화면에 2단계를 켠 직원의 코드 칸을 둔다. 비밀(코드 단계의 비밀번호 · 2단계 키 · 앱 비밀번호 값)은 화면 메모리에만 두고, 세션이 바뀌면 그 창을 모두 닫는다 — 12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다. 비밀이 드는 요청은 Pinia 액션 밖에서 보낸다(devtools 기록). QR 은 uqr 0.1.3(MIT · 의존성 없음 — 2단계 인증 화면 청크에만)으로 SVG 를 그린다(v-html 없음). Co-Authored-By: Claude Opus 5.5 --- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/components/LinkedText.vue | 31 + frontend/src/components/QrCodeImage.vue | 68 ++ .../src/components/auth/LoginCodeStep.vue | 344 ++++++++++ .../settings/AppPasswordCreateDialog.vue | 184 ++++++ .../settings/AppPasswordDeleteDialog.vue | 66 ++ .../settings/AppPasswordResultDialog.vue | 200 ++++++ .../components/settings/AppPasswordTable.vue | 208 ++++++ .../settings/EndOtherSessionsDialog.vue | 77 +++ .../components/settings/LoginEventTable.vue | 435 +++++++++++++ .../components/settings/LoginSessionList.vue | 439 +++++++++++++ .../settings/MailClientSettings.vue | 117 ++++ .../src/components/settings/OtpCodeField.vue | 137 ++++ .../settings/PasswordChangeForm.vue | 74 ++- .../src/components/settings/PasswordField.vue | 10 +- .../components/settings/SettingsPageFrame.vue | 5 +- .../settings/TwoFactorDisableDialog.vue | 229 +++++++ .../settings/TwoFactorEnableDialog.vue | 403 ++++++++++++ .../settings/TwoFactorEnrollmentScan.vue | 300 +++++++++ .../src/composables/useAccountSecurity.ts | 129 ++++ frontend/src/composables/useApi.spec.ts | 5 + frontend/src/composables/useAuth.spec.ts | 36 +- frontend/src/composables/useAuth.ts | 25 +- frontend/src/composables/useLoginChallenge.ts | 89 +++ frontend/src/pages/LoginPage.spec.ts | 303 ++++++++- frontend/src/pages/LoginPage.vue | 298 ++++++--- .../settings/AccountAppPasswordsPage.spec.ts | 427 ++++++++++++ .../settings/AccountAppPasswordsPage.vue | 430 +++++++++++++ .../settings/AccountLoginHistoryPage.spec.ts | 506 +++++++++++++++ .../settings/AccountLoginHistoryPage.vue | 94 +++ .../settings/AccountPasswordPage.spec.ts | 116 +++- .../pages/settings/AccountPasswordPage.vue | 23 +- .../settings/AccountTwoFactorPage.spec.ts | 608 ++++++++++++++++++ .../pages/settings/AccountTwoFactorPage.vue | 459 +++++++++++++ .../src/pages/settings/SettingsLayout.spec.ts | 46 +- frontend/src/router/routes.ts | 23 +- .../src/shared/constants/account-security.ts | 294 +++++++++ frontend/src/shared/constants/account.ts | 8 +- frontend/src/shared/constants/icons.ts | 16 + .../src/shared/constants/settings-menu.ts | 16 +- frontend/src/shared/types/account-security.ts | 109 ++++ frontend/src/shared/types/auth.ts | 23 +- .../src/shared/utils/account-security.spec.ts | 219 +++++++ frontend/src/shared/utils/account-security.ts | 172 +++++ frontend/src/shared/utils/format.ts | 7 + frontend/src/shared/utils/qr-code.spec.ts | 80 +++ frontend/src/shared/utils/qr-code.ts | 40 ++ .../src/stores/app-passwords.store.spec.ts | 79 +++ frontend/src/stores/app-passwords.store.ts | 97 +++ .../src/stores/login-history.store.spec.ts | 84 +++ frontend/src/stores/login-history.store.ts | 213 ++++++ frontend/src/stores/two-factor.store.spec.ts | 73 +++ frontend/src/stores/two-factor.store.ts | 89 +++ frontend/src/test-utils/router.ts | 19 + 55 files changed, 8475 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/LinkedText.vue create mode 100644 frontend/src/components/QrCodeImage.vue create mode 100644 frontend/src/components/auth/LoginCodeStep.vue create mode 100644 frontend/src/components/settings/AppPasswordCreateDialog.vue create mode 100644 frontend/src/components/settings/AppPasswordDeleteDialog.vue create mode 100644 frontend/src/components/settings/AppPasswordResultDialog.vue create mode 100644 frontend/src/components/settings/AppPasswordTable.vue create mode 100644 frontend/src/components/settings/EndOtherSessionsDialog.vue create mode 100644 frontend/src/components/settings/LoginEventTable.vue create mode 100644 frontend/src/components/settings/LoginSessionList.vue create mode 100644 frontend/src/components/settings/MailClientSettings.vue create mode 100644 frontend/src/components/settings/OtpCodeField.vue create mode 100644 frontend/src/components/settings/TwoFactorDisableDialog.vue create mode 100644 frontend/src/components/settings/TwoFactorEnableDialog.vue create mode 100644 frontend/src/components/settings/TwoFactorEnrollmentScan.vue create mode 100644 frontend/src/composables/useAccountSecurity.ts create mode 100644 frontend/src/composables/useLoginChallenge.ts create mode 100644 frontend/src/pages/settings/AccountAppPasswordsPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountAppPasswordsPage.vue create mode 100644 frontend/src/pages/settings/AccountLoginHistoryPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountLoginHistoryPage.vue create mode 100644 frontend/src/pages/settings/AccountTwoFactorPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountTwoFactorPage.vue create mode 100644 frontend/src/shared/constants/account-security.ts create mode 100644 frontend/src/shared/types/account-security.ts create mode 100644 frontend/src/shared/utils/account-security.spec.ts create mode 100644 frontend/src/shared/utils/account-security.ts create mode 100644 frontend/src/shared/utils/qr-code.spec.ts create mode 100644 frontend/src/shared/utils/qr-code.ts create mode 100644 frontend/src/stores/app-passwords.store.spec.ts create mode 100644 frontend/src/stores/app-passwords.store.ts create mode 100644 frontend/src/stores/login-history.store.spec.ts create mode 100644 frontend/src/stores/login-history.store.ts create mode 100644 frontend/src/stores/two-factor.store.spec.ts create mode 100644 frontend/src/stores/two-factor.store.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a4a8c6..51a3ef7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "dompurify": "^3.4.17", "pinia": "^3.0.4", "pretendard": "^1.3.9", + "uqr": "0.1.3", "vue": "^3.5.43", "vue-router": "^4.4.5" }, @@ -6811,6 +6812,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fab549e..51aa287 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "dompurify": "^3.4.17", "pinia": "^3.0.4", "pretendard": "^1.3.9", + "uqr": "0.1.3", "vue": "^3.5.43", "vue-router": "^4.4.5" }, diff --git a/frontend/src/components/LinkedText.vue b/frontend/src/components/LinkedText.vue new file mode 100644 index 0000000..5c95e4c --- /dev/null +++ b/frontend/src/components/LinkedText.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/QrCodeImage.vue b/frontend/src/components/QrCodeImage.vue new file mode 100644 index 0000000..314bacb --- /dev/null +++ b/frontend/src/components/QrCodeImage.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/components/auth/LoginCodeStep.vue b/frontend/src/components/auth/LoginCodeStep.vue new file mode 100644 index 0000000..2698faf --- /dev/null +++ b/frontend/src/components/auth/LoginCodeStep.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/frontend/src/components/settings/AppPasswordCreateDialog.vue b/frontend/src/components/settings/AppPasswordCreateDialog.vue new file mode 100644 index 0000000..b8553e8 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordCreateDialog.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend/src/components/settings/AppPasswordDeleteDialog.vue b/frontend/src/components/settings/AppPasswordDeleteDialog.vue new file mode 100644 index 0000000..afbabf1 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordDeleteDialog.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/components/settings/AppPasswordResultDialog.vue b/frontend/src/components/settings/AppPasswordResultDialog.vue new file mode 100644 index 0000000..a4f7a14 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordResultDialog.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/src/components/settings/AppPasswordTable.vue b/frontend/src/components/settings/AppPasswordTable.vue new file mode 100644 index 0000000..ee85d70 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordTable.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/frontend/src/components/settings/EndOtherSessionsDialog.vue b/frontend/src/components/settings/EndOtherSessionsDialog.vue new file mode 100644 index 0000000..46f457f --- /dev/null +++ b/frontend/src/components/settings/EndOtherSessionsDialog.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/settings/LoginEventTable.vue b/frontend/src/components/settings/LoginEventTable.vue new file mode 100644 index 0000000..70a4a4b --- /dev/null +++ b/frontend/src/components/settings/LoginEventTable.vue @@ -0,0 +1,435 @@ + + + + + diff --git a/frontend/src/components/settings/LoginSessionList.vue b/frontend/src/components/settings/LoginSessionList.vue new file mode 100644 index 0000000..d9bf48a --- /dev/null +++ b/frontend/src/components/settings/LoginSessionList.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/frontend/src/components/settings/MailClientSettings.vue b/frontend/src/components/settings/MailClientSettings.vue new file mode 100644 index 0000000..8f0ba55 --- /dev/null +++ b/frontend/src/components/settings/MailClientSettings.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/settings/OtpCodeField.vue b/frontend/src/components/settings/OtpCodeField.vue new file mode 100644 index 0000000..89d8626 --- /dev/null +++ b/frontend/src/components/settings/OtpCodeField.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend/src/components/settings/PasswordChangeForm.vue b/frontend/src/components/settings/PasswordChangeForm.vue index 8c49795..b3e0ac3 100644 --- a/frontend/src/components/settings/PasswordChangeForm.vue +++ b/frontend/src/components/settings/PasswordChangeForm.vue @@ -7,46 +7,62 @@ // 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError). // 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다) // - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다 -// 고정 id — #password-current · #password-next · #password-confirm · #password-submit · #password-status -import { computed, onBeforeUnmount, ref } from 'vue' +// - 2단계 인증을 켠 직원(12단계 — 계약 5절 "비밀번호 바꾸기")은 맨 아래 「인증 앱 코드」 칸(코드가 30초마다 바뀌어 다른 칸을 다 쓴 뒤 넣는다). +// 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007). 코드 틀림(AUTH_006)은 그 칸 아래 · 칸을 비운다. +// 다른 탭에서 켜서 코드 없이 보냈으면(AUTH_007) 2단계 상태를 다시 받고 칸을 보인다 — 못 받아도 칸은 보인다 +// 고정 id — #password-current · #password-next · #password-confirm · #password-code · #password-submit · #password-status +import { computed, nextTick, onBeforeUnmount, ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' +import OtpCodeField from '@/components/settings/OtpCodeField.vue' import PasswordField from '@/components/settings/PasswordField.vue' import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi' import { useAuth } from '@/composables/useAuth' import { PASSWORD_TEXT } from '@/shared/constants/account' import type { ErrorCode } from '@/shared/constants/error-codes.generated' +import { isCompleteOtp } from '@/shared/utils/account-security' import { hasPasswordChangeProblem, passwordChangeProblems, type PasswordChangeProblems, } from '@/shared/utils/password-change' import { useAuthStore } from '@/stores/auth.store' +import { useTwoFactorStore } from '@/stores/two-factor.store' /** 칸 아래에 보이는 서버 거절 — 알림은 끈다 */ -const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001'] +const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001', 'AUTH_006', 'AUTH_007'] const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null } const { changePassword } = useAuth() const authStore = useAuthStore() +const twoFactorStore = useTwoFactorStore() const currentRef = ref | null>(null) const nextRef = ref | null>(null) const confirmRef = ref | null>(null) +const codeRef = ref | null>(null) const current = ref('') const next = ref('') const confirm = ref('') +const code = ref('') const showCurrent = ref(false) const showNext = ref(false) const problems = ref(NO_PROBLEMS) /** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */ const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null) +/** 코드 칸 아래 — 6자리가 아님(AUTH_007 문구) · 서버의 AUTH_006 · AUTH_007 */ +const codeError = ref(null) +/** 서버가 코드를 요구했다(AUTH_007) — 상태를 다시 받지 못해도 칸을 보인다 */ +const codeRequired = ref(false) const submitting = ref(false) const done = ref(false) /** 화면이 사라졌다 — 칸 아래 글을 볼 곳이 없다 */ let unmounted = false +/** 「인증 앱 코드」 칸을 보인다 — 2단계 인증을 켠 직원 */ +const showCode = computed(() => twoFactorStore.isEnabled || codeRequired.value) + const currentError = computed(() => { if (problems.value.current === 'missing') return getErrorMessage('VAL_001') return serverError.value?.field === 'current' ? serverError.value.message : null @@ -78,6 +94,12 @@ function setConfirm(value: string) { done.value = false } +function setCode(value: string) { + code.value = value + codeError.value = null + done.value = false +} + function focusFirstProblem(found: PasswordChangeProblems) { if (found.current) currentRef.value?.focus() else if (found.next) nextRef.value?.focus() @@ -88,23 +110,52 @@ function clearFields() { current.value = '' next.value = '' confirm.value = '' + code.value = '' showCurrent.value = false showNext.value = false } +/** 코드 칸 아래에 알리고 칸을 비운 뒤 초점 — 다른 탭에서 켜 칸이 막 생겼으면 그린 뒤에 */ +async function showCodeError(message: string) { + codeError.value = message + code.value = '' + await nextTick() + codeRef.value?.focus() +} + +/** 다른 탭에서 2단계 인증을 켰다(AUTH_007) — 상태를 다시 받고 칸을 보인다 */ +async function requireCode(message: string) { + codeRequired.value = true + await showCodeError(message) + void twoFactorStore.refresh() +} + async function submit() { if (submitting.value) return done.value = false serverError.value = null const found = passwordChangeProblems({ current: current.value, next: next.value, confirm: confirm.value }) problems.value = found + const codeMissing = showCode.value && !isCompleteOtp(code.value) + codeError.value = codeMissing ? getErrorMessage('AUTH_007') : null if (hasPasswordChangeProblem(found)) { focusFirstProblem(found) return } + if (codeMissing) { + codeRef.value?.focus() + return + } submitting.value = true try { - await changePassword({ currentPassword: current.value, newPassword: next.value }, { silent: INLINE_CODES }) + await changePassword( + { + currentPassword: current.value, + newPassword: next.value, + ...(showCode.value ? { otpCode: code.value } : {}), + }, + { silent: INLINE_CODES }, + ) clearFields() problems.value = NO_PROBLEMS done.value = true @@ -120,6 +171,10 @@ async function submit() { } else if (apiError.code === 'VAL_001') { serverError.value = { field: 'next', message: apiError.message } nextRef.value?.focus() + } else if (apiError.code === 'AUTH_006') { + await showCodeError(apiError.message) + } else if (apiError.code === 'AUTH_007') { + await requireCode(apiError.message) } } finally { submitting.value = false @@ -182,6 +237,17 @@ onBeforeUnmount(() => { :error="confirmError" @update:model-value="setConfirm" /> +