feat(auth): OTP 프론트 화면 배선

This commit is contained in:
GUARDiA 2026-07-04 08:28:09 +09:00
parent 1409760aa3
commit d8ea204514
13 changed files with 963 additions and 624 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fdfaf5" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@ -15,8 +16,8 @@
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-CbRMKyZ9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CS-pep58.css">
<script type="module" crossorigin src="/assets/index-Cc_He-EQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGX5ektI.css">
</head>
<body>
<div id="root"></div>

View File

@ -43,6 +43,7 @@ import Settings from './admin/Settings'
import AdminApp from './admin/AdminApp'
import AiTechniques from './admin/AiTechniques'
import AiPlatformSettings from './admin/AiPlatformSettings'
import MyPage from './admin/MyPage'
// UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향)
import UiwsLayout from './pages/uiws/UiwsLayout'
@ -96,6 +97,7 @@ export default function App() {
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="mypage" element={<MyPage />} />
<Route path="stores" element={<Stores />} />
<Route path="products" element={<Products />} />
<Route path="inventory" element={<Inventory />} />

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Outlet, Navigate, NavLink, useNavigate } from 'react-router-dom'
import { Outlet, Navigate, NavLink, Link, useNavigate } from 'react-router-dom'
import {
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
@ -122,7 +122,7 @@ export default function AdminLayout() {
<div className="text-sm text-slate-400 truncate">{t('admin.header')}</div>
<div className="flex items-center gap-4">
<LanguageSwitcher variant="admin" />
<span className="flex items-center gap-1.5 text-sm text-slate-300"><UserCircle size={18} /> {who || 'admin'} <span className="text-[10px] text-brand">{role}</span></span>
<Link to="/admin/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지 / My Page"><UserCircle size={18} /> {who || 'admin'} <span className="text-[10px] text-brand">{role}</span></Link>
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"><LogOut size={16} /> {t('admin.signOut')}</button>
</div>
</header>

View File

@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { login, getMe } from '../api/client'
import { verify2fa, authSignup, authFindId, authResetPassword } from '../api/uiws'
import { verify2fa, verifyOtp, authSignup, authFindId, authResetPassword } from '../api/uiws'
import LanguageSwitcher from '../i18n/LanguageSwitcher'
/** 로그인 보조 모달 종류(UIWS auth 패턴 이식). */
type HelperKind = 'signup' | 'findId' | 'resetPw'
/** 2차 인증 방식 — OTP: Authenticator 등록됨, OTP_SETUP: 최초 QR 등록, EMAIL: 이메일 코드(하위호환). */
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
/**
* /() .
@ -23,12 +25,18 @@ export default function AdminLogin() {
// 2FA 단계
const [step, setStep] = useState<'login' | 'verify'>('login')
const [verifyToken, setVerifyToken] = useState('')
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
const [maskedEmail, setMaskedEmail] = useState('')
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
const [code, setCode] = useState('')
// 로그인 보조 모달(UIWS auth 패턴 이식): 회원가입 / 아이디찾기 / 비밀번호 초기화
const [helper, setHelper] = useState<HelperKind | null>(null)
const nav = useNavigate()
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
useEffect(() => {
document.documentElement.classList.add('admin-shell')
return () => document.documentElement.classList.remove('admin-shell')
@ -50,9 +58,13 @@ export default function AdminLogin() {
const res = await login(username, password)
const data = res.data?.data || {}
if (data.twofa === 'true') {
// 운영 계정 2FA — 코드 입력 단계로 전환(코드는 메일/로그로만 발송, 화면 미표시)
// 운영 계정 2FA — verifyMethod 로 분기(OTP/OTP_SETUP: Authenticator, 그 외: 이메일 코드)
setVerifyToken(data.verifyToken || '')
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(data.maskedEmail || '')
setQrImage(data.qrImage || '')
setSecret(data.secret || '')
setCode('')
setStep('verify')
return
}
@ -67,9 +79,14 @@ export default function AdminLogin() {
const submitVerify = async (e: React.FormEvent) => {
e.preventDefault(); setErr('')
try {
const res = await verify2fa(verifyToken, code.trim())
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
const res = isOtp
? await verifyOtp(verifyToken, code.trim())
: await verify2fa(verifyToken, code.trim())
const token = res.data?.data?.token
if (!token) throw new Error('no token')
// 시크릿 잔존 방지
setQrImage(''); setSecret('')
await finishLogin(token)
} catch {
setErr('인증 코드가 올바르지 않거나 만료되었습니다. (Invalid or expired code)')
@ -111,15 +128,46 @@ export default function AdminLogin() {
<span className="text-xl font-bold">2 / 2FA</span>
</div>
<p className="text-center text-sm text-slate-400 mb-2"> 2 .</p>
{isOtp ? (
isSetup ? (
<>
<p className="text-center text-[12px] text-slate-500 mb-3">
. QR을 Authenticator (Google·Microsoft) 6 .
</p>
{qrImage && (
<div className="flex justify-center mb-3">
<img src={qrImage} alt="OTP QR" width={180} height={180}
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
</div>
)}
{secret && (
<div className="mb-4">
<div className="text-[11px] text-slate-500 mb-1">QR </div>
<code className="block text-xs text-accent bg-ink border border-edge rounded-md px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
</>
) : (
<p className="text-center text-[12px] text-slate-500 mb-6">
Authenticator 6 .
</p>
)
) : (
<p className="text-center text-[12px] text-slate-500 mb-6">
{maskedEmail || '등록된 이메일'} .
</p>
)}
<label className="block text-xs text-slate-400 mb-1"> (6)</label>
<input value={code} onChange={e => setCode(e.target.value)} inputMode="numeric" maxLength={6} autoFocus
<input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric" autoComplete="one-time-code" maxLength={6} autoFocus
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm tracking-[0.4em] text-center focus:border-brand outline-none" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90"> / Verify</button>
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr('') }}
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{isSetup ? '등록하고 로그인 / Register' : '확인 / Verify'}</button>
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr(''); setQrImage(''); setSecret('') }}
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-brand"> / Back</button>
</form>
)}

View File

@ -0,0 +1,255 @@
import { useEffect, useState } from 'react'
import {
ShieldCheck, QrCode, Lock, Eye, EyeOff, CheckCircle2, AlertTriangle,
} from 'lucide-react'
import {
getMe, otpSetup, otpConfirm, otpDisable, changePassword,
} from '../api/client'
/**
* OTP 2 (Authenticator //) + .
* (admin-shell). .
* 보안: otpSetup secret/qrImage / .
* / , 4(·8·· ).
*/
const MIN_PW = 8
type OtpPhase = 'idle' | 'setup' | 'done'
function errMsg(e: any, fallback: string): string {
if (e?.response?.status === 403) return '권한이 없습니다.'
return e?.response?.data?.message || fallback
}
export default function MyPage() {
const [username, setUsername] = useState('')
const [otpEnabled, setOtpEnabled] = useState<boolean | null>(null)
// ── OTP 상태머신 ──────────────────────────────────────────────────────
const [phase, setPhase] = useState<OtpPhase>('idle')
const [qrImage, setQrImage] = useState('')
const [secret, setSecret] = useState('')
const [otpCode, setOtpCode] = useState('')
const [otpBusy, setOtpBusy] = useState(false)
const [otpMsg, setOtpMsg] = useState<{ ok: boolean; text: string } | null>(null)
// ── 비밀번호 변경 ─────────────────────────────────────────────────────
const [curPw, setCurPw] = useState('')
const [newPw, setNewPw] = useState('')
const [newPw2, setNewPw2] = useState('')
const [showPw, setShowPw] = useState(false)
const [pwBusy, setPwBusy] = useState(false)
const [pwMsg, setPwMsg] = useState<{ ok: boolean; text: string } | null>(null)
const loadMe = () =>
getMe().then((me: any) => {
setUsername(me?.username || localStorage.getItem('mall_admin_user') || '')
// 백엔드가 상태를 내려주면 반영, 없으면 null(중립 표시)
if (typeof me?.otpEnabled === 'boolean') setOtpEnabled(me.otpEnabled)
else if (typeof me?.verifyMethod === 'string') setOtpEnabled(me.verifyMethod === 'OTP')
}).catch(() => {})
useEffect(() => { loadMe() }, [])
const startSetup = async () => {
setOtpMsg(null); setOtpBusy(true)
try {
const d = await otpSetup()
setQrImage(d?.qrImage || '')
setSecret(d?.secret || '')
setOtpCode('')
setPhase('setup')
} catch (e) {
setOtpMsg({ ok: false, text: errMsg(e, 'OTP 등록을 시작하지 못했습니다.') })
} finally { setOtpBusy(false) }
}
const confirmOtp = async () => {
setOtpMsg(null); setOtpBusy(true)
try {
await otpConfirm(otpCode)
// 시크릿 잔존 방지
setSecret(''); setQrImage(''); setOtpCode('')
setPhase('done'); setOtpEnabled(true)
setOtpMsg({ ok: true, text: '2차 인증이 활성화되었습니다.' })
} catch (e) {
setOtpMsg({ ok: false, text: errMsg(e, '코드가 일치하지 않거나 만료되었습니다.') })
} finally { setOtpBusy(false) }
}
const cancelSetup = () => {
setPhase('idle'); setSecret(''); setQrImage(''); setOtpCode(''); setOtpMsg(null)
}
const disableOtp = async () => {
if (!window.confirm('Authenticator 2차 인증을 해제하시겠습니까?')) return
setOtpMsg(null); setOtpBusy(true)
try {
await otpDisable()
setPhase('idle'); setSecret(''); setQrImage(''); setOtpEnabled(false)
setOtpMsg({ ok: true, text: '2차 인증이 해제되었습니다.' })
} catch (e) {
setOtpMsg({ ok: false, text: errMsg(e, 'OTP 해제에 실패했습니다.') })
} finally { setOtpBusy(false) }
}
const submitPw = async () => {
setPwMsg(null)
if (!curPw) { setPwMsg({ ok: false, text: '현재 비밀번호를 입력하세요.' }); return }
if (newPw.length < MIN_PW) { setPwMsg({ ok: false, text: `새 비밀번호는 최소 ${MIN_PW}자 이상이어야 합니다.` }); return }
if (newPw !== newPw2) { setPwMsg({ ok: false, text: '새 비밀번호가 일치하지 않습니다.' }); return }
if (newPw === curPw) { setPwMsg({ ok: false, text: '새 비밀번호는 현재 비밀번호와 달라야 합니다.' }); return }
setPwBusy(true)
try {
await changePassword(curPw, newPw)
setCurPw(''); setNewPw(''); setNewPw2('')
setPwMsg({ ok: true, text: '비밀번호가 변경되었습니다.' })
} catch (e) {
setPwMsg({ ok: false, text: errMsg(e, '비밀번호 변경에 실패했습니다.') })
} finally { setPwBusy(false) }
}
const inputCls =
'w-full px-3 py-2 rounded-lg bg-ink border border-edge text-sm focus:border-brand outline-none'
const codeCls = inputCls + ' tracking-[0.4em] text-center text-lg'
const StatusMsg = ({ m }: { m: { ok: boolean; text: string } | null }) =>
m ? (
<div className={`flex items-center gap-2 text-sm rounded-lg px-3 py-2 mb-3 border ${
m.ok
? 'bg-accent/10 border-accent/40 text-accent'
: 'bg-rose-500/10 border-rose-500/40 text-rose-300'
}`}>
{m.ok ? <CheckCircle2 size={16} /> : <AlertTriangle size={16} />}
{m.text}
</div>
) : null
return (
<div className="max-w-2xl">
<h1 className="text-xl font-bold mb-1"> / My Page</h1>
<p className="text-sm text-slate-400 mb-6">{username && <>: <span className="text-slate-200">{username}</span></>}</p>
{/* ── OTP 2차 인증 ──────────────────────────────────────────────── */}
<section className="bg-card border border-edge rounded-xl p-5 mb-6">
<div className="flex items-center gap-2 mb-1">
<ShieldCheck size={18} className="text-brand" />
<h2 className="font-semibold">2 Authenticator(OTP)</h2>
</div>
<p className="text-xs text-slate-400 mb-4">
Google·Microsoft Authenticator 6 2 .
{otpEnabled !== null && (
<span className="ml-2">
:{' '}
<span className={otpEnabled ? 'text-accent' : 'text-slate-300'}>
{otpEnabled ? 'OTP 사용 중' : '미설정'}
</span>
</span>
)}
</p>
<StatusMsg m={otpMsg} />
{phase === 'idle' && (
<div className="flex flex-wrap gap-2">
<button onClick={startSetup} disabled={otpBusy}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand text-ink text-sm font-semibold disabled:opacity-60">
<QrCode size={16} /> {otpBusy ? '발급 중…' : otpEnabled ? 'OTP 재설정 시작' : 'OTP 등록 시작'}
</button>
{otpEnabled && (
<button onClick={disableOtp} disabled={otpBusy}
className="px-3 py-2 rounded-lg border border-rose-500/50 text-rose-300 text-sm hover:bg-rose-500/10 disabled:opacity-60">
Authenticator
</button>
)}
</div>
)}
{phase === 'setup' && (
<div>
<ol className="list-decimal list-inside text-sm text-slate-300 leading-7 mb-3">
<li>Authenticator QR을 .</li>
<li> .</li>
<li> 6 .</li>
</ol>
{qrImage && (
<div className="flex justify-center mb-3">
<img src={qrImage} alt="OTP QR" width={200} height={200}
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
</div>
)}
{secret && (
<div className="mb-4">
<div className="text-[11px] text-slate-500 mb-1"> </div>
<code className="block text-xs text-accent bg-ink border border-edge rounded-md px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
<label className="block text-xs text-slate-400 mb-1"> 6 </label>
<input value={otpCode} onChange={e => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="000000"
className={codeCls + ' mb-3'} />
<div className="flex gap-2">
<button onClick={confirmOtp} disabled={otpBusy || otpCode.length !== 6}
className="px-4 py-2 rounded-lg bg-brand text-ink text-sm font-semibold disabled:opacity-60">
{otpBusy ? '확인 중…' : '코드 확인 · 활성화'}
</button>
<button onClick={cancelSetup} disabled={otpBusy}
className="px-4 py-2 rounded-lg border border-edge text-sm text-slate-300 hover:bg-card/60">
</button>
</div>
</div>
)}
{phase === 'done' && (
<div className="flex flex-wrap gap-2">
<button onClick={disableOtp} disabled={otpBusy}
className="px-3 py-2 rounded-lg border border-rose-500/50 text-rose-300 text-sm hover:bg-rose-500/10 disabled:opacity-60">
Authenticator
</button>
</div>
)}
</section>
{/* ── 비밀번호 변경 ─────────────────────────────────────────────── */}
<section className="bg-card border border-edge rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<Lock size={18} className="text-brand" />
<h2 className="font-semibold"> / Change Password</h2>
</div>
<StatusMsg m={pwMsg} />
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-400 mb-1"> </label>
<input type={showPw ? 'text' : 'password'} value={curPw} autoComplete="current-password"
onChange={e => setCurPw(e.target.value)} className={inputCls} />
</div>
<div>
<label className="block text-xs text-slate-400 mb-1"> ( {MIN_PW})</label>
<input type={showPw ? 'text' : 'password'} value={newPw} autoComplete="new-password"
onChange={e => setNewPw(e.target.value)} className={inputCls} />
</div>
<div>
<label className="block text-xs text-slate-400 mb-1"> </label>
<input type={showPw ? 'text' : 'password'} value={newPw2} autoComplete="new-password"
onChange={e => setNewPw2(e.target.value)} className={inputCls} />
</div>
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer select-none">
<button type="button" onClick={() => setShowPw(!showPw)} className="text-slate-400 hover:text-brand">
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</label>
<button onClick={submitPw} disabled={pwBusy}
className="px-4 py-2 rounded-lg bg-brand text-ink text-sm font-semibold disabled:opacity-60">
{pwBusy ? '변경 중…' : '비밀번호 변경'}
</button>
</div>
</section>
</div>
)
}

View File

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { UserCog, Plus } from 'lucide-react'
import { getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser } from '../api/client'
import { getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser, adminOtpReset } from '../api/client'
const ROLES = ['USER', 'MANAGER', 'ADMIN']
@ -17,6 +17,8 @@ export default function UserManagement() {
const toggleActive = async (id: number, active: boolean) => { await updateUserActive(id, !active).catch(() => {}); refresh() }
const reset = async (id: number) => { const pw = prompt('New password'); if (pw) { await resetPassword(id, pw).catch(() => {}); alert('Password updated') } }
const del = async (id: number) => { if (confirm('Are you sure you want to delete?')) { await deleteUser(id).catch(() => {}); refresh() } }
// OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
const otpReset = async (u: any) => { if (confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) { await adminOtpReset(u.id).catch(() => {}); alert('OTP를 초기화했습니다.'); refresh() } }
return (
<div>
@ -53,6 +55,7 @@ export default function UserManagement() {
<td className="text-center">
<div className="inline-flex gap-1">
<button onClick={() => reset(u.id)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300">Password</button>
<button onClick={() => otpReset(u)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300" title="OTP 초기화">OTP Reset</button>
<button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button>
</div>
</td>

View File

@ -36,6 +36,14 @@ export const register = (username: string, password: string, displayName: string
api.post('/api/mall/auth/register', { username, password, displayName })
export const getMe = () => u(api.get('/api/mall/auth/me'))
/* : OTP 2 (//) + (, access )
* 불변: otpSetup (secret/qrImage/otpAuthUri) / . */
export const otpSetup = () => u<{ secret: string; otpAuthUri: string; qrImage: string }>(api.post('/api/mall/auth/otp/setup', {}))
export const otpConfirm = (code: string) => u(api.post('/api/mall/auth/otp/confirm', { code }))
export const otpDisable = () => u(api.post('/api/mall/auth/otp/disable', {}))
export const changePassword = (currentPassword: string, newPassword: string) =>
u(api.post('/api/mall/auth/change-password', { currentPassword, newPassword }))
/* ───────────── 1. 매장 Store ───────────── */
export const getStores = (activeOnly = true) => u(api.get(`/api/mall/store?activeOnly=${activeOnly}`))
export const getStore = (id: number) => u(api.get(`/api/mall/store/${id}`))
@ -196,6 +204,8 @@ export const updateUserRole = (id: number, role: string) => u(api.put(`/api/admi
export const updateUserActive = (id: number, active: boolean) => u(api.put(`/api/admin/users/${id}/active`, { active }))
export const resetPassword = (id: number, password: string) => u(api.put(`/api/admin/users/${id}/password`, { password }))
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
export const adminOtpReset = (id: number) => u(api.post(`/api/admin/users/${id}/otp-reset`, {}))
export const getAuditLogs = (action = '', actor = '', limit = 100) => {
const p = new URLSearchParams(); if (action) p.set('action', action); if (actor) p.set('actor', actor); p.set('limit', String(limit))
return u(api.get(`/api/admin/audit?${p}`))

View File

@ -10,8 +10,12 @@ import api from './client'
*/
// ── 2FA (운영 로그인 2차 검증)
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL(step=EMAIL) 일 때 사용.
export const verify2fa = (verifyToken: string, code: string) =>
api.post('/api/mall/auth/verify', { verifyToken, code })
// verifyOtp: Authenticator(TOTP) 경로. verifyMethod=OTP | OTP_SETUP 일 때 사용.
export const verifyOtp = (verifyToken: string, code: string) =>
api.post('/api/mall/auth/verify-otp', { verifyToken, code })
// ── 로그인 보조 3종 (UIWS auth 패턴 이식, 관리자/운영 계정 대상, 무인증 접근)
// 응답 봉투 { success, message, data } — 호출부는 res.data.data 로 페이로드 접근.