feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化
- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理 - 正则匹配流水线 code,实时命中预览 - 内置用户分配面板,无需跳转系统管理 - 支持创建/编辑/删除/启用禁用 Item Role - 修复 PipelineEditor 成员管理 http 双解包 bug - 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns) - 新增批量删除流水线运行记录 API - Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组 - 恢复系统管理菜单(不受发布权限改动影响) - 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
1 parent
0fea0ccc20
commit
24f955832e
40 files changed
+7530
-1032
No files matched your search
@@ -0,0 +1,411 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus, Search } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { deployApi, userApi, pipelineApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
interface ItemRole {
|
||||
id: number; name: string; code: string; pattern: string; role: string;
|
||||
description: string; status: number; created_at: string;
|
||||
}
|
||||
|
||||
interface AssignedUser {
|
||||
id: number; user_id: number; item_role_id: number;
|
||||
user?: { id: number; username: string; real_name: string };
|
||||
item_role?: ItemRole;
|
||||
}
|
||||
|
||||
export default function DeployRBAC() {
|
||||
const [roles, setRoles] = useState<ItemRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Pipeline list for live preview
|
||||
const [pipelines, setPipelines] = useState<any[]>([]);
|
||||
const [previewRoleId, setPreviewRoleId] = useState<number | null>(null);
|
||||
|
||||
// User assignment
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [assignments, setAssignments] = useState<AssignedUser[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [assignRoleId, setAssignRoleId] = useState<string>('');
|
||||
const [assignLoading, setAssignLoading] = useState(false);
|
||||
|
||||
// Create
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newPattern, setNewPattern] = useState('');
|
||||
const [newRole, setNewRole] = useState('editor');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
// Edit
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [editPattern, setEditPattern] = useState('');
|
||||
const [editRole, setEditRole] = useState('');
|
||||
|
||||
const _confirm = useConfirm();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rolesRes, pipesRes, usersRes] = await Promise.all([
|
||||
deployApi.getItemRoles(),
|
||||
pipelineApi.getPipelines({ page_size: 200 }),
|
||||
userApi.getUsers({ page_size: 500 }),
|
||||
]);
|
||||
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as any)?.list || (rolesRes as any)?.data || []);
|
||||
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as any)?.list || (pipesRes as any)?.data || []);
|
||||
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as any)?.list || (usersRes as any)?.data || [];
|
||||
setUsers(uList);
|
||||
} catch { /* */ }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
const loadAssignments = async () => {
|
||||
if (!selectedUserId) { setAssignments([]); return; }
|
||||
try {
|
||||
const res: any = await deployApi.getUserItemRoles(Number(selectedUserId));
|
||||
setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []);
|
||||
} catch { setAssignments([]); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadAssignments(); }, [selectedUserId]);
|
||||
|
||||
// Compute matched pipeline codes for each role
|
||||
const matchCount = useMemo(() => {
|
||||
const map: Record<number, string[]> = {};
|
||||
roles.forEach(r => {
|
||||
try {
|
||||
const re = new RegExp(r.pattern);
|
||||
map[r.id] = pipelines.filter(p => re.test(p.code)).map(p => p.code);
|
||||
} catch { map[r.id] = []; }
|
||||
});
|
||||
return map;
|
||||
}, [roles, pipelines]);
|
||||
|
||||
const previewRole = previewRoleId ? roles.find(r => r.id === previewRoleId) : null;
|
||||
const previewMatches = previewRole ? (matchCount[previewRoleId!] || []) : [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
|
||||
toast.error('名称、代码、正则表达式不能为空'); return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await deployApi.createItemRole({ name: newName.trim(), code: newCode.trim(), pattern: newPattern.trim(), role: newRole, description: newDesc });
|
||||
toast.success('已创建');
|
||||
setShowCreate(false);
|
||||
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
|
||||
loadData();
|
||||
} catch (e: any) { toast.error(e?.message || '创建失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: number) => {
|
||||
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
|
||||
toast.success('已更新');
|
||||
setEditing(null);
|
||||
loadData();
|
||||
} catch (e: any) { toast.error(e?.message || '更新失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (role: ItemRole) => {
|
||||
const ok = await _confirm({ title: '删除角色', description: `确定删除「${role.name}」?匹配 ${matchCount[role.id]?.length || 0} 条流水线`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (role: ItemRole) => {
|
||||
try {
|
||||
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
|
||||
toast.success(role.status ? '已禁用' : '已启用');
|
||||
loadData();
|
||||
} catch { toast.error('操作失败'); }
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和角色'); return; }
|
||||
setAssignLoading(true);
|
||||
try {
|
||||
await deployApi.assignItemRole(Number(selectedUserId), { user_id: Number(selectedUserId), item_role_id: Number(assignRoleId) });
|
||||
toast.success('已分配');
|
||||
setAssignRoleId('');
|
||||
loadAssignments();
|
||||
} catch (e: any) { toast.error(e?.message || '分配失败'); }
|
||||
finally { setAssignLoading(false); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await deployApi.removeItemRole(Number(selectedUserId), id);
|
||||
toast.success('已撤销');
|
||||
loadAssignments();
|
||||
} catch { toast.error('撤销失败'); }
|
||||
};
|
||||
|
||||
const roleBadgeClass = (role: string) =>
|
||||
role === 'owner' ? 'bg-amber-500/20 text-amber-400 border-amber-500/30' :
|
||||
role === 'editor' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
|
||||
'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
|
||||
const selectedUser = users.find(u => String(u.id) === selectedUserId);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<Shield className="w-5 h-5 text-purple-500" />发布权限
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||
Jenkins-style Role Strategy — 按流水线 code 正则匹配分配角色
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)} disabled={showCreate}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ====== Pipeline Preview ====== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线命中预览</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">— 选择一个角色查看哪些流水线会被匹配</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Button variant={previewRoleId === null ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(null)}>全部 ({pipelines.length})</Button>
|
||||
{roles.map(r => (
|
||||
<Button key={r.id} variant={previewRoleId === r.id ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(r.id)}
|
||||
className={r.status === 0 ? 'opacity-40' : ''}>
|
||||
{r.name} ({matchCount[r.id]?.length || 0})
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
|
||||
{previewRoleId === null
|
||||
? pipelines.map(p => <Badge key={p.id} variant="secondary" className="text-xs"><GitBranch className="w-3 h-3 mr-0.5" />{p.code}</Badge>)
|
||||
: pipelines.map(p => {
|
||||
const matched = previewMatches.includes(p.code);
|
||||
return (
|
||||
<Badge key={p.id} variant={matched ? 'default' : 'secondary'}
|
||||
className={`text-xs ${matched ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' : 'opacity-30'}`}>
|
||||
<GitBranch className="w-3 h-3 mr-0.5" />{p.code}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
}
|
||||
{previewRoleId !== null && previewMatches.length === 0 && (
|
||||
<span className="text-xs text-red-400">无匹配 — 请调整正则表达式</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex items-center gap-2"><Plus className="w-4 h-4" />新建 Item Role</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs">名称</Label><Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="如: backend" className="h-8 text-sm" /></div>
|
||||
<div className="space-y-1"><Label className="text-xs">代码 (唯一标识)</Label><Input value={newCode} onChange={e => setNewCode(e.target.value)} placeholder="如: backend" className="h-8 text-sm font-mono" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs flex items-center gap-1"><Regex className="w-3 h-3" />正则匹配 Pattern</Label>
|
||||
<Input value={newPattern} onChange={e => { setNewPattern(e.target.value); }} placeholder='如: ^cex-.*' className="h-8 text-sm font-mono" />
|
||||
{newPattern && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
实时匹配: {(() => { try { const n = pipelines.filter(p => new RegExp(newPattern).test(p.code)).length; return n > 0 ? `✅ 命中 ${n} 条流水线` : '⚠️ 无匹配'; } catch { return '❌ 正则语法错误'; } })()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">角色</Label>
|
||||
<Select value={newRole} onValueChange={setNewRole}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者 (owner)</SelectItem>
|
||||
<SelectItem value="editor">编辑者 (editor)</SelectItem>
|
||||
<SelectItem value="viewer">查看者 (viewer)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">描述 (可选)</Label><Input value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="简要说明此角色的用途" className="h-8 text-sm" /></div>
|
||||
<Button onClick={handleCreate} disabled={saving} className="w-full">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Role list */}
|
||||
{roles.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
<Shield className="w-10 h-10 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">暂无 Item Role</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{roles.map(role => (
|
||||
<Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${role.status === 0 ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Regex className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{role.name}</span>
|
||||
<Badge className={`text-xs border ${roleBadgeClass(role.role)}`}>{role.role === 'owner' ? '拥有者' : role.role === 'editor' ? '编辑者' : '查看者'}</Badge>
|
||||
{role.status === 0 && <Badge variant="secondary" className="text-xs text-red-400">已禁用</Badge>}
|
||||
<Badge variant="secondary" className="text-xs cursor-pointer" onClick={() => setPreviewRoleId(previewRoleId === role.id ? null : role.id)}>
|
||||
<GitBranch className="w-3 h-3 mr-0.5" />{matchCount[role.id]?.length || 0} 条
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5 truncate">
|
||||
<code className="font-mono text-purple-400 bg-purple-500/10 px-1 rounded">{role.code}</code>
|
||||
<span className="mx-1">→</span>
|
||||
<code className="font-mono text-blue-400 bg-blue-500/10 px-1 rounded">{role.pattern}</code>
|
||||
{role.description && <span className="ml-2 opacity-60">{role.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{editing === role.id ? (
|
||||
<>
|
||||
<Input value={editPattern} onChange={e => setEditPattern(e.target.value)} className="h-7 w-36 text-xs font-mono" placeholder="正则表达式" />
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者</SelectItem>
|
||||
<SelectItem value="editor">编辑者</SelectItem>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-green-400" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}><Save className="w-3 h-3 mr-1" />编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== User Assignment ====== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserPlus className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">分配角色给用户</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-end mb-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label className="text-xs">选择用户</Label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择用户..." /></SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
<div className="px-2 py-1.5 sticky top-0 bg-popover z-10"><Input placeholder="搜索用户..." className="h-7 text-xs" /></div>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={String(u.id)}>
|
||||
{u.real_name || u.username} ({u.username})
|
||||
{assignments.some(a => a.user_id === u.id) && <span className="ml-2 text-xs text-purple-400">已分配</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 w-48">
|
||||
<Label className="text-xs">Item Role</Label>
|
||||
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择角色" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.filter(r => r.status === 1).map(r => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({r.role})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="h-8" onClick={handleAssign} disabled={assignLoading || !selectedUserId || !assignRoleId}>
|
||||
{assignLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <UserPlus className="w-3.5 h-3.5 mr-1" />}
|
||||
分配
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Assigned roles for selected user */}
|
||||
{selectedUser && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Users className="w-3 h-3 mr-1" />{selectedUser.real_name || selectedUser.username}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">已分配角色:</span>
|
||||
</div>
|
||||
{assignments.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">该用户尚未分配任何 Item Role</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{assignments.map(a => {
|
||||
const roleInfo = a.item_role || roles.find(r => r.id === a.item_role_id);
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between py-1.5 px-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={`text-xs border ${roleBadgeClass(roleInfo?.role || 'viewer')}`}>
|
||||
{roleInfo?.role === 'owner' ? '拥有者' : roleInfo?.role === 'editor' ? '编辑者' : '查看者'}
|
||||
</Badge>
|
||||
<span className="text-[var(--dg-text-primary)]">{roleInfo?.name || `#${a.item_role_id}`}</span>
|
||||
{roleInfo && (
|
||||
<code className="font-mono text-[var(--dg-text-muted)] bg-[var(--dg-bg-primary)] px-1 rounded text-xs">
|
||||
{roleInfo.pattern}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-400" onClick={() => handleRevoke(a.id)}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user