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

This commit is contained in:
GUARDiA 2026-07-04 08:15:37 +09:00
parent d7d3de7fb6
commit 7644d9ed8b
13 changed files with 951 additions and 599 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>
<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>

View File

@ -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 />} />

View File

@ -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')

View File

@ -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 추출.

View File

@ -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>

View File

@ -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})` : ''}.
</p>
<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>

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 (//) + .
* (_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>
)
}

View File

@ -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>