generated from ttipo/project-base
feat(frontend): 12단계 계정 보안 화면 — 로그인 코드 단계 · 2단계 인증 · 앱 비밀번호 · 로그인 기록
로그인은 비밀번호가 맞고 2단계를 켠 직원만 코드 단계로 간다. 설정 › 계정에 2단계 인증 · 앱 비밀번호 · 로그인 기록을 더하고, 비밀번호 화면에 2단계를 켠 직원의 코드 칸을 둔다. 비밀(코드 단계의 비밀번호 · 2단계 키 · 앱 비밀번호 값)은 화면 메모리에만 두고, 세션이 바뀌면 그 창을 모두 닫는다 — 12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다. 비밀이 드는 요청은 Pinia 액션 밖에서 보낸다(devtools 기록). QR 은 uqr 0.1.3(MIT · 의존성 없음 — 2단계 인증 화면 청크에만)으로 SVG 를 그린다(v-html 없음). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
// 글 가운데 앱 안 링크 하나 — "켜기 전에 [앱 비밀번호]를 만들어 두면 편합니다." 처럼 앞 · 링크 · 뒤 글을 빈칸 없이 잇는다
|
||||
// (RouterLink 를 글 사이에 바로 쓰면 템플릿의 줄바꿈이 링크 글 앞뒤의 빈칸이 된다 — 링크는 RouterLink 의 custom 슬롯으로 <a> 하나만 그린다)
|
||||
// 사용 예: <LinkedText :before="…" link="앱 비밀번호" :after="…" :to="{ name: SETTINGS_ROUTES.appPasswords }" />
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
before?: string
|
||||
link: string
|
||||
after?: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
before: '',
|
||||
after: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ before }}<RouterLink
|
||||
v-slot="{ href, navigate }"
|
||||
:to="to"
|
||||
custom
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
>{{ link }}</a>
|
||||
</RouterLink>{{ after }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// QR 코드 그림 — 2단계 인증 켜기 창 (디자인 원본 SettingsTwoFactor: 흰 바탕 · 테두리 상자 안의 QR)
|
||||
// 칸은 shared/utils/qr-code.ts 가 고르고 여기서는 SVG <rect> 를 v-for 로 그린다 — v-html 을 쓰지 않는다.
|
||||
// 바깥 여백(quiet zone)은 칸 2개 + 상자 안쪽 여백 — 읽는 앱이 그림의 끝을 알아보게 둔다.
|
||||
// 이름(label)은 role="img" 의 aria-label — 그림의 내용(비밀이 든 주소)은 읽어 주지 않는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { qrModuleRuns } from '@/shared/utils/qr-code'
|
||||
|
||||
interface Props {
|
||||
/** QR 에 담을 글 — otpauth 주소 */
|
||||
value: string
|
||||
/** 그림의 이름 (스크린리더) */
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** 바깥 여백 칸 수 */
|
||||
const QUIET_ZONE = 2
|
||||
|
||||
const modules = computed(() => qrModuleRuns(props.value))
|
||||
const extent = computed(() => modules.value.size + QUIET_ZONE * 2)
|
||||
const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="qr-code"
|
||||
role="img"
|
||||
:aria-label="label"
|
||||
:viewBox="viewBox"
|
||||
shape-rendering="crispEdges"
|
||||
data-qr-code
|
||||
>
|
||||
<rect
|
||||
class="qr-code__background"
|
||||
:width="extent"
|
||||
:height="extent"
|
||||
/>
|
||||
<rect
|
||||
v-for="run in modules.runs"
|
||||
:key="`${run.y}-${run.x}`"
|
||||
class="qr-code__module"
|
||||
:x="run.x + QUIET_ZONE"
|
||||
:y="run.y + QUIET_ZONE"
|
||||
:width="run.width"
|
||||
height="1"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 160 — 실제 otpauth 주소는 49칸 남짓이라 디자인 보드(128 — 25칸짜리 예시)보다 키워 칸 하나가 3px 를 넘게 한다 */
|
||||
.qr-code {
|
||||
display: block;
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
.qr-code__background {
|
||||
fill: var(--color-surface);
|
||||
}
|
||||
|
||||
.qr-code__module {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
// 로그인 — 코드 단계 (디자인 원본 LoginTwoFactor · README "12단계 › 로그인 — 코드 단계" · 계약 5절 "로그인")
|
||||
// 틀은 로그인 화면과 같다: 제목 "2단계 인증" + 설명 → 주소 + [다른 계정으로 로그인] → (위쪽 빨간 상자) → 코드 칸 + [확인] → 도움말 → 메일 프로그램 안내.
|
||||
// - 코드 칸: 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code".
|
||||
// maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다
|
||||
// - 코드 틀림(AUTH_006)은 칸 아래 빨간 글(부모가 칸을 비우고 초점을 돌려준다), 시도 과다(REQ_001)는 위쪽 상자 — 로그인 오류와 같은 자리 · 같은 모양
|
||||
// - 확인 중: 「확인하는 중…」(로그인 단추와 같은 모양 · aria-busy), 칸은 읽기만, [다른 계정으로 로그인]도 누를 수 없다(aria-disabled).
|
||||
// [확인]은 disabled 가 아니라 aria-disabled — 키보드 초점을 잃지 않게(시도 과다 · 장애 뒤에도 그 자리에 남는다). 바쁜 동안의 누름은 부모가 무시한다
|
||||
// 상태는 부모(LoginPage)가 가진다 — 비밀번호를 다시 실어 보내는 일이 부모 몫이라서. 고정 id: #login-code · #login-code-error · #login-code-alert
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { LOGIN_TWO_FACTOR_TEXT, OTP_TEXT } from '@/shared/constants/account-security'
|
||||
import { normalizeOtpInput } from '@/shared/utils/account-security'
|
||||
|
||||
interface Props {
|
||||
/** 비밀번호를 확인한 주소 */
|
||||
email: string
|
||||
/** 코드 칸의 값 (v-model:code) */
|
||||
code: string
|
||||
submitting: boolean
|
||||
/** 칸 아래 오류 — 사전 문구(AUTH_006 · AUTH_007) */
|
||||
codeError: string | null
|
||||
/** 위쪽 상자 — 사전 문구(REQ_001) */
|
||||
alert: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:code', value: string): void
|
||||
(e: 'submit'): void
|
||||
(e: 'other-account'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const normalized = normalizeOtpInput(target.value)
|
||||
// 값이 그대로면 다시 그리지 않으므로(숫자가 아닌 글자를 쳤을 때) 칸의 글도 직접 맞춘다
|
||||
if (target.value !== normalized) target.value = normalized
|
||||
emit('update:code', normalized)
|
||||
}
|
||||
|
||||
function onOtherAccount() {
|
||||
if (!props.submitting) emit('other-account')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-step">
|
||||
<div class="code-step__heading">
|
||||
<h1 class="code-step__title">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.title }}
|
||||
</h1>
|
||||
<p class="code-step__subtitle">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="code-step__account">
|
||||
<AppIcon
|
||||
name="user"
|
||||
class="code-step__account-icon"
|
||||
/>
|
||||
<span
|
||||
class="code-step__email"
|
||||
data-code-step-email
|
||||
>{{ email }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="code-step__other"
|
||||
:aria-disabled="submitting ? 'true' : undefined"
|
||||
data-code-step-other
|
||||
@click="onOtherAccount"
|
||||
>
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.otherAccount }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="alert"
|
||||
id="login-code-alert"
|
||||
class="code-step__alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="code-step__alert-icon"
|
||||
/>
|
||||
<span>{{ alert }}</span>
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="code-step__form"
|
||||
novalidate
|
||||
@submit.prevent="emit('submit')"
|
||||
>
|
||||
<div class="code-step__field">
|
||||
<label
|
||||
for="login-code"
|
||||
class="code-step__label"
|
||||
>{{ LOGIN_TWO_FACTOR_TEXT.codeLabel }}</label>
|
||||
<input
|
||||
id="login-code"
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
name="one-time-code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
spellcheck="false"
|
||||
:placeholder="OTP_TEXT.placeholder"
|
||||
:value="code"
|
||||
:readonly="submitting"
|
||||
:class="['code-step__input', { 'is-invalid': codeError }]"
|
||||
:aria-invalid="codeError ? 'true' : undefined"
|
||||
:aria-describedby="codeError ? 'login-code-error' : undefined"
|
||||
@input="onInput"
|
||||
>
|
||||
<p
|
||||
v-if="codeError"
|
||||
id="login-code-error"
|
||||
class="code-step__error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
class="code-step__error-icon"
|
||||
/>
|
||||
<span>{{ codeError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="code-step__submit"
|
||||
:aria-disabled="submitting ? 'true' : undefined"
|
||||
:aria-busy="submitting ? 'true' : 'false'"
|
||||
>
|
||||
{{ submitting ? LOGIN_TWO_FACTOR_TEXT.submitting : LOGIN_TWO_FACTOR_TEXT.submit }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="code-step__help">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.help }}
|
||||
</p>
|
||||
<p class="code-step__note">
|
||||
{{ LOGIN_TWO_FACTOR_TEXT.note }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 로그인 화면의 패널과 같은 간격 — 묶음 사이 22 */
|
||||
.code-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.code-step__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.code-step__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-heading-lg);
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-heading);
|
||||
}
|
||||
|
||||
.code-step__subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 주소 줄 48 — 바탕 #FBFBFC · 테두리, 오른쪽 끝에 [다른 계정으로 로그인](44) */
|
||||
.code-step__account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--space-48);
|
||||
padding: var(--space-1) var(--space-1) var(--space-1) var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.code-step__account-icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.code-step__email {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-step__other {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-step__other:hover:not([aria-disabled='true']) {
|
||||
color: var(--color-brand-strong);
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.code-step__other[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* 위쪽 빨간 상자 — 로그인 오류와 같은 모양 */
|
||||
.code-step__alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.code-step__alert-icon {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.code-step__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
}
|
||||
|
||||
.code-step__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.code-step__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 18px · 글자 사이 0.3em · 고른 폭 숫자 (디자인 원본 LoginTwoFactor) */
|
||||
.code-step__input {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-title-lg);
|
||||
letter-spacing: 0.3em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.code-step__input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-danger);
|
||||
}
|
||||
|
||||
.code-step__input:read-only {
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.code-step__error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.code-step__error-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.code-step__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-6);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-step__submit:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 확인하는 동안 — 흐리게 (disabled 가 아니라 aria-disabled — 초점을 잃지 않게, 누름은 부모가 무시한다) */
|
||||
.code-step__submit[aria-disabled='true'] {
|
||||
opacity: 0.7;
|
||||
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);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호 만들기 창 — 모달 400(medium) · 처음 초점은 이름 칸 (디자인 원본 SettingsAppPasswords "만들기 창" · 계약 5절 "앱 비밀번호")
|
||||
// 「이름」 + "어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지." → [만들기] [취소]
|
||||
// - 칸 아래(알림 없이): "이름을 넣어 주세요." · "같은 이름의 앱 비밀번호가 있습니다."(앞뒤 공백을 지운 값으로 화면이 먼저 본다) ·
|
||||
// 서버의 거절(그사이 다른 창에서 5개를 채움 AUTH_009 · 형식 VAL_001)은 부모가 사전 문구를 error 로 넘긴다
|
||||
// - Enter 는 [만들기]와 같다(한글 조합 중의 Enter 는 글자 확정이라 무시한다). 만드는 동안 [만들기]는 aria-disabled · aria-busy(초점을 지킨다)
|
||||
// - 요청은 부모(앱 비밀번호 화면)가 보낸다 — 값(password)이 든 응답이 이 창의 이벤트로 오가지 않게, 결과 창도 부모가 연다
|
||||
// 고정 id — #app-password-create-title · #app-password-name · #app-password-name-help
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import {
|
||||
APP_PASSWORD_CREATE_TEXT,
|
||||
APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
} from '@/shared/constants/account-security'
|
||||
import type { AppPassword } from '@/shared/types/account-security'
|
||||
import { appPasswordNameProblem, type AppPasswordNameProblem } from '@/shared/utils/account-security'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 지금 목록 — 같은 이름을 먼저 본다 */
|
||||
existing: readonly AppPassword[]
|
||||
/** 만드는 중 — [만들기]를 다시 누를 수 없다 */
|
||||
pending?: boolean
|
||||
/** 서버가 거절한 까닭(사전 문구) — 이름을 고치면 부모가 지운다(clear-error) */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 앞뒤 공백을 지운 이름 */
|
||||
(e: 'submit', name: string): void
|
||||
(e: 'close'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pending: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const PROBLEM_TEXT: Readonly<Record<AppPasswordNameProblem, string>> = {
|
||||
empty: APP_PASSWORD_CREATE_TEXT.empty,
|
||||
duplicate: APP_PASSWORD_CREATE_TEXT.duplicate,
|
||||
tooLong: getErrorMessage('VAL_001'),
|
||||
}
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const name = ref<string>('')
|
||||
/** 보내기 전에 막은 까닭 */
|
||||
const formatError = ref<string | null>(null)
|
||||
|
||||
const shownError = computed(() => formatError.value ?? props.error)
|
||||
const message = computed(() => shownError.value ?? APP_PASSWORD_CREATE_TEXT.nameHelp)
|
||||
|
||||
function onInput(event: Event) {
|
||||
name.value = (event.target as HTMLInputElement).value
|
||||
formatError.value = null
|
||||
if (props.error) emit('clear-error')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (props.pending) return
|
||||
const problem = appPasswordNameProblem(name.value, props.existing)
|
||||
if (problem) {
|
||||
formatError.value = PROBLEM_TEXT[problem]
|
||||
inputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
emit('submit', name.value.trim())
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
// 열 때마다 빈 칸으로
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
name.value = ''
|
||||
formatError.value = null
|
||||
},
|
||||
)
|
||||
|
||||
// 서버가 거절하면 칸에 초점을 돌려준다 — 고쳐 쓰기 쉽게
|
||||
watch(
|
||||
() => props.error,
|
||||
(error) => {
|
||||
if (error) void nextTick(() => inputRef.value?.focus())
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
medium
|
||||
:title="APP_PASSWORD_CREATE_TEXT.title"
|
||||
title-id="app-password-create-title"
|
||||
described-by="app-password-name-help"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="create-name">
|
||||
<label
|
||||
for="app-password-name"
|
||||
class="create-name__label"
|
||||
>{{ APP_PASSWORD_CREATE_TEXT.nameLabel }}</label>
|
||||
<input
|
||||
id="app-password-name"
|
||||
ref="inputRef"
|
||||
class="form-input create-name__input"
|
||||
type="text"
|
||||
:value="name"
|
||||
:maxlength="APP_PASSWORD_NAME_MAX_LENGTH"
|
||||
:placeholder="APP_PASSWORD_CREATE_TEXT.namePlaceholder"
|
||||
autocomplete="off"
|
||||
:aria-invalid="shownError ? 'true' : 'false'"
|
||||
aria-describedby="app-password-name-help"
|
||||
autofocus
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<p
|
||||
id="app-password-name-help"
|
||||
:class="['form-message', { 'form-message--error': shownError }]"
|
||||
data-app-password-name-message
|
||||
>
|
||||
<AppIcon
|
||||
v-if="shownError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ message }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:aria-disabled="pending ? 'true' : undefined"
|
||||
:aria-busy="pending ? 'true' : undefined"
|
||||
data-app-password-create-submit
|
||||
@click="submit"
|
||||
>
|
||||
{{ APP_PASSWORD_CREATE_TEXT.submit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-app-password-create-cancel
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ APP_PASSWORD_CREATE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.create-name__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.create-name__input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호 지우기 확인 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 취소 (디자인 원본 SettingsAppPasswords "지우기 확인 창")
|
||||
// "‘휴대폰 메일 앱’ 앱 비밀번호를 지울까요?" / "이 비밀번호로 연결한 메일 프로그램은 다음 연결부터 로그인하지 못합니다. 지운 비밀번호는 되돌릴 수 없습니다."
|
||||
// [지우기](빨강 채움) [취소]. 지우는 동안 [지우기]는 aria-disabled · aria-busy(초점을 지킨다)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { APP_PASSWORD_DELETE_TEXT } from '@/shared/constants/account-security'
|
||||
|
||||
interface Props {
|
||||
/** 지울 앱 비밀번호의 이름 — 없으면 닫힌다 */
|
||||
name: string | null
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() => (props.name === null ? '' : APP_PASSWORD_DELETE_TEXT.title(props.name)))
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.busy) emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="name !== null"
|
||||
role="alertdialog"
|
||||
medium
|
||||
:title="title"
|
||||
:message="APP_PASSWORD_DELETE_TEXT.message"
|
||||
title-id="app-password-delete-title"
|
||||
message-id="app-password-delete-message"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
data-app-password-delete-confirm
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ APP_PASSWORD_DELETE_TEXT.confirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
data-app-password-delete-cancel
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ APP_PASSWORD_DELETE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호를 만든 결과 창 — 모달 620(wide) · 처음 초점 [복사] (디자인 원본 SettingsAppPasswords "만든 결과 창" · 계약 5절 "앱 비밀번호")
|
||||
// "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
|
||||
// - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다(조각 사이를 여백으로 — 골라 복사해도 값 그대로) — [복사]는 값 그대로 복사한다.
|
||||
// "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." — 글자 수는 받은 값의 길이로 쓴다.
|
||||
// 묶음은 줄바꿈 없는 inline-block 덩어리 — 넓으면 한 줄, 좁으면 묶음 사이에서만 줄이 바뀐다(묶음이 둘로 갈리지 않는다 — 2026-10-31 브라우저 점검)
|
||||
// - 경고 "이 창을 닫으면 다시 볼 수 없습니다. …" + 설정값 요약 + [닫기]
|
||||
// - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
|
||||
// - [닫기] · Esc 로 닫으면 부모가 값을 화면 메모리에서도 지운다. 복사하지 않고 닫아도 다시 묻지 않는다
|
||||
// [복사]는 44 — 디자인 보드 크기(README 의 36 은 밀도 예외 줄의 단추와 묶어 적은 것 — 창의 주 동작이라 보통 크기로 둔다)
|
||||
// 고정 id — #app-password-result-title · #app-password-result-message
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import MailClientSettings from '@/components/settings/MailClientSettings.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { APP_PASSWORD_RESULT_TEXT } from '@/shared/constants/account-security'
|
||||
import { groupCharacters } from '@/shared/utils/account-security'
|
||||
import { copyText } from '@/shared/utils/clipboard'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
/** 만든 앱 비밀번호의 이름 · 값 — 없으면 닫힌다 */
|
||||
result: { name: string; password: string } | null
|
||||
/** 직원 주소 — 설정값 요약의 사용자 이름 */
|
||||
email: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const groups = computed(() => (props.result ? groupCharacters(props.result.password) : []))
|
||||
const message = computed(() => (props.result ? APP_PASSWORD_RESULT_TEXT.message(props.result.name) : ''))
|
||||
const lengthNote = computed(() =>
|
||||
APP_PASSWORD_RESULT_TEXT.lengthNote(formatCount(props.result?.password.length ?? 0)),
|
||||
)
|
||||
|
||||
async function copy() {
|
||||
const value = props.result?.password
|
||||
if (!value) return
|
||||
if (await copyText(value)) toast.success(APP_PASSWORD_RESULT_TEXT.copied)
|
||||
else toast.error(APP_PASSWORD_RESULT_TEXT.copyFailed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="result !== null"
|
||||
wide
|
||||
:title="APP_PASSWORD_RESULT_TEXT.title"
|
||||
:message="message"
|
||||
title-id="app-password-result-title"
|
||||
message-id="app-password-result-message"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="result__value-box">
|
||||
<code
|
||||
class="result__value"
|
||||
data-app-password-value
|
||||
><span
|
||||
v-for="(group, index) in groups"
|
||||
:key="index"
|
||||
class="result__group"
|
||||
>{{ group }}</span></code>
|
||||
<button
|
||||
type="button"
|
||||
class="result__copy"
|
||||
:aria-label="APP_PASSWORD_RESULT_TEXT.copyLabel"
|
||||
autofocus
|
||||
data-app-password-copy
|
||||
@click="copy"
|
||||
>
|
||||
<AppIcon
|
||||
name="copy"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ APP_PASSWORD_RESULT_TEXT.copy }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="result__length">
|
||||
{{ lengthNote }}
|
||||
</p>
|
||||
<div class="result__warning">
|
||||
<AppIcon
|
||||
name="alert-triangle"
|
||||
:stroke-width="1.9"
|
||||
class="result__warning-icon"
|
||||
/>
|
||||
<span class="result__warning-text"><strong class="result__warning-strong">{{
|
||||
APP_PASSWORD_RESULT_TEXT.warningStrong
|
||||
}}</strong>{{ APP_PASSWORD_RESULT_TEXT.warningAfter }}</span>
|
||||
</div>
|
||||
<MailClientSettings
|
||||
:email="email"
|
||||
compact
|
||||
/>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
data-app-password-result-close
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ APP_PASSWORD_RESULT_TEXT.close }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 값 상자 — 테두리 · 바탕 #F8F9FB · 안쪽 8 8 8 16 */
|
||||
.result__value-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-8) var(--space-8) var(--space-8) var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
/* 값 — 고정폭 15px, 4자 조각 사이를 여백으로(띄어쓰기 없이). 누르면 한 번에 고른다 */
|
||||
.result__value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: Consolas, 'SFMono-Regular', Menlo, monospace;
|
||||
font-size: var(--font-size-title-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/*
|
||||
* 4자 묶음 — 줄바꿈 없는 덩어리. inline-block 앞뒤가 줄이 바뀔 수 있는 자리라 좁으면 묶음 사이에서만 줄이 바뀐다.
|
||||
* block · flex 로 나누지 않는다 — 골라 복사한 값에 줄바꿈이 끼면 메일 프로그램이 받지 않는다
|
||||
*/
|
||||
.result__group {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result__group:not(:last-child) {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
.result__copy {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result__copy:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.result__length {
|
||||
margin: calc(var(--space-4) * -1) 0 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 경고 — 노란 상자 · 경고 삼각형 */
|
||||
.result__warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.result__warning-icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.result__warning-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.result__warning-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 비밀번호 표 (디자인 원본 SettingsAppPasswords — 캔버스는 grid 로 그렸지만 구현은 실제 table)
|
||||
// 이름 · 만든 날 · 마지막 사용 · [지우기](36 — 밀도 예외, 빨간 글). 최근에 만든 것이 위, 긴 이름은 말줄임.
|
||||
// 마지막 사용은 "오늘 오전 9:12 · IMAP · 211.186.95.252" — 쓰지 않았으면 「아직 쓰지 않음」(회색 — 오래 쓰지 않은 줄은 지워도 된다는 신호)
|
||||
// 지우는 중인 줄의 단추는 aria-disabled · aria-busy (다시 누르는 것은 부모가 막는다)
|
||||
import { APP_PASSWORD_TEXT } from '@/shared/constants/account-security'
|
||||
import type { AppPassword } from '@/shared/types/account-security'
|
||||
import { appPasswordLastUsedText } from '@/shared/utils/account-security'
|
||||
import { formatBlockedDay } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
items: readonly AppPassword[]
|
||||
/** 지금 시각(ms) — "오늘 · 어제" 와 연도 표기의 기준 */
|
||||
now: number
|
||||
/** 지우는 중인 줄 */
|
||||
removingId?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'delete', item: AppPassword): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
removingId: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-password-table">
|
||||
<table class="app-password-table__table">
|
||||
<caption class="visually-hidden">
|
||||
{{ APP_PASSWORD_TEXT.listLabel }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col>
|
||||
<col class="app-password-table__col-created">
|
||||
<col class="app-password-table__col-used">
|
||||
<col class="app-password-table__col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
{{ APP_PASSWORD_TEXT.columnName }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ APP_PASSWORD_TEXT.columnCreated }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ APP_PASSWORD_TEXT.columnLastUsed }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="visually-hidden">{{ APP_PASSWORD_TEXT.columnActions }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:data-app-password="item.id"
|
||||
>
|
||||
<td
|
||||
class="app-password-table__name"
|
||||
:title="item.name"
|
||||
>
|
||||
{{ item.name }}
|
||||
</td>
|
||||
<td class="app-password-table__created">
|
||||
{{ formatBlockedDay(item.createdAt, new Date(now)) }}
|
||||
</td>
|
||||
<td
|
||||
:class="['app-password-table__used', { 'app-password-table__used--never': !item.lastUsed }]"
|
||||
data-app-password-used
|
||||
>
|
||||
{{ appPasswordLastUsedText(item.lastUsed, now) ?? APP_PASSWORD_TEXT.neverUsed }}
|
||||
</td>
|
||||
<td class="app-password-table__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="app-password-table__delete"
|
||||
:aria-label="APP_PASSWORD_TEXT.deleteLabel(item.name)"
|
||||
:aria-disabled="removingId ? 'true' : undefined"
|
||||
:aria-busy="removingId === item.id ? 'true' : undefined"
|
||||
data-app-password-delete
|
||||
@click="emit('delete', item)"
|
||||
>
|
||||
{{ APP_PASSWORD_TEXT.delete }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-password-table {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 칸: 이름(나머지) · 만든 날 88 · 마지막 사용 264 · 지우기 72, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ap-grid) */
|
||||
.app-password-table__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.app-password-table__col-created {
|
||||
width: calc(5.5rem + var(--space-12));
|
||||
}
|
||||
|
||||
.app-password-table__col-used {
|
||||
width: calc(16.5rem + var(--space-12));
|
||||
}
|
||||
|
||||
.app-password-table__col-actions {
|
||||
width: calc(4.5rem + var(--space-12) + var(--space-8));
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0 0 0 var(--space-12);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--space-14);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
/* 머리 줄 32 — 바탕 #FBFBFC · 12px 굵게 회색 */
|
||||
thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 줄 44 */
|
||||
tbody td {
|
||||
height: var(--size-touch);
|
||||
}
|
||||
|
||||
tbody tr + tr td {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.app-password-table__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-password-table__created {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-password-table__used {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-password-table__used--never {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.app-password-table__actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* [지우기] 36 — 밀도 예외 (--size-notice-action), 빨간 글 테두리 단추 */
|
||||
.app-password-table__delete {
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-password-table__delete:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
.app-password-table__delete[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// [다른 곳 모두 로그아웃] 확인 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 취소 (디자인 원본 SettingsLoginHistory "다른 곳 모두 로그아웃 — 확인 창")
|
||||
// "다른 곳의 로그인을 모두 끊을까요?" / "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. …" + 작은 글 "메일 프로그램은 끊기지 않습니다 — …"
|
||||
// [모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소]. 끊는 동안 [모두 로그아웃]은 aria-disabled · aria-busy(초점을 지킨다)
|
||||
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 제목 · 글이 "이 브라우저도 로그아웃된다"로 바뀐다 — 부모가 넘긴다
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { END_OTHER_SESSIONS_TEXT } from '@/shared/constants/account-security'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 창 제목 */
|
||||
title: string
|
||||
/** 창 글 — 끊을 곳의 수와 이름 (endOtherSessionsMessage) */
|
||||
message: string
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.busy) emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
medium
|
||||
:title="title"
|
||||
:message="message"
|
||||
title-id="end-other-sessions-title"
|
||||
message-id="end-other-sessions-message"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<p class="end-others__note">
|
||||
{{ END_OTHER_SESSIONS_TEXT.note }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
data-end-others-confirm
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ END_OTHER_SESSIONS_TEXT.confirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
data-end-others-cancel
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ END_OTHER_SESSIONS_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.end-others__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 로그인 기록 › 최근 로그인 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인 기록")
|
||||
// 「최근 로그인」 + "최근 90일 · 웹메일과 메일 프로그램"(실패만이면 "최근 90일 · 실패 4번") · 오른쪽 전환 [전체 · 실패만](36 — .segmented 밀도 예외)
|
||||
// 표(구현은 실제 table): 시각 · 방식 · IP · 기기 · 결과 — 최근 순서 · 20개씩 [더 보기](44)
|
||||
// - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
|
||||
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
|
||||
// - 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」 — 실패는 빨간 굵은 글자만(뱃지 없음)
|
||||
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."
|
||||
// - 보기를 바꾸면 그 보기로 처음부터 받는다 — 받는 동안 지난 목록은 그대로 두고 aria-busy (login-history.store)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
|
||||
import type { LoginEvent } from '@/shared/types/account-security'
|
||||
import {
|
||||
loginAppPasswordLabel,
|
||||
loginDeviceLabel,
|
||||
loginMethodLabel,
|
||||
loginResultLabel,
|
||||
} from '@/shared/utils/account-security'
|
||||
import { formatCount, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import { useLoginHistoryStore, type LoginEventFilter } from '@/stores/login-history.store'
|
||||
|
||||
interface Props {
|
||||
/** 지금 시각(ms) — "오늘 오전 9:30" 의 기준 */
|
||||
now: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const loginHistoryStore = useLoginHistoryStore()
|
||||
|
||||
const FILTERS: readonly { value: LoginEventFilter; label: string }[] = [
|
||||
{ value: 'all', label: LOGIN_HISTORY_TEXT.filterAll },
|
||||
{ value: 'failures', label: LOGIN_HISTORY_TEXT.filterFailures },
|
||||
]
|
||||
|
||||
const events = computed(() => loginHistoryStore.events)
|
||||
const failuresShown = computed(() => loginHistoryStore.loadedFilter === 'failures')
|
||||
const hint = computed(() => {
|
||||
if (!failuresShown.value) return LOGIN_HISTORY_TEXT.eventsHintAll
|
||||
const total = loginHistoryStore.eventsPageInfo?.total
|
||||
return typeof total === 'number'
|
||||
? LOGIN_HISTORY_TEXT.eventsHintFailures(formatCount(total))
|
||||
: LOGIN_HISTORY_TEXT.eventsHintFailuresUnknown
|
||||
})
|
||||
const emptyTitle = computed(() =>
|
||||
failuresShown.value ? LOGIN_HISTORY_TEXT.emptyFailuresTitle : LOGIN_HISTORY_TEXT.emptyAllTitle,
|
||||
)
|
||||
const emptyHint = computed(() =>
|
||||
failuresShown.value ? LOGIN_HISTORY_TEXT.emptyFailuresHint : LOGIN_HISTORY_TEXT.emptyAllHint,
|
||||
)
|
||||
|
||||
function timeText(event: LoginEvent): string {
|
||||
return formatRelativeDayTime(event.occurredAt, new Date(props.now))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="events"
|
||||
aria-labelledby="login-events-title"
|
||||
>
|
||||
<div class="events__head">
|
||||
<div class="events__heading">
|
||||
<h2
|
||||
id="login-events-title"
|
||||
class="events__title"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.eventsTitle }}
|
||||
</h2>
|
||||
<span
|
||||
class="events__hint"
|
||||
data-events-hint
|
||||
>{{ hint }}</span>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
class="segmented segmented--outline"
|
||||
:aria-label="LOGIN_HISTORY_TEXT.filterLabel"
|
||||
>
|
||||
<button
|
||||
v-for="filter in FILTERS"
|
||||
:key="filter.value"
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="loginHistoryStore.eventsFilter === filter.value"
|
||||
:data-events-filter="filter.value"
|
||||
@click="loginHistoryStore.setFilter(filter.value)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="events.length > 0"
|
||||
class="events__table-box"
|
||||
:aria-busy="loginHistoryStore.eventsBusy ? 'true' : undefined"
|
||||
>
|
||||
<table class="events__table">
|
||||
<caption class="visually-hidden">
|
||||
{{ LOGIN_HISTORY_TEXT.eventsTitle }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="events__col-time">
|
||||
<col>
|
||||
<col class="events__col-ip">
|
||||
<col class="events__col-device">
|
||||
<col class="events__col-result">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnTime }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnMethod }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnIp }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnKind }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnResult }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:data-login-event="event.id"
|
||||
>
|
||||
<td class="events__time">
|
||||
{{ timeText(event) }}
|
||||
</td>
|
||||
<td class="events__method">
|
||||
<span class="events__method-name">{{ loginMethodLabel(event.method) }}</span>
|
||||
<span
|
||||
v-if="loginAppPasswordLabel(event)"
|
||||
class="events__app-password"
|
||||
data-event-app-password
|
||||
>{{ loginAppPasswordLabel(event) }}</span>
|
||||
</td>
|
||||
<td class="events__ip">
|
||||
<template v-if="event.ip">
|
||||
{{ event.ip }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.unknownValue }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td
|
||||
class="events__device"
|
||||
data-event-device
|
||||
>
|
||||
<template v-if="loginDeviceLabel(event)">
|
||||
{{ loginDeviceLabel(event) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="events__unknown"
|
||||
aria-hidden="true"
|
||||
>—</span>
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.unknownValue }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td
|
||||
:class="['events__result', { 'events__result--failure': loginResultLabel(event).isFailure }]"
|
||||
data-event-result
|
||||
>
|
||||
{{ loginResultLabel(event).label }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="events__empty"
|
||||
:aria-busy="loginHistoryStore.eventsBusy ? 'true' : undefined"
|
||||
data-events-empty
|
||||
>
|
||||
<p class="events__empty-title">
|
||||
{{ emptyTitle }}
|
||||
</p>
|
||||
<p class="events__empty-hint">
|
||||
{{ emptyHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loginHistoryStore.hasMoreEvents"
|
||||
class="events__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="events__more-button"
|
||||
:aria-disabled="loginHistoryStore.loadingMore || loginHistoryStore.eventsBusy ? 'true' : undefined"
|
||||
:aria-busy="loginHistoryStore.loadingMore ? 'true' : undefined"
|
||||
data-events-more
|
||||
@click="loginHistoryStore.loadMoreEvents()"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.more }}
|
||||
</button>
|
||||
<span class="events__more-hint">{{ LOGIN_HISTORY_TEXT.moreHint }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.events__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-16);
|
||||
min-height: var(--size-touch);
|
||||
}
|
||||
|
||||
.events__heading {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
}
|
||||
|
||||
.events__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.events__hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.events__table-box {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 다시 받는 동안 — 지난 목록을 흐리게 */
|
||||
.events__table-box[aria-busy='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 칸: 시각 124 · 방식(나머지) · IP 112 · 기기 120 · 결과 142, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .lh-grid) */
|
||||
.events__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.events__col-time {
|
||||
width: calc(7.75rem + var(--space-14));
|
||||
}
|
||||
|
||||
.events__col-ip {
|
||||
width: calc(7rem + var(--space-12));
|
||||
}
|
||||
|
||||
.events__col-device {
|
||||
width: calc(7.5rem + var(--space-12));
|
||||
}
|
||||
|
||||
.events__col-result {
|
||||
width: calc(8.875rem + var(--space-12) + var(--space-8));
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-4) 0 var(--space-4) var(--space-12);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--space-14);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
thead th {
|
||||
height: var(--size-table-head);
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
height: var(--size-touch);
|
||||
}
|
||||
|
||||
tbody tr + tr td {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.events__time {
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.events__method {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.events__method-name {
|
||||
display: block;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.events__app-password {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.events__ip {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.events__device {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.events__unknown {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.events__result {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 실패 — 빨간 굵은 글자만 (뱃지 없음) */
|
||||
.events__result--failure {
|
||||
font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 빈 상태 — 가운데 정렬 상자 (디자인 원본 SettingsLoginHistory "빈 상태") */
|
||||
.events__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-28) var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-sunken);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events__empty[aria-busy='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.events__empty-title {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events__empty-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.events__more {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-14);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* [더 보기] 44 — 흰 바탕 · 테두리 */
|
||||
.events__more-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events__more-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.events__more-button[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.events__more-hint {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 로그인 기록 › 로그인된 브라우저 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저")
|
||||
// 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
|
||||
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
|
||||
// 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르는 브라우저는 "알 수 없는 브라우저", 모르는 IP 는 「—」.
|
||||
// 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
|
||||
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
|
||||
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
|
||||
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함 — 서버는 모두 끊는다) 확인 창이 이 브라우저도 로그아웃된다고 알리고,
|
||||
// 끊은 뒤에는 목록을 다시 받는다(스토어 — 이 브라우저도 끊겼으면 세션 만료 창으로 이어진다)
|
||||
// - 세션이 바뀌면 확인 창을 닫는다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LinkedText from '@/components/LinkedText.vue'
|
||||
import EndOtherSessionsDialog from '@/components/settings/EndOtherSessionsDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { END_OTHER_SESSIONS_TEXT, LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { AuthSession } from '@/shared/types/account-security'
|
||||
import { deviceLabel, endOtherSessionsMessage, isMobileDevice, otherSessions } from '@/shared/utils/account-security'
|
||||
import { formatCount, formatRelativeDayTime } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useLoginHistoryStore } from '@/stores/login-history.store'
|
||||
|
||||
interface Props {
|
||||
sessions: readonly AuthSession[]
|
||||
/** 지금 시각(ms) — "오늘 오전 9:05" 의 기준 */
|
||||
now: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const loginHistoryStore = useLoginHistoryStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const titleRef = ref<HTMLElement | null>(null)
|
||||
const tableRef = ref<HTMLElement | null>(null)
|
||||
/** 끊는 중인 줄 */
|
||||
const endingId = ref<string | null>(null)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
const endingOthers = ref<boolean>(false)
|
||||
|
||||
const others = computed(() => otherSessions(props.sessions))
|
||||
/** 서버가 이 브라우저를 알아봤다(지금 이 브라우저 줄이 있다) — 없으면 [다른 곳 모두 로그아웃]이 이 브라우저까지 끊는다 */
|
||||
const hasCurrent = computed(() => props.sessions.some((session) => session.isCurrent))
|
||||
const confirmTitle = computed(() => (hasCurrent.value ? END_OTHER_SESSIONS_TEXT.title : END_OTHER_SESSIONS_TEXT.titleAll))
|
||||
const countText = computed(() => LOGIN_HISTORY_TEXT.sessionsCount(formatCount(props.sessions.length)))
|
||||
/** 아래 안내의 앞 글 — 다른 곳이 없으면 "다른 곳에 로그인된 브라우저가 없습니다." 를 앞에 */
|
||||
const noteBefore = computed(() =>
|
||||
others.value.length === 0
|
||||
? `${LOGIN_HISTORY_TEXT.noOthers}${LOGIN_HISTORY_TEXT.sessionsNoteBefore}`
|
||||
: LOGIN_HISTORY_TEXT.sessionsNoteBefore,
|
||||
)
|
||||
const confirmMessage = computed(() => endOtherSessionsMessage(others.value, !hasCurrent.value))
|
||||
const appPasswordsRoute = { name: SETTINGS_ROUTES.appPasswords }
|
||||
|
||||
function lastActiveText(session: AuthSession): string {
|
||||
return formatRelativeDayTime(session.lastActiveAt, new Date(props.now))
|
||||
}
|
||||
|
||||
/** 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 묶음 제목 */
|
||||
async function focusAfterRemoval(index: number) {
|
||||
await nextTick()
|
||||
const active = document.activeElement
|
||||
if (active !== null && active !== document.body) return
|
||||
const buttons = Array.from(tableRef.value?.querySelectorAll<HTMLButtonElement>('[data-session-end]') ?? [])
|
||||
const target = buttons[index] ?? buttons[index - 1]
|
||||
if (target) target.focus()
|
||||
else titleRef.value?.focus()
|
||||
}
|
||||
|
||||
async function endSession(session: AuthSession) {
|
||||
if (endingId.value || endingOthers.value) return
|
||||
const index = others.value.findIndex((item) => item.id === session.id)
|
||||
endingId.value = session.id
|
||||
try {
|
||||
await loginHistoryStore.endSession(session.id)
|
||||
toast.success(LOGIN_HISTORY_TEXT.sessionEnded(deviceLabel(session.device)))
|
||||
await focusAfterRemoval(index)
|
||||
} catch {
|
||||
// 인터셉터가 알렸다 — 줄은 그대로
|
||||
} finally {
|
||||
endingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmEndOthers() {
|
||||
if (endingOthers.value) return
|
||||
const session = authStore.generation
|
||||
endingOthers.value = true
|
||||
try {
|
||||
const { ended, includedThisBrowser } = await loginHistoryStore.endOtherSessions()
|
||||
if (authStore.generation !== session) return
|
||||
confirmOpen.value = false
|
||||
const count = formatCount(ended)
|
||||
toast.success(includedThisBrowser ? LOGIN_HISTORY_TEXT.allEnded(count) : LOGIN_HISTORY_TEXT.othersEnded(count))
|
||||
// 누른 단추가 사라졌다 — 묶음 제목으로 (이 브라우저까지 끊었으면 목록을 다시 받는 중이라 제목이 없을 수 있다)
|
||||
await nextTick()
|
||||
titleRef.value?.focus()
|
||||
} catch {
|
||||
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
|
||||
} finally {
|
||||
endingOthers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 세션이 바뀌면 확인 창을 닫는다 — 세션 만료 창과 겹치지 않게
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
confirmOpen.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="sessions"
|
||||
aria-labelledby="login-sessions-title"
|
||||
>
|
||||
<div class="sessions__head">
|
||||
<div class="sessions__heading">
|
||||
<h2
|
||||
id="login-sessions-title"
|
||||
ref="titleRef"
|
||||
class="sessions__title"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.sessionsTitle }}
|
||||
</h2>
|
||||
<span
|
||||
class="sessions__count"
|
||||
data-sessions-count
|
||||
>{{ countText }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="others.length > 0"
|
||||
type="button"
|
||||
class="sessions__end-others"
|
||||
data-sessions-end-others
|
||||
@click="confirmOpen = true"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.endOthers }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="tableRef"
|
||||
class="sessions__table-box"
|
||||
>
|
||||
<table class="sessions__table">
|
||||
<caption class="visually-hidden">
|
||||
{{ LOGIN_HISTORY_TEXT.sessionsTitle }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="sessions__col-icon">
|
||||
<col>
|
||||
<col class="sessions__col-ip">
|
||||
<col class="sessions__col-active">
|
||||
<col class="sessions__col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.columnKind }}</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnDevice }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnIp }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ LOGIN_HISTORY_TEXT.columnLastActive }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.columnActions }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
:data-session="session.id"
|
||||
>
|
||||
<td class="sessions__icon">
|
||||
<AppIcon
|
||||
:name="isMobileDevice(session.device) ? 'smartphone' : 'monitor'"
|
||||
:label="isMobileDevice(session.device) ? LOGIN_HISTORY_TEXT.kindMobile : LOGIN_HISTORY_TEXT.kindPc"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="sessions__device"
|
||||
data-session-device
|
||||
>
|
||||
{{ deviceLabel(session.device) }}
|
||||
</td>
|
||||
<td class="sessions__ip">
|
||||
<template v-if="session.ip">
|
||||
{{ session.ip }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="visually-hidden">{{ LOGIN_HISTORY_TEXT.unknownValue }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td
|
||||
:class="['sessions__active', { 'sessions__active--current': session.isCurrent }]"
|
||||
data-session-active
|
||||
>
|
||||
{{ session.isCurrent ? LOGIN_HISTORY_TEXT.current : lastActiveText(session) }}
|
||||
</td>
|
||||
<td class="sessions__actions">
|
||||
<button
|
||||
v-if="!session.isCurrent"
|
||||
type="button"
|
||||
class="sessions__end"
|
||||
:aria-label="LOGIN_HISTORY_TEXT.endSessionLabel(deviceLabel(session.device))"
|
||||
:aria-disabled="endingId || endingOthers ? 'true' : undefined"
|
||||
:aria-busy="endingId === session.id ? 'true' : undefined"
|
||||
data-session-end
|
||||
@click="endSession(session)"
|
||||
>
|
||||
{{ LOGIN_HISTORY_TEXT.endSession }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="sessions__note"
|
||||
data-sessions-note
|
||||
>
|
||||
<LinkedText
|
||||
:before="noteBefore"
|
||||
:link="LOGIN_HISTORY_TEXT.sessionsNoteLink"
|
||||
:after="LOGIN_HISTORY_TEXT.sessionsNoteAfter"
|
||||
:to="appPasswordsRoute"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<EndOtherSessionsDialog
|
||||
:open="confirmOpen"
|
||||
:title="confirmTitle"
|
||||
:message="confirmMessage"
|
||||
:busy="endingOthers"
|
||||
@confirm="confirmEndOthers"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.sessions__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-16);
|
||||
min-height: var(--size-touch);
|
||||
}
|
||||
|
||||
.sessions__heading {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
}
|
||||
|
||||
.sessions__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.sessions__title:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sessions__title:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--color-brand);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.sessions__count {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* [다른 곳 모두 로그아웃] 44 — 흰 바탕 · 테두리 */
|
||||
.sessions__end-others {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions__end-others:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.sessions__table-box {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 칸: 아이콘 16 · 브라우저(나머지) · IP 112 · 마지막 활동 150 · 단추 84, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ss-grid) */
|
||||
.sessions__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.sessions__col-icon {
|
||||
width: calc(var(--space-14) + 1rem);
|
||||
}
|
||||
|
||||
.sessions__col-ip {
|
||||
width: calc(7rem + var(--space-12));
|
||||
}
|
||||
|
||||
.sessions__col-active {
|
||||
width: calc(9.375rem + var(--space-12));
|
||||
}
|
||||
|
||||
.sessions__col-actions {
|
||||
width: calc(5.25rem + var(--space-12) + var(--space-8));
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0 0 0 var(--space-12);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--space-14);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
height: var(--size-touch);
|
||||
}
|
||||
|
||||
tbody tr + tr td {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.sessions__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.sessions__device {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions__ip {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sessions__active {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 지금 이 브라우저 — 청록 굵게, 단추 없음 */
|
||||
.sessions__active--current {
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.sessions__actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* [로그아웃] 36 — 밀도 예외 (--size-notice-action) */
|
||||
.sessions__end {
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions__end:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.sessions__end[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.sessions__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 프로그램 설정값 (디자인 원본 SettingsAppPasswords — 화면의 설정값 상자 · 만든 결과 창 아래 요약)
|
||||
// 받는 메일(IMAP) 호스트 · 포트 993 · SSL/TLS / 보내는 메일(SMTP) 호스트 · 포트 465 · SSL/TLS / 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호.
|
||||
// 호스트 · 포트는 상수 하나(MAIL_CLIENT_SETTINGS)에 둔다 — 전 직원 전환 때 다시 본다.
|
||||
// compact: 결과 창의 요약 — 비밀번호 줄 없이, 12.5px · 위 선 하나 (화면의 상자는 줄마다 선이 있는 테두리 상자)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { APP_PASSWORD_TEXT, MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security'
|
||||
|
||||
interface Props {
|
||||
/** 직원 주소 — 사용자 이름 */
|
||||
email: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const { host, imapPort, smtpPort, security } = MAIL_CLIENT_SETTINGS
|
||||
|
||||
const serverLine = (port: number) =>
|
||||
props.compact ? `${port} · ${security}` : `${APP_PASSWORD_TEXT.port(port)} · ${security}`
|
||||
|
||||
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 }]),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dl
|
||||
:class="['client-settings', { 'client-settings--compact': compact }]"
|
||||
data-mail-client-settings
|
||||
>
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.term"
|
||||
>
|
||||
<dt class="client-settings__term">
|
||||
{{ row.term }}
|
||||
</dt>
|
||||
<dd class="client-settings__value">
|
||||
<span
|
||||
v-if="row.strong"
|
||||
class="client-settings__strong"
|
||||
>{{ row.strong }}</span>{{ row.rest }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 화면의 상자 — 이름 칸 136 · 줄마다 선 · 안쪽 9 14 */
|
||||
.client-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem minmax(0, 1fr);
|
||||
margin: 0;
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.client-settings__term,
|
||||
.client-settings__value {
|
||||
margin: 0;
|
||||
padding: var(--space-9) var(--space-14);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.client-settings__term:nth-last-of-type(1),
|
||||
.client-settings__value:nth-last-of-type(1) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.client-settings__term {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.client-settings__value {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.client-settings__strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 결과 창의 요약 — 이름 칸 124 · 12.5px · 위 선 하나 */
|
||||
.client-settings--compact {
|
||||
grid-template-columns: 7.75rem minmax(0, 1fr);
|
||||
gap: var(--space-6) var(--space-12);
|
||||
padding-top: var(--space-12);
|
||||
border: 0;
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__term,
|
||||
.client-settings--compact .client-settings__value {
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__term {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__value {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.client-settings--compact .client-settings__strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
// 인증 앱 코드 칸 — 이름 · 칸(폭 200 · 44 · 16px · 글자 사이 0.24em · 고른 폭 숫자) · 안내 · 오류 (디자인 원본 SettingsTwoFactor)
|
||||
// 2단계 인증 켜기 · 끄기 창과 비밀번호 화면(2단계 인증을 켠 직원 — 양식 맨 아래)이 쓴다. 로그인 코드 단계는 로그인 화면 모양의 칸을 따로 둔다(LoginCodeStep).
|
||||
// - 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code"
|
||||
// maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다
|
||||
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. 칸의 설명(aria-describedby)은 안내 → 오류 차례 (PasswordField 와 같다)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { OTP_TEXT } from '@/shared/constants/account-security'
|
||||
import { normalizeOtpInput } from '@/shared/utils/account-security'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
label: string
|
||||
modelValue: string
|
||||
hint?: string
|
||||
/** 오류 문구 — 사전 문구 */
|
||||
error?: string | null
|
||||
/** 보내는 중 — 읽기만 */
|
||||
readonly?: boolean
|
||||
/** 이름을 작게(12.5px · 회색) — 끄기 창처럼 칸이 둘뿐인 확인 창 */
|
||||
compactLabel?: boolean
|
||||
/** 대화상자를 열면 이 칸에 초점 (켜기 창 — 처음 초점은 코드 칸) */
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hint: '',
|
||||
error: null,
|
||||
readonly: false,
|
||||
compactLabel: false,
|
||||
autofocus: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hintId = computed(() => `${props.id}-hint`)
|
||||
const errorId = computed(() => `${props.id}-error`)
|
||||
const describedBy = computed(() => {
|
||||
const ids = [props.hint ? hintId.value : '', props.error ? errorId.value : ''].filter(Boolean)
|
||||
return ids.length > 0 ? ids.join(' ') : undefined
|
||||
})
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const normalized = normalizeOtpInput(target.value)
|
||||
// 값이 그대로면 다시 그리지 않으므로(숫자가 아닌 글자를 쳤을 때) 칸의 글도 직접 맞춘다
|
||||
if (target.value !== normalized) target.value = normalized
|
||||
emit('update:modelValue', normalized)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="otp-field">
|
||||
<label
|
||||
:for="id"
|
||||
:class="['otp-field__label', { 'otp-field__label--compact': compactLabel }]"
|
||||
>{{ label }}</label>
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
spellcheck="false"
|
||||
:placeholder="OTP_TEXT.placeholder"
|
||||
:value="modelValue"
|
||||
:readonly="readonly"
|
||||
:autofocus="autofocus"
|
||||
class="form-input otp-field__input"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
>
|
||||
<p
|
||||
v-if="hint"
|
||||
:id="hintId"
|
||||
class="form-message"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otp-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.otp-field__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 끄기 창 — 칸 이름 12.5px · 회색 (디자인 원본 SettingsTwoFactor "끄기 창") */
|
||||
.otp-field__label--compact {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 폭 200 · 16px · 글자 사이 0.24em · 고른 폭 숫자 */
|
||||
.otp-field__input {
|
||||
width: 12.5rem;
|
||||
max-width: 100%;
|
||||
font-size: var(--font-size-title);
|
||||
letter-spacing: 0.24em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.otp-field__input:read-only {
|
||||
background: var(--color-surface-panel);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -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<InstanceType<typeof PasswordField> | null>(null)
|
||||
const nextRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const confirmRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
|
||||
|
||||
const current = ref<string>('')
|
||||
const next = ref<string>('')
|
||||
const confirm = ref<string>('')
|
||||
const code = ref<string>('')
|
||||
const showCurrent = ref<boolean>(false)
|
||||
const showNext = ref<boolean>(false)
|
||||
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
|
||||
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
|
||||
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
|
||||
/** 코드 칸 아래 — 6자리가 아님(AUTH_007 문구) · 서버의 AUTH_006 · AUTH_007 */
|
||||
const codeError = ref<string | null>(null)
|
||||
/** 서버가 코드를 요구했다(AUTH_007) — 상태를 다시 받지 못해도 칸을 보인다 */
|
||||
const codeRequired = ref<boolean>(false)
|
||||
const submitting = ref<boolean>(false)
|
||||
const done = ref<boolean>(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"
|
||||
/>
|
||||
<OtpCodeField
|
||||
v-if="showCode"
|
||||
id="password-code"
|
||||
ref="codeRef"
|
||||
:label="PASSWORD_TEXT.code"
|
||||
:model-value="code"
|
||||
:hint="PASSWORD_TEXT.codeHint"
|
||||
:error="codeError"
|
||||
:readonly="submitting"
|
||||
@update:model-value="setCode"
|
||||
/>
|
||||
<div class="password-form__actions">
|
||||
<button
|
||||
id="password-submit"
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
hintIsError?: boolean
|
||||
/** 오류 문구 — 사전 문구 또는 디자인 문구 */
|
||||
error?: string | null
|
||||
/** 이름을 작게(12.5px · 회색) — 2단계 인증 끄기 창처럼 칸이 둘뿐인 확인 창 (디자인 원본 SettingsTwoFactor) */
|
||||
compactLabel?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -33,6 +35,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
hint: '',
|
||||
hintIsError: false,
|
||||
error: null,
|
||||
compactLabel: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -59,7 +62,7 @@ defineExpose({
|
||||
<div class="password-field">
|
||||
<label
|
||||
:for="id"
|
||||
class="password-field__label"
|
||||
:class="['password-field__label', { 'password-field__label--compact': compactLabel }]"
|
||||
>{{ label }}</label>
|
||||
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
|
||||
<input
|
||||
@@ -127,6 +130,11 @@ defineExpose({
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.password-field__label--compact {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 상자 안에 칸과 단추 — 상자가 테두리 · 초점을 그린다 (base.css .form-input). 칸 · 단추가 상자 높이(44)를 채운다 */
|
||||
.password-field__box {
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
|
||||
// 제목 바로 옆(같은 줄 · 글자 아래 선)에 작은 글을 둘 때는 title-aside 슬롯 (디자인 원본 SettingsSendRead 의 "3 / 500")
|
||||
// 설명 글 안에 링크를 넣을 때는 description 슬롯 — 주면 description 글 대신 그린다 (디자인 원본 SettingsLoginHistory 의 「비밀번호를 바꾸고」 · 「2단계 인증」)
|
||||
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
@@ -33,7 +34,9 @@ defineProps<Props>()
|
||||
<slot name="title-aside" />
|
||||
</div>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
// 2단계 인증 끄기 창 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 같다 (디자인 원본 SettingsTwoFactor "끄기 창" · 계약 5절 "2단계 인증")
|
||||
// "2단계 인증을 끌까요?" / "끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다." + 지금 비밀번호 · 인증 앱 코드 → [끄기](빨강) [취소].
|
||||
// 두 칸에서 Enter 도 [끄기]와 같다
|
||||
// - 틀리면 켜기 창과 같은 칸 아래 글(알림 없이): 지금 비밀번호 AUTH_005 · 코드 AUTH_006(칸을 비운다). 서버가 비밀번호를 먼저 본다 — 둘 다 비었으면 비밀번호부터
|
||||
// - 끄기 요청은 이 창이 보낸다 — 지금 비밀번호 · 코드가 스토어 액션의 인자로 개발 도구(Pinia 타임라인)에 남지 않게. 스토어에는 비밀 없는 결과만(apply)
|
||||
// - 끄는 동안 [끄기]는 aria-disabled · aria-busy(초점을 지킨다). 몇 번째로 연 창인가(openSeq)로 닫았다 다시 연 창에 앞 요청의 거절을 넣지 않는다
|
||||
// - 세션이 바뀌면(로그아웃 · 만료 · 다른 기기의 [다른 곳 모두 로그아웃]) 두 칸을 비우고 닫는다. 닫을 때도 두 칸을 비운다
|
||||
// 고정 id — #two-factor-disable-title · #two-factor-disable-message · #two-factor-disable-password · #two-factor-disable-code
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
|
||||
import PasswordField from '@/components/settings/PasswordField.vue'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { TWO_FACTOR_DISABLE_TEXT } from '@/shared/constants/account-security'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { isCompleteOtp } from '@/shared/utils/account-security'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
/** 껐다 — 상태는 스토어에 넣었다 */
|
||||
(e: 'disabled'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 칸 아래에 보이는 서버 거절 — 알림을 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'AUTH_006']
|
||||
|
||||
const api = useAccountSecurity()
|
||||
const authStore = useAuthStore()
|
||||
const twoFactorStore = useTwoFactorStore()
|
||||
|
||||
const passwordRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
|
||||
|
||||
const password = ref<string>('')
|
||||
const code = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const passwordError = ref<string | null>(null)
|
||||
const codeError = ref<string | null>(null)
|
||||
const submitting = ref<boolean>(false)
|
||||
/** 창을 연 차례 — 열 때 · 세션이 바뀔 때 오른다. 보낼 때 잡아 두고 응답이 오면 견준다 */
|
||||
let openSeq = 0
|
||||
let unmounted = false
|
||||
|
||||
function resetForm() {
|
||||
password.value = ''
|
||||
code.value = ''
|
||||
showPassword.value = false
|
||||
passwordError.value = null
|
||||
codeError.value = null
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
function setPassword(value: string) {
|
||||
password.value = value
|
||||
passwordError.value = null
|
||||
}
|
||||
|
||||
function setCode(value: string) {
|
||||
code.value = value
|
||||
codeError.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const passwordProblem = password.value === '' ? getErrorMessage('VAL_001') : null
|
||||
const codeProblem = isCompleteOtp(code.value) ? null : getErrorMessage('AUTH_007')
|
||||
passwordError.value = passwordProblem
|
||||
codeError.value = codeProblem
|
||||
if (passwordProblem) {
|
||||
passwordRef.value?.focus()
|
||||
return
|
||||
}
|
||||
if (codeProblem) {
|
||||
codeRef.value?.focus()
|
||||
return
|
||||
}
|
||||
const opened = openSeq
|
||||
const session = authStore.generation
|
||||
submitting.value = true
|
||||
try {
|
||||
const status = await api.deactivateTwoFactor(
|
||||
{ currentPassword: password.value, otpCode: code.value },
|
||||
{ silent: INLINE_CODES },
|
||||
)
|
||||
if (authStore.generation !== session) return
|
||||
// 서버가 껐다 — 창을 닫았거나 다시 열었어도 화면의 상태는 바꾼다
|
||||
twoFactorStore.apply(status)
|
||||
if (unmounted) return
|
||||
if (opened === openSeq) resetForm()
|
||||
emit('disabled')
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
if (authStore.generation !== session) return
|
||||
if (unmounted || !props.open || opened !== openSeq) {
|
||||
// 보낸 창이 없다 — 닫힌 채면 칸 아래 글 대신 알림으로, 다시 연 창에는 앞 요청의 거절을 넣지 않는다
|
||||
if (opened === openSeq && INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'AUTH_005') {
|
||||
passwordError.value = apiError.message
|
||||
passwordRef.value?.focus()
|
||||
} else if (apiError.code === 'AUTH_006') {
|
||||
codeError.value = apiError.message
|
||||
code.value = ''
|
||||
codeRef.value?.focus()
|
||||
}
|
||||
// 그 밖의 실패(시도 과다 REQ_001 · 메일 서버 장애 등)는 인터셉터가 알렸다
|
||||
} finally {
|
||||
if (opened === openSeq) submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호 · 코드 칸에서 Enter — [끄기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다) */
|
||||
function onFieldKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
|
||||
// 열 때 · 닫을 때 두 칸을 비운다 — 열 때마다 차례를 올린다
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) openSeq += 1
|
||||
resetForm()
|
||||
},
|
||||
)
|
||||
|
||||
// 세션이 바뀌면 두 칸을 비우고 닫는다 — 앞 요청의 결과도 버린다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
openSeq += 1
|
||||
resetForm()
|
||||
if (props.open) emit('close')
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
resetForm()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
medium
|
||||
:title="TWO_FACTOR_DISABLE_TEXT.title"
|
||||
:message="TWO_FACTOR_DISABLE_TEXT.message"
|
||||
title-id="two-factor-disable-title"
|
||||
message-id="two-factor-disable-message"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="disable__fields"
|
||||
@keydown="onFieldKeydown"
|
||||
>
|
||||
<PasswordField
|
||||
id="two-factor-disable-password"
|
||||
ref="passwordRef"
|
||||
:label="TWO_FACTOR_DISABLE_TEXT.passwordLabel"
|
||||
:model-value="password"
|
||||
autocomplete="current-password"
|
||||
:shown="showPassword"
|
||||
:toggle-label="TWO_FACTOR_DISABLE_TEXT.showPassword"
|
||||
:error="passwordError"
|
||||
compact-label
|
||||
@update:model-value="setPassword"
|
||||
@toggle="showPassword = !showPassword"
|
||||
/>
|
||||
<OtpCodeField
|
||||
id="two-factor-disable-code"
|
||||
ref="codeRef"
|
||||
:label="TWO_FACTOR_DISABLE_TEXT.codeLabel"
|
||||
:model-value="code"
|
||||
:error="codeError"
|
||||
:readonly="submitting"
|
||||
compact-label
|
||||
@update:model-value="setCode"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:aria-disabled="submitting ? 'true' : undefined"
|
||||
:aria-busy="submitting ? 'true' : undefined"
|
||||
data-disable-submit
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitting ? TWO_FACTOR_DISABLE_TEXT.submitting : TWO_FACTOR_DISABLE_TEXT.submit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
data-disable-cancel
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ TWO_FACTOR_DISABLE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.disable__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
// 2단계 인증 켜기 창 — 모달 620(wide · 필터 고치기 창과 같은 폭) · 처음 초점은 코드 칸 (디자인 원본 SettingsTwoFactor "켜기 창" · 계약 5절 "2단계 인증")
|
||||
// ① QR · 키 · [복사](TwoFactorEnrollmentScan) ② 앱에 표시된 6자리 코드 ③ 지금 비밀번호 → [켜기] [취소]. 두 칸에서 Enter 도 [켜기]와 같다
|
||||
// - 키는 창을 열 때마다 새로 만든다(POST /two-factor/enrollments — 10분 안에 켠다). 키 · 주소는 이 창의 화면 메모리에만 두고 닫으면 버린다
|
||||
// - 칸 아래 오류(알림 없이 · 첫 잘못된 칸에 초점): 코드 AUTH_006(칸을 비운다) · 지금 비밀번호 AUTH_005. 둘 다 틀렸으면 코드부터 — 서버가 코드를 먼저 본다.
|
||||
// QR · 키는 그대로라 다시 찍지 않아도 된다
|
||||
// - AUTH_008(등록이 없음 · 10분 지남 · 더 새 등록 · 그사이 다른 곳에서 켜짐) — 처음부터 다시: 상태를 다시 받아 켜졌으면 창을 닫고 알리고,
|
||||
// 아니면 창 위에 사전 문구를 보이고 새 키로 다시 그린다(코드는 비운다)
|
||||
// - 켜기 요청은 이 창이 보낸다 — 지금 비밀번호 · 코드가 스토어 액션의 인자로 개발 도구(Pinia 타임라인)에 남지 않게. 스토어에는 비밀 없는 결과만(apply)
|
||||
// - 켜는 동안 「켜는 중…」 · [켜기]는 aria-disabled(초점을 지킨다) · [취소]는 누를 수 있다(그래도 서버가 켰으면 화면은 「켜짐」이 된다)
|
||||
// - 몇 번째로 연 창인가(openSeq)를 보낼 때 잡아 둔다 — 닫았다 다시 연 창에 앞 요청의 거절 · 처음부터 다시를 넣지 않고, 다시 열면 「켜는 중」도 처음으로
|
||||
// - 세션이 바뀌면(로그아웃 · 만료 · 다른 기기의 [다른 곳 모두 로그아웃]) 키 · 칸을 지우고 닫는다 — 세션 만료 창 뒤에 QR · 키 · 비밀번호가 남지 않게
|
||||
// 고정 id — #two-factor-enable-title · #two-factor-enable-code · #two-factor-enable-password
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
|
||||
import PasswordField from '@/components/settings/PasswordField.vue'
|
||||
import TwoFactorEnrollmentScan from '@/components/settings/TwoFactorEnrollmentScan.vue'
|
||||
import { useAccountSecurity } from '@/composables/useAccountSecurity'
|
||||
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { TWO_FACTOR_ENABLE_TEXT } from '@/shared/constants/account-security'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
|
||||
import { isCompleteOtp } from '@/shared/utils/account-security'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useTwoFactorStore } from '@/stores/two-factor.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 직원 주소 — "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." */
|
||||
email: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
/** 켰다 — 상태는 스토어에 넣었다 */
|
||||
(e: 'enabled'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 칸 아래 · 창 위에 보이는 서버 거절 — 알림을 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'AUTH_006', 'AUTH_008']
|
||||
|
||||
const api = useAccountSecurity()
|
||||
const authStore = useAuthStore()
|
||||
const twoFactorStore = useTwoFactorStore()
|
||||
|
||||
const codeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
|
||||
const passwordRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
|
||||
/** 등록 — 비밀이 들어 있다. 반응형 깊이 없이 둔다 */
|
||||
const enrollment = shallowRef<TwoFactorEnrollment | null>(null)
|
||||
const enrollStatus = ref<'loading' | 'ready' | 'error'>('loading')
|
||||
const enrollError = ref<ApiError | null>(null)
|
||||
/** 창 위 안내 — 처음부터 다시(AUTH_008) */
|
||||
const notice = ref<string | null>(null)
|
||||
const code = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const codeError = ref<string | null>(null)
|
||||
const passwordError = ref<string | null>(null)
|
||||
const submitting = ref<boolean>(false)
|
||||
|
||||
/** 창을 연 차례 — 열 때 · 세션이 바뀔 때 오른다. 보낼 때 잡아 두고 응답이 오면 견준다 */
|
||||
let openSeq = 0
|
||||
/** 등록을 받는 차례 — 창을 닫거나 다시 받으면 앞의 응답은 버린다 */
|
||||
let enrollSeq = 0
|
||||
let unmounted = false
|
||||
|
||||
/** [켜기]를 누를 수 없다 — 키가 아직 없거나 켜는 중 */
|
||||
const unavailable = computed(() => enrollStatus.value !== 'ready' || submitting.value)
|
||||
|
||||
function clearSecrets() {
|
||||
enrollSeq += 1
|
||||
enrollment.value = null
|
||||
code.value = ''
|
||||
password.value = ''
|
||||
showPassword.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
clearSecrets()
|
||||
enrollStatus.value = 'loading'
|
||||
enrollError.value = null
|
||||
notice.value = null
|
||||
codeError.value = null
|
||||
passwordError.value = null
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
/** 보낸 그 창이 아직 열려 있고 세션도 그대로인가 — 늦게 온 응답을 이 창에 쓸지 */
|
||||
function stillHere(session: number, opened: number): boolean {
|
||||
return !unmounted && props.open && opened === openSeq && authStore.generation === session
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증이 그사이 다른 곳에서 켜졌는지 상태를 다시 받아 본다 — 켜졌으면(true) 보낸 창이 아직 있을 때만 닫고 사전 문구로 알린다.
|
||||
* 상태를 못 받았거나 아직 꺼져 있으면 false (부르는 쪽이 새 키로 다시 한다)
|
||||
*/
|
||||
async function closeIfEnabledElsewhere(error: ApiError, session: number, opened: number): Promise<boolean> {
|
||||
const status = await twoFactorStore.refresh()
|
||||
if (!status?.isEnabled) return false
|
||||
if (stillHere(session, opened)) {
|
||||
notifyApiError(error)
|
||||
emit('close')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function startEnrollment() {
|
||||
enrollSeq += 1
|
||||
const current = enrollSeq
|
||||
const opened = openSeq
|
||||
const session = authStore.generation
|
||||
enrollment.value = null
|
||||
enrollStatus.value = 'loading'
|
||||
enrollError.value = null
|
||||
try {
|
||||
const result = await api.startTwoFactorEnrollment({ silent: true })
|
||||
if (current !== enrollSeq || !stillHere(session, opened)) return
|
||||
enrollment.value = result
|
||||
enrollStatus.value = 'ready'
|
||||
} catch (caught) {
|
||||
if (current !== enrollSeq || !stillHere(session, opened)) return
|
||||
const apiError = toApiError(caught)
|
||||
// 이미 켜져 있다 — 그사이 다른 곳에서 켰다
|
||||
if (apiError.code === 'AUTH_008' && (await closeIfEnabledElsewhere(apiError, session, opened))) return
|
||||
if (current !== enrollSeq || !stillHere(session, opened)) return
|
||||
enrollStatus.value = 'error'
|
||||
enrollError.value = apiError
|
||||
}
|
||||
}
|
||||
|
||||
/** 처음부터 다시(AUTH_008) — 켜졌으면 닫고, 아니면 창 위에 알리고 새 키로 다시 그린다 */
|
||||
async function restart(error: ApiError, session: number, opened: number) {
|
||||
if (await closeIfEnabledElsewhere(error, session, opened)) return
|
||||
if (!stillHere(session, opened)) return
|
||||
notice.value = error.message
|
||||
code.value = ''
|
||||
codeError.value = null
|
||||
passwordError.value = null
|
||||
await startEnrollment()
|
||||
await nextTick()
|
||||
if (stillHere(session, opened)) codeRef.value?.focus()
|
||||
}
|
||||
|
||||
function setCode(value: string) {
|
||||
code.value = value
|
||||
codeError.value = null
|
||||
}
|
||||
|
||||
function setPassword(value: string) {
|
||||
password.value = value
|
||||
passwordError.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const current = enrollment.value
|
||||
if (unavailable.value || !current) return
|
||||
const codeProblem = isCompleteOtp(code.value) ? null : getErrorMessage('AUTH_007')
|
||||
const passwordProblem = password.value === '' ? getErrorMessage('VAL_001') : null
|
||||
codeError.value = codeProblem
|
||||
passwordError.value = passwordProblem
|
||||
if (codeProblem) {
|
||||
codeRef.value?.focus()
|
||||
return
|
||||
}
|
||||
if (passwordProblem) {
|
||||
passwordRef.value?.focus()
|
||||
return
|
||||
}
|
||||
const opened = openSeq
|
||||
const session = authStore.generation
|
||||
submitting.value = true
|
||||
try {
|
||||
const status = await api.activateTwoFactor(
|
||||
{ enrollmentId: current.enrollmentId, otpCode: code.value, currentPassword: password.value },
|
||||
{ silent: INLINE_CODES },
|
||||
)
|
||||
if (authStore.generation !== session) return
|
||||
// 서버가 켰다 — 창을 닫았거나 다시 열었어도 화면의 상태는 바꾼다
|
||||
twoFactorStore.apply(status)
|
||||
if (unmounted) return
|
||||
if (opened === openSeq) clearSecrets()
|
||||
emit('enabled')
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
if (authStore.generation !== session) return
|
||||
if (!stillHere(session, opened)) {
|
||||
// 보낸 창이 없다 — 닫힌 채면 칸 아래 글 대신 알림으로(처음부터 다시는 다음에 열 때 새 키로 한다), 다시 연 창에는 넣지 않는다
|
||||
if (opened === openSeq && apiError.code !== 'AUTH_008' && INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'AUTH_006') {
|
||||
codeError.value = apiError.message
|
||||
code.value = ''
|
||||
codeRef.value?.focus()
|
||||
} else if (apiError.code === 'AUTH_005') {
|
||||
passwordError.value = apiError.message
|
||||
passwordRef.value?.focus()
|
||||
} else if (apiError.code === 'AUTH_008') {
|
||||
await restart(apiError, session, opened)
|
||||
}
|
||||
// 그 밖의 실패(시도 과다 REQ_001 · 메일 서버 장애 등)는 인터셉터가 알렸다
|
||||
} finally {
|
||||
if (opened === openSeq) submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 코드 · 비밀번호 칸에서 Enter — [켜기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다) */
|
||||
function onFieldKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
if (!(event.target instanceof HTMLInputElement)) return
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 열 때마다 새 키 — 닫으면 키 · 칸을 버린다
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) openSeq += 1
|
||||
resetForm()
|
||||
if (open) void startEnrollment()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 세션이 바뀌면 키 · 칸을 지우고 닫는다 — 앞 요청의 결과도 버린다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
openSeq += 1
|
||||
resetForm()
|
||||
if (props.open) emit('close')
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
clearSecrets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
wide
|
||||
:title="TWO_FACTOR_ENABLE_TEXT.title"
|
||||
title-id="two-factor-enable-title"
|
||||
@cancel="cancel"
|
||||
>
|
||||
<p
|
||||
v-if="notice"
|
||||
class="enable__notice"
|
||||
role="alert"
|
||||
data-enable-notice
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
class="enable__notice-icon"
|
||||
/>
|
||||
<span>{{ notice }}</span>
|
||||
</p>
|
||||
<ol
|
||||
class="enable__steps"
|
||||
@keydown="onFieldKeydown"
|
||||
>
|
||||
<li class="enable__step">
|
||||
<span
|
||||
class="enable__number"
|
||||
aria-hidden="true"
|
||||
>1</span>
|
||||
<div class="enable__step-body">
|
||||
<TwoFactorEnrollmentScan
|
||||
:enrollment="enrollment"
|
||||
:status="enrollStatus"
|
||||
:error="enrollError"
|
||||
:email="email"
|
||||
@retry="startEnrollment"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li class="enable__step">
|
||||
<span
|
||||
class="enable__number"
|
||||
aria-hidden="true"
|
||||
>2</span>
|
||||
<div class="enable__step-body">
|
||||
<OtpCodeField
|
||||
id="two-factor-enable-code"
|
||||
ref="codeRef"
|
||||
:label="TWO_FACTOR_ENABLE_TEXT.codeLabel"
|
||||
:model-value="code"
|
||||
:error="codeError"
|
||||
:readonly="submitting"
|
||||
autofocus
|
||||
@update:model-value="setCode"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li class="enable__step">
|
||||
<span
|
||||
class="enable__number"
|
||||
aria-hidden="true"
|
||||
>3</span>
|
||||
<div class="enable__step-body enable__step-body--password">
|
||||
<PasswordField
|
||||
id="two-factor-enable-password"
|
||||
ref="passwordRef"
|
||||
:label="TWO_FACTOR_ENABLE_TEXT.passwordLabel"
|
||||
:model-value="password"
|
||||
autocomplete="current-password"
|
||||
:shown="showPassword"
|
||||
:toggle-label="TWO_FACTOR_ENABLE_TEXT.showPassword"
|
||||
:error="passwordError"
|
||||
@update:model-value="setPassword"
|
||||
@toggle="showPassword = !showPassword"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
:aria-busy="submitting ? 'true' : undefined"
|
||||
data-enable-submit
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitting ? TWO_FACTOR_ENABLE_TEXT.submitting : TWO_FACTOR_ENABLE_TEXT.submit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-enable-cancel
|
||||
@click="cancel"
|
||||
>
|
||||
{{ TWO_FACTOR_ENABLE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 창 위 안내 — 처음부터 다시 (로그인 오류와 같은 빨간 상자) */
|
||||
.enable__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
margin: var(--space-4) 0 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.enable__notice-icon {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* 차례 1 · 2 · 3 — 청록 굵은 숫자(동그라미 없음) · 숫자 칸 22 · 사이 18 */
|
||||
.enable__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-18);
|
||||
margin: var(--space-8) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.enable__step {
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-22) minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.enable__number {
|
||||
font-weight: 700;
|
||||
color: var(--color-brand-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.enable__step-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 지금 비밀번호 칸 — 최대 320 */
|
||||
.enable__step-body--password {
|
||||
max-width: 20rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
// 2단계 인증 켜기 창의 ① — QR · 키 · [복사] (디자인 원본 SettingsTwoFactor "켜기 창" · 계약 5절 "2단계 인증")
|
||||
// "인증 앱으로 QR 코드를 찍어 주세요" + QR(화면이 otpauthUrl 로 그린다 — QrCodeImage) + "인증 앱에서 [+] → 「QR 코드 스캔」…" +
|
||||
// "찍을 수 없으면 이 키를 입력하세요" — 32자 키(4자씩 띄움 · 고정폭 · 띄어쓰기 없이 조각만 — 골라 복사해도 키 그대로) + [복사](36 — 밀도 예외) +
|
||||
// "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." + 켜지 않고 닫으면 키를 버린다는 안내
|
||||
// - 키를 받는 동안은 QR 자리에 회색 상자(aria-busy), 못 받으면 사전 문구 + [다시 시도](부모가 다시 받는다)
|
||||
// - 키는 부모(켜기 창)의 화면 메모리에서만 받는다 — 여기서도 남기지 않는다. [복사]는 키를 띄어쓰기 없이
|
||||
// - 키는 일부러 두 줄(한 줄에 4묶음)로 그리고, 줄은 묶음 사이에서만 바뀐다 — 묶음 · 줄은 줄바꿈 없는 inline-block 덩어리다.
|
||||
// block 으로 나누면 골라 복사할 때 줄바꿈이 끼므로 inline-block 으로 둔다(글자 사이에 띄어쓰기 · 줄바꿈 문자를 넣지 않는다 — 읽어 주는 글도 32자 그대로).
|
||||
// 좁은 폭에서는 줄 안에서도 묶음 사이에서만 줄이 바뀐다 (2026-10-31 브라우저 점검 — 620 창에서 마지막 묶음이 "EL / WN" 으로 갈렸다)
|
||||
// 고정 id — #two-factor-key-label
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import QrCodeImage from '@/components/QrCodeImage.vue'
|
||||
import type { ApiError } from '@/composables/useApi'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { TWO_FACTOR_ENABLE_TEXT, TWO_FACTOR_KEY_GROUPS_PER_LINE } from '@/shared/constants/account-security'
|
||||
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
|
||||
import { chunkItems, groupCharacters } from '@/shared/utils/account-security'
|
||||
import { copyText } from '@/shared/utils/clipboard'
|
||||
|
||||
interface Props {
|
||||
/** 등록 — 키 · otpauth 주소. 받는 중이거나 못 받았으면 null */
|
||||
enrollment: TwoFactorEnrollment | null
|
||||
status: 'loading' | 'ready' | 'error'
|
||||
/** 못 받은 까닭 — 사전 문구 */
|
||||
error: ApiError | null
|
||||
/** 직원 주소 — "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." */
|
||||
email: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'retry'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
/** 키의 줄 — 줄마다 4자 묶음 넷 */
|
||||
const keyLines = computed(() =>
|
||||
props.enrollment ? chunkItems(groupCharacters(props.enrollment.secret), TWO_FACTOR_KEY_GROUPS_PER_LINE) : [],
|
||||
)
|
||||
|
||||
async function copyKey() {
|
||||
const secret = props.enrollment?.secret
|
||||
if (!secret) return
|
||||
if (await copyText(secret)) toast.success(TWO_FACTOR_ENABLE_TEXT.copied)
|
||||
else toast.error(TWO_FACTOR_ENABLE_TEXT.copyFailed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scan">
|
||||
<span class="scan__title">{{ TWO_FACTOR_ENABLE_TEXT.scanTitle }}</span>
|
||||
<div
|
||||
v-if="status === 'error' && error"
|
||||
class="scan__load-error"
|
||||
role="alert"
|
||||
data-enable-error
|
||||
>
|
||||
<span>{{ error.message }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="scan__retry"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ TWO_FACTOR_ENABLE_TEXT.retry }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="scan__body"
|
||||
>
|
||||
<div
|
||||
class="scan__qr-frame"
|
||||
:aria-busy="status === 'loading' ? 'true' : undefined"
|
||||
>
|
||||
<QrCodeImage
|
||||
v-if="enrollment"
|
||||
:value="enrollment.otpauthUrl"
|
||||
:label="TWO_FACTOR_ENABLE_TEXT.qrLabel"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="scan__qr-placeholder"
|
||||
>
|
||||
<span class="visually-hidden">{{ TWO_FACTOR_ENABLE_TEXT.loading }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="scan__text">
|
||||
<p class="scan__hint">
|
||||
{{ TWO_FACTOR_ENABLE_TEXT.scanHint }}
|
||||
</p>
|
||||
<div class="scan__key">
|
||||
<span
|
||||
id="two-factor-key-label"
|
||||
class="scan__key-label"
|
||||
>{{ TWO_FACTOR_ENABLE_TEXT.keyLabel }}</span>
|
||||
<div class="scan__key-row">
|
||||
<code
|
||||
class="scan__key-value"
|
||||
data-enable-key
|
||||
>
|
||||
<span
|
||||
v-for="(line, lineIndex) in keyLines"
|
||||
:key="lineIndex"
|
||||
class="scan__key-line"
|
||||
>
|
||||
<span
|
||||
v-for="(group, index) in line"
|
||||
:key="index"
|
||||
class="scan__key-group"
|
||||
>{{ group }}</span>
|
||||
</span>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="scan__copy"
|
||||
:aria-label="TWO_FACTOR_ENABLE_TEXT.copyLabel"
|
||||
aria-describedby="two-factor-key-label"
|
||||
:aria-disabled="enrollment ? undefined : 'true'"
|
||||
data-enable-copy
|
||||
@click="copyKey"
|
||||
>
|
||||
<AppIcon
|
||||
name="copy"
|
||||
:size="14"
|
||||
/>
|
||||
<span>{{ TWO_FACTOR_ENABLE_TEXT.copy }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="scan__key-note">{{ TWO_FACTOR_ENABLE_TEXT.savedAs(email) }}</span>
|
||||
<span class="scan__key-note">{{ TWO_FACTOR_ENABLE_TEXT.discardNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.scan__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scan__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-16);
|
||||
}
|
||||
|
||||
/* QR 상자 — 흰 바탕 · 테두리 · 안쪽 6 */
|
||||
.scan__qr-frame {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-6);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.scan__qr-placeholder {
|
||||
display: block;
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.scan__text {
|
||||
flex: 1 1 15rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.scan__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.scan__key {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
padding-top: var(--space-10);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.scan__key-label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.scan__key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 키 — 고정폭 13px, 4자 조각 사이를 여백으로(띄어쓰기 없이 — 골라 복사해도 키 그대로). 누르면 한 번에 고른다 */
|
||||
.scan__key-value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: var(--space-6) var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-panel);
|
||||
font-family: Consolas, 'SFMono-Regular', Menlo, monospace;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/*
|
||||
* 한 줄 — 상자 폭을 다 차지해 두 줄(4묶음 + 4묶음)로 쌓인다. block 이 아니라 inline-block 이다 —
|
||||
* block 으로 나누면 골라 복사한 글에 줄바꿈이 낀다. 상자가 4묶음보다 좁으면 줄 안에서도 묶음 사이에서만 줄이 바뀐다
|
||||
*/
|
||||
.scan__key-line {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 4자 묶음 — 줄바꿈 없는 덩어리 (inline-block 앞뒤가 줄이 바뀔 수 있는 자리다 — 묶음 가운데서는 바뀌지 않는다) */
|
||||
.scan__key-group {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scan__key-group:not(:last-child) {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
/* [복사] 36 — 밀도 예외 (--size-notice-action, 차단 풀기와 같다) */
|
||||
.scan__copy {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scan__copy:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.scan__copy[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scan__key-note {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 키를 받지 못했을 때 — 사전 문구 + [다시 시도] */
|
||||
.scan__load-error {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-12);
|
||||
padding: var(--space-12) var(--space-14);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.scan__retry {
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scan__retry:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user