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" /> +