# Guide #65 — Partner UI OTP 연동 + 로그인 이력 제거

**작성일**: 2026-03-26 (갱신: 2026-03-26)
**대상**: partner-ui (Vue 3)
**상태**: 갱신 — 실제 코드 기준으로 전면 재작성

---

## 1. 현황

### 1-1. 백엔드 — 완성

`@RequiresOtp` 적용된 **8개 엔드포인트**:

| # | 엔드포인트 | 설명 |
|---|-----------|------|
| 1 | `PUT /api/partner/settings/exchange-rate` | 환율 정책 수정 |
| 2 | `PUT /api/partner/settings/fee-rate` | 수수료율 변경 |
| 3 | `POST /api/partner/integrations/api-key/regenerate` | API 키 재발급 |
| 4 | `POST /api/partner/withdrawals` | 출금 요청 |
| 5 | `POST /api/partner/withdrawals/{id}/approve` | 출금 승인 |
| 6 | `POST /api/partner/subpartners` | 하위 파트너 등록 |
| 7 | `DELETE /api/partner/subpartners/{id}` | 하위 파트너 삭제 |
| 8 | `POST /api/partner/account/password` | 비밀번호 변경 |

**동작**: 2FA 미활성화 → 스킵(통과) / 2FA 활성화 + 헤더 없음 → `OTP_REQUIRED` / 2FA 활성화 + 유효 OTP → 통과

### 1-2. 프론트엔드 인프라 — 완성

| 파일 | 상태 |
|------|------|
| `composables/useOtpVerification.ts` | ✅ `withOtp()` 래퍼 |
| `components/common/OtpVerificationModal.vue` | ✅ 6자리 슬라이드 드로어 |
| `api/client.ts` | ✅ 401-OTP 에러 감지 (토큰 삭제 방지) |

### 1-3. 문제 — 뷰 미적용

8개 뷰 모두 `api.put()` / service 메서드 직접 호출 → OTP 에러 시 빨간 에러 텍스트만 표시.
`withOtp()` 래핑 + `OtpVerificationModal` 배치가 필요.

---

## 2. Part A — api 헬퍼에 OTP 지원 추가

### 2-1. 문제

현재 `api` 객체의 `put/post/delete`는 커스텀 헤더를 전달할 수 없음:

```ts
// 현재 — headers 옵션 없음
put: <T>(url: string, data?: unknown, opts?: { params?: RequestParams }) =>
  apiClient.put<T>(url, data, { params: buildParams(opts?.params) }).then((r) => r.data),
```

### 2-2. 해결 — headers 옵션 추가

**파일**: `src/api/client.ts`

`api` 객체의 `post`, `put`, `delete` 에 `headers` 옵션을 추가:

```ts
export const api = {
  get: <T>(url: string, params?: RequestParams) =>
    apiClient.get<T>(url, { params: buildParams(params) }).then((r) => r.data),

  post: <T>(url: string, data?: unknown, opts?: { params?: RequestParams; headers?: Record<string, string> }) =>
    apiClient.post<T>(url, data, {
      params: buildParams(opts?.params),
      headers: opts?.headers,
    }).then((r) => r.data),

  put: <T>(url: string, data?: unknown, opts?: { params?: RequestParams; headers?: Record<string, string> }) =>
    apiClient.put<T>(url, data, {
      params: buildParams(opts?.params),
      headers: opts?.headers,
    }).then((r) => r.data),

  patch: <T>(url: string, data?: unknown, opts?: { params?: RequestParams; headers?: Record<string, string> }) =>
    apiClient.patch<T>(url, data, {
      params: buildParams(opts?.params),
      headers: opts?.headers,
    }).then((r) => r.data),

  delete: <T>(url: string, opts?: { headers?: Record<string, string> }) =>
    apiClient.delete<T>(url, {
      headers: opts?.headers,
    }).then((r) => r.data),
}
```

### 2-3. OTP 헤더 빌드 유틸 (선택)

매번 삼항 연산이 반복되므로, 유틸 함수 추가:

```ts
/** OTP 코드가 있으면 X-OTP-Code 헤더 객체 반환 */
export function otpHeaders(otpCode?: string): Record<string, string> | undefined {
  return otpCode ? { 'X-OTP-Code': otpCode } : undefined
}
```

사용 패턴:

