feat(auth): OTP 프론트 화면 배선
This commit is contained in:
parent
1409760aa3
commit
d8ea204514
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
625
backend/src/main/resources/static/assets/index-Cc_He-EQ.js
Normal file
625
backend/src/main/resources/static/assets/index-Cc_He-EQ.js
Normal file
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 |
@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#fdfaf5" />
|
<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" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.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"
|
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"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script type="module" crossorigin src="/assets/index-CbRMKyZ9.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cc_He-EQ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CS-pep58.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DGX5ektI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import Settings from './admin/Settings'
|
|||||||
import AdminApp from './admin/AdminApp'
|
import AdminApp from './admin/AdminApp'
|
||||||
import AiTechniques from './admin/AiTechniques'
|
import AiTechniques from './admin/AiTechniques'
|
||||||
import AiPlatformSettings from './admin/AiPlatformSettings'
|
import AiPlatformSettings from './admin/AiPlatformSettings'
|
||||||
|
import MyPage from './admin/MyPage'
|
||||||
|
|
||||||
// UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향)
|
// UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향)
|
||||||
import UiwsLayout from './pages/uiws/UiwsLayout'
|
import UiwsLayout from './pages/uiws/UiwsLayout'
|
||||||
@ -96,6 +97,7 @@ export default function App() {
|
|||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="mypage" element={<MyPage />} />
|
||||||
<Route path="stores" element={<Stores />} />
|
<Route path="stores" element={<Stores />} />
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<Products />} />
|
||||||
<Route path="inventory" element={<Inventory />} />
|
<Route path="inventory" element={<Inventory />} />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 {
|
import {
|
||||||
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
|
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
|
||||||
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
|
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="text-sm text-slate-400 truncate">{t('admin.header')}</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<LanguageSwitcher variant="admin" />
|
<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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { login, getMe } from '../api/client'
|
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'
|
import LanguageSwitcher from '../i18n/LanguageSwitcher'
|
||||||
|
|
||||||
/** 로그인 보조 모달 종류(UIWS auth 패턴 이식). */
|
/** 로그인 보조 모달 종류(UIWS auth 패턴 이식). */
|
||||||
type HelperKind = 'signup' | 'findId' | 'resetPw'
|
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 단계
|
// 2FA 단계
|
||||||
const [step, setStep] = useState<'login' | 'verify'>('login')
|
const [step, setStep] = useState<'login' | 'verify'>('login')
|
||||||
const [verifyToken, setVerifyToken] = useState('')
|
const [verifyToken, setVerifyToken] = useState('')
|
||||||
|
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
|
||||||
const [maskedEmail, setMaskedEmail] = useState('')
|
const [maskedEmail, setMaskedEmail] = useState('')
|
||||||
|
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
|
||||||
|
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
// 로그인 보조 모달(UIWS auth 패턴 이식): 회원가입 / 아이디찾기 / 비밀번호 초기화
|
// 로그인 보조 모달(UIWS auth 패턴 이식): 회원가입 / 아이디찾기 / 비밀번호 초기화
|
||||||
const [helper, setHelper] = useState<HelperKind | null>(null)
|
const [helper, setHelper] = useState<HelperKind | null>(null)
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
|
|
||||||
|
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
|
||||||
|
const isSetup = verifyMethod === 'OTP_SETUP'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.add('admin-shell')
|
document.documentElement.classList.add('admin-shell')
|
||||||
return () => document.documentElement.classList.remove('admin-shell')
|
return () => document.documentElement.classList.remove('admin-shell')
|
||||||
@ -50,9 +58,13 @@ export default function AdminLogin() {
|
|||||||
const res = await login(username, password)
|
const res = await login(username, password)
|
||||||
const data = res.data?.data || {}
|
const data = res.data?.data || {}
|
||||||
if (data.twofa === 'true') {
|
if (data.twofa === 'true') {
|
||||||
// 운영 계정 2FA — 코드 입력 단계로 전환(코드는 메일/로그로만 발송, 화면 미표시)
|
// 운영 계정 2FA — verifyMethod 로 분기(OTP/OTP_SETUP: Authenticator, 그 외: 이메일 코드)
|
||||||
setVerifyToken(data.verifyToken || '')
|
setVerifyToken(data.verifyToken || '')
|
||||||
|
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
|
||||||
setMaskedEmail(data.maskedEmail || '')
|
setMaskedEmail(data.maskedEmail || '')
|
||||||
|
setQrImage(data.qrImage || '')
|
||||||
|
setSecret(data.secret || '')
|
||||||
|
setCode('')
|
||||||
setStep('verify')
|
setStep('verify')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -67,9 +79,14 @@ export default function AdminLogin() {
|
|||||||
const submitVerify = async (e: React.FormEvent) => {
|
const submitVerify = async (e: React.FormEvent) => {
|
||||||
e.preventDefault(); setErr('')
|
e.preventDefault(); setErr('')
|
||||||
try {
|
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
|
const token = res.data?.data?.token
|
||||||
if (!token) throw new Error('no token')
|
if (!token) throw new Error('no token')
|
||||||
|
// 시크릿 잔존 방지
|
||||||
|
setQrImage(''); setSecret('')
|
||||||
await finishLogin(token)
|
await finishLogin(token)
|
||||||
} catch {
|
} catch {
|
||||||
setErr('인증 코드가 올바르지 않거나 만료되었습니다. (Invalid or expired code)')
|
setErr('인증 코드가 올바르지 않거나 만료되었습니다. (Invalid or expired code)')
|
||||||
@ -111,15 +128,46 @@ export default function AdminLogin() {
|
|||||||
<span className="text-xl font-bold">2차 인증 / 2FA</span>
|
<span className="text-xl font-bold">2차 인증 / 2FA</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-slate-400 mb-2">운영 계정 보안을 위한 2차 인증입니다.</p>
|
<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">
|
<p className="text-center text-[12px] text-slate-500 mb-6">
|
||||||
인증 코드를 {maskedEmail || '등록된 이메일'} 로 보냈습니다.
|
인증 코드를 {maskedEmail || '등록된 이메일'} 로 보냈습니다.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="block text-xs text-slate-400 mb-1">인증 코드 (6자리)</label>
|
<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" />
|
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>}
|
{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 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('') }}
|
<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>
|
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-brand">← 돌아가기 / Back</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
255
frontend/src/admin/MyPage.tsx
Normal file
255
frontend/src/admin/MyPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { UserCog, Plus } from 'lucide-react'
|
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']
|
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 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 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() } }
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -53,6 +55,7 @@ export default function UserManagement() {
|
|||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<div className="inline-flex gap-1">
|
<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={() => 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>
|
<button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -36,6 +36,14 @@ export const register = (username: string, password: string, displayName: string
|
|||||||
api.post('/api/mall/auth/register', { username, password, displayName })
|
api.post('/api/mall/auth/register', { username, password, displayName })
|
||||||
export const getMe = () => u(api.get('/api/mall/auth/me'))
|
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 ───────────── */
|
/* ───────────── 1. 매장 Store ───────────── */
|
||||||
export const getStores = (activeOnly = true) => u(api.get(`/api/mall/store?activeOnly=${activeOnly}`))
|
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}`))
|
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 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 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}`)
|
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) => {
|
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))
|
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}`))
|
return u(api.get(`/api/admin/audit?${p}`))
|
||||||
|
|||||||
@ -10,8 +10,12 @@ import api from './client'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// ── 2FA (운영 로그인 2차 검증)
|
// ── 2FA (운영 로그인 2차 검증)
|
||||||
|
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL(step=EMAIL) 일 때 사용.
|
||||||
export const verify2fa = (verifyToken: string, code: string) =>
|
export const verify2fa = (verifyToken: string, code: string) =>
|
||||||
api.post('/api/mall/auth/verify', { verifyToken, code })
|
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 패턴 이식, 관리자/운영 계정 대상, 무인증 접근)
|
// ── 로그인 보조 3종 (UIWS auth 패턴 이식, 관리자/운영 계정 대상, 무인증 접근)
|
||||||
// 응답 봉투 { success, message, data } — 호출부는 res.data.data 로 페이로드 접근.
|
// 응답 봉투 { success, message, data } — 호출부는 res.data.data 로 페이로드 접근.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user