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

This commit is contained in:
GUARDiA 2026-07-04 08:23:28 +09:00
parent e2363abaa4
commit fc922f74a1
13 changed files with 966 additions and 610 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.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -3,9 +3,10 @@
<head> <head>
<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" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>GUARDiA CMS — AI 콘텐츠 관리</title> <title>GUARDiA CMS — AI 콘텐츠 관리</title>
<script type="module" crossorigin src="/assets/index-CVTAEWqv.js"></script> <script type="module" crossorigin src="/assets/index-lCwctxBX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CAmwXOHj.css"> <link rel="stylesheet" crossorigin href="/assets/index--ySdlt3w.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -25,6 +25,7 @@ import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings' import SystemSettings from './pages/SystemSettings'
import AiPlatformSettings from './pages/AiPlatformSettings' import AiPlatformSettings from './pages/AiPlatformSettings'
import MobileApp from './pages/MobileApp' import MobileApp from './pages/MobileApp'
import MyPage from './pages/MyPage'
// UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가 // UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가
import UiwsLayout from './components/uiws/UiwsLayout' import UiwsLayout from './components/uiws/UiwsLayout'
import WorklogList from './pages/uiws/WorklogList' import WorklogList from './pages/uiws/WorklogList'
@ -49,6 +50,7 @@ export default function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/mypage" element={<MyPage />} />
<Route path="/contents" element={<Contents />} /> <Route path="/contents" element={<Contents />} />
<Route path="/contents/new" element={<ContentEditor />} /> <Route path="/contents/new" element={<ContentEditor />} />
<Route path="/contents/:id" element={<ContentEditor />} /> <Route path="/contents/:id" element={<ContentEditor />} />

View File

