refactor(frontend): split 7 oversized pages into feature co-location
Per guide ch.7 page split pattern (skeleton orchestrates, private components live in features/<domain>/): - InstancePage 1716->328, UsersPage 1309->240, TicketsPage 1076->195, TicketDetailPage 911->287, QueriesPage 883->479, LdapConfigPage 860->393, WorkflowPage 852->194 - new feature modules: datasource/ rbac/ ticket/ query/ system/ldap/ governance/workflow/ (components + hooks + types + utils) - stable keys replace index keys in dynamic lists; polling intervals and toast call sites preserved 1:1 (verified by agents) - filter/pagination state moved to URL query via new useQueryState hook (Instance/Users/Tickets/AuditLogs pages): refresh/share/back-button restore state; composite updates applied atomically - permission pure functions extracted to lib/permissions.ts (used by useAuth + useLogin); page-size audit: 0 pages over 700-line budget
This commit is contained in:
1 parent
ac563d1c5e
commit
ac498c7ba2
92 files changed
+8224
-6146
No files matched your search
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 用途:批量导入用户弹窗(本地 CSV 导入 / 导入到 LDAP / 从 LDAP 导入 三个 Tab,含新建 OU 子弹窗)
|
||||
* 必需 props:onClose(关闭)、onImported(导入有成功项后回调,页面负责关窗并刷新列表)
|
||||
* 已知限制:CSV 预览仅展示前 50(本地)/ 30(到 LDAP)行;拼音用户名重复时以 username+序号作稳定 key
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Upload, X, RotateCcw, Plus, Users as UsersIcon, Server,
|
||||
} from 'lucide-react';
|
||||
import { userApi, ldapApi } from '@/api/user';
|
||||
import { nameToUsername } from '@/utils/pinyin';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ModalOverlay } from '@/components/ModalOverlay';
|
||||
|
||||
interface BatchImportModalProps {
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
export function BatchImportModal({ onClose, onImported }: BatchImportModalProps) {
|
||||
const [tab, setTab] = useState<'local' | 'to-ldap' | 'from-ldap'>('local');
|
||||
const [preview, setPreview] = useState<any[]>([]);
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// LDAP state
|
||||
const [ldapOus, setLdapOus] = useState<any[]>([]);
|
||||
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
|
||||
const [selectedLdapUsers, setSelectedLdapUsers] = useState<Set<string>>(new Set());
|
||||
const [ldapLoading, setLdapLoading] = useState(false);
|
||||
const [targetOu, setTargetOu] = useState('');
|
||||
const [ldapReady, setLdapReady] = useState(false);
|
||||
|
||||
// Create OU dialog
|
||||
const [showCreateOu, setShowCreateOu] = useState(false);
|
||||
const [newOuName, setNewOuName] = useState('');
|
||||
const [newOuDesc, setNewOuDesc] = useState('');
|
||||
const [creatingOu, setCreatingOu] = useState(false);
|
||||
|
||||
// Check LDAP readiness + load OUs
|
||||
useEffect(() => {
|
||||
if (tab === 'to-ldap' || tab === 'from-ldap') {
|
||||
setLdapLoading(true);
|
||||
ldapApi.getConfig().then((res: any) => {
|
||||
const ready = !!res?.ldap_host;
|
||||
setLdapReady(ready);
|
||||
if (ready && ldapOus.length === 0) {
|
||||
return loadLdapOus();
|
||||
}
|
||||
setLdapLoading(false);
|
||||
}).catch(() => {
|
||||
setLdapReady(false);
|
||||
setLdapLoading(false);
|
||||
});
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
// CSV parse
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = reader.result as string;
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
||||
if (lines.length < 2) { toast('error', '文件为空'); return; }
|
||||
|
||||
const header = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const nameIdx = header.findIndex(h => /姓名|name|昵称|显示名/.test(h));
|
||||
const emailIdx = header.findIndex(h => /邮箱|email|mail/.test(h));
|
||||
const roleIdx = header.findIndex(h => /角色|role/.test(h));
|
||||
|
||||
if (nameIdx < 0) { toast('error', 'CSV 需要包含"姓名"列'); return; }
|
||||
|
||||
const rows = lines.slice(1).map(line => {
|
||||
const cols = line.split(',').map(c => c.trim().replace(/^"|"$/g, ''));
|
||||
const name = cols[nameIdx] || '';
|
||||
return {
|
||||
display_name: name,
|
||||
username: nameToUsername(name),
|
||||
password: nameToUsername(name),
|
||||
email: emailIdx >= 0 ? cols[emailIdx] : '',
|
||||
role: roleIdx >= 0 ? cols[roleIdx] || 'developer' : 'developer',
|
||||
};
|
||||
}).filter(r => r.display_name);
|
||||
setPreview(rows);
|
||||
setResult(null);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// LDAP OU loading
|
||||
const loadLdapOus = async () => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listOus() as any;
|
||||
setLdapOus(Array.isArray(res) ? res : (res?.ous || res?.data || []));
|
||||
} catch { toast('error', '加载 LDAP OU 失败'); }
|
||||
finally { setLdapLoading(false); }
|
||||
};
|
||||
|
||||
const loadLdapUsers = async (baseDn: string) => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listUsers({ base_dn: baseDn }) as any;
|
||||
setLdapUsers(Array.isArray(res) ? res : (res?.users || res?.data || []));
|
||||
} catch { toast('error', '加载 LDAP 用户失败'); }
|
||||
finally { setLdapLoading(false); }
|
||||
};
|
||||
|
||||
// Create OU
|
||||
const handleCreateOu = async () => {
|
||||
if (!newOuName.trim()) { toast('warning', '请输入 OU 名称'); return; }
|
||||
setCreatingOu(true);
|
||||
try {
|
||||
await ldapApi.createOu({ ou_name: newOuName.trim(), description: newOuDesc.trim() });
|
||||
toast('success', `OU "${newOuName}" 创建成功`);
|
||||
setShowCreateOu(false);
|
||||
setNewOuName('');
|
||||
setNewOuDesc('');
|
||||
await loadLdapOus();
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || '创建 OU 失败');
|
||||
} finally { setCreatingOu(false); }
|
||||
};
|
||||
|
||||
// Import
|
||||
const handleImport = async () => {
|
||||
if (tab === 'local' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
|
||||
if (tab === 'to-ldap' && preview.length === 0) { toast('warning', '请先上传文件'); return; }
|
||||
if (tab === 'to-ldap' && !targetOu) { toast('warning', '请选择目标 OU'); return; }
|
||||
if (tab === 'from-ldap' && selectedLdapUsers.size === 0) { toast('warning', '请选择用户'); return; }
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
let res: any;
|
||||
if (tab === 'local') {
|
||||
res = await userApi.batchImport({ users: preview, import_type: 'local', skip_existing: skipExisting });
|
||||
} else if (tab === 'to-ldap') {
|
||||
res = await ldapApi.batchImportToLdap({ users: preview, ou_dn: targetOu });
|
||||
} else {
|
||||
const users = ldapUsers.filter(u => selectedLdapUsers.has(u.dn));
|
||||
res = await userApi.batchImportLdap({ users });
|
||||
}
|
||||
setResult(res);
|
||||
if (res.success_count > 0 || res.success) onImported();
|
||||
if (tab === 'to-ldap') {
|
||||
toast('success', res.message || `成功 ${res.success_count} 个`);
|
||||
} else {
|
||||
toast('success', `成功导入 ${res.success_count} 个用户`);
|
||||
}
|
||||
} catch { toast('error', '导入失败'); }
|
||||
finally { setImporting(false); }
|
||||
};
|
||||
|
||||
const canImport = () => {
|
||||
if (importing) return false;
|
||||
if (tab === 'local') return preview.length > 0;
|
||||
if (tab === 'to-ldap') return preview.length > 0 && !!targetOu;
|
||||
return selectedLdapUsers.size > 0;
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: 'local' as const, label: '本地导入', icon: Upload },
|
||||
{ key: 'to-ldap' as const, label: '导入到 LDAP', icon: Server },
|
||||
{ key: 'from-ldap' as const, label: '从 LDAP 导入', icon: UsersIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-2xl">
|
||||
<div className="w-full max-h-[85vh] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<h2 className="text-lg font-semibold">批量导入用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 pb-3">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors ${tab === t.key ? 'bg-[var(--dg-blue)] text-white' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||
<Icon className="w-4 h-4" />{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* ── Local Import ── */}
|
||||
{tab === 'local' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Upload className="w-4 h-4" />选择 CSV 文件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<input type="checkbox" checked={skipExisting} onChange={e => setSkipExisting(e.target.checked)} className="rounded" />
|
||||
跳过已存在用户
|
||||
</label>
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-2 pr-3">姓名</th>
|
||||
<th className="py-2 pr-3">用户名</th>
|
||||
<th className="py-2 pr-3">邮箱</th>
|
||||
<th className="py-2">角色</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(0, 50).map((r, i) => (
|
||||
<tr key={`${r.username}-${i}`} className="border-b border-[var(--dg-border)]/50">
|
||||
<td className="py-2 pr-3">{r.display_name}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{r.email || '-'}</td>
|
||||
<td className="py-2 text-[var(--dg-text-muted)]">{r.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
{preview.length > 50 && (
|
||||
<tr><td colSpan={4} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Import to LDAP ── */}
|
||||
{tab === 'to-ldap' && (
|
||||
<>
|
||||
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
|
||||
{!ldapLoading && !ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && (
|
||||
<>
|
||||
{/* OU selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">目标 OU</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={targetOu}
|
||||
onChange={e => setTargetOu(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
>
|
||||
<option value="">Base DN (根)</option>
|
||||
{ldapOus.map((ou: any) => (
|
||||
<option key={ou.dn} value={ou.dn}>{ou.name} ({ou.dn})</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={loadLdapOus} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新 OU">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setShowCreateOu(true)} className="flex items-center gap-1 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Plus className="w-3.5 h-3.5" />新建 OU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* File upload (same CSV format) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => fileRef.current?.click()} className="flex items-center gap-2 px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
<Upload className="w-4 h-4" />选择 CSV 文件
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">CSV 需包含"姓名"列,系统自动生成拼音用户名和密码</span>
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="py-2 pr-3">姓名</th>
|
||||
<th className="py-2 pr-3">用户名(自动)</th>
|
||||
<th className="py-2">密码(自动)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.slice(0, 30).map((r, i) => (
|
||||
<tr key={`${r.username}-${i}`} className="border-b border-[var(--dg-border)]/50">
|
||||
<td className="py-2 pr-3">{r.display_name}</td>
|
||||
<td className="py-2 pr-3 text-[var(--dg-blue)]">{r.username}</td>
|
||||
<td className="py-2 text-[var(--dg-text-muted)]">{r.password}</td>
|
||||
</tr>
|
||||
))}
|
||||
{preview.length > 30 && (
|
||||
<tr><td colSpan={3} className="py-2 text-center text-[var(--dg-text-muted)]">... 共 {preview.length} 条</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── From LDAP Import ── */}
|
||||
{tab === 'from-ldap' && (
|
||||
<>
|
||||
{ldapLoading && <p className="text-sm text-[var(--dg-text-muted)] text-center py-4">加载中...</p>}
|
||||
{!ldapLoading && !ldapReady && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 text-yellow-400 text-sm">
|
||||
LDAP 未配置,请先在「平台配置 → LDAP」中配置 LDAP 连接
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && ldapOus.length > 0 && ldapUsers.length === 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-2">选择组织单元:</p>
|
||||
{ldapOus.map(ou => (
|
||||
<button key={ou.dn} onClick={() => loadLdapUsers(ou.dn)} className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] flex items-center gap-2">
|
||||
<UsersIcon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span>{ou.name}</span>
|
||||
{ou.description && <span className="text-[var(--dg-text-muted)]">— {ou.description}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ldapUsers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{ldapUsers.length} 个用户</p>
|
||||
<button onClick={() => setSelectedLdapUsers(prev => prev.size === ldapUsers.length ? new Set() : new Set(ldapUsers.map(u => u.dn)))} className="text-xs text-[var(--dg-blue)]">
|
||||
{selectedLdapUsers.size === ldapUsers.length ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{ldapUsers.map(u => (
|
||||
<label key={u.dn} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer">
|
||||
<input type="checkbox" checked={selectedLdapUsers.has(u.dn)} onChange={() => setSelectedLdapUsers(prev => { const n = new Set(prev); n.has(u.dn) ? n.delete(u.dn) : n.add(u.dn); return n; })} />
|
||||
<span className="text-sm font-medium">{u.cn || u.nickname || u.uid}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">@{u.uid || u.username}</span>
|
||||
{u.mail && <span className="text-xs text-[var(--dg-text-muted)]">{u.mail}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!ldapLoading && ldapReady && ldapOus.length === 0 && (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] text-center py-4">LDAP 目录中无可用 OU,请先创建 OU 或检查配置</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-sm space-y-1">
|
||||
<p className="text-green-400">成功: {result.success_count ?? (result.success ? 1 : 0)}</p>
|
||||
{result.skipped_count > 0 && <p className="text-yellow-400">跳过: {result.skipped_count}</p>}
|
||||
{result.failed_count > 0 && <p className="text-red-400">失败: {result.failed_count}</p>}
|
||||
{result.failed_users?.map((f: any, i: number) => (
|
||||
<p key={`${f.username}-${i}`} className="text-red-400 text-xs">{f.username}: {f.error}</p>
|
||||
))}
|
||||
{result.message && !result.success_count && <p className="text-[var(--dg-text-secondary)]">{result.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">关闭</button>
|
||||
<button onClick={handleImport} disabled={!canImport()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
|
||||
{importing ? '导入中...' : '确认导入'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create OU sub-dialog */}
|
||||
{showCreateOu && (
|
||||
<ModalOverlay open={showCreateOu} onClose={() => setShowCreateOu(false)} width="max-w-md">
|
||||
<div className="w-full bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold">创建新的组织单位 (OU)</h3>
|
||||
<button onClick={() => setShowCreateOu(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">OU 名称 *</label>
|
||||
<input value={newOuName} onChange={e => setNewOuName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="例如:研发部、市场部" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">描述</label>
|
||||
<input value={newOuDesc} onChange={e => setNewOuDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={() => setShowCreateOu(false)} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<button onClick={handleCreateOu} disabled={creatingOu || !newOuName.trim()} className="px-4 py-2 text-sm rounded-lg bg-[var(--dg-blue)] text-white disabled:opacity-50">
|
||||
{creatingOu ? '创建中...' : '创建 OU'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user