Add company documents list and upload page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
companyDocuments,
|
||||
companyDocumentVersions,
|
||||
users
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import {
|
||||
saveCompanyFile,
|
||||
deleteCompanyFile,
|
||||
isAllowedMime,
|
||||
MAX_BYTES,
|
||||
ALLOWED_MIME
|
||||
} from '$lib/server/uploads/index.js';
|
||||
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
const DOCUMENT_CATEGORIES = [
|
||||
'dbd_registration',
|
||||
'affidavit',
|
||||
'memorandum',
|
||||
'articles_of_association',
|
||||
'vat_registration',
|
||||
'tax_id_document',
|
||||
'bank_document',
|
||||
'director_id',
|
||||
'director_signature_card',
|
||||
'shareholder_list',
|
||||
'annual_filing',
|
||||
'contract',
|
||||
'license',
|
||||
'insurance',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
type DocumentCategory = (typeof DOCUMENT_CATEGORIES)[number];
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
await parent();
|
||||
|
||||
const categoryFilter = url.searchParams.get('category') as DocumentCategory | null;
|
||||
const isValidCategory = categoryFilter && DOCUMENT_CATEGORIES.includes(categoryFilter);
|
||||
|
||||
const whereClauses = [
|
||||
eq(companyDocuments.companyId, params.companyId),
|
||||
isNull(companyDocuments.deletedAt)
|
||||
];
|
||||
if (isValidCategory) {
|
||||
whereClauses.push(eq(companyDocuments.category, categoryFilter));
|
||||
}
|
||||
|
||||
const docs = await db
|
||||
.select({
|
||||
id: companyDocuments.id,
|
||||
category: companyDocuments.category,
|
||||
customLabel: companyDocuments.customLabel,
|
||||
title: companyDocuments.title,
|
||||
description: companyDocuments.description,
|
||||
expiresAt: companyDocuments.expiresAt,
|
||||
notes: companyDocuments.notes,
|
||||
createdAt: companyDocuments.createdAt,
|
||||
updatedAt: companyDocuments.updatedAt,
|
||||
createdByName: users.displayName,
|
||||
createdByEmail: users.email
|
||||
})
|
||||
.from(companyDocuments)
|
||||
.leftJoin(users, eq(companyDocuments.createdBy, users.id))
|
||||
.where(and(...whereClauses))
|
||||
.orderBy(desc(companyDocuments.updatedAt));
|
||||
|
||||
// Collect latest version info per document.
|
||||
const docIds = docs.map((d) => d.id);
|
||||
const latestByDoc = new Map<
|
||||
string,
|
||||
{
|
||||
versionNumber: number;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
uploadedAt: Date;
|
||||
versionCount: number;
|
||||
}
|
||||
>();
|
||||
|
||||
if (docIds.length > 0) {
|
||||
const versionRows = await db
|
||||
.select({
|
||||
documentId: companyDocumentVersions.documentId,
|
||||
versionNumber: companyDocumentVersions.versionNumber,
|
||||
fileName: companyDocumentVersions.fileName,
|
||||
mimeType: companyDocumentVersions.mimeType,
|
||||
sizeBytes: companyDocumentVersions.sizeBytes,
|
||||
uploadedAt: companyDocumentVersions.uploadedAt
|
||||
})
|
||||
.from(companyDocumentVersions)
|
||||
.where(inArray(companyDocumentVersions.documentId, docIds))
|
||||
.orderBy(desc(companyDocumentVersions.versionNumber));
|
||||
|
||||
const countRows = await db
|
||||
.select({
|
||||
documentId: companyDocumentVersions.documentId,
|
||||
count: sql<number>`count(*)::int`
|
||||
})
|
||||
.from(companyDocumentVersions)
|
||||
.where(inArray(companyDocumentVersions.documentId, docIds))
|
||||
.groupBy(companyDocumentVersions.documentId);
|
||||
|
||||
const countByDoc = new Map(countRows.map((r) => [r.documentId, r.count]));
|
||||
|
||||
for (const v of versionRows) {
|
||||
if (!latestByDoc.has(v.documentId)) {
|
||||
latestByDoc.set(v.documentId, {
|
||||
versionNumber: v.versionNumber,
|
||||
fileName: v.fileName,
|
||||
mimeType: v.mimeType,
|
||||
sizeBytes: v.sizeBytes,
|
||||
uploadedAt: v.uploadedAt,
|
||||
versionCount: countByDoc.get(v.documentId) ?? 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const documents = docs.map((d) => ({
|
||||
...d,
|
||||
latest: latestByDoc.get(d.id) ?? null
|
||||
}));
|
||||
|
||||
return {
|
||||
documents,
|
||||
categoryFilter: isValidCategory ? categoryFilter : null
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
upload: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'accountant'
|
||||
]);
|
||||
|
||||
const fd = await request.formData();
|
||||
const category = fd.get('category')?.toString() as DocumentCategory | undefined;
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const file = fd.get('file') as File | null;
|
||||
|
||||
if (!category || !DOCUMENT_CATEGORIES.includes(category)) {
|
||||
return fail(400, { error: 'Category is required' });
|
||||
}
|
||||
if (!title) return fail(400, { error: 'Title is required' });
|
||||
if (!file || !(file instanceof File) || file.size === 0) {
|
||||
return fail(400, { error: 'File is required' });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return fail(400, { error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)` });
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!isAllowedMime(mime)) {
|
||||
return fail(400, {
|
||||
error: `File type not allowed. Allowed: ${ALLOWED_MIME.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
const customLabel = trimOrNull(fd.get('customLabel'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const expiresAt = trimOrNull(fd.get('expiresAt'));
|
||||
const notes = trimOrNull(fd.get('notes'));
|
||||
const comment = trimOrNull(fd.get('comment'));
|
||||
|
||||
// Insert document row first so we can link the version to it.
|
||||
const [newDoc] = await db
|
||||
.insert(companyDocuments)
|
||||
.values({
|
||||
companyId: params.companyId,
|
||||
category,
|
||||
customLabel,
|
||||
title,
|
||||
description,
|
||||
expiresAt: expiresAt ?? undefined,
|
||||
notes,
|
||||
createdBy: user.id
|
||||
})
|
||||
.returning({ id: companyDocuments.id });
|
||||
|
||||
let saved;
|
||||
try {
|
||||
saved = await saveCompanyFile(params.companyId, file);
|
||||
} catch (err) {
|
||||
// roll back the document row on disk failure
|
||||
await db.delete(companyDocuments).where(eq(companyDocuments.id, newDoc.id));
|
||||
console.error('saveCompanyFile failed', err);
|
||||
return fail(500, { error: 'Failed to save file to disk' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(companyDocumentVersions).values({
|
||||
documentId: newDoc.id,
|
||||
versionNumber: 1,
|
||||
fileName: file.name,
|
||||
storedPath: saved.storedPath,
|
||||
mimeType: saved.mimeType,
|
||||
sizeBytes: saved.sizeBytes,
|
||||
uploadedBy: user.id,
|
||||
comment
|
||||
});
|
||||
} catch (err) {
|
||||
await deleteCompanyFile(saved.storedPath);
|
||||
await db.delete(companyDocuments).where(eq(companyDocuments.id, newDoc.id));
|
||||
console.error('insert version failed', err);
|
||||
return fail(500, { error: 'Failed to register file version' });
|
||||
}
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'document_uploaded',
|
||||
`Document "${title}" uploaded (${category})`,
|
||||
{ documentId: newDoc.id, fileName: file.name, sizeBytes: saved.sizeBytes }
|
||||
);
|
||||
|
||||
redirect(303, `/companies/${params.companyId}/documents/${newDoc.id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { formatDate, formatDateTime } from '$lib/utils/date.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const canUpload = $derived(
|
||||
data.companyRoles.includes('admin') || data.companyRoles.includes('accountant')
|
||||
);
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
dbd_registration: 'DBD Registration',
|
||||
affidavit: 'Affidavit (หนังสือรับรอง)',
|
||||
memorandum: 'Memorandum (MOA)',
|
||||
articles_of_association: 'Articles of Association',
|
||||
vat_registration: 'VAT Registration (PP.20)',
|
||||
tax_id_document: 'Tax ID Document',
|
||||
bank_document: 'Bank Document',
|
||||
director_id: 'Director ID',
|
||||
director_signature_card: 'Director Signature Card',
|
||||
shareholder_list: 'Shareholder List (BOJ.5)',
|
||||
annual_filing: 'Annual Filing',
|
||||
contract: 'Contract',
|
||||
license: 'License',
|
||||
insurance: 'Insurance',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
const CATEGORY_BADGE: Record<string, string> = {
|
||||
dbd_registration: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
affidavit: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
memorandum: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
articles_of_association: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
vat_registration: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
tax_id_document: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
bank_document: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
director_id: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
director_signature_card: 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300',
|
||||
shareholder_list: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/40 dark:text-fuchsia-300',
|
||||
annual_filing: 'bg-lime-100 text-lime-700 dark:bg-lime-900/40 dark:text-lime-300',
|
||||
contract: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
license: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||
insurance: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
|
||||
const ALL_CATEGORIES = Object.keys(CATEGORY_LABELS);
|
||||
|
||||
let showUpload = $state(false);
|
||||
let searchQuery = $state('');
|
||||
|
||||
function humanSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function isExpired(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
|
||||
const filteredDocuments = $derived.by(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return data.documents;
|
||||
return data.documents.filter((d) =>
|
||||
d.title.toLowerCase().includes(q) ||
|
||||
(d.customLabel?.toLowerCase().includes(q) ?? false) ||
|
||||
(d.description?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
function categoryHref(cat: string | null): string {
|
||||
const base = `/companies/${$page.params.companyId}/documents`;
|
||||
return cat ? `${base}?category=${cat}` : base;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Documents - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Company Documents</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
DBD registration, MOA, director IDs, licences, contracts, and other legal paperwork. Visible to
|
||||
admin, manager, and accountant. Upload and delete are admin/accountant only.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload card -->
|
||||
{#if canUpload}
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Upload Document</h2>
|
||||
<button
|
||||
onclick={() => (showUpload = !showUpload)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{showUpload ? 'Cancel' : '+ New Document'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showUpload}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/upload"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
}}
|
||||
class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||
>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="file" class={labelCls}>File *</label>
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
name="file"
|
||||
required
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic,.doc,.docx,.xls,.xlsx"
|
||||
class={inputCls}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
PDF, image, or Office document up to 25 MB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="title" class={labelCls}>Title *</label>
|
||||
<input id="title" name="title" required class={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="category" class={labelCls}>Category *</label>
|
||||
<select id="category" name="category" required class={inputCls}>
|
||||
{#each ALL_CATEGORIES as cat}
|
||||
<option value={cat}>{CATEGORY_LABELS[cat]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="customLabel" class={labelCls}>Custom Label</label>
|
||||
<input
|
||||
id="customLabel"
|
||||
name="customLabel"
|
||||
placeholder="e.g. MoA 2026 amendment"
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="expiresAt" class={labelCls}>Expiry Date</label>
|
||||
<input id="expiresAt" name="expiresAt" type="date" class={inputCls} />
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label for="description" class={labelCls}>Description</label>
|
||||
<textarea id="description" name="description" rows="2" class={inputCls}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label for="notes" class={labelCls}>Notes</label>
|
||||
<textarea id="notes" name="notes" rows="2" class={inputCls}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label for="comment" class={labelCls}>Version Note</label>
|
||||
<input
|
||||
id="comment"
|
||||
name="comment"
|
||||
placeholder="Optional note for this first version"
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Filter pills -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={categoryHref(null)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {data.categoryFilter ===
|
||||
null
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
All
|
||||
</a>
|
||||
{#each ALL_CATEGORIES as cat}
|
||||
<a
|
||||
href={categoryHref(cat)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {data.categoryFilter ===
|
||||
cat
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search documents by title, label, or description..."
|
||||
bind:value={searchQuery}
|
||||
class={inputCls + ' max-w-lg'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
{#if filteredDocuments.length === 0}
|
||||
<p class="p-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{data.documents.length === 0
|
||||
? 'No documents uploaded yet.'
|
||||
: 'No documents match your search.'}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr class="text-left text-gray-500 dark:text-gray-400">
|
||||
<th class="px-3 py-2 font-medium">Title</th>
|
||||
<th class="px-3 py-2 font-medium">Category</th>
|
||||
<th class="px-3 py-2 font-medium">Versions</th>
|
||||
<th class="px-3 py-2 font-medium">Size</th>
|
||||
<th class="px-3 py-2 font-medium">Expires</th>
|
||||
<th class="px-3 py-2 font-medium">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredDocuments as doc}
|
||||
<tr
|
||||
class="cursor-pointer border-t border-gray-100 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/50"
|
||||
onclick={() => {
|
||||
window.location.href = `/companies/${$page.params.companyId}/documents/${doc.id}`;
|
||||
}}
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{doc.title}</div>
|
||||
{#if doc.customLabel}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{doc.customLabel}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {CATEGORY_BADGE[
|
||||
doc.category
|
||||
]}"
|
||||
>
|
||||
{CATEGORY_LABELS[doc.category]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{doc.latest?.versionCount ?? 0}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{doc.latest ? humanSize(doc.latest.sizeBytes) : '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
{#if doc.expiresAt}
|
||||
<span
|
||||
class={isExpired(doc.expiresAt)
|
||||
? 'font-medium text-red-600 dark:text-red-400'
|
||||
: 'text-gray-600 dark:text-gray-400'}
|
||||
>
|
||||
{formatDate(doc.expiresAt)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{doc.latest ? formatDateTime(doc.latest.uploadedAt) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
Reference in New Issue
Block a user