feat(auth): OTP 프론트 화면 배선
This commit is contained in:
parent
e2363abaa4
commit
fc922f74a1
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
598
backend/src/main/resources/static/assets/index-lCwctxBX.js
Normal file
598
backend/src/main/resources/static/assets/index-lCwctxBX.js
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/src/main/resources/static/favicon.ico
Normal file
BIN
backend/src/main/resources/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<script type="module" crossorigin src="/assets/index-CVTAEWqv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CAmwXOHj.css">
|
||||
<script type="module" crossorigin src="/assets/index-lCwctxBX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index--ySdlt3w.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -25,6 +25,7 @@ import AuditLog from './pages/AuditLog'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||
import MobileApp from './pages/MobileApp'
|
||||
import MyPage from './pages/MyPage'
|
||||
// UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가
|
||||
import UiwsLayout from './components/uiws/UiwsLayout'
|
||||
import WorklogList from './pages/uiws/WorklogList'
|
||||
@ -49,6 +50,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/mypage" element={<MyPage />} />
|
||||
<Route path="/contents" element={<Contents />} />
|
||||
<Route path="/contents/new" element={<ContentEditor />} />
|
||||
<Route path="/contents/:id" element={<ContentEditor />} />
|
||||
|
||||
@ -35,6 +35,15 @@ export const login = (username: string, password: string) =>
|
||||
api.post('/api/cms/auth/login', { username, password })
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
export const getDashboardSummary = () => unwrap(api.get('/api/cms/dashboard/summary'))
|
||||
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 resetPassword = (id: number, password: string) => 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) => api.post(`/api/admin/users/${id}/otp-reset`)
|
||||
|
||||
export const getAuditLogs = (action = '', actor = '', limit = 100) =>
|
||||
api.get(`/api/admin/audit${qs({ action, actor, limit })}`)
|
||||
|
||||
@ -8,8 +8,12 @@ import api from './client'
|
||||
*/
|
||||
|
||||
// ── 2FA (CMS auth prefix /api/cms/auth)
|
||||
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL 일 때 사용.
|
||||
export const verify2fa = (verifyToken: string, code: string) =>
|
||||
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)
|
||||
// 대상=운영자 계정(cms_user). 회원가입=승인대기, 아이디찾기=마스킹, 비번초기화=메일/로그(응답 무노출).
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { 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'}`} />
|
||||
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
|
||||
</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}
|
||||
{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">
|
||||
<LogOut size={16} /> 로그아웃
|
||||
</button>
|
||||
|
||||
@ -1,28 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { login, getMe } from '../api/client'
|
||||
import { verify2fa } from '../api/uiws'
|
||||
import { verify2fa, verifyOtp } from '../api/uiws'
|
||||
import AuthHelperModals, { type AuthHelperKind } from './AuthHelperModals'
|
||||
|
||||
/**
|
||||
* CMS 로그인 — UIWS 2FA 레이어 대응.
|
||||
* - 1차: /api/cms/auth/login → 2FA off 면 { token } 즉시 발급(회귀 0),
|
||||
* 2FA on 이면 { verifyToken, step, maskedEmail } → 2차 코드 입력 단계로 전환.
|
||||
* - 2차: /api/cms/auth/verify (verifyToken + code) → access 토큰 발급.
|
||||
* 인증코드는 서버 메일/로그 채널로만 전달(응답·화면에 코드 노출 없음).
|
||||
* CMS 로그인 — UIWS 2FA 레이어 + Authenticator(OTP) 확장.
|
||||
* 1단계: 아이디/비밀번호 → /api/cms/auth/login.
|
||||
* - twofa="false": 즉시 { token } 발급(기존 단일 로그인 회귀 0).
|
||||
* - 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() {
|
||||
const [step, setStep] = useState<'login' | 'verify'>('login')
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
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 [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [helper, setHelper] = useState<AuthHelperKind | null>(null)
|
||||
const nav = useNavigate()
|
||||
|
||||
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
|
||||
const isSetup = verifyMethod === 'OTP_SETUP'
|
||||
|
||||
// 토큰 저장 + 역할 선조회 후 대시보드 진입(공통)
|
||||
const finishLogin = async (token: string) => {
|
||||
localStorage.setItem('cms_token', token)
|
||||
@ -42,12 +55,14 @@ export default function Login() {
|
||||
const res = await login(username, password)
|
||||
const d = res.data?.data || {}
|
||||
if (d.twofa === 'true') {
|
||||
// 2FA on — 2차 코드 입력 단계로
|
||||
setVerifyToken(d.verifyToken || '')
|
||||
setVerifyMethod((d.verifyMethod as VerifyMethod) || 'EMAIL')
|
||||
setMaskedEmail(d.maskedEmail || '')
|
||||
setQrImage(d.qrImage || '')
|
||||
setSecret(d.secret || '')
|
||||
setCode('')
|
||||
setStep('verify')
|
||||
} else {
|
||||
// 2FA off(또는 미설정) — 기존 단일 로그인 흐름
|
||||
const token = d.token
|
||||
if (!token) throw new Error('no token')
|
||||
await finishLogin(token)
|
||||
@ -63,7 +78,10 @@ export default function Login() {
|
||||
e.preventDefault()
|
||||
setErr(''); setBusy(true)
|
||||
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')
|
||||
await finishLogin(token)
|
||||
@ -74,6 +92,11 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
const backToLogin = () => {
|
||||
setStep('login'); setCode(''); setErr('')
|
||||
setQrImage(''); setSecret('') // 시크릿 잔존 방지
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-ink">
|
||||
<form onSubmit={step === 'login' ? submitLogin : submitVerify}
|
||||
@ -83,15 +106,15 @@ export default function Login() {
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
<span className="text-xl font-bold">GUARDiA CMS</span>
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">AI 콘텐츠 관리 시스템</p>
|
||||
|
||||
{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>
|
||||
<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" />
|
||||
<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" />
|
||||
{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">
|
||||
@ -109,18 +132,54 @@ export default function Login() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-xs text-slate-400 mb-4">
|
||||
2차 인증 코드를 입력하세요{maskedEmail ? ` (${maskedEmail} 발송)` : ''}.
|
||||
<div className="flex items-center gap-2 justify-center mb-4">
|
||||
<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>
|
||||
{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>
|
||||
<input value={code} onChange={e => setCode(e.target.value)} inputMode="numeric"
|
||||
maxLength={6} autoFocus placeholder="6자리 코드"
|
||||
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" />
|
||||
<input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 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.4em] text-center focus:border-brand outline-none" />
|
||||
{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">
|
||||
{busy ? '확인 중…' : '인증 확인'}
|
||||
<button disabled={busy || code.length !== 6}
|
||||
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 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">
|
||||
← 로그인으로 돌아가기
|
||||
</button>
|
||||
|
||||
257
frontend/src/pages/MyPage.tsx
Normal file
257
frontend/src/pages/MyPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
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 {
|
||||
getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser,
|
||||
adminOtpReset,
|
||||
} from '../api/client'
|
||||
|
||||
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 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 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)) }
|
||||
|
||||
return (
|
||||
@ -81,6 +86,7 @@ export default function UserManagement() {
|
||||
<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={() => 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>
|
||||
</div></td>
|
||||
</tr>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user