@ -35,6 +35,15 @@ export const login = (username: string, password: string) =>
api.post('/api/cms/auth/login', { username, password }) api.post('/api/cms/auth/login', { username, password })
export const getMe = () => api.get('/api/cms/auth/me') export const getMe = () => api.get('/api/cms/auth/me')
// ── 2FA / OTP / 계정 보안 (access 토큰 필요, 마이페이지) ────────────────────
// OTP 등록/재설정/해제 + 비밀번호 변경. 응답은 { success, message, data } 봉투(raw).
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 절대 금지.
export const otpSetup = () => api.post('/api/cms/auth/otp/setup')
export const otpConfirm = (code: string) => api.post('/api/cms/auth/otp/confirm', { code })
export const otpDisable = () => api.post('/api/cms/auth/otp/disable')
export const changePassword = (currentPassword: string, newPassword: string) =>
api.post('/api/cms/auth/change-password', { currentPassword, newPassword })
// ── Dashboard ───────────────────────────────────────────────────────── // ── Dashboard ─────────────────────────────────────────────────────────
export const getDashboardSummary = () => unwrap(api.get('/api/cms/dashboard/summary')) export const getDashboardSummary = () => unwrap(api.get('/api/cms/dashboard/summary'))
export const getDashboardWorkflow = () => unwrap(api.get('/api/cms/dashboard/workflow')) export const getDashboardWorkflow = () => unwrap(api.get('/api/cms/dashboard/workflow'))
@ -212,6 +221,8 @@ export const updateUserRole = (id: number, role: string) => api.put(`/api/admin/
export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active }) export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active })
export const resetPassword = (id: number, password: string) => api.put(`/api/admin/users/${id}/password`, { password }) export const resetPassword = (id: number, password: string) => 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) => api.post(`/api/admin/users/${id}/otp-reset`)
export const getAuditLogs = (action = '', actor = '', limit = 100) => export const getAuditLogs = (action = '', actor = '', limit = 100) =>
api.get(`/api/admin/audit${qs({ action, actor, limit })}`) api.get(`/api/admin/audit${qs({ action, actor, limit })}`)

View File

@ -8,8 +8,12 @@ import api from './client'
*/ */
// ── 2FA (CMS auth prefix /api/cms/auth) // ── 2FA (CMS auth prefix /api/cms/auth)
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL 일 때 사용.
export const verify2fa = (verifyToken: string, code: string) => export const verify2fa = (verifyToken: string, code: string) =>
api.post('/api/cms/auth/verify', { verifyToken, code }) api.post('/api/cms/auth/verify', { verifyToken, code })
// verifyOtp: Authenticator(TOTP) 경로. verifyMethod=OTP | OTP_SETUP 일 때 사용.
export const verifyOtp = (verifyToken: string, code: string) =>
api.post('/api/cms/auth/verify-otp', { verifyToken, code })
// ── 로그인 보조 3종(UIWS auth 패턴 이식, prefix /api/auth — permitAll) // ── 로그인 보조 3종(UIWS auth 패턴 이식, prefix /api/auth — permitAll)
// 대상=운영자 계정(cms_user). 회원가입=승인대기, 아이디찾기=마스킹, 비번초기화=메일/로그(응답 무노출). // 대상=운영자 계정(cms_user). 회원가입=승인대기, 아이디찾기=마스킹, 비번초기화=메일/로그(응답 무노출).

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { LogOut, UserCircle } from 'lucide-react' import { LogOut, UserCircle } from 'lucide-react'
import { getAiStatus } from '../api/client' import { getAiStatus } from '../api/client'
@ -30,10 +30,10 @@ export default function Header() {
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} /> <span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'} AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
</span> </span>
<span className="flex items-center gap-1.5 text-sm text-slate-300"> <Link to="/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지">
<UserCircle size={18} /> {user} <UserCircle size={18} /> {user}
{role && <span className="text-[11px] text-slate-500">({role})</span>} {role && <span className="text-[11px] text-slate-500">({role})</span>}
</span> </Link>
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"> <button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand">
<LogOut size={16} /> <LogOut size={16} />
</button> </button>

View File

@ -1,28 +1,41 @@
import { useState } from 'react' import { useState } from 'react'
import { ShieldCheck } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { login, getMe } from '../api/client' import { login, getMe } from '../api/client'
import { verify2fa } from '../api/uiws' import { verify2fa, verifyOtp } from '../api/uiws'
import AuthHelperModals, { type AuthHelperKind } from './AuthHelperModals' import AuthHelperModals, { type AuthHelperKind } from './AuthHelperModals'
/** /**
* CMS UIWS 2FA . * CMS UIWS 2FA + Authenticator(OTP) .
* - 1: /api/cms/auth/login 2FA off { token } ( 0), * 1단계: 아이디/ /api/cms/auth/login.
* 2FA on { verifyToken, step, maskedEmail } 2 . * - twofa="false": { token } ( 0).
* - 2: /api/cms/auth/verify (verifyToken + code) access . * - twofa="true": verifyMethod 2 .
* / (· ). * · OTP : Authenticator 6 /verify-otp
* · OTP_SETUP : QR(qrImage)+(secret) 6 /verify-otp
* · EMAIL(): 6 /verify ()
* 2단계: verify-token + access getMe /dashboard.
* 보안: OTP secret/QR· / .
*/ */
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
export default function Login() { export default function Login() {
const [step, setStep] = useState<'login' | 'verify'>('login') const [step, setStep] = useState<'login' | 'verify'>('login')
const [username, setUsername] = useState('admin') const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
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 [err, setErr] = useState('') const [err, setErr] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [helper, setHelper] = useState<AuthHelperKind | null>(null) const [helper, setHelper] = useState<AuthHelperKind | null>(null)
const nav = useNavigate() const nav = useNavigate()
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
// 토큰 저장 + 역할 선조회 후 대시보드 진입(공통) // 토큰 저장 + 역할 선조회 후 대시보드 진입(공통)
const finishLogin = async (token: string) => { const finishLogin = async (token: string) => {
localStorage.setItem('cms_token', token) localStorage.setItem('cms_token', token)
@ -42,12 +55,14 @@ export default function Login() {
const res = await login(username, password) const res = await login(username, password)
const d = res.data?.data || {} const d = res.data?.data || {}
if (d.twofa === 'true') { if (d.twofa === 'true') {
// 2FA on — 2차 코드 입력 단계로
setVerifyToken(d.verifyToken || '') setVerifyToken(d.verifyToken || '')
setVerifyMethod((d.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(d.maskedEmail || '') setMaskedEmail(d.maskedEmail || '')
setQrImage(d.qrImage || '')
setSecret(d.secret || '')
setCode('')
setStep('verify') setStep('verify')
} else { } else {
// 2FA off(또는 미설정) — 기존 단일 로그인 흐름
const token = d.token const token = d.token
if (!token) throw new Error('no token') if (!token) throw new Error('no token')
await finishLogin(token) await finishLogin(token)
@ -63,7 +78,10 @@ export default function Login() {
e.preventDefault() e.preventDefault()
setErr(''); setBusy(true) setErr(''); setBusy(true)
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')
await finishLogin(token) await finishLogin(token)
@ -74,6 +92,11 @@ export default function Login() {
} }
} }
const backToLogin = () => {
setStep('login'); setCode(''); setErr('')
setQrImage(''); setSecret('') // 시크릿 잔존 방지
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-ink"> <div className="min-h-screen flex items-center justify-center bg-ink">
<form onSubmit={step === 'login' ? submitLogin : submitVerify} <form onSubmit={step === 'login' ? submitLogin : submitVerify}
@ -83,15 +106,15 @@ export default function Login() {
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} /> onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
<span className="text-xl font-bold">GUARDiA CMS</span> <span className="text-xl font-bold">GUARDiA CMS</span>
</div> </div>
<p className="text-center text-sm text-slate-400 mb-6">AI </p>
{step === 'login' ? ( {step === 'login' ? (
<> <>
<p className="text-center text-sm text-slate-400 mb-6">AI </p>
<label className="block text-xs text-slate-400 mb-1"></label> <label className="block text-xs text-slate-400 mb-1"></label>
<input value={username} onChange={e => setUsername(e.target.value)} <input value={username} onChange={e => setUsername(e.target.value)} autoComplete="username"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" /> className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
<label className="block text-xs text-slate-400 mb-1"></label> <label className="block text-xs text-slate-400 mb-1"></label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} <input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" /> className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm 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 disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60"> <button disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
@ -109,18 +132,54 @@ export default function Login() {
</> </>
) : ( ) : (
<> <>
<p className="text-center text-xs text-slate-400 mb-4"> <div className="flex items-center gap-2 justify-center mb-4">
2 {maskedEmail ? ` (${maskedEmail} 발송)` : ''}. <ShieldCheck size={20} className="text-brand" />
<span className="text-lg font-bold">2 </span>
</div>
{isOtp ? (
isSetup ? (
<>
<p className="text-center text-sm text-slate-400 mb-3">
. QR을 Authenticator (Google·Microsoft)
6 .
</p> </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-sm text-slate-400 mb-6">
Authenticator 6 .
</p>
)
) : (
<p className="text-center text-xs text-slate-400 mb-4">
6 {maskedEmail ? ` (${maskedEmail})` : ''}.
</p>
)}
<label className="block text-xs text-slate-400 mb-1"> </label> <label className="block text-xs text-slate-400 mb-1"> </label>
<input value={code} onChange={e => setCode(e.target.value)} inputMode="numeric" <input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6} autoFocus placeholder="6자리 코드" inputMode="numeric" autoComplete="one-time-code" maxLength={6} autoFocus placeholder="000000"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm tracking-[0.3em] 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 disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60"> <button disabled={busy || code.length !== 6}
{busy ? '확인 중…' : '인증 확인'} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '인증 중…' : isSetup ? '등록하고 로그인' : '인증 확인'}
</button> </button>
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr('') }} <button type="button" onClick={backToLogin}
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-slate-200"> className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-slate-200">
</button> </button>

View File

@ -0,0 +1,257 @@
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 (//) + .
* : /api/cms/auth/otp/{setup,confirm,disable} · /api/cms/auth/change-password.
* 보안: setup 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(r => {
const me = r.data?.data || {}
setUsername(me.username || localStorage.getItem('cms_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 res = await otpSetup()
const d = res.data?.data || {}
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"></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"> </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,8 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Plus, Trash2, KeyRound, Power, ShieldCheck } from 'lucide-react' import { Plus, Trash2, KeyRound, Power, ShieldCheck, RefreshCw } from 'lucide-react'
import { CMS_ROLES } from '../components/rbac' import { CMS_ROLES } from '../components/rbac'
import { import {
getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser, getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser,
adminOtpReset,
} from '../api/client' } from '../api/client'
interface User { id: number; username: string; displayName?: string; role: string; active: boolean; createdAt: string | null } interface User { id: number; username: string; displayName?: string; role: string; active: boolean; createdAt: string | null }
@ -35,6 +36,10 @@ export default function UserManagement() {
const changeRole = (u: User, role: string) => wrap(() => updateUserRole(u.id, role)) const changeRole = (u: User, role: string) => wrap(() => updateUserRole(u.id, role))
const toggleActive = (u: User) => wrap(() => updateUserActive(u.id, !u.active)) const toggleActive = (u: User) => wrap(() => updateUserActive(u.id, !u.active))
const doReset = (u: User) => { const pw = window.prompt(`'${u.username}' 의 새 비밀번호`); if (pw) wrap(() => resetPassword(u.id, pw)) } const doReset = (u: User) => { const pw = window.prompt(`'${u.username}' 의 새 비밀번호`); if (pw) wrap(() => resetPassword(u.id, pw)) }
const otpReset = (u: User) => {
if (!window.confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) return
wrap(() => adminOtpReset(u.id))
}
const remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) } const remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) }
return ( return (
@ -81,6 +86,7 @@ export default function UserManagement() {
<td className="px-4"><div className="flex items-center justify-end gap-3"> <td className="px-4"><div className="flex items-center justify-end gap-3">
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button> <button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button> <button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
<button onClick={() => otpReset(u)} title="OTP 초기화" className="text-slate-400 hover:text-brand"><RefreshCw size={16} /></button>
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button> <button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
</div></td> </div></td>
</tr> </tr>