feat(auth): OTP 프론트 화면 배선
This commit is contained in:
parent
d7d3de7fb6
commit
7644d9ed8b
591
backend/src/main/resources/static/assets/index-BBuKnaPd.js
Normal file
591
backend/src/main/resources/static/assets/index-BBuKnaPd.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
File diff suppressed because one or more lines are too long
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 MES — AI 제조실행시스템</title>
|
||||
<script type="module" crossorigin src="/assets/index-CwsiO9y6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BMixRD2-.css">
|
||||
<script type="module" crossorigin src="/assets/index-BBuKnaPd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bw9GVsq6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Layout from './components/Layout'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Login from './pages/Login'
|
||||
import MyPage from './pages/MyPage'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
// MES
|
||||
import Jobs from './pages/Jobs'
|
||||
@ -64,6 +65,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/mypage" element={<MyPage />} />
|
||||
{/* MES */}
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/progress" element={<Progress />} />
|
||||
|
||||
@ -35,6 +35,15 @@ export const login = (username: string, password: string) =>
|
||||
api.post('/api/mes/auth/login', { username, password })
|
||||
export const getMe = () => api.get('/api/mes/auth/me')
|
||||
|
||||
// ── 2FA / OTP / 계정 보안 (access 토큰 필요, prefix /api/mes/auth) ──────
|
||||
// 마이페이지 OTP 등록/재설정/해제 + 비밀번호 변경.
|
||||
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지.
|
||||
export const otpSetup = () => api.post('/api/mes/auth/otp/setup')
|
||||
export const otpConfirm = (code: string) => api.post('/api/mes/auth/otp/confirm', { code })
|
||||
export const otpDisable = () => api.post('/api/mes/auth/otp/disable')
|
||||
export const changePassword = (currentPassword: string, newPassword: string) =>
|
||||
api.post('/api/mes/auth/change-password', { currentPassword, newPassword })
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────
|
||||
export const getDashboardSummary = () => unwrap(api.get('/api/mes/dashboard/summary'))
|
||||
export const getWorkorderStatus = () => unwrap(api.get('/api/mes/dashboard/workorder-status'))
|
||||
@ -324,6 +333,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 = 200) =>
|
||||
api.get(`/api/admin/audit${qs({ action, actor, limit })}`)
|
||||
export const getSettings = () => api.get('/api/admin/settings')
|
||||
|
||||
@ -8,8 +8,12 @@ import api from './client'
|
||||
*/
|
||||
|
||||
// ── 2FA (MES AuthController: /api/mes/auth/verify)
|
||||
// verify2fa: 이메일 인증코드 경로(하위호환). verifyMethod=EMAIL 일 때 사용.
|
||||
export const verify2fa = (verifyToken: string, code: string) =>
|
||||
api.post('/api/mes/auth/verify', { verifyToken, code })
|
||||
// verifyOtp: Authenticator(TOTP) 경로. verifyMethod=OTP | OTP_SETUP 일 때 사용.
|
||||
export const verifyOtp = (verifyToken: string, code: string) =>
|
||||
api.post('/api/mes/auth/verify-otp', { verifyToken, code })
|
||||
|
||||
// ── 로그인 보조 3종 (MES AuthHelperController: /api/mes/auth/{signup,find-id,reset-password})
|
||||
// 무인증 접근(permitAll). 응답 래퍼 {success,message,data} 그대로 반환 — 호출부에서 data 추출.
|
||||
|
||||
@ -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,16 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Cpu } from 'lucide-react'
|
||||
import { Cpu, ShieldCheck } from 'lucide-react'
|
||||
import { login, getMe } from '../api/client'
|
||||
import { verify2fa, signup, findId, resetPassword } from '../api/uiws'
|
||||
import { verify2fa, verifyOtp, signup, findId, resetPassword } from '../api/uiws'
|
||||
|
||||
type HelperMode = 'signup' | 'find-id' | 'reset-password'
|
||||
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
|
||||
|
||||
/**
|
||||
* GUARDiA MES 로그인 — UIWS 2FA 레이어 대응.
|
||||
* 1차 login 응답이 twofa="true" 면 인증코드 입력 단계로 전환(verifyToken 보관) →
|
||||
* /api/mes/auth/verify 로 코드 검증 후 access 토큰 저장. twofa!="true"(off)면 즉시 로그인(회귀 0).
|
||||
* 인증코드는 서버 로그/메일로만 전달 — 화면/응답에 코드 노출 없음.
|
||||
* GUARDiA MES 로그인 — UIWS 2FA 레이어 + Authenticator(OTP) 확장.
|
||||
* 1차 login 응답이 twofa="true" 면 verifyMethod 로 2차 분기(verifyToken 보관):
|
||||
* · OTP : Authenticator 6자리 → /api/mes/auth/verify-otp
|
||||
* · OTP_SETUP : QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp (등록 순간만 노출)
|
||||
* · EMAIL(기타): 이메일 인증코드 6자리 → /api/mes/auth/verify (하위호환)
|
||||
* twofa!="true"(off)면 즉시 로그인(회귀 0). 코드·시크릿·QR 은 화면 표시용만 — 로그/저장 금지.
|
||||
*/
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('admin')
|
||||
@ -19,10 +22,16 @@ export default function Login() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
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('')
|
||||
const nav = useNavigate()
|
||||
|
||||
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
|
||||
const isSetup = verifyMethod === 'OTP_SETUP'
|
||||
|
||||
// ── 로그인 보조 3종(회원가입/아이디찾기/비밀번호초기화) 모달 상태 ──────────────
|
||||
const [helper, setHelper] = useState<HelperMode | null>(null)
|
||||
const [hForm, setHForm] = useState({ username: '', password: '', displayName: '', email: '' })
|
||||
@ -87,9 +96,13 @@ export default function Login() {
|
||||
const res = await login(username, password)
|
||||
const data = res.data?.data || {}
|
||||
if (data.twofa === 'true') {
|
||||
// 2단계: verify-token 보관 후 코드 입력 화면으로(코드는 미표시)
|
||||
// 2단계: verify-token 보관 후 verifyMethod 로 분기(OTP/OTP_SETUP/EMAIL)
|
||||
setVerifyToken(data.verifyToken)
|
||||
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
|
||||
setMaskedEmail(data.maskedEmail || '')
|
||||
setQrImage(data.qrImage || '')
|
||||
setSecret(data.secret || '')
|
||||
setCode('')
|
||||
setStep('verify')
|
||||
return
|
||||
}
|
||||
@ -105,7 +118,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())
|
||||
await finish(res.data?.data?.token)
|
||||
} catch {
|
||||
setErr('인증 코드가 올바르지 않거나 만료되었습니다.')
|
||||
@ -135,22 +151,61 @@ export default function Login() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-slate-400 mb-3">
|
||||
2차 인증 코드를 입력하세요{maskedEmail ? ` (${maskedEmail})` : ''}.
|
||||
<div className="flex items-center gap-2 justify-center mb-3">
|
||||
<ShieldCheck size={18} className="text-brand" />
|
||||
<span className="text-sm font-semibold">2차 인증</span>
|
||||
</div>
|
||||
|
||||
{isOtp ? (
|
||||
isSetup ? (
|
||||
<>
|
||||
<p className="text-center text-xs 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-xs text-slate-400 mb-4">
|
||||
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" autoFocus placeholder="6자리 코드"
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none tracking-widest" />
|
||||
<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 focus:border-brand outline-none tracking-[0.4em] text-center" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{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 ? '처리 중…' : (step === 'login' ? '로그인' : '인증 확인')}
|
||||
<button disabled={busy || (step === 'verify' && 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 ? '처리 중…' : step === 'login' ? '로그인' : isSetup ? '등록하고 로그인' : '인증 확인'}
|
||||
</button>
|
||||
{step === 'verify' && (
|
||||
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr('') }}
|
||||
<button type="button"
|
||||
onClick={() => { setStep('login'); setCode(''); setErr(''); setQrImage(''); setSecret('') }}
|
||||
className="w-full mt-2 py-2 rounded-lg text-xs text-slate-400 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차 인증(등록/재설정/해제) + 비밀번호 변경.
|
||||
* 표준 스펙(_ai_track/design/otp_mypage_spec.md B·C·+) 준수, guardia-ocr MyPage 미러.
|
||||
* 보안: 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('mes_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 { MES_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 }
|
||||
@ -36,6 +37,10 @@ export default function UserManagement() {
|
||||
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 remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) }
|
||||
const otpReset = (u: User) => {
|
||||
if (!window.confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) return
|
||||
wrap(() => adminOtpReset(u.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -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