mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(build): resolve all TypeScript compilation errors and Next.js 15 dynamic route slug conflicts
- Fix Next.js 15 async params in 4 API route handlers (accounts, providers, registered-keys) - Move providers/[id]/limits → providers/[provider]/limits to resolve slug name conflict - Add keytar to serverExternalPackages and KNOWN_EXTERNALS in next.config.mjs - Fix Zod z.record() arity across a2a.ts and issues/report/route.ts - Fix SearchResponse interface (optional answer property) in SearchTools and ResultsPanel - Fix ProviderLimits implicit any types in index.tsx and utils.tsx - Fix better-sqlite3 prepare<T> generic usage in secrets.ts - Remove duplicate pricing keys (gemini-3-flash-preview) - Cast analytics result, ApiErrorType import, TaskRoutingConfig type - Remove rogue app/ duplicate directory from project root Resolves: #560
This commit is contained in:
@@ -17,6 +17,7 @@ const nextConfig = {
|
||||
"pino-pretty",
|
||||
"thread-stream",
|
||||
"better-sqlite3",
|
||||
"keytar",
|
||||
"zod",
|
||||
"child_process",
|
||||
"fs",
|
||||
@@ -70,6 +71,7 @@ const nextConfig = {
|
||||
|
||||
const KNOWN_EXTERNALS = new Set([
|
||||
"better-sqlite3",
|
||||
"keytar",
|
||||
"zod",
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
|
||||
@@ -57,7 +57,7 @@ export const TaskInputSchema = z.object({
|
||||
role: z
|
||||
.enum(["coding", "review", "planning", "analysis", "debugging", "documentation"])
|
||||
.optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const CostEnvelopeSchema = z.object({
|
||||
@@ -120,7 +120,7 @@ export type PolicyVerdict = z.infer<typeof PolicyVerdictSchema>;
|
||||
export const JsonRpcRequestSchema = z.object({
|
||||
jsonrpc: z.literal("2.0"),
|
||||
method: z.enum(["message/send", "message/stream", "tasks/get", "tasks/cancel"]),
|
||||
params: z.record(z.unknown()),
|
||||
params: z.record(z.string(), z.unknown()),
|
||||
id: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ export const MessageSendParamsSchema = z.object({
|
||||
message: z.object({
|
||||
role: z.string().default("user"),
|
||||
content: z.string(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
config: z
|
||||
.object({
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SearchResponse {
|
||||
id: string;
|
||||
provider: string;
|
||||
query: string;
|
||||
answer?: string;
|
||||
results: SearchResult[];
|
||||
cached: boolean;
|
||||
usage: {
|
||||
|
||||
@@ -20,7 +20,7 @@ interface SearchResponse {
|
||||
provider: string;
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
answer: string | null;
|
||||
answer?: string | null;
|
||||
cached: boolean;
|
||||
usage: {
|
||||
queries_used: number;
|
||||
|
||||
@@ -138,67 +138,70 @@ export default function ProviderLimits() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchQuota = useCallback(async (connectionId, provider, options = {}) => {
|
||||
const force = options?.force === true;
|
||||
// Debounce: skip if last fetch was < MIN_FETCH_INTERVAL_MS ago
|
||||
const now = Date.now();
|
||||
const lastFetch = lastFetchTimeRef.current[connectionId] || 0;
|
||||
if (!force && now - lastFetch < MIN_FETCH_INTERVAL_MS) {
|
||||
return; // Skip, data is still fresh
|
||||
}
|
||||
lastFetchTimeRef.current[connectionId] = now;
|
||||
|
||||
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||
try {
|
||||
const response = await fetch(`/api/usage/${connectionId}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMsg = errorData.error || response.statusText;
|
||||
if (response.status === 404) return;
|
||||
if (response.status === 401) {
|
||||
setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: { quotas: [], message: errorMsg },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
||||
const fetchQuota = useCallback(
|
||||
async (connectionId, provider, options: { force?: boolean } = {}) => {
|
||||
const force = options?.force === true;
|
||||
// Debounce: skip if last fetch was < MIN_FETCH_INTERVAL_MS ago
|
||||
const now = Date.now();
|
||||
const lastFetch = lastFetchTimeRef.current[connectionId] || 0;
|
||||
if (!force && now - lastFetch < MIN_FETCH_INTERVAL_MS) {
|
||||
return; // Skip, data is still fresh
|
||||
}
|
||||
const data = await response.json();
|
||||
const parsedQuotas = parseQuotaData(provider, data);
|
||||
lastFetchTimeRef.current[connectionId] = now;
|
||||
|
||||
// T13: If resetAt already passed but provider still returned stale cumulative usage,
|
||||
// display 0 immediately and trigger a background probe to refresh snapshot.
|
||||
const hasStaleAfterReset = parsedQuotas.some((q) => q?.staleAfterReset === true);
|
||||
if (hasStaleAfterReset) {
|
||||
const lastProbeAt = staleProbeRef.current[connectionId] || 0;
|
||||
if (Date.now() - lastProbeAt >= MIN_FETCH_INTERVAL_MS) {
|
||||
staleProbeRef.current[connectionId] = Date.now();
|
||||
setTimeout(() => {
|
||||
fetchQuota(connectionId, provider, { force: true }).catch(() => {});
|
||||
}, 5000);
|
||||
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||
try {
|
||||
const response = await fetch(`/api/usage/${connectionId}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMsg = errorData.error || response.statusText;
|
||||
if (response.status === 404) return;
|
||||
if (response.status === 401) {
|
||||
setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: { quotas: [], message: errorMsg },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
const data = await response.json();
|
||||
const parsedQuotas = parseQuotaData(provider, data);
|
||||
|
||||
setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: {
|
||||
quotas: parsedQuotas,
|
||||
plan: data.plan || null,
|
||||
message: data.message || null,
|
||||
raw: data,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: error.message || "Failed to fetch quota",
|
||||
}));
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, [connectionId]: false }));
|
||||
}
|
||||
}, []);
|
||||
// T13: If resetAt already passed but provider still returned stale cumulative usage,
|
||||
// display 0 immediately and trigger a background probe to refresh snapshot.
|
||||
const hasStaleAfterReset = parsedQuotas.some((q) => q?.staleAfterReset === true);
|
||||
if (hasStaleAfterReset) {
|
||||
const lastProbeAt = staleProbeRef.current[connectionId] || 0;
|
||||
if (Date.now() - lastProbeAt >= MIN_FETCH_INTERVAL_MS) {
|
||||
staleProbeRef.current[connectionId] = Date.now();
|
||||
setTimeout(() => {
|
||||
fetchQuota(connectionId, provider, { force: true }).catch(() => {});
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: {
|
||||
quotas: parsedQuotas,
|
||||
plan: data.plan || null,
|
||||
message: data.message || null,
|
||||
raw: data,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: error.message || "Failed to fetch quota",
|
||||
}));
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, [connectionId]: false }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const refreshProvider = useCallback(
|
||||
async (connectionId, provider) => {
|
||||
|
||||
@@ -84,7 +84,7 @@ function isPastResetWindow(resetAt) {
|
||||
return Date.now() >= resetTime;
|
||||
}
|
||||
|
||||
function normalizeQuotaEntry(name, quota = {}, extras = {}) {
|
||||
function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) {
|
||||
const usedRaw = Number(quota?.used || 0);
|
||||
const totalRaw = Number(quota?.total || 0);
|
||||
const resetAt = quota?.resetAt || null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextResponse, type Request } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { setDefaultFastServiceTierEnabled } from "@omniroute/open-sse/executors/codex.ts";
|
||||
import { updateCodexServiceTierSchema } from "@/shared/validation/schemas";
|
||||
@@ -35,7 +35,7 @@ export async function PUT(request: Request) {
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(updateCodexServiceTierSchema, rawBody);
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
import { updateProxyConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import {
|
||||
createErrorResponse,
|
||||
createErrorResponseFromUnknown,
|
||||
type ApiErrorType,
|
||||
} from "@/lib/api/errorResponse";
|
||||
import type { z } from "zod";
|
||||
|
||||
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
|
||||
@@ -174,7 +178,8 @@ export async function PUT(request: Request) {
|
||||
} catch (error) {
|
||||
const routeError = toApiRouteError(error);
|
||||
const status = Number(routeError.status) || 500;
|
||||
const type = routeError.type || (status === 400 ? "invalid_request" : "server_error");
|
||||
const type = (routeError.type ||
|
||||
(status === 400 ? "invalid_request" : "server_error")) as ApiErrorType;
|
||||
return createErrorResponse({ status, message: routeError.message, type });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ export async function PUT(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const config = validation.data;
|
||||
const config =
|
||||
validation.data as import("@omniroute/open-sse/services/taskAwareRouter.ts").TaskRoutingConfig;
|
||||
|
||||
setTaskRoutingConfig(config);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function GET(request) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const analytics = await computeAnalytics(history, range, connectionMap);
|
||||
const analytics: any = await computeAnalytics(history, range, connectionMap);
|
||||
|
||||
// T01: fallback transparency metrics from call_logs (requested_model vs routed model).
|
||||
try {
|
||||
|
||||
@@ -13,20 +13,21 @@ const limitsSchema = z.object({
|
||||
* GET /api/v1/accounts/[id]/limits
|
||||
* Get the current issuance limits for an account.
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const limits = getAccountKeyLimit(params.id);
|
||||
return NextResponse.json({ accountId: params.id, limits: limits ?? null });
|
||||
const resolvedParams = await params;
|
||||
const limits = getAccountKeyLimit(resolvedParams.id);
|
||||
return NextResponse.json({ accountId: resolvedParams.id, limits: limits ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/accounts/[id]/limits
|
||||
* Configure issuance limits for an account.
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
@@ -43,7 +44,8 @@ export async function PUT(request: Request, { params }: { params: { id: string }
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
setAccountKeyLimit(params.id, parsed.data);
|
||||
const updated = getAccountKeyLimit(params.id);
|
||||
return NextResponse.json({ accountId: params.id, limits: updated });
|
||||
const resolvedParams = await params;
|
||||
setAccountKeyLimit(resolvedParams.id, parsed.data);
|
||||
const updated = getAccountKeyLimit(resolvedParams.id);
|
||||
return NextResponse.json({ accountId: resolvedParams.id, limits: updated });
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function POST(request) {
|
||||
let dynamicProviders: ReturnType<typeof buildDynamicAudioProvider>[] = [];
|
||||
try {
|
||||
const nodes = await getProviderNodes();
|
||||
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
|
||||
dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : [])
|
||||
.filter((n: ProviderNodeRow) => {
|
||||
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
|
||||
try {
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function POST(request) {
|
||||
let dynamicProviders: ReturnType<typeof buildDynamicAudioProvider>[] = [];
|
||||
try {
|
||||
const nodes = await getProviderNodes();
|
||||
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
|
||||
dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : [])
|
||||
.filter((n: ProviderNodeRow) => {
|
||||
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@ const reportSchema = z.object({
|
||||
accountId: z.string().max(120).optional(),
|
||||
requestId: z.string().max(200).optional(),
|
||||
errorCode: z.string().max(100).optional(),
|
||||
details: z.record(z.unknown()).optional(),
|
||||
details: z.record(z.string(), z.unknown()).optional(),
|
||||
labels: z.array(z.string().max(50)).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -10,23 +10,24 @@ const limitsSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/providers/[id]/limits
|
||||
* GET /api/v1/providers/[provider]/limits
|
||||
* Get the current issuance limits for a provider.
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const limits = getProviderKeyLimit(params.id);
|
||||
return NextResponse.json({ provider: params.id, limits: limits ?? null });
|
||||
const { provider } = await params;
|
||||
const limits = getProviderKeyLimit(provider);
|
||||
return NextResponse.json({ provider, limits: limits ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/providers/[id]/limits
|
||||
* PUT /api/v1/providers/[provider]/limits
|
||||
* Configure issuance limits for a provider.
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ provider: string }> }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
@@ -43,7 +44,8 @@ export async function PUT(request: Request, { params }: { params: { id: string }
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
setProviderKeyLimit(params.id, parsed.data);
|
||||
const updated = getProviderKeyLimit(params.id);
|
||||
return NextResponse.json({ provider: params.id, limits: updated });
|
||||
const { provider } = await params;
|
||||
setProviderKeyLimit(provider, parsed.data);
|
||||
const updated = getProviderKeyLimit(provider);
|
||||
return NextResponse.json({ provider, limits: updated });
|
||||
}
|
||||
@@ -7,15 +7,20 @@ import { revokeRegisteredKey } from "@/lib/db/registeredKeys";
|
||||
*
|
||||
* Explicit revoke endpoint (supports clients that cannot issue DELETE requests).
|
||||
*/
|
||||
export async function POST(request: Request, { params }: { params: { id: string } }) {
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const revoked = revokeRegisteredKey(params.id);
|
||||
const resolvedParams = await params;
|
||||
const revoked = revokeRegisteredKey(resolvedParams.id);
|
||||
if (!revoked) {
|
||||
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: resolvedParams.id,
|
||||
revokedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import { getRegisteredKey, revokeRegisteredKey } from "@/lib/db/registeredKeys";
|
||||
|
||||
// ─── GET /api/v1/registered-keys/[id] ────────────────────────────────────────
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const key = getRegisteredKey(params.id);
|
||||
const resolvedParams = await params;
|
||||
const key = getRegisteredKey(resolvedParams.id);
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
@@ -19,15 +20,20 @@ export async function GET(request: Request, { params }: { params: { id: string }
|
||||
|
||||
// ─── DELETE /api/v1/registered-keys/[id] ─────────────────────────────────────
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const revoked = revokeRegisteredKey(params.id);
|
||||
const resolvedParams = await params;
|
||||
const revoked = revokeRegisteredKey(resolvedParams.id);
|
||||
if (!revoked) {
|
||||
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: resolvedParams.id,
|
||||
revokedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
interface SecretRow {
|
||||
value?: unknown;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function getPersistedSecret(key: string): string | null {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare<SecretRow>("SELECT value FROM key_value WHERE namespace = 'secrets' AND key = ?")
|
||||
.get(key);
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'secrets' AND key = ?")
|
||||
.get(key) as SecretRow | undefined;
|
||||
return typeof row?.value === "string" ? JSON.parse(row.value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -19,8 +19,9 @@ export function getPersistedSecret(key: string): string | null {
|
||||
export function persistSecret(key: string, value: string): void {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
db.prepare("INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('secrets', ?, ?)")
|
||||
.run(key, JSON.stringify(value));
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('secrets', ?, ?)"
|
||||
).run(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Non-fatal: secrets still work for the current process if persistence fails.
|
||||
}
|
||||
|
||||
@@ -219,20 +219,7 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
input: 0.5,
|
||||
output: 3.0,
|
||||
cached: 0.03,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
input: 0.5,
|
||||
output: 3.0,
|
||||
cached: 0.03,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
|
||||
"gemini-2.5-pro": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
|
||||
Reference in New Issue
Block a user