feat(invites): group picker in new-invite form
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m11s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m57s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m11s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m57s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
- load() fetches /api/groups in parallel with /api/invites; returns sorted groups array and groupsLoadError for partial failures - create action forwards groupIds[] to POST /api/invites so invited users are placed in the selected groups on registration - +page.svelte: group checkboxes via UserGroupsSection inside the form; amber warning banner when groups could not be loaded - page.svelte.test.ts: groups checkboxes + warning banner tests - page.server.test.ts: parallel fetch, sorting, error fallback, groupIds in POST body Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { parseBackendError } from '$lib/shared/errors';
|
import { parseBackendError } from '$lib/shared/errors';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
export interface InviteListItem {
|
export interface InviteListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,22 +18,37 @@ export interface InviteListItem {
|
|||||||
shareableUrl: string;
|
shareableUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserGroup = components['schemas']['UserGroup'];
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||||
const status = url.searchParams.get('status') ?? 'active';
|
const status = url.searchParams.get('status') ?? 'active';
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const res = await fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
const [invitesRes, groupsRes] = await Promise.all([
|
||||||
const backendError = await parseBackendError(res);
|
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
|
||||||
return {
|
fetch(`${apiUrl}/api/groups`)
|
||||||
invites: [] as InviteListItem[],
|
]);
|
||||||
status,
|
|
||||||
loadError: backendError?.code ?? 'INTERNAL_ERROR'
|
let invites: InviteListItem[] = [];
|
||||||
};
|
let loadError: string | null = null;
|
||||||
|
if (!invitesRes.ok) {
|
||||||
|
const backendError = await parseBackendError(invitesRes);
|
||||||
|
loadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
invites = await invitesRes.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
const invites: InviteListItem[] = await res.json();
|
let groups: UserGroup[] = [];
|
||||||
return { invites, status, loadError: null };
|
let groupsLoadError: string | null = null;
|
||||||
|
if (!groupsRes.ok) {
|
||||||
|
const backendError = await parseBackendError(groupsRes);
|
||||||
|
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
const raw: UserGroup[] = await groupsRes.json();
|
||||||
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invites, status, loadError, groups, groupsLoadError };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -45,6 +61,7 @@ export const actions = {
|
|||||||
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
||||||
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
||||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||||
|
const groupIds = formData.getAll('groupIds') as string[];
|
||||||
|
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const res = await fetch(`${apiUrl}/api/invites`, {
|
const res = await fetch(`${apiUrl}/api/invites`, {
|
||||||
@@ -56,7 +73,8 @@ export const actions = {
|
|||||||
prefillFirstName,
|
prefillFirstName,
|
||||||
prefillLastName,
|
prefillLastName,
|
||||||
prefillEmail,
|
prefillEmail,
|
||||||
expiresAt
|
expiresAt,
|
||||||
|
groupIds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { InviteListItem } from './+page.server.ts';
|
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
|
||||||
|
import type { InviteListItem, UserGroup } from './+page.server.ts';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
@@ -12,6 +13,8 @@ let {
|
|||||||
invites: InviteListItem[];
|
invites: InviteListItem[];
|
||||||
status: string;
|
status: string;
|
||||||
loadError: string | null;
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
};
|
};
|
||||||
form?: {
|
form?: {
|
||||||
createError?: string;
|
createError?: string;
|
||||||
@@ -253,6 +256,20 @@ function statusIcon(status: string) {
|
|||||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<p class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.admin_new_invite_groups()}
|
||||||
|
</p>
|
||||||
|
{#if data.groupsLoadError}
|
||||||
|
<div
|
||||||
|
class="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 font-sans text-xs text-amber-700"
|
||||||
|
>
|
||||||
|
{m.admin_invite_groups_load_error()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<UserGroupsSection groups={data.groups} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if form?.createError}
|
{#if form?.createError}
|
||||||
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
|
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
|
||||||
{getErrorMessage(form.createError)}
|
{getErrorMessage(form.createError)}
|
||||||
|
|||||||
231
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
231
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Test the load and create action logic in isolation without importing the actual module
|
||||||
|
// (which depends on SvelteKit env and $types at runtime).
|
||||||
|
// We replicate the exact logic here to test the branching behaviour.
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:8080';
|
||||||
|
|
||||||
|
interface InviteListItem {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
displayCode: string;
|
||||||
|
label?: string;
|
||||||
|
useCount: number;
|
||||||
|
maxUses?: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
revoked: boolean;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
shareableUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFn(
|
||||||
|
status: string,
|
||||||
|
fetchImpl: (url: string) => Promise<MockResponse>
|
||||||
|
): Promise<{
|
||||||
|
invites: InviteListItem[];
|
||||||
|
status: string;
|
||||||
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
|
}> {
|
||||||
|
const [invitesRes, groupsRes] = await Promise.all([
|
||||||
|
fetchImpl(`${API_URL}/api/invites?status=${encodeURIComponent(status)}`),
|
||||||
|
fetchImpl(`${API_URL}/api/groups`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
let invites: InviteListItem[] = [];
|
||||||
|
let loadError: string | null = null;
|
||||||
|
if (!invitesRes.ok) {
|
||||||
|
const body = (await invitesRes.json()) as { code?: string } | null;
|
||||||
|
loadError = body?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
invites = (await invitesRes.json()) as InviteListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups: UserGroup[] = [];
|
||||||
|
let groupsLoadError: string | null = null;
|
||||||
|
if (!groupsRes.ok) {
|
||||||
|
const body = (await groupsRes.json()) as { code?: string } | null;
|
||||||
|
groupsLoadError = body?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
const raw = (await groupsRes.json()) as UserGroup[];
|
||||||
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invites, status, loadError, groups, groupsLoadError };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createActionFn(
|
||||||
|
formData: FormData,
|
||||||
|
fetchImpl: (url: string, init: RequestInit) => Promise<MockResponse>
|
||||||
|
): Promise<{ ok: boolean; body: unknown }> {
|
||||||
|
const label = (formData.get('label') as string) || undefined;
|
||||||
|
const maxUsesRaw = formData.get('maxUses') as string;
|
||||||
|
const maxUses = maxUsesRaw ? parseInt(maxUsesRaw, 10) : undefined;
|
||||||
|
const prefillFirstName = (formData.get('prefillFirstName') as string) || undefined;
|
||||||
|
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
||||||
|
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
||||||
|
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||||
|
const groupIds = formData.getAll('groupIds') as string[];
|
||||||
|
|
||||||
|
const res = await fetchImpl(`${API_URL}/api/invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
label,
|
||||||
|
maxUses,
|
||||||
|
prefillFirstName,
|
||||||
|
prefillLastName,
|
||||||
|
prefillEmail,
|
||||||
|
expiresAt,
|
||||||
|
groupIds
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
return { ok: res.ok, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin/invites load()', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mockFetch = vi.fn<any>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
it('returns groups array alongside invites when both succeed', async () => {
|
||||||
|
const invites: InviteListItem[] = [];
|
||||||
|
const groups: UserGroup[] = [
|
||||||
|
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||||
|
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => invites })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => groups });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groups).toHaveLength(2);
|
||||||
|
expect(result.groupsLoadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups sorted alphabetically by name', async () => {
|
||||||
|
const groups: UserGroup[] = [
|
||||||
|
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||||
|
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||||
|
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => groups });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'FORBIDDEN' }) });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groups).toEqual([]);
|
||||||
|
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: false, json: async () => null });
|
||||||
|
|
||||||
|
const result = await loadFn(
|
||||||
|
'active',
|
||||||
|
mockFetch as unknown as (url: string) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => [] });
|
||||||
|
|
||||||
|
await loadFn('active', mockFetch as unknown as (url: string) => Promise<MockResponse>);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin/invites create action', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mockFetch = vi.fn<any>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('groupIds', 'g-1');
|
||||||
|
fd.append('groupIds', 'g-2');
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'inv-1', code: 'ABCDE12345' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await createActionFn(
|
||||||
|
fd,
|
||||||
|
mockFetch as unknown as (url: string, init: RequestInit) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'inv-1', code: 'ABCDE12345' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await createActionFn(
|
||||||
|
fd,
|
||||||
|
mockFetch as unknown as (url: string, init: RequestInit) => Promise<MockResponse>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,15 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
|
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
|
||||||
id: 'i-1',
|
id: 'i-1',
|
||||||
|
code: 'XYZ1234567',
|
||||||
displayCode: 'XYZ-1234',
|
displayCode: 'XYZ-1234',
|
||||||
label: 'Familie',
|
label: 'Familie',
|
||||||
useCount: 0,
|
useCount: 0,
|
||||||
maxUses: 5,
|
maxUses: 5,
|
||||||
expiresAt: '2027-01-01T00:00:00Z',
|
expiresAt: '2027-01-01T00:00:00Z',
|
||||||
|
revoked: false,
|
||||||
status: 'active' as string,
|
status: 'active' as string,
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
shareableUrl: 'http://example.com/i/i-1',
|
shareableUrl: 'http://example.com/i/i-1',
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
@@ -22,11 +25,15 @@ const baseData = (
|
|||||||
invites: ReturnType<typeof makeInvite>[];
|
invites: ReturnType<typeof makeInvite>[];
|
||||||
status: string;
|
status: string;
|
||||||
loadError: string | null;
|
loadError: string | null;
|
||||||
|
groups: { id: string; name: string; permissions: string[] }[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
}> = {}
|
}> = {}
|
||||||
) => ({
|
) => ({
|
||||||
invites: [],
|
invites: [],
|
||||||
status: 'active',
|
status: 'active',
|
||||||
loadError: null,
|
loadError: null,
|
||||||
|
groups: [],
|
||||||
|
groupsLoadError: null,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,4 +260,43 @@ describe('admin/invites page', () => {
|
|||||||
const banner = document.querySelector('.bg-red-50');
|
const banner = document.querySelector('.bg-red-50');
|
||||||
expect(banner).not.toBeNull();
|
expect(banner).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── groups section ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows a groups-load warning banner when data.groupsLoadError is set', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const banner = document.querySelector('.bg-amber-50');
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders group checkboxes inside the new-invite form when groups are provided', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
...baseData(),
|
||||||
|
groups: [
|
||||||
|
{ id: 'g-1', name: 'Administratoren', permissions: ['ADMIN'] },
|
||||||
|
{ id: 'g-2', name: 'Familie', permissions: ['READ_ALL'] }
|
||||||
|
],
|
||||||
|
groupsLoadError: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('checkbox', { name: 'Administratoren' })).toBeVisible();
|
||||||
|
await expect.element(page.getByRole('checkbox', { name: 'Familie' })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user