fix(frontend): E2E-Test-Suite vollständig grün machen
- Robuster gegen undefined API-Daten in Mail, ContactDetail, ContactsList, Settings, Sidebar - E2E-Mocks korrigiert für Kontakt-Detail, Mail-Liste/Folders und Plugin-Toggle - Auth-Store mit persist-Middleware für E2E-Login - test-results/ in .gitignore aufgenommen Playwright E2E: 34/34 passed
This commit is contained in:
@@ -33,6 +33,7 @@ export interface ContactDetailProps {
|
||||
loading?: boolean;
|
||||
onEdit?: () => void;
|
||||
onDeleted: () => void;
|
||||
dataTestId?: string;
|
||||
}
|
||||
|
||||
function Field({ label, value, fieldName }: { label: string; value?: string | null; fieldName?: string }) {
|
||||
@@ -156,7 +157,7 @@ function getIcon(name: string): React.ReactNode {
|
||||
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) {
|
||||
export function ContactDetail({ contact, loading, onEdit, onDeleted, dataTestId = 'contact-detail' }: ContactDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const deleteMutation = useDeleteUnifiedContact();
|
||||
@@ -173,9 +174,9 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
const pluginTabs = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.detail_tabs)
|
||||
.filter((t) => t.entity_type === 'contact')
|
||||
() => (manifests || [])
|
||||
.flatMap((m) => (Array.isArray(m.detail_tabs) ? m.detail_tabs : []))
|
||||
.filter((t): t is NonNullable<typeof t> => !!t && t.entity_type === 'contact')
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[manifests]
|
||||
);
|
||||
@@ -283,7 +284,7 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full" data-testid="contact-detail">
|
||||
<div className="overflow-y-auto h-full" data-testid={dataTestId}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
|
||||
@@ -87,9 +87,9 @@ export function Sidebar() {
|
||||
permission: item.to === '/dashboard' ? 'dashboard:read' : item.to === '/contacts' ? 'contacts:read' : undefined,
|
||||
}));
|
||||
|
||||
const pluginItems = manifests
|
||||
.flatMap((m) => m.menu_items)
|
||||
.filter(item => !item.permission || canAccess(item.permission))
|
||||
const pluginItems = (manifests || [])
|
||||
.flatMap((m) => (Array.isArray(m.menu_items) ? m.menu_items : []))
|
||||
.filter((item): item is NonNullable<typeof item> => !!item && (!item.permission || canAccess(item.permission)))
|
||||
.map(item => ({
|
||||
path: item.path,
|
||||
labelKey: item.label_key,
|
||||
|
||||
@@ -110,12 +110,12 @@ export function MailDetail({
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2">
|
||||
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.to')}:</span>
|
||||
<span className="break-words">{mail.to_addresses.join(', ')}</span>
|
||||
<span className="break-words">{(mail.to_addresses ?? []).join(', ')}</span>
|
||||
</div>
|
||||
{mail.cc_addresses.length > 0 && (
|
||||
{mail.cc_addresses && mail.cc_addresses.length > 0 && (
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2">
|
||||
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.cc')}:</span>
|
||||
<span className="break-words">{mail.cc_addresses.join(', ')}</span>
|
||||
<span className="break-words">{(mail.cc_addresses ?? []).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2">
|
||||
|
||||
@@ -151,7 +151,7 @@ function matchesCondition(mail: any, cond: FilterCondition): boolean {
|
||||
}
|
||||
|
||||
export function applyFilters(mails: any[], filters: FilterState): any[] {
|
||||
if (!filters.conditions.length) return mails;
|
||||
if (!(filters.conditions?.length ?? 0)) return mails;
|
||||
if (filters.logic === 'AND') {
|
||||
return mails.filter((m) => filters.conditions.every((cond) => matchesCondition(m, cond)));
|
||||
} else {
|
||||
@@ -208,7 +208,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const activeCount = filters.conditions.length;
|
||||
const activeCount = filters.conditions?.length ?? 0;
|
||||
|
||||
const addCondition = () => {
|
||||
onFiltersChange({
|
||||
@@ -248,7 +248,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
|
||||
const handleSaveFilter = () => {
|
||||
if (!onSaveFilter) return;
|
||||
if (filters.conditions.length === 0) return;
|
||||
if ((filters.conditions?.length ?? 0) === 0) return;
|
||||
const name = window.prompt('Name für diesen Filter:', 'Mein Filter');
|
||||
if (!name) return;
|
||||
onSaveFilter(name, { ...filters, conditions: filters.conditions.map((c) => ({ ...c })) });
|
||||
@@ -345,7 +345,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
)}
|
||||
|
||||
{/* Saved filters */}
|
||||
{savedFilters.length > 0 && (
|
||||
{(savedFilters?.length ?? 0) > 0 && (
|
||||
<div className="px-4 py-2 border-b border-secondary-100">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div>
|
||||
<div className="space-y-0.5">
|
||||
@@ -366,7 +366,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
|
||||
{/* Filter rows */}
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{filters.conditions.length === 0 && (
|
||||
{(filters.conditions?.length ?? 0) === 0 && (
|
||||
<div className="text-center py-6 text-xs text-secondary-400">
|
||||
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
|
||||
</div>
|
||||
|
||||
@@ -301,7 +301,7 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
|
||||
);
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
if ((accounts?.length ?? 0) === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('mail.noFolders')}
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface GroupedMails {
|
||||
}
|
||||
|
||||
export function applyGrouping(mails: any[], groupState: GroupState): GroupedMails[] {
|
||||
if (!groupState.conditions.length) return [{ key: 'all', label: 'Alle', mails }];
|
||||
if (!(groupState.conditions?.length ?? 0)) return [{ key: 'all', label: 'Alle', mails }];
|
||||
const defs = GROUP_FIELDS;
|
||||
|
||||
function groupRecursive(items: any[], conditions: GroupCondition[], depth: number): GroupedMails[] {
|
||||
@@ -139,7 +139,7 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const activeCount = groupState.conditions.length;
|
||||
const activeCount = groupState.conditions?.length ?? 0;
|
||||
|
||||
const addCondition = () => {
|
||||
onGroupChange({ conditions: [...groupState.conditions, { id: newGroupId(), field: 'from' }] });
|
||||
|
||||
@@ -52,8 +52,8 @@ function GroupSection({
|
||||
const hasSubGroups = group.subGroups && group.subGroups.length > 0;
|
||||
// Count total mails including subGroups
|
||||
const totalCount = hasSubGroups
|
||||
? group.subGroups!.reduce((sum, sub) => sum + sub.mails.length, 0)
|
||||
: group.mails.length;
|
||||
? group.subGroups!.reduce((sum, sub) => sum + (sub.mails?.length ?? 0), 0)
|
||||
: (group.mails?.length ?? 0);
|
||||
|
||||
return (
|
||||
<li className="bg-secondary-50/30">
|
||||
@@ -110,7 +110,7 @@ export function MailList({
|
||||
hasMore = false,
|
||||
}: MailListProps) {
|
||||
const { t } = useTranslation();
|
||||
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
|
||||
const allSelected = (mails?.length ?? 0) > 0 && mails.every((m) => selectedMailIds.has(m.id));
|
||||
const scrollRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Infinite scroll handler
|
||||
@@ -121,7 +121,7 @@ export function MailList({
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && mails.length === 0) {
|
||||
if (loading && (mails?.length ?? 0) === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="mail-list-loading">
|
||||
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
|
||||
@@ -130,7 +130,7 @@ export function MailList({
|
||||
);
|
||||
}
|
||||
|
||||
if (mails.length === 0) {
|
||||
if ((mails?.length ?? 0) === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('mail.noMails')}
|
||||
@@ -228,7 +228,7 @@ export function MailList({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && mails.length > 0 && <Loader2 className="w-4 h-4 animate-spin text-secondary-400" />}
|
||||
{loading && (mails?.length ?? 0) > 0 && <Loader2 className="w-4 h-4 animate-spin text-secondary-400" />}
|
||||
</div>
|
||||
|
||||
{/* Mail list — infinite scroll with optional grouping */}
|
||||
@@ -254,7 +254,7 @@ export function MailList({
|
||||
) : (
|
||||
mails.map((mail) => renderMailItem(mail))
|
||||
)}
|
||||
{loading && mails.length > 0 && (
|
||||
{loading && (mails?.length ?? 0) > 0 && (
|
||||
<li className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-secondary-400" />
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,7 @@ function compareValues(a: any, b: any, fieldType: FieldType): number {
|
||||
}
|
||||
|
||||
export function applySorting(mails: any[], sortState: SortState): any[] {
|
||||
if (!sortState.conditions.length) return mails;
|
||||
if (!(sortState.conditions?.length ?? 0)) return mails;
|
||||
const sorted = [...mails];
|
||||
sorted.sort((a, b) => {
|
||||
for (const cond of sortState.conditions) {
|
||||
@@ -111,7 +111,7 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const activeCount = sortState.conditions.length;
|
||||
const activeCount = sortState.conditions?.length ?? 0;
|
||||
|
||||
const addCondition = () => {
|
||||
onSortChange({
|
||||
|
||||
@@ -70,6 +70,7 @@ export function ContactsListPage() {
|
||||
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
|
||||
const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set());
|
||||
const openWindow = useWindowStore((s) => s.openWindow);
|
||||
const closeWindow = useWindowStore((s) => s.closeWindow);
|
||||
|
||||
// Bulk action mutations
|
||||
const deleteContactMut = useDeleteUnifiedContact();
|
||||
@@ -223,32 +224,32 @@ export function ContactsListPage() {
|
||||
|
||||
// Handle create
|
||||
const handleCreate = useCallback(() => {
|
||||
openWindow({
|
||||
const windowId = openWindow({
|
||||
title: t('contacts.create'),
|
||||
type: 'contact-create',
|
||||
component: ContactEditForm,
|
||||
componentProps: {
|
||||
onClose: () => {},
|
||||
onClose: () => closeWindow(windowId),
|
||||
onSaved: handleSaved,
|
||||
},
|
||||
});
|
||||
}, [openWindow, t, handleSaved]);
|
||||
}, [openWindow, closeWindow, t, handleSaved]);
|
||||
|
||||
// Handle edit
|
||||
const handleEdit = useCallback(() => {
|
||||
if (selectedContact) {
|
||||
openWindow({
|
||||
const windowId = openWindow({
|
||||
title: t('contacts.edit'),
|
||||
type: 'contact-edit',
|
||||
component: ContactEditForm,
|
||||
componentProps: {
|
||||
contact: selectedContact,
|
||||
onClose: () => {},
|
||||
onClose: () => closeWindow(windowId),
|
||||
onSaved: handleSaved,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [selectedContact, openWindow, t, handleSaved]);
|
||||
}, [selectedContact, openWindow, closeWindow, t, handleSaved]);
|
||||
|
||||
// Handle delete (from detail)
|
||||
const handleDeleted = useCallback(() => {
|
||||
@@ -747,6 +748,7 @@ export function ContactsListPage() {
|
||||
loading={loadingDetail}
|
||||
onEdit={handleEdit}
|
||||
onDeleted={handleDeleted}
|
||||
dataTestId="contact-detail-mobile"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+19
-15
@@ -99,10 +99,10 @@ export function MailPage() {
|
||||
// Apply filter + sort to mails before rendering
|
||||
const processedMails = useMemo(() => {
|
||||
let result = mails;
|
||||
if (mailFilterState.conditions.length > 0) {
|
||||
if ((mailFilterState.conditions || []).length > 0) {
|
||||
result = applyMailFilters(result, mailFilterState);
|
||||
}
|
||||
if (mailSortState.conditions.length > 0) {
|
||||
if ((mailSortState.conditions || []).length > 0) {
|
||||
result = applyMailSorting(result, mailSortState);
|
||||
}
|
||||
return result;
|
||||
@@ -110,7 +110,7 @@ export function MailPage() {
|
||||
|
||||
// Apply grouping after filter+sort
|
||||
const groupedMails = useMemo(() => {
|
||||
if (mailGroupState.conditions.length === 0) return null;
|
||||
if ((mailGroupState.conditions || []).length === 0) return null;
|
||||
return applyMailGrouping(processedMails, mailGroupState);
|
||||
}, [processedMails, mailGroupState]);
|
||||
|
||||
@@ -153,7 +153,7 @@ export function MailPage() {
|
||||
|
||||
// Load folders for ALL accounts
|
||||
const loadAllFolders = useCallback(async () => {
|
||||
if (accounts.length === 0) return;
|
||||
if ((accounts?.length ?? 0) === 0) return;
|
||||
setLoadingFolders(true);
|
||||
try {
|
||||
const allFolders: MailFolder[] = [];
|
||||
@@ -175,7 +175,7 @@ export function MailPage() {
|
||||
|
||||
// Auto-select first folder when folders are loaded
|
||||
useEffect(() => {
|
||||
if (folders.length > 0 && !selectedFolderId) {
|
||||
if ((folders?.length ?? 0) > 0 && !selectedFolderId) {
|
||||
setSelectedFolderId(folders[0].id);
|
||||
setSelectedAccountId(folders[0].account_id);
|
||||
}
|
||||
@@ -189,21 +189,25 @@ export function MailPage() {
|
||||
try {
|
||||
if (searchQuery.trim()) {
|
||||
const result = await searchMails(searchQuery);
|
||||
const resultMails = result.mails ?? [];
|
||||
const resultTotal = result.total ?? 0;
|
||||
// Only update if still on the same folder
|
||||
if (selectedFolderIdRef.current === currentFolderId) {
|
||||
setMails(result.mails);
|
||||
setMailsTotal(result.total);
|
||||
setMails(resultMails);
|
||||
setMailsTotal(resultTotal);
|
||||
}
|
||||
} else {
|
||||
// When grouping is active, load all mails at once (no pagination)
|
||||
const isGrouping = mailGroupState.conditions.length > 0;
|
||||
const isGrouping = (mailGroupState.conditions || []).length > 0;
|
||||
const pageToLoad = isGrouping ? 1 : mailsPage;
|
||||
const result = await fetchMails(currentFolderId, pageToLoad, sortBy, sortOrder, isGrouping ? 10000 : undefined);
|
||||
const resultMails = result.mails ?? [];
|
||||
const resultTotal = result.total ?? 0;
|
||||
// Only update if still on the same folder
|
||||
if (selectedFolderIdRef.current === currentFolderId) {
|
||||
// Append for infinite scroll (page > 1), replace on page 1 or folder change or grouping
|
||||
setMails(!isGrouping && mailsPage > 1 ? (prev) => [...prev, ...result.mails] : result.mails);
|
||||
setMailsTotal(result.total);
|
||||
setMails(!isGrouping && mailsPage > 1 ? (prev) => [...(prev ?? []), ...resultMails] : resultMails);
|
||||
setMailsTotal(resultTotal);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -851,7 +855,7 @@ export function MailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
if ((accounts?.length ?? 0) === 0) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="mail-page">
|
||||
<EmptyState
|
||||
@@ -911,11 +915,11 @@ export function MailPage() {
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onSelectAll={handleSelectAll}
|
||||
onLoadMore={() => {
|
||||
if (mails.length < mailsTotal) {
|
||||
if ((mails?.length ?? 0) < mailsTotal) {
|
||||
setMailsPage((p) => p + 1);
|
||||
}
|
||||
}}
|
||||
hasMore={mailGroupState.conditions.length === 0 && mails.length < mailsTotal}
|
||||
hasMore={(mailGroupState.conditions || []).length === 0 && (mails?.length ?? 0) < mailsTotal}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -982,11 +986,11 @@ export function MailPage() {
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onSelectAll={handleSelectAll}
|
||||
onLoadMore={() => {
|
||||
if (mails.length < mailsTotal) {
|
||||
if ((mails?.length ?? 0) < mailsTotal) {
|
||||
setMailsPage((p) => p + 1);
|
||||
}
|
||||
}}
|
||||
hasMore={mailGroupState.conditions.length === 0 && mails.length < mailsTotal}
|
||||
hasMore={(mailGroupState.conditions || []).length === 0 && (mails?.length ?? 0) < mailsTotal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,8 +8,9 @@ export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
const pluginSettingsPages = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.settings_pages)
|
||||
() => (manifests || [])
|
||||
.flatMap((m) => (Array.isArray(m.settings_pages) ? m.settings_pages : []))
|
||||
.filter((p): p is NonNullable<typeof p> => !!p && !!p.path)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[manifests]
|
||||
);
|
||||
|
||||
@@ -180,7 +180,9 @@ export function SettingsPluginsPage() {
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<Plugin | null>(null);
|
||||
const [confirmRemoveData, setConfirmRemoveData] = useState(false);
|
||||
|
||||
const plugins: Plugin[] = data ?? [];
|
||||
const plugins: Plugin[] = Array.isArray(data)
|
||||
? data
|
||||
: (data as any)?.plugins ?? [];
|
||||
|
||||
const handleInstall = async (plugin: Plugin) => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
@@ -34,39 +35,49 @@ export interface AuthState {
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
(set) => ({
|
||||
user: null,
|
||||
currentTenant: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
setUser: (user) =>
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
currentTenant: user?.tenants?.[0] ?? null,
|
||||
}),
|
||||
setTenant: (tenant) => set({ currentTenant: tenant }),
|
||||
setAuthenticated: (authed) => set({ isAuthenticated: authed }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setPermissions: (perms, isSystemAdmin, fieldPerms) =>
|
||||
set((state) => ({
|
||||
user: state.user
|
||||
? {
|
||||
...state.user,
|
||||
permissions: perms,
|
||||
is_system_admin: isSystemAdmin,
|
||||
field_permissions: fieldPerms,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
currentTenant: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
}),
|
||||
})
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
currentTenant: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
setUser: (user) =>
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
currentTenant: user?.tenants?.[0] ?? null,
|
||||
}),
|
||||
setTenant: (tenant) => set({ currentTenant: tenant }),
|
||||
setAuthenticated: (authed) => set({ isAuthenticated: authed }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setPermissions: (perms, isSystemAdmin, fieldPerms) =>
|
||||
set((state) => ({
|
||||
user: state.user
|
||||
? {
|
||||
...state.user,
|
||||
permissions: perms,
|
||||
is_system_admin: isSystemAdmin,
|
||||
field_permissions: fieldPerms,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
currentTenant: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'auth-store',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
currentTenant: state.currentTenant,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user