Projektstart
This commit is contained in:
@@ -14,6 +14,8 @@ type Account = {
|
||||
provider: string;
|
||||
oauthConnected?: boolean;
|
||||
oauthExpiresAt?: string | null;
|
||||
oauthHealthy?: boolean;
|
||||
oauthError?: { code: string; message: string };
|
||||
};
|
||||
|
||||
type Rule = {
|
||||
@@ -89,10 +91,16 @@ export default function App() {
|
||||
const enriched = await Promise.all((accountsData.accounts ?? []).map(async (account: Account) => {
|
||||
if (account.provider === "GMAIL") {
|
||||
try {
|
||||
const status = await apiFetch(`/oauth/gmail/status/${account.id}`, {}, authToken);
|
||||
return { ...account, oauthConnected: status.connected, oauthExpiresAt: status.expiresAt };
|
||||
const status = await apiFetch(`/oauth/gmail/ping/${account.id}`, {}, authToken);
|
||||
return {
|
||||
...account,
|
||||
oauthConnected: status.connected,
|
||||
oauthHealthy: status.healthy,
|
||||
oauthExpiresAt: status.expiresAt,
|
||||
oauthError: status.error
|
||||
};
|
||||
} catch {
|
||||
return { ...account, oauthConnected: false };
|
||||
return { ...account, oauthConnected: false, oauthHealthy: false };
|
||||
}
|
||||
}
|
||||
return account;
|
||||
@@ -348,7 +356,7 @@ export default function App() {
|
||||
<div>
|
||||
<p className="badge">v0.1</p>
|
||||
<h1>{t("appName")}</h1>
|
||||
<p className="tagline">{tenant?.name ?? "Tenant"}</p>
|
||||
<p className="tagline">{tenant?.name ?? t("tenantFallback")}</p>
|
||||
</div>
|
||||
<div className="lang">
|
||||
<span>{user?.email ?? ""}</span>
|
||||
@@ -363,7 +371,7 @@ export default function App() {
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={handleLogout}>Logout</button>
|
||||
<button type="button" onClick={handleLogout}>{t("logout")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -562,7 +570,14 @@ export default function App() {
|
||||
{accounts.map((account) => (
|
||||
<div key={account.id} className="list-item">
|
||||
<div>
|
||||
<strong>{account.email}</strong>
|
||||
<div className="badge">
|
||||
<strong>{account.email}</strong>
|
||||
{account.provider === "GMAIL" && (
|
||||
<span className={`status-badge ${account.oauthConnected ? "" : "missing"}`}>
|
||||
{account.oauthConnected ? t("badgeConnected") : t("badgeMissing")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
{account.provider === "GMAIL"
|
||||
? t("providerGmail")
|
||||
@@ -575,9 +590,19 @@ export default function App() {
|
||||
</p>
|
||||
{account.provider === "GMAIL" && (
|
||||
<p className="status-note">
|
||||
{t("statusLabel")}: {account.oauthHealthy ? t("badgeHealthy") : t("badgeUnhealthy")} ·{" "}
|
||||
{t("adminExpiresAt")}: {account.oauthExpiresAt ? new Date(account.oauthExpiresAt).toLocaleString() : t("oauthStatusUnknown")}
|
||||
</p>
|
||||
)}
|
||||
{account.provider === "GMAIL" && account.oauthError && (
|
||||
<p className="status-note">
|
||||
{account.oauthError.code === "invalid_grant"
|
||||
? t("oauthErrorInvalidGrant")
|
||||
: account.oauthError.code === "token_expired"
|
||||
? t("oauthErrorExpired")
|
||||
: t("oauthErrorUnknown")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.provider === "GMAIL" && (
|
||||
<button className="ghost" onClick={() => startGmailOauth(account.id)}>
|
||||
@@ -596,7 +621,10 @@ export default function App() {
|
||||
<div key={rule.id} className="list-item">
|
||||
<div>
|
||||
<strong>{rule.name}</strong>
|
||||
<p>{rule.conditions.length} Bedingungen · {rule.actions.length} Aktionen</p>
|
||||
<p>
|
||||
{t("ruleConditionsCount", { count: rule.conditions.length })} ·{" "}
|
||||
{t("ruleActionsCount", { count: rule.actions.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={() => handleDeleteRule(rule.id)}>{t("delete")}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "./api";
|
||||
import { apiFetch, createEventSourceFor } from "./api";
|
||||
import { downloadFile } from "./export";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Tenant = {
|
||||
@@ -48,8 +49,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [exportTenantId, setExportTenantId] = useState<string | null>(null);
|
||||
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "done">("idle");
|
||||
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "done" | "failed">("idle");
|
||||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
||||
const [exportScope, setExportScope] = useState<"all" | "users" | "accounts" | "jobs" | "rules">("all");
|
||||
const [exportFormat, setExportFormat] = useState<"json" | "csv" | "zip">("json");
|
||||
const [tenantSort, setTenantSort] = useState<"recent" | "oldest" | "name">("recent");
|
||||
const [userSort, setUserSort] = useState<"recent" | "oldest" | "email">("recent");
|
||||
const [accountSort, setAccountSort] = useState<"recent" | "oldest" | "email">("recent");
|
||||
const [jobSort, setJobSort] = useState<"recent" | "oldest" | "status">("recent");
|
||||
|
||||
const loadAll = async () => {
|
||||
const tenantData = await apiFetch("/admin/tenants", {}, token);
|
||||
@@ -81,14 +88,36 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
const exportTenant = async (tenant: Tenant) => {
|
||||
setExportTenantId(tenant.id);
|
||||
setExportStatus("loading");
|
||||
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
|
||||
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `tenant-${tenant.id}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
if (exportFormat === "json") {
|
||||
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?scope=${exportScope}`, {}, token);
|
||||
const blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
||||
downloadFile(blob, `tenant-${tenant.id}.json`);
|
||||
} else if (exportFormat === "csv") {
|
||||
await exportTenantCsv(tenant);
|
||||
return;
|
||||
} else {
|
||||
const result = await apiFetch(`/admin/tenants/${tenant.id}/export?format=zip&scope=${exportScope}`, {}, token);
|
||||
setExportJobId(result.jobId);
|
||||
const source = createEventSourceFor(`exports/${result.jobId}`, token);
|
||||
source.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === "DONE") {
|
||||
const base = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
|
||||
const response = await fetch(`${base}/admin/exports/${result.jobId}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const blob = await response.blob();
|
||||
downloadFile(blob, `tenant-${tenant.id}.zip`);
|
||||
setExportStatus("done");
|
||||
source.close();
|
||||
setTimeout(() => setExportStatus("idle"), 1500);
|
||||
} else if (data.status === "FAILED") {
|
||||
setExportStatus("failed");
|
||||
source.close();
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
setExportStatus("done");
|
||||
setTimeout(() => setExportStatus("idle"), 1500);
|
||||
};
|
||||
@@ -102,12 +131,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
});
|
||||
const text = await response.text();
|
||||
const blob = new Blob([text], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `tenant-${tenant.id}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadFile(blob, `tenant-${tenant.id}.csv`);
|
||||
setExportStatus("done");
|
||||
setTimeout(() => setExportStatus("idle"), 1500);
|
||||
};
|
||||
@@ -170,6 +194,37 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
loadAll().catch(() => undefined);
|
||||
};
|
||||
|
||||
const sortBy = <T,>(items: T[], mode: string, getKey: (item: T) => string) => {
|
||||
const sorted = [...items];
|
||||
if (mode === "name" || mode === "email" || mode === "status") {
|
||||
sorted.sort((a, b) => getKey(a).localeCompare(getKey(b)));
|
||||
} else if (mode === "oldest") {
|
||||
sorted.reverse();
|
||||
}
|
||||
return sorted;
|
||||
};
|
||||
|
||||
const tenantsSorted = sortBy(tenants, tenantSort, (t) => t.name);
|
||||
const usersSorted = sortBy(users, userSort, (u) => u.email);
|
||||
const accountsSorted = sortBy(accounts, accountSort, (a) => a.email);
|
||||
const jobsSorted = sortBy(jobs, jobSort, (j) => j.status);
|
||||
const mapJobStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case "RUNNING":
|
||||
return t("statusRunning");
|
||||
case "QUEUED":
|
||||
return t("statusQueued");
|
||||
case "SUCCEEDED":
|
||||
return t("statusSucceeded");
|
||||
case "FAILED":
|
||||
return t("statusFailed");
|
||||
case "CANCELED":
|
||||
return t("statusCanceled");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-panel">
|
||||
<div className="admin-tabs">
|
||||
@@ -187,6 +242,16 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
{activeTab === "tenants" && (
|
||||
<div className="card">
|
||||
<h3>{t("adminTenants")}</h3>
|
||||
<div className="filter-row">
|
||||
<label>
|
||||
{t("adminSortLabel")}
|
||||
<select value={tenantSort} onChange={(event) => setTenantSort(event.target.value as typeof tenantSort)}>
|
||||
<option value="recent">{t("adminSortRecent")}</option>
|
||||
<option value="oldest">{t("adminSortOldest")}</option>
|
||||
<option value="name">{t("adminSortName")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="export-panel">
|
||||
<h4>{t("adminTenantExport")}</h4>
|
||||
<p className="status-note">{t("adminExportHint")}</p>
|
||||
@@ -200,11 +265,24 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<option value="rules">{t("adminExportRules")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<span>{t("adminExportFormat")}</span>
|
||||
<select value={exportFormat} onChange={(event) => setExportFormat(event.target.value as typeof exportFormat)}>
|
||||
<option value="json">{t("exportFormatJson")}</option>
|
||||
<option value="csv">{t("exportFormatCsv")}</option>
|
||||
<option value="zip">{t("exportFormatZip")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{tenants.map((tenant) => (
|
||||
{tenantsSorted.map((tenant) => (
|
||||
<div key={tenant.id} className="list-item">
|
||||
<div>
|
||||
<strong>{tenant.name}</strong>
|
||||
<div className="badge">
|
||||
<strong>{tenant.name}</strong>
|
||||
<span className={`status-badge ${tenant.isActive ? "" : "missing"}`}>
|
||||
{tenant.isActive ? t("adminActive") : t("adminInactive")}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{t("countUsers", { count: tenant._count?.users ?? 0 })} ·{" "}
|
||||
{t("countAccounts", { count: tenant._count?.mailboxAccounts ?? 0 })} ·{" "}
|
||||
@@ -212,8 +290,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => exportTenant(tenant)}>{t("adminExportJson")}</button>
|
||||
<button className="ghost" onClick={() => exportTenantCsv(tenant)}>{t("adminExportCsv")}</button>
|
||||
<button className="ghost" onClick={() => exportTenant(tenant)}>{t("adminExportStart")}</button>
|
||||
<button className="ghost" onClick={() => toggleTenant(tenant)}>
|
||||
{tenant.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
</button>
|
||||
@@ -223,7 +300,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
))}
|
||||
{exportTenantId && (
|
||||
<p className="status-note">
|
||||
{exportStatus === "loading" ? t("adminExporting") : exportStatus === "done" ? t("adminExportDone") : ""}
|
||||
{exportStatus === "loading"
|
||||
? t("adminExporting")
|
||||
: exportStatus === "done"
|
||||
? t("adminExportDone")
|
||||
: exportStatus === "failed"
|
||||
? t("adminExportFailed")
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -232,10 +315,25 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
{activeTab === "users" && (
|
||||
<div className="card">
|
||||
<h3>{t("adminUsers")}</h3>
|
||||
{users.map((user) => (
|
||||
<div className="filter-row">
|
||||
<label>
|
||||
{t("adminSortLabel")}
|
||||
<select value={userSort} onChange={(event) => setUserSort(event.target.value as typeof userSort)}>
|
||||
<option value="recent">{t("adminSortRecent")}</option>
|
||||
<option value="oldest">{t("adminSortOldest")}</option>
|
||||
<option value="email">{t("adminSortEmail")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{usersSorted.map((user) => (
|
||||
<div key={user.id} className="list-item">
|
||||
<div>
|
||||
<strong>{user.email}</strong>
|
||||
<div className="badge">
|
||||
<strong>{user.email}</strong>
|
||||
<span className={`status-badge ${user.isActive ? "" : "missing"}`}>
|
||||
{user.isActive ? t("adminActive") : t("adminInactive")}
|
||||
</span>
|
||||
</div>
|
||||
<p>{user.role} · {user.tenant?.name ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
@@ -277,10 +375,25 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
{activeTab === "accounts" && (
|
||||
<div className="card">
|
||||
<h3>{t("adminAccounts")}</h3>
|
||||
{accounts.map((account) => (
|
||||
<div className="filter-row">
|
||||
<label>
|
||||
{t("adminSortLabel")}
|
||||
<select value={accountSort} onChange={(event) => setAccountSort(event.target.value as typeof accountSort)}>
|
||||
<option value="recent">{t("adminSortRecent")}</option>
|
||||
<option value="oldest">{t("adminSortOldest")}</option>
|
||||
<option value="email">{t("adminSortEmail")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{accountsSorted.map((account) => (
|
||||
<div key={account.id} className="list-item">
|
||||
<div>
|
||||
<strong>{account.email}</strong>
|
||||
<div className="badge">
|
||||
<strong>{account.email}</strong>
|
||||
<span className={`status-badge ${account.isActive ? "" : "missing"}`}>
|
||||
{account.isActive ? t("adminActive") : t("adminInactive")}
|
||||
</span>
|
||||
</div>
|
||||
<p>{account.provider} · {account.tenant?.name ?? "-"}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={() => toggleAccount(account)}>
|
||||
@@ -294,10 +407,20 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
{activeTab === "jobs" && (
|
||||
<div className="card">
|
||||
<h3>{t("adminJobs")}</h3>
|
||||
{jobs.map((job) => (
|
||||
<div className="filter-row">
|
||||
<label>
|
||||
{t("adminSortLabel")}
|
||||
<select value={jobSort} onChange={(event) => setJobSort(event.target.value as typeof jobSort)}>
|
||||
<option value="recent">{t("adminSortRecent")}</option>
|
||||
<option value="oldest">{t("adminSortOldest")}</option>
|
||||
<option value="status">{t("adminSortStatus")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{jobsSorted.map((job) => (
|
||||
<div key={job.id} className="list-item">
|
||||
<div>
|
||||
<strong>{job.status}</strong>
|
||||
<strong>{mapJobStatus(job.status)}</strong>
|
||||
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
|
||||
@@ -24,3 +24,8 @@ export const createEventSource = (jobId: string, token: string) => {
|
||||
const url = `${apiUrl}/jobs/${jobId}/stream?token=${encodeURIComponent(token)}`;
|
||||
return new EventSource(url);
|
||||
};
|
||||
|
||||
export const createEventSourceFor = (path: string, token: string) => {
|
||||
const url = `${apiUrl}/jobs/${path}/stream?token=${encodeURIComponent(token)}`;
|
||||
return new EventSource(url);
|
||||
};
|
||||
|
||||
8
frontend/src/export.ts
Normal file
8
frontend/src/export.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const downloadFile = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -98,6 +98,7 @@
|
||||
"adminExportCsv": "Export CSV",
|
||||
"adminExporting": "Export läuft...",
|
||||
"adminExportDone": "Export bereit",
|
||||
"adminExportFailed": "Export fehlgeschlagen",
|
||||
"adminLastConnected": "Zuletzt verbunden",
|
||||
"adminExpiresAt": "Läuft ab",
|
||||
"oauthStatusUnknown": "Unbekannt",
|
||||
@@ -112,6 +113,8 @@
|
||||
"adminExportStart": "Export starten",
|
||||
"badgeConnected": "Verbunden",
|
||||
"badgeMissing": "Fehlt",
|
||||
"badgeHealthy": "Healthy",
|
||||
"badgeUnhealthy": "Unhealthy",
|
||||
"statusLabel": "Status",
|
||||
"ruleConditionHeader": "Header",
|
||||
"ruleConditionSubject": "Subject",
|
||||
@@ -121,5 +124,24 @@
|
||||
"ruleActionMove": "Move",
|
||||
"ruleActionDelete": "Delete",
|
||||
"ruleActionArchive": "Archive",
|
||||
"ruleActionLabel": "Label"
|
||||
"ruleActionLabel": "Label",
|
||||
"adminExportFormat": "Format",
|
||||
"exportFormatJson": "JSON",
|
||||
"exportFormatCsv": "CSV",
|
||||
"exportFormatZip": "ZIP",
|
||||
"logout": "Logout",
|
||||
"tenantFallback": "Tenant",
|
||||
"ruleConditionsCount": "{{count}} Bedingungen",
|
||||
"ruleActionsCount": "{{count}} Aktionen",
|
||||
"oauthErrorInvalidGrant": "Token ungültig oder widerrufen",
|
||||
"oauthErrorExpired": "Token abgelaufen",
|
||||
"oauthErrorUnknown": "OAuth Fehler",
|
||||
"adminActive": "Aktiv",
|
||||
"adminInactive": "Inaktiv",
|
||||
"adminSortLabel": "Sortierung",
|
||||
"adminSortRecent": "Neueste",
|
||||
"adminSortOldest": "Älteste",
|
||||
"adminSortName": "Name",
|
||||
"adminSortEmail": "Email",
|
||||
"adminSortStatus": "Status"
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"adminExportCsv": "Export CSV",
|
||||
"adminExporting": "Exporting...",
|
||||
"adminExportDone": "Export ready",
|
||||
"adminExportFailed": "Export failed",
|
||||
"adminLastConnected": "Last connected",
|
||||
"adminExpiresAt": "Expires",
|
||||
"oauthStatusUnknown": "Unknown",
|
||||
@@ -112,6 +113,8 @@
|
||||
"adminExportStart": "Start export",
|
||||
"badgeConnected": "Connected",
|
||||
"badgeMissing": "Missing",
|
||||
"badgeHealthy": "Healthy",
|
||||
"badgeUnhealthy": "Unhealthy",
|
||||
"statusLabel": "Status",
|
||||
"ruleConditionHeader": "Header",
|
||||
"ruleConditionSubject": "Subject",
|
||||
@@ -121,5 +124,24 @@
|
||||
"ruleActionMove": "Move",
|
||||
"ruleActionDelete": "Delete",
|
||||
"ruleActionArchive": "Archive",
|
||||
"ruleActionLabel": "Label"
|
||||
"ruleActionLabel": "Label",
|
||||
"adminExportFormat": "Format",
|
||||
"exportFormatJson": "JSON",
|
||||
"exportFormatCsv": "CSV",
|
||||
"exportFormatZip": "ZIP",
|
||||
"logout": "Logout",
|
||||
"tenantFallback": "Tenant",
|
||||
"ruleConditionsCount": "{{count}} conditions",
|
||||
"ruleActionsCount": "{{count}} actions",
|
||||
"oauthErrorInvalidGrant": "Token revoked or invalid",
|
||||
"oauthErrorExpired": "Token expired",
|
||||
"oauthErrorUnknown": "OAuth error",
|
||||
"adminActive": "Active",
|
||||
"adminInactive": "Inactive",
|
||||
"adminSortLabel": "Sort",
|
||||
"adminSortRecent": "Newest",
|
||||
"adminSortOldest": "Oldest",
|
||||
"adminSortName": "Name",
|
||||
"adminSortEmail": "Email",
|
||||
"adminSortStatus": "Status"
|
||||
}
|
||||
|
||||
@@ -170,6 +170,20 @@ select {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user