```ts
import { api, otpHeaders } from '@/api/client'

await withOtp((otpCode) =>
  api.put('/api/partner/settings/fee-rate', payload, { headers: otpHeaders(otpCode) })
)
```

---

## 3. withOtp() 흐름 요약

```
save() 호출
  ↓
withOtp(apiCall)
  ↓ 첫 시도: apiCall() — otpCode = undefined → 헤더 없이 요청
  ├─ 2FA 비활성화 → 서버 통과 → 성공 ✓
  └─ 2FA 활성화 → 서버 OTP_REQUIRED 에러
       ↓
     OtpVerificationModal 자동 표시
       ↓
     유저 6자리 입력 → onOtpSubmit(code)
       ↓
     apiCall(code) 재시도 → X-OTP-Code 헤더 포함
       ├─ 성공 → 모달 닫기 → resolve
       └─ 코드 틀림 → otpError 표시 (재입력 가능)
```

---

## 4. Part B — 뷰별 OTP 연동 (8개)

모든 뷰에 공통으로 추가할 것:

```ts
// script setup 상단
import { useOtpVerification } from '@/composables/useOtpVerification'
import OtpVerificationModal from '@/components/common/OtpVerificationModal.vue'
import { api, otpHeaders } from '@/api/client'

const { showOtpModal, otpError, withOtp, onOtpSubmit, onOtpCancel } = useOtpVerification()
```

```html
<!-- template 최하단 (닫는 태그 직전) -->
<OtpVerificationModal
  :open="showOtpModal"
  :error="otpError"
  @submit="onOtpSubmit"
  @cancel="onOtpCancel"
/>
```

---

### B-1. FeeRateView.vue (수수료율)

**파일**: `src/views/partner/settings/FeeRateView.vue`

**현재 코드** — `save()`:

```ts
async function save() {
  error.value = ''; message.value = ''; saving.value = true
  try {
    await api.put('/api/partner/settings/fee-rate', {
      depositFeeRate: Number(depositFeeRate.value) / 100,
    })
    message.value = '수수료율이 저장되었습니다.'
  } catch (e) { error.value = (e as Error).message } finally { saving.value = false }
}
```

**변경 후**:

```ts
async function save() {
  error.value = ''; message.value = ''; saving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.put('/api/partner/settings/fee-rate', {
        depositFeeRate: Number(depositFeeRate.value) / 100,
      }, { headers: otpHeaders(otpCode) })
    )
    message.value = '수수료율이 저장되었습니다.'
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

**변경 요약**: (1) `api.put` 호출을 `withOtp()` 래핑 (2) `otpHeaders(otpCode)` 전달 (3) `'OTP 인증 취소'` 에러 무시

---

### B-2. ExchangeRateView.vue (환율 정책)

**파일**: `src/views/partner/settings/ExchangeRateView.vue`

**현재 코드** — `save()`:

```ts
async function save() {
  saving.value = true
  message.value = ''
  try {
    await api.put('/api/partner/settings/exchange-rate', {
      rateType: rateType.value,
      fixedRate: rateType.value === 'FIXED' ? Number(fixedRate.value) : null,
    })
    message.value = '환율 정책이 저장되었습니다.'
  } finally { saving.value = false }
}
```

**변경 후**:

```ts
const error = ref('')  // ← 추가 필요

