fix(#411): remediate frontend review findings — wire fetchWithRetry, fix error handling
- Wire fetchWithRetry into login page config fetch (was dead code) - Remove duplicate ERROR_CODE_MESSAGES, use parseAuthError from auth-errors.ts - Fix OAuth sign-in fire-and-forget: add .catch() with PDA error + loading reset - Fix credential login catch: use parseAuthError for better error messages - Add user feedback when auth config fetch fails (was silent degradation) - Fix sign-out failure: use logAuthError and set authError state - Enable fetchWithRetry production logging for retry visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,19 +35,34 @@ vi.mock("@/lib/config", () => ({
|
|||||||
API_BASE_URL: "http://localhost:3001",
|
API_BASE_URL: "http://localhost:3001",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock fetchWithRetry to behave like fetch for test purposes
|
||||||
|
const { mockFetchWithRetry } = vi.hoisted(() => ({
|
||||||
|
mockFetchWithRetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/fetch-with-retry", () => ({
|
||||||
|
fetchWithRetry: mockFetchWithRetry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock parseAuthError to use the real implementation
|
||||||
|
vi.mock("@/lib/auth/auth-errors", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/auth/auth-errors")>("@/lib/auth/auth-errors");
|
||||||
|
return actual;
|
||||||
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Helpers */
|
/* Helpers */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function mockFetchConfig(config: AuthConfigResponse): void {
|
function mockFetchConfig(config: AuthConfigResponse): void {
|
||||||
(global.fetch as Mock).mockResolvedValueOnce({
|
mockFetchWithRetry.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: (): Promise<AuthConfigResponse> => Promise.resolve(config),
|
json: (): Promise<AuthConfigResponse> => Promise.resolve(config),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockFetchFailure(): void {
|
function mockFetchFailure(): void {
|
||||||
(global.fetch as Mock).mockRejectedValueOnce(new Error("Network error"));
|
mockFetchWithRetry.mockRejectedValueOnce(new Error("Network error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const OAUTH_ONLY_CONFIG: AuthConfigResponse = {
|
const OAUTH_ONLY_CONFIG: AuthConfigResponse = {
|
||||||
@@ -72,15 +87,16 @@ const BOTH_PROVIDERS_CONFIG: AuthConfigResponse = {
|
|||||||
describe("LoginPage", (): void => {
|
describe("LoginPage", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
global.fetch = vi.fn();
|
|
||||||
// Reset search params to empty for each test
|
// Reset search params to empty for each test
|
||||||
mockSearchParams.delete("error");
|
mockSearchParams.delete("error");
|
||||||
|
// Default: OAuth2 returns a resolved promise (fire-and-forget redirect)
|
||||||
|
mockOAuth2.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders loading state initially", (): void => {
|
it("renders loading state initially", (): void => {
|
||||||
// Never resolve fetch so it stays in loading state
|
// Never resolve fetch so it stays in loading state
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise to test loading state
|
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise to test loading state
|
||||||
(global.fetch as Mock).mockReturnValueOnce(new Promise(() => {}));
|
mockFetchWithRetry.mockReturnValueOnce(new Promise(() => {}));
|
||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
@@ -105,13 +121,13 @@ describe("LoginPage", (): void => {
|
|||||||
expect(main).toHaveClass("flex", "min-h-screen");
|
expect(main).toHaveClass("flex", "min-h-screen");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches /auth/config on mount", async (): Promise<void> => {
|
it("fetches /auth/config on mount using fetchWithRetry", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(global.fetch).toHaveBeenCalledWith("http://localhost:3001/auth/config");
|
expect(mockFetchWithRetry).toHaveBeenCalledWith("http://localhost:3001/auth/config");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,7 +180,7 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to email-only on fetch failure", async (): Promise<void> => {
|
it("falls back to email-only on fetch failure and shows unavailability message", async (): Promise<void> => {
|
||||||
mockFetchFailure();
|
mockFetchFailure();
|
||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
@@ -175,10 +191,13 @@ describe("LoginPage", (): void => {
|
|||||||
|
|
||||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show the unavailability banner (fix #5)
|
||||||
|
expect(screen.getByText("Some sign-in options may be temporarily unavailable.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to email-only on non-ok response", async (): Promise<void> => {
|
it("falls back to email-only on non-ok response", async (): Promise<void> => {
|
||||||
(global.fetch as Mock).mockResolvedValueOnce({
|
mockFetchWithRetry.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
});
|
});
|
||||||
@@ -210,6 +229,26 @@ describe("LoginPage", (): void => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows error when OAuth sign-in fails", async (): Promise<void> => {
|
||||||
|
mockFetchConfig(OAUTH_ONLY_CONFIG);
|
||||||
|
mockOAuth2.mockRejectedValueOnce(new Error("Provider unavailable"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /continue with authentik/i }));
|
||||||
|
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unable to connect to the sign-in provider. Please try again in a moment.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("calls signIn.email and redirects on success", async (): Promise<void> => {
|
it("calls signIn.email and redirects on success", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } });
|
mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } });
|
||||||
@@ -261,9 +300,9 @@ describe("LoginPage", (): void => {
|
|||||||
expect(mockPush).not.toHaveBeenCalled();
|
expect(mockPush).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows generic error on unexpected sign-in exception", async (): Promise<void> => {
|
it("shows parseAuthError message on unexpected sign-in exception", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
mockSignInEmail.mockRejectedValueOnce(new Error("Network failure"));
|
mockSignInEmail.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
@@ -277,8 +316,9 @@ describe("LoginPage", (): void => {
|
|||||||
await user.click(screen.getByRole("button", { name: /continue/i }));
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
|
// parseAuthError maps TypeError("Failed to fetch") to network_error message
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Something went wrong. Please try again in a moment.")
|
screen.getByText("Unable to connect. Check your network and try again.")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -333,7 +373,7 @@ describe("LoginPage", (): void => {
|
|||||||
it("loading spinner has role=status", (): void => {
|
it("loading spinner has role=status", (): void => {
|
||||||
// Never resolve fetch so it stays in loading state
|
// Never resolve fetch so it stays in loading state
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
|
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
|
||||||
(global.fetch as Mock).mockReturnValueOnce(new Promise(() => {}));
|
mockFetchWithRetry.mockReturnValueOnce(new Promise(() => {}));
|
||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Loader2 } from "lucide-react";
|
|||||||
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
||||||
import { API_BASE_URL } from "@/lib/config";
|
import { API_BASE_URL } from "@/lib/config";
|
||||||
import { signIn } from "@/lib/auth-client";
|
import { signIn } from "@/lib/auth-client";
|
||||||
|
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
||||||
|
import { parseAuthError } from "@/lib/auth/auth-errors";
|
||||||
import { OAuthButton } from "@/components/auth/OAuthButton";
|
import { OAuthButton } from "@/components/auth/OAuthButton";
|
||||||
import { LoginForm } from "@/components/auth/LoginForm";
|
import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
import { AuthDivider } from "@/components/auth/AuthDivider";
|
import { AuthDivider } from "@/components/auth/AuthDivider";
|
||||||
@@ -17,22 +19,6 @@ const EMAIL_ONLY_CONFIG: AuthConfigResponse = {
|
|||||||
providers: [{ id: "email", name: "Email", type: "credentials" }],
|
providers: [{ id: "email", name: "Email", type: "credentials" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Maps URL error codes to PDA-friendly messages (no alarming language). */
|
|
||||||
const ERROR_CODE_MESSAGES: Record<string, string> = {
|
|
||||||
access_denied: "Authentication paused. Please try again when ready.",
|
|
||||||
invalid_credentials: "The email and password combination wasn't recognized.",
|
|
||||||
server_error: "The service is taking a break. Please try again in a moment.",
|
|
||||||
network_error: "Unable to connect. Check your network and try again.",
|
|
||||||
rate_limited: "You've tried a few times. Take a moment and try again shortly.",
|
|
||||||
session_expired: "Your session ended. Please sign in again when ready.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_ERROR_MESSAGE = "Authentication didn't complete. Please try again when ready.";
|
|
||||||
|
|
||||||
function mapErrorCodeToMessage(code: string): string {
|
|
||||||
return ERROR_CODE_MESSAGES[code] ?? DEFAULT_ERROR_MESSAGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage(): ReactElement {
|
export default function LoginPage(): ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -47,7 +33,8 @@ export default function LoginPage(): ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const errorCode = searchParams.get("error");
|
const errorCode = searchParams.get("error");
|
||||||
if (errorCode) {
|
if (errorCode) {
|
||||||
setUrlError(mapErrorCodeToMessage(errorCode));
|
const parsed = parseAuthError(errorCode);
|
||||||
|
setUrlError(parsed.message);
|
||||||
// Clean up the URL by removing the error param without triggering navigation
|
// Clean up the URL by removing the error param without triggering navigation
|
||||||
const nextParams = new URLSearchParams(searchParams.toString());
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
nextParams.delete("error");
|
nextParams.delete("error");
|
||||||
@@ -61,7 +48,7 @@ export default function LoginPage(): ReactElement {
|
|||||||
|
|
||||||
async function fetchConfig(): Promise<void> {
|
async function fetchConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/config`);
|
const response = await fetchWithRetry(`${API_BASE_URL}/auth/config`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch auth config");
|
throw new Error("Failed to fetch auth config");
|
||||||
}
|
}
|
||||||
@@ -69,9 +56,13 @@ export default function LoginPage(): ReactElement {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error("[Auth] Failed to load auth config:", err);
|
||||||
|
}
|
||||||
setConfig(EMAIL_ONLY_CONFIG);
|
setConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
setUrlError("Some sign-in options may be temporarily unavailable.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -98,7 +89,14 @@ export default function LoginPage(): ReactElement {
|
|||||||
const handleOAuthLogin = useCallback((providerId: string): void => {
|
const handleOAuthLogin = useCallback((providerId: string): void => {
|
||||||
setOauthLoading(providerId);
|
setOauthLoading(providerId);
|
||||||
setError(null);
|
setError(null);
|
||||||
void signIn.oauth2({ providerId, callbackURL: "/" });
|
signIn.oauth2({ providerId, callbackURL: "/" }).catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||||
|
}
|
||||||
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
|
setOauthLoading(null);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCredentialsLogin = useCallback(
|
const handleCredentialsLogin = useCallback(
|
||||||
@@ -118,8 +116,12 @@ export default function LoginPage(): ReactElement {
|
|||||||
} else {
|
} else {
|
||||||
router.push("/tasks");
|
router.push("/tasks");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
setError("Something went wrong. Please try again in a moment.");
|
const parsed = parseAuthError(err);
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error("[Auth] Credentials sign-in failed:", err);
|
||||||
|
}
|
||||||
|
setError(parsed.message);
|
||||||
} finally {
|
} finally {
|
||||||
setCredentialsLoading(false);
|
setCredentialsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
|
|||||||
try {
|
try {
|
||||||
await apiPost("/auth/sign-out");
|
await apiPost("/auth/sign-out");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Sign out error:", error);
|
logAuthError("Sign out request did not complete", error);
|
||||||
|
setAuthError("network");
|
||||||
} finally {
|
} finally {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
expiresAtRef.current = null;
|
expiresAtRef.current = null;
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ function mockResponse(status: number, ok?: boolean): Response {
|
|||||||
|
|
||||||
describe("fetchWithRetry", (): void => {
|
describe("fetchWithRetry", (): void => {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
const originalEnv = process.env.NODE_ENV;
|
|
||||||
const sleepMock = vi.mocked(sleep);
|
const sleepMock = vi.mocked(sleep);
|
||||||
|
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
@@ -52,7 +51,6 @@ describe("fetchWithRetry", (): void => {
|
|||||||
afterEach((): void => {
|
afterEach((): void => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
process.env.NODE_ENV = originalEnv;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed on first attempt without retrying", async (): Promise<void> => {
|
it("should succeed on first attempt without retrying", async (): Promise<void> => {
|
||||||
@@ -203,8 +201,7 @@ describe("fetchWithRetry", (): void => {
|
|||||||
expect(recordedDelays).toEqual([1000, 2000, 4000]);
|
expect(recordedDelays).toEqual([1000, 2000, 4000]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log retry attempts in development mode", async (): Promise<void> => {
|
it("should log retry attempts in all environments", async (): Promise<void> => {
|
||||||
process.env.NODE_ENV = "development";
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {});
|
||||||
|
|
||||||
const okResponse = mockResponse(200);
|
const okResponse = mockResponse(200);
|
||||||
@@ -222,18 +219,22 @@ describe("fetchWithRetry", (): void => {
|
|||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT log retry attempts in production mode", async (): Promise<void> => {
|
it("should log retry attempts for HTTP errors", async (): Promise<void> => {
|
||||||
process.env.NODE_ENV = "production";
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation((): void => {});
|
||||||
|
|
||||||
|
const serverError = mockResponse(500);
|
||||||
const okResponse = mockResponse(200);
|
const okResponse = mockResponse(200);
|
||||||
|
|
||||||
vi.mocked(global.fetch)
|
vi.mocked(global.fetch)
|
||||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
.mockResolvedValueOnce(serverError)
|
||||||
.mockResolvedValueOnce(okResponse);
|
.mockResolvedValueOnce(okResponse);
|
||||||
|
|
||||||
await fetchWithRetry("https://api.example.com/auth/config");
|
await fetchWithRetry("https://api.example.com/auth/config");
|
||||||
|
|
||||||
expect(warnSpy).not.toHaveBeenCalled();
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[Auth] Retry 1/3 after HTTP 500"),
|
||||||
|
);
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,9 +80,7 @@ export async function fetchWithRetry(
|
|||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
const delay = computeDelay(attempt, baseDelayMs, backoffFactor);
|
const delay = computeDelay(attempt, baseDelayMs, backoffFactor);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`);
|
||||||
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after HTTP ${response.status}, waiting ${delay}ms...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -96,9 +94,7 @@ export async function fetchWithRetry(
|
|||||||
lastError = error;
|
lastError = error;
|
||||||
const delay = computeDelay(attempt, baseDelayMs, backoffFactor);
|
const delay = computeDelay(attempt, baseDelayMs, backoffFactor);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`);
|
||||||
console.warn(`[Auth] Retry ${attempt + 1}/${maxRetries} after ${parsed.code}, waiting ${delay}ms...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user