forked from baron/baron-sso
test: raise frontend coverage baselines
This commit is contained in:
161
devfront/src/features/auth/authPages.test.tsx
Normal file
161
devfront/src/features/auth/authPages.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AuthCallbackPage from "./AuthCallbackPage";
|
||||
import AuthGuard from "./AuthGuard";
|
||||
import AuthPage from "./AuthPage";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: undefined as
|
||||
| {
|
||||
state?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
signinRedirect: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/auth", () => ({
|
||||
userManager: {
|
||||
signinPopupCallback: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user = undefined;
|
||||
authState.signinRedirect.mockReset();
|
||||
authState.signinRedirect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function renderWithRouter(
|
||||
element: React.ReactElement,
|
||||
{
|
||||
entry = "/",
|
||||
path = "*",
|
||||
}: {
|
||||
entry?: string;
|
||||
path?: string;
|
||||
} = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path={path} element={element}>
|
||||
<Route index element={<div>Protected outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login route</div>} />
|
||||
<Route path="/clients" element={<div>Clients route</div>} />
|
||||
<Route path="/profile" element={<div>Profile route</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("devfront auth pages", () => {
|
||||
it("renders the static auth planning page", async () => {
|
||||
const container = await renderWithRouter(<AuthPage />);
|
||||
|
||||
expect(container.textContent).toContain("Admin auth guardrails");
|
||||
expect(container.textContent).toContain("Device approval");
|
||||
});
|
||||
|
||||
it("renders login page and starts SSO redirect from the action button", async () => {
|
||||
const container = await renderWithRouter(<LoginPage />, {
|
||||
entry: "/login?returnTo=/profile",
|
||||
path: "/login",
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("개발자 포털 로그인");
|
||||
|
||||
const loginButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("SSO 계정으로 로그인"),
|
||||
);
|
||||
await act(async () => {
|
||||
(loginButton as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(authState.signinRedirect).toHaveBeenCalledWith({
|
||||
state: { returnTo: "/clients" },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => {
|
||||
authState.isLoading = true;
|
||||
const loading = await renderWithRouter(<AuthGuard />);
|
||||
expect(loading.textContent).toContain("Loading...");
|
||||
|
||||
authState.isLoading = false;
|
||||
authState.error = new Error("OIDC failed");
|
||||
const error = await renderWithRouter(<AuthGuard />);
|
||||
expect(error.textContent).toContain("Authentication Error");
|
||||
|
||||
const retryButton = error.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
retryButton.click();
|
||||
});
|
||||
expect(authState.signinRedirect).toHaveBeenCalled();
|
||||
|
||||
authState.error = null;
|
||||
const redirected = await renderWithRouter(<AuthGuard />);
|
||||
expect(redirected.textContent).toContain("Login route");
|
||||
|
||||
authState.isAuthenticated = true;
|
||||
const protectedPage = await renderWithRouter(<AuthGuard />);
|
||||
expect(protectedPage.textContent).toContain("Protected outlet");
|
||||
});
|
||||
|
||||
it("navigates from callback by auth result and stored return target", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = { state: { returnTo: "/profile" } };
|
||||
|
||||
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
|
||||
entry: "/auth/callback",
|
||||
path: "/auth/callback",
|
||||
});
|
||||
expect(authenticated.textContent).toContain("Profile route");
|
||||
|
||||
authState.isAuthenticated = false;
|
||||
authState.error = new Error("callback failed");
|
||||
const failed = await renderWithRouter(<AuthCallbackPage />, {
|
||||
entry: "/auth/callback",
|
||||
path: "/auth/callback",
|
||||
});
|
||||
expect(failed.textContent).toContain("Login route");
|
||||
});
|
||||
});
|
||||
54
devfront/src/features/coverage/commonSort.test.ts
Normal file
54
devfront/src/features/coverage/commonSort.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareNullableValues,
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../../common/core/utils/sort";
|
||||
|
||||
describe("common sort utilities in devfront coverage", () => {
|
||||
it("keeps nullish values last and compares normalized primitive values", () => {
|
||||
expect(compareNullableValues(null, "alpha", "asc")).toBe(1);
|
||||
expect(compareNullableValues("alpha", undefined, "asc")).toBe(-1);
|
||||
expect(compareNullableValues("Beta", "alpha", "asc")).toBe(1);
|
||||
expect(compareNullableValues("Beta", "alpha", "desc")).toBe(-1);
|
||||
expect(compareNullableValues(true, false, "asc")).toBe(1);
|
||||
expect(
|
||||
compareNullableValues(
|
||||
new Date("2026-05-02T00:00:00Z"),
|
||||
new Date("2026-05-01T00:00:00Z"),
|
||||
"asc",
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("toggles sort direction and sorts with default and custom resolvers", () => {
|
||||
const firstSort = toggleSort(null, "name");
|
||||
expect(firstSort).toEqual({ key: "name", direction: "asc" });
|
||||
expect(toggleSort(firstSort, "name")).toEqual({
|
||||
key: "name",
|
||||
direction: "desc",
|
||||
});
|
||||
expect(toggleSort(firstSort, "createdAt")).toEqual({
|
||||
key: "createdAt",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
const rows = [
|
||||
{ name: "charlie", rank: 3, nested: { score: 20 } },
|
||||
{ name: "Alpha", rank: 1, nested: { score: 30 } },
|
||||
{ name: "bravo", rank: 2, nested: { score: 10 } },
|
||||
];
|
||||
|
||||
expect(
|
||||
sortItems(rows, { key: "name", direction: "asc" }).map(
|
||||
(row) => row.name,
|
||||
),
|
||||
).toEqual(["Alpha", "bravo", "charlie"]);
|
||||
expect(
|
||||
sortItems(rows, { key: "score", direction: "desc" }, {
|
||||
score: (row) => row.nested.score,
|
||||
}).map((row) => row.name),
|
||||
).toEqual(["Alpha", "charlie", "bravo"]);
|
||||
expect(sortItems(rows, null)).not.toBe(rows);
|
||||
});
|
||||
});
|
||||
383
devfront/src/features/coverage/pageSmoke.test.tsx
Normal file
383
devfront/src/features/coverage/pageSmoke.test.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||
import ClientConsentsPage from "../clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../clients/ClientRelationsPage";
|
||||
import ClientsPage from "../clients/ClientsPage";
|
||||
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
|
||||
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
|
||||
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
|
||||
import ProfilePage from "../profile/ProfilePage";
|
||||
|
||||
const authProfile = {
|
||||
sub: "user-1",
|
||||
role: "super_admin",
|
||||
tenant_id: "tenant-1",
|
||||
companyCode: "HANMAC",
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: authProfile,
|
||||
},
|
||||
signinRedirect: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const clientSummary = {
|
||||
id: "client-a",
|
||||
name: "Console App",
|
||||
type: "private" as const,
|
||||
status: "active" as const,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
redirectUris: ["https://app.example/callback"],
|
||||
scopes: ["openid", "profile"],
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
metadata: {
|
||||
headless_login_enabled: true,
|
||||
headless_login_jwks_uri: "https://app.example/jwks.json",
|
||||
id_token_claims: [
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: "employee_id",
|
||||
value: "E001",
|
||||
valueType: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const clientDetail = {
|
||||
client: {
|
||||
...clientSummary,
|
||||
clientSecret: "secret-value",
|
||||
jwksUri: "https://app.example/jwks.json",
|
||||
backchannelLogoutUri: "https://app.example/logout",
|
||||
backchannelLogoutSessionRequired: true,
|
||||
grantTypes: ["authorization_code"],
|
||||
responseTypes: ["code"],
|
||||
},
|
||||
endpoints: {
|
||||
discovery: "https://sso.example/.well-known/openid-configuration",
|
||||
issuer: "https://sso.example",
|
||||
authorization: "https://sso.example/oauth2/auth",
|
||||
token: "https://sso.example/oauth2/token",
|
||||
userinfo: "https://sso.example/userinfo",
|
||||
},
|
||||
headlessJwksCache: {
|
||||
clientId: "client-a",
|
||||
jwksUri: "https://app.example/jwks.json",
|
||||
cachedAt: "2026-05-01T00:00:00Z",
|
||||
expiresAt: "2026-05-02T00:00:00Z",
|
||||
lastCheckedAt: "2026-05-01T01:00:00Z",
|
||||
lastSuccessfulVerificationAt: "2026-05-01T01:00:00Z",
|
||||
lastRefreshStatus: "success" as const,
|
||||
cachedKids: ["kid-1"],
|
||||
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../lib/devApi", () => ({
|
||||
fetchClients: vi.fn(async () => ({
|
||||
items: [clientSummary],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})),
|
||||
fetchDevStats: vi.fn(async () => ({
|
||||
total_clients: 1,
|
||||
active_sessions: 12,
|
||||
auth_failures_24h: 2,
|
||||
})),
|
||||
fetchDevRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
period: "day",
|
||||
items: [
|
||||
{
|
||||
date: "2026-05-01",
|
||||
tenantId: "tenant-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "Hanmac",
|
||||
clientId: "client-a",
|
||||
clientName: "Console App",
|
||||
loginRequests: 10,
|
||||
otherRequests: 4,
|
||||
uniqueSubjects: 3,
|
||||
},
|
||||
{
|
||||
date: "2026-05-08",
|
||||
tenantId: "tenant-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "Hanmac",
|
||||
clientId: "client-a",
|
||||
clientName: "Console App",
|
||||
loginRequests: 8,
|
||||
otherRequests: 5,
|
||||
uniqueSubjects: 4,
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchClient: vi.fn(async () => clientDetail),
|
||||
fetchClientRelations: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
relation: "admins",
|
||||
subject: "User:user-1",
|
||||
subjectType: "User",
|
||||
subjectId: "user-1",
|
||||
userName: "Dev Admin",
|
||||
userEmail: "dev@example.com",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchDevUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Editor User",
|
||||
email: "editor@example.com",
|
||||
loginId: "editor",
|
||||
},
|
||||
],
|
||||
})),
|
||||
addClientRelation: vi.fn(async () => ({
|
||||
relation: "admins",
|
||||
subject: "User:user-2",
|
||||
subjectType: "User",
|
||||
subjectId: "user-2",
|
||||
})),
|
||||
removeClientRelation: vi.fn(async () => undefined),
|
||||
updateClientStatus: vi.fn(async () => clientDetail),
|
||||
createClient: vi.fn(async () => clientDetail),
|
||||
updateClient: vi.fn(async () => clientDetail),
|
||||
rotateClientSecret: vi.fn(async () => clientDetail),
|
||||
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
|
||||
revokeHeadlessJwksCache: vi.fn(async () => undefined),
|
||||
deleteClient: vi.fn(async () => undefined),
|
||||
fetchConsents: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
subject: "user-1",
|
||||
userName: "Consent User",
|
||||
clientId: "client-a",
|
||||
clientName: "Console App",
|
||||
grantedScopes: ["openid", "profile"],
|
||||
authenticatedAt: "2026-05-01T02:00:00Z",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
status: "active",
|
||||
tenantId: "tenant-1",
|
||||
tenantName: "Hanmac",
|
||||
},
|
||||
],
|
||||
})),
|
||||
revokeConsent: vi.fn(async () => undefined),
|
||||
listIdpConfigsForClient: vi.fn(async () => [
|
||||
{
|
||||
id: "idp-1",
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "Workspace OIDC",
|
||||
status: "active",
|
||||
issuer_url: "https://accounts.example",
|
||||
oidc_client_id: "oidc-client",
|
||||
scopes: "openid email profile",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
createIdpConfigForClient: vi.fn(async (payload) => ({
|
||||
id: "idp-1",
|
||||
...payload,
|
||||
status: "active",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
})),
|
||||
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
|
||||
id: idpId,
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "Provider",
|
||||
status: "active",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
...payload,
|
||||
})),
|
||||
deleteIdpConfig: vi.fn(async () => undefined),
|
||||
fetchDevAuditLogs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
event_id: "event-1",
|
||||
timestamp: "2026-05-01T00:00:00Z",
|
||||
user_id: "user-1",
|
||||
event_type: "client.update",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
details: "{\"client_id\":\"client-a\"}",
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
})),
|
||||
fetchMyTenants: vi.fn(async () => [
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "Hanmac",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
status: "active",
|
||||
description: "",
|
||||
memberCount: 10,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
|
||||
requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
|
||||
fetchDeveloperRequests: vi.fn(async () => [
|
||||
{
|
||||
id: 1,
|
||||
userId: "user-3",
|
||||
tenantId: "tenant-1",
|
||||
name: "Requester",
|
||||
organization: "Hanmac",
|
||||
email: "requester@example.com",
|
||||
reason: "Need RP access",
|
||||
status: "pending",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
|
||||
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
|
||||
cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
|
||||
}));
|
||||
|
||||
vi.mock("../auth/authApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
email: "dev@example.com",
|
||||
name: "Dev Admin",
|
||||
role: "super_admin",
|
||||
tenantId: "tenant-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function renderPage(
|
||||
element: React.ReactElement,
|
||||
{
|
||||
path = "/",
|
||||
entry = path,
|
||||
}: {
|
||||
path?: string;
|
||||
entry?: string;
|
||||
} = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path={path} element={element} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("devfront coverage smoke pages", () => {
|
||||
it("renders overview, client list, audit, developer request, and profile pages", async () => {
|
||||
const overview = await renderPage(<GlobalOverviewPage />);
|
||||
expect(overview.textContent).toContain("Console App");
|
||||
|
||||
const clients = await renderPage(<ClientsPage />);
|
||||
expect(clients.textContent).toContain("Console App");
|
||||
|
||||
const audit = await renderPage(<AuditLogsPage />);
|
||||
expect(audit.textContent).toContain("client.update");
|
||||
|
||||
const requests = await renderPage(<DeveloperRequestPage />);
|
||||
expect(requests.textContent).toContain("Requester");
|
||||
|
||||
const profile = await renderPage(<ProfilePage />);
|
||||
expect(profile.textContent).toContain("Dev Admin");
|
||||
});
|
||||
|
||||
it("renders client detail, settings, consent, federation, and relationship pages", async () => {
|
||||
const details = await renderPage(<ClientDetailsPage />, {
|
||||
path: "/clients/:id",
|
||||
entry: "/clients/client-a",
|
||||
});
|
||||
expect(details.textContent).toContain("Console App");
|
||||
|
||||
const settings = await renderPage(<ClientGeneralPage />, {
|
||||
path: "/clients/:id/settings",
|
||||
entry: "/clients/client-a/settings",
|
||||
});
|
||||
expect(settings.textContent).toContain("Console App");
|
||||
|
||||
const consents = await renderPage(<ClientConsentsPage />, {
|
||||
path: "/clients/:id/consents",
|
||||
entry: "/clients/client-a/consents",
|
||||
});
|
||||
expect(consents.textContent).toContain("Consent User");
|
||||
|
||||
const federation = await renderPage(<ClientFederationPage />, {
|
||||
path: "/clients/:id/federation",
|
||||
entry: "/clients/client-a/federation",
|
||||
});
|
||||
expect(federation.textContent).toContain("Workspace OIDC");
|
||||
|
||||
const relations = await renderPage(<ClientRelationsPage />, {
|
||||
path: "/clients/:id/relationships",
|
||||
entry: "/clients/client-a/relationships",
|
||||
});
|
||||
expect(relations.textContent).toContain("Dev Admin");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user