async function save() {
  saving.value = true; message.value = ''; error.value = ''
  try {
    await withOtp((otpCode?: string) =>
      api.put('/api/partner/settings/exchange-rate', {
        rateType: rateType.value,
        fixedRate: rateType.value === 'FIXED' ? Number(fixedRate.value) : null,
      }, { headers: otpHeaders(otpCode) })
    )
    message.value = '환율 정책이 저장되었습니다.'
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

**template 추가**: 기존 `message` 표시 위에 에러 표시 추가:

```html
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<p v-if="message" class="text-sm text-green-600">{{ message }}</p>
```

---

### B-3. WithdrawalNewView.vue (출금 요청)

**파일**: `src/views/partner/withdrawals/WithdrawalNewView.vue`

**현재 코드** — `onSubmit()`:

```ts
async function onSubmit() {
  error.value = ''
  if (!networkCurrency.value.networkId || !networkCurrency.value.currencyId) {
    error.value = '네트워크와 통화를 선택하세요.'
    return
  }
  const addr = addressMode.value === 'whitelist'
    ? whitelist.value.find(w => w.id === selectedWhitelistId.value)?.address
    : toAddress.value
  if (!addr) { error.value = '수신 주소를 선택하거나 입력하세요.'; return }

  saving.value = true
  try {
    await withdrawalService.createWithdrawal({
      currencyId: networkCurrency.value.currencyId,
      networkId: networkCurrency.value.networkId,
      toAddress: addr,
      whitelistId: addressMode.value === 'whitelist' ? selectedWhitelistId.value : undefined,
      amount: Number(amount.value),
      partnerUserId: partnerUserId.value || undefined,
    })
    success.value = true
  } catch (e) { error.value = (e as Error).message } finally { saving.value = false }
}
```

**변경 후** — `withdrawalService` 대신 `api.post` 직접 사용:

```ts
async function onSubmit() {
  error.value = ''
  if (!networkCurrency.value.networkId || !networkCurrency.value.currencyId) {
    error.value = '네트워크와 통화를 선택하세요.'
    return
  }
  const addr = addressMode.value === 'whitelist'
    ? whitelist.value.find(w => w.id === selectedWhitelistId.value)?.address
    : toAddress.value
  if (!addr) { error.value = '수신 주소를 선택하거나 입력하세요.'; return }

  saving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.post('/api/partner/withdrawals', {
        currencyId: networkCurrency.value.currencyId,
        networkId: networkCurrency.value.networkId,
        toAddress: addr,
        whitelistId: addressMode.value === 'whitelist' ? selectedWhitelistId.value : undefined,
        amount: Number(amount.value),
        partnerUserId: partnerUserId.value || undefined,
      }, { headers: otpHeaders(otpCode) })
    )
    success.value = true
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

**import 변경**: `withdrawalService`의 `createWithdrawal` import 제거 가능 (whitelist 조회는 유지).

> **대안**: `withdrawalService.createWithdrawal()`에 `otpCode` 파라미터를 추가해도 됨.
> 그 경우 service 내부에서 `api.post(..., { headers: otpHeaders(otpCode) })` 처리.

---

### B-4. WithdrawalPolicyView.vue (출금 정책)

**파일**: `src/views/partner/withdrawals/WithdrawalPolicyView.vue`

> ⚠️ 현재 `PUT /api/partner/withdrawals/policy` 에는 `@RequiresOtp`가 없음.
> 출금 정책 변경도 보안 민감이므로 **백엔드에 `@RequiresOtp` 추가 권장**.
> 프론트 선적용 — 서버에 `@RequiresOtp` 없으면 OTP 모달이 뜨지 않고 바로 통과됨.

**현재 코드** — `save()`:

```ts
async function save() {
  error.value = ''; message.value = ''; saving.value = true
  try {
    await withdrawalService.updateWithdrawalPolicy({
      singleLimit: form.value.singleLimit ? Number(form.value.singleLimit) : null,
      dailyLimit: form.value.dailyLimit ? Number(form.value.dailyLimit) : null,
      autoApproveThreshold: form.value.autoApproveThreshold ? Number(form.value.autoApproveThreshold) : null,
      addressWhitelistEnabled: form.value.addressWhitelistEnabled,
    })
    message.value = '출금 정책이 저장되었습니다.'
    fetchPolicy()
  } catch (e) { error.value = (e as Error).message } finally { saving.value = false }
}
```

**변경 후**:

```ts
async function save() {
  error.value = ''; message.value = ''; saving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.put('/api/partner/settings/withdrawal-policy', {
        singleLimit: form.value.singleLimit ? Number(form.value.singleLimit) : null,
        dailyLimit: form.value.dailyLimit ? Number(form.value.dailyLimit) : null,
        autoApproveThreshold: form.value.autoApproveThreshold ? Number(form.value.autoApproveThreshold) : null,
        addressWhitelistEnabled: form.value.addressWhitelistEnabled,
      }, { headers: otpHeaders(otpCode) })
    )
    message.value = '출금 정책이 저장되었습니다.'
    fetchPolicy()
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

> **URL 확인 필요**: `withdrawalService.updateWithdrawalPolicy()`가 어떤 URL로 요청하는지 확인 후 맞출 것.

---

### B-5. MyAccountView.vue (비밀번호 변경)

**파일**: `src/views/partner/account/MyAccountView.vue`

**현재 코드** — `changePassword()`:

```ts
async function changePassword() {
  pwError.value = ''; pwMessage.value = ''
  if (newPassword.value !== newPasswordConfirm.value) { pwError.value = '새 비밀번호가 일치하지 않습니다.'; return }
  pwSaving.value = true
  try {
    const resp = await accountService.changePassword({
      currentPassword: currentPassword.value,
      newPassword: newPassword.value,
      newPasswordConfirm: newPasswordConfirm.value,
    })
    pwMessage.value = resp.message ?? '비밀번호가 변경되었습니다.'
    currentPassword.value = ''; newPassword.value = ''; newPasswordConfirm.value = ''
  } catch (e) { pwError.value = (e as Error).message } finally { pwSaving.value = false }
}
```

**변경 후**:

```ts
async function changePassword() {
  pwError.value = ''; pwMessage.value = ''
  if (newPassword.value !== newPasswordConfirm.value) { pwError.value = '새 비밀번호가 일치하지 않습니다.'; return }
  pwSaving.value = true
  try {
    const resp = await withOtp((otpCode?: string) =>
      api.post<{ message?: string }>('/api/partner/account/password', {
        currentPassword: currentPassword.value,
        newPassword: newPassword.value,
        newPasswordConfirm: newPasswordConfirm.value,
      }, { headers: otpHeaders(otpCode) })
    )
    pwMessage.value = resp?.message ?? '비밀번호가 변경되었습니다.'
    currentPassword.value = ''; newPassword.value = ''; newPasswordConfirm.value = ''
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      pwError.value = (e as Error).message
    }
  } finally { pwSaving.value = false }
}
```

---

### B-6. ApiKeyView.vue (API 키 재발급)

**파일**: `src/views/partner/settings/ApiKeyView.vue`

**현재 코드** — `regenerate()`:

```ts
async function regenerate() {
  regenerating.value = true
  try {
    const resp = await integrationService.regenerateApiKey()
    apiKey.value = resp
    newApiKey.value = resp.apiKey ?? null
    showConfirm.value = false
  } finally {
    regenerating.value = false
  }
}
```

**변경 후**:

```ts
const error = ref('')  // ← 추가

async function regenerate() {
  regenerating.value = true; error.value = ''
  try {
    const resp = await withOtp((otpCode?: string) =>
      api.post<ApiKeyResponse>('/api/partner/integrations/api-key/regenerate', null, {
        headers: otpHeaders(otpCode),
      })
    )
    apiKey.value = resp
    newApiKey.value = resp.apiKey ?? null
    showConfirm.value = false
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally {
    regenerating.value = false
  }
}
```

**template 추가**: 기존 "재발급 확인" 버튼 영역에 에러 표시:

```html
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
```

---

### B-7. 출금 승인 (WithdrawalDetailView 또는 해당 뷰)

출금 상세에서 승인 버튼이 있는 뷰를 찾아 적용.

**패턴**:

```ts
async function approve(withdrawalId: number) {
  try {
    await withOtp((otpCode?: string) =>
      api.post(`/api/partner/withdrawals/${withdrawalId}/approve`, null, {
        headers: otpHeaders(otpCode),
      })
    )
    // 성공: 목록 새로고침 등
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  }
}
```

> 출금 승인 뷰가 WithdrawalListView의 인라인 버튼인지, 별도 DetailView인지에 따라 배치 위치 조정.

---

### B-8. SubPartnerNewView.vue (하위 파트너 등록)

**파일**: `src/views/partner/subpartners/SubPartnerNewView.vue`

등록 submit 함수에 `withOtp()` 래핑:

```ts
async function onSubmit() {
  error.value = ''; saving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.post('/api/partner/subpartners', {
        // ... form data ...
      }, { headers: otpHeaders(otpCode) })
    )
    // 성공 처리
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

### B-9. SubPartnerDetailView.vue (하위 파트너 삭제)

삭제 함수에 `withOtp()` 래핑:

```ts
async function deletePartner(partnerId: number) {
  try {
    await withOtp((otpCode?: string) =>
      api.delete(`/api/partner/subpartners/${partnerId}`, {
        headers: otpHeaders(otpCode),
      })
    )
    // 성공: 목록으로 이동
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  }
}
```

---

## 5. Part C — 로그인 이력 탭 제거

### C-1. MyAccountView.vue

**① 타입 변경:**

```diff
- const activeTab = ref<'profile' | 'password' | '2fa' | 'history'>('profile')
+ const activeTab = ref<'profile' | 'password' | '2fa'>('profile')
```

**② tabs 배열에서 history 제거:**

```diff
  const tabs = [
    { key: 'profile' as const, label: '기본 정보' },
    { key: 'password' as const, label: '비밀번호 변경' },
    { key: '2fa' as const, label: '2FA 설정' },
-   { key: 'history' as const, label: '로그인 이력' },
  ]
```

**③ history 탭 template 제거:**

```diff
- <!-- 로그인 이력 -->
- <Card v-if="activeTab === 'history'">
-   <CardContent class="pt-6">
-     <div class="rounded border border-amber-500/50 bg-amber-500/10 p-4">
-       <p class="text-sm text-amber-700">로그인 이력 API가 아직 제공되지 않습니다.</p>
-     </div>
-   </CardContent>
- </Card>
```

### C-2. LoginHistoryView.vue (있으면 삭제)

파일이 존재하면 삭제: `src/views/partner/account/LoginHistoryView.vue`

### C-3. router

로그인 이력 관련 라우트가 있으면 제거.

---

## 6. 체크리스트

| # | 항목 | 파일 | 상태 |
|---|------|------|------|
| **Part A** | | | |
| 1 | `api.put/post/delete` headers 옵션 추가 | `api/client.ts` | ☐ |
| 2 | `otpHeaders()` 유틸 함수 추가 | `api/client.ts` | ☐ |
| **Part B** | | | |
| 3 | FeeRateView `save()` withOtp 래핑 | `settings/FeeRateView.vue` | ☐ |
| 4 | ExchangeRateView `save()` withOtp 래핑 + error ref 추가 | `settings/ExchangeRateView.vue` | ☐ |
| 5 | WithdrawalNewView `onSubmit()` withOtp 래핑 | `withdrawals/WithdrawalNewView.vue` | ☐ |
| 6 | WithdrawalPolicyView `save()` withOtp 래핑 | `withdrawals/WithdrawalPolicyView.vue` | ☐ |
| 7 | MyAccountView `changePassword()` withOtp 래핑 | `account/MyAccountView.vue` | ☐ |
| 8 | ApiKeyView `regenerate()` withOtp 래핑 + error ref 추가 | `settings/ApiKeyView.vue` | ☐ |
| 9 | 출금 승인 함수 withOtp 래핑 | 출금 상세 뷰 | ☐ |
| 10 | SubPartnerNewView 등록 withOtp 래핑 | `subpartners/SubPartnerNewView.vue` | ☐ |
| 11 | SubPartnerDetailView 삭제 withOtp 래핑 | `subpartners/SubPartnerDetailView.vue` | ☐ |
| **Part C** | | | |
| 12 | 로그인 이력 탭 + template 제거 | `account/MyAccountView.vue` | ☐ |
| 13 | LoginHistoryView.vue 삭제 (있으면) | `account/LoginHistoryView.vue` | ☐ |
| 14 | router 이력 라우트 정리 (있으면) | `router/index.ts` | ☐ |
| **테스트** | | | |
| 15 | 2FA ON: 수수료 저장 → OTP 모달 → 코드 입력 → 저장 성공 | 브라우저 | ☐ |
| 16 | 2FA OFF: 수수료 저장 → OTP 모달 안 뜸 → 바로 성공 | 브라우저 | ☐ |
| 17 | OTP 모달 취소 → 에러 메시지 미표시 확인 | 브라우저 | ☐ |
| 18 | 잘못된 OTP → 모달 내 에러 → 재입력 가능 확인 | 브라우저 | ☐ |
| 19 | 타입 체크 + 빌드 통과 | 터미널 | ☐ |

---

## 7. 핵심 요약

```
변경 3단계:

① client.ts — api.put/post/delete에 { headers } 옵션 추가 + otpHeaders() 유틸
② 8개 뷰 — save/submit 함수를 withOtp() 래핑 + OtpVerificationModal 배치
③ MyAccountView — 로그인 이력 탭 제거
```
