2022-11-18 09:22:43 +00:00
|
|
|
/*
|
2024-09-09 13:57:16 +00:00
|
|
|
Copyright 2024 New Vector Ltd.
|
2022-11-18 09:22:43 +00:00
|
|
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
|
|
|
2024-09-09 13:57:16 +00:00
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
|
|
Please see LICENSE files in the repository root for full details.
|
2022-11-18 09:22:43 +00:00
|
|
|
*/
|
|
|
|
|
2022-11-22 06:58:37 +00:00
|
|
|
import React from "react";
|
|
|
|
import { mocked } from "jest-mock";
|
2024-10-14 16:11:58 +00:00
|
|
|
import { act, render, RenderResult, screen } from "jest-matrix-react";
|
2022-11-22 06:58:37 +00:00
|
|
|
import userEvent from "@testing-library/user-event";
|
|
|
|
import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix";
|
|
|
|
|
2024-10-15 13:57:26 +00:00
|
|
|
import ForgotPassword from "../../../../../src/components/structures/auth/ForgotPassword";
|
|
|
|
import { ValidatedServerConfig } from "../../../../../src/utils/ValidatedServerConfig";
|
2023-03-24 19:39:24 +00:00
|
|
|
import {
|
|
|
|
clearAllModals,
|
|
|
|
filterConsole,
|
|
|
|
flushPromisesWithFakeTimers,
|
|
|
|
stubClient,
|
|
|
|
waitEnoughCyclesForModal,
|
2024-10-15 13:57:26 +00:00
|
|
|
} from "../../../../test-utils";
|
|
|
|
import AutoDiscoveryUtils from "../../../../../src/utils/AutoDiscoveryUtils";
|
2022-11-22 06:58:37 +00:00
|
|
|
|
|
|
|
jest.mock("matrix-js-sdk/src/matrix", () => ({
|
|
|
|
...jest.requireActual("matrix-js-sdk/src/matrix"),
|
|
|
|
createClient: jest.fn(),
|
|
|
|
}));
|
|
|
|
|
|
|
|
describe("<ForgotPassword>", () => {
|
|
|
|
const testEmail = "user@example.com";
|
|
|
|
const testSid = "sid42";
|
|
|
|
const testPassword = "cRaZyP4ssw0rd!";
|
|
|
|
let client: MatrixClient;
|
|
|
|
let serverConfig: ValidatedServerConfig;
|
|
|
|
let onComplete: () => void;
|
2022-12-06 09:01:25 +00:00
|
|
|
let onLoginClick: () => void;
|
2022-11-22 06:58:37 +00:00
|
|
|
let renderResult: RenderResult;
|
|
|
|
|
|
|
|
const typeIntoField = async (label: string, value: string): Promise<void> => {
|
|
|
|
await act(async () => {
|
|
|
|
await userEvent.type(screen.getByLabelText(label), value, { delay: null });
|
|
|
|
// the message is shown after some time
|
|
|
|
jest.advanceTimersByTime(500);
|
2022-11-18 09:22:43 +00:00
|
|
|
});
|
2022-11-22 06:58:37 +00:00
|
|
|
};
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
const click = async (element: Element): Promise<void> => {
|
2022-11-22 06:58:37 +00:00
|
|
|
await act(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await userEvent.click(element, { delay: null });
|
2022-12-06 09:01:25 +00:00
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const itShouldCloseTheDialogAndShowThePasswordInput = (): void => {
|
|
|
|
it("should close the dialog and show the password input", () => {
|
|
|
|
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
|
|
|
|
expect(screen.getByText("Reset your password")).toBeInTheDocument();
|
2022-11-18 09:22:43 +00:00
|
|
|
});
|
2022-11-22 06:58:37 +00:00
|
|
|
};
|
|
|
|
|
2023-01-05 16:05:21 +00:00
|
|
|
filterConsole(
|
|
|
|
// not implemented by js-dom https://github.com/jsdom/jsdom/issues/1937
|
|
|
|
"Not implemented: HTMLFormElement.prototype.requestSubmit",
|
|
|
|
);
|
2022-11-23 08:01:42 +00:00
|
|
|
|
2023-01-05 16:05:21 +00:00
|
|
|
beforeEach(() => {
|
2022-11-22 06:58:37 +00:00
|
|
|
client = stubClient();
|
|
|
|
mocked(createClient).mockReturnValue(client);
|
|
|
|
|
2023-05-05 08:11:14 +00:00
|
|
|
serverConfig = { hsName: "example.com" } as ValidatedServerConfig;
|
2022-11-22 06:58:37 +00:00
|
|
|
|
|
|
|
onComplete = jest.fn();
|
2022-12-06 09:01:25 +00:00
|
|
|
onLoginClick = jest.fn();
|
2022-11-22 06:58:37 +00:00
|
|
|
|
|
|
|
jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig);
|
|
|
|
jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError");
|
2022-11-18 09:22:43 +00:00
|
|
|
});
|
|
|
|
|
2023-03-24 19:39:24 +00:00
|
|
|
afterEach(async () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
// clean up modals
|
2023-03-24 19:39:24 +00:00
|
|
|
await clearAllModals();
|
2022-11-18 09:22:43 +00:00
|
|
|
});
|
|
|
|
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeAll(() => {
|
|
|
|
jest.useFakeTimers();
|
|
|
|
});
|
2022-11-18 09:22:43 +00:00
|
|
|
|
2022-11-22 06:58:37 +00:00
|
|
|
afterAll(() => {
|
|
|
|
jest.useRealTimers();
|
|
|
|
});
|
2022-11-18 09:22:43 +00:00
|
|
|
|
2022-11-22 06:58:37 +00:00
|
|
|
describe("when starting a password reset flow", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
renderResult = render(
|
|
|
|
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
|
|
|
|
);
|
|
|
|
});
|
2022-11-18 09:22:43 +00:00
|
|
|
|
2022-11-22 06:58:37 +00:00
|
|
|
it("should show the email input and mention the homeserver", () => {
|
|
|
|
expect(screen.queryByLabelText("Email address")).toBeInTheDocument();
|
|
|
|
expect(screen.queryByText("example.com")).toBeInTheDocument();
|
2022-11-18 09:22:43 +00:00
|
|
|
});
|
|
|
|
|
2022-11-22 06:58:37 +00:00
|
|
|
describe("and updating the server config", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
serverConfig.hsName = "example2.com";
|
|
|
|
renderResult.rerender(
|
|
|
|
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
|
|
|
|
);
|
|
|
|
});
|
2022-11-18 09:22:43 +00:00
|
|
|
|
2022-11-22 06:58:37 +00:00
|
|
|
it("should show the new homeserver server name", () => {
|
|
|
|
expect(screen.queryByText("example2.com")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
2022-11-18 09:22:43 +00:00
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and clicking »Sign in instead«", () => {
|
2022-12-06 09:01:25 +00:00
|
|
|
beforeEach(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Sign in instead"));
|
2022-12-06 09:01:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should call onLoginClick()", () => {
|
|
|
|
expect(onLoginClick).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and entering a non-email value", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
await typeIntoField("Email address", "not en email");
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should show a message about the wrong format", () => {
|
|
|
|
expect(screen.getByText("The email address doesn't appear to be valid.")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and submitting an unknown email", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
await typeIntoField("Email address", testEmail);
|
|
|
|
mocked(client).requestPasswordEmailToken.mockRejectedValue({
|
|
|
|
errcode: "M_THREEPID_NOT_FOUND",
|
|
|
|
});
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Send email"));
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should show an email not found message", () => {
|
|
|
|
expect(screen.getByText("This email address was not found")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and a connection error occurs", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
await typeIntoField("Email address", testEmail);
|
|
|
|
mocked(client).requestPasswordEmailToken.mockRejectedValue({
|
|
|
|
name: "ConnectionError",
|
|
|
|
});
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Send email"));
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should show an info about that", () => {
|
|
|
|
expect(
|
|
|
|
screen.getByText(
|
|
|
|
"Cannot reach homeserver: " +
|
|
|
|
"Ensure you have a stable internet connection, or get in touch with the server admin",
|
2022-12-12 11:24:14 +00:00
|
|
|
),
|
2022-11-22 06:58:37 +00:00
|
|
|
).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and the server liveness check fails", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
await typeIntoField("Email address", testEmail);
|
|
|
|
mocked(AutoDiscoveryUtils.validateServerConfigWithStaticUrls).mockRejectedValue({});
|
|
|
|
mocked(AutoDiscoveryUtils.authComponentStateForError).mockReturnValue({
|
|
|
|
serverErrorIsFatal: true,
|
|
|
|
serverIsAlive: false,
|
|
|
|
serverDeadError: "server down",
|
|
|
|
});
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Send email"));
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should show the server error", () => {
|
|
|
|
expect(screen.queryByText("server down")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and submitting an known email", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
await typeIntoField("Email address", testEmail);
|
|
|
|
mocked(client).requestPasswordEmailToken.mockResolvedValue({
|
|
|
|
sid: testSid,
|
|
|
|
});
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Send email"));
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should send the mail and show the check email view", () => {
|
|
|
|
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
|
|
|
|
testEmail,
|
|
|
|
expect.any(String),
|
|
|
|
1, // second send attempt
|
|
|
|
);
|
|
|
|
expect(screen.getByText("Check your email to continue")).toBeInTheDocument();
|
|
|
|
expect(screen.getByText(testEmail)).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and clicking »Re-enter email address«", () => {
|
2022-12-06 09:01:25 +00:00
|
|
|
beforeEach(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Re-enter email address"));
|
2022-12-06 09:01:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("go back to the email input", () => {
|
|
|
|
expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and clicking »Resend«", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Resend"));
|
2022-11-22 06:58:37 +00:00
|
|
|
// the message is shown after some time
|
|
|
|
jest.advanceTimersByTime(500);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should should resend the mail and show the tooltip", () => {
|
|
|
|
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
|
|
|
|
testEmail,
|
|
|
|
expect.any(String),
|
|
|
|
2, // second send attempt
|
|
|
|
);
|
2024-01-09 11:46:27 +00:00
|
|
|
expect(
|
|
|
|
screen.getByRole("tooltip", { name: "Verification link email resent!" }),
|
|
|
|
).toBeInTheDocument();
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and clicking »Next«", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Next"));
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should show the password input view", () => {
|
|
|
|
expect(screen.getByText("Reset your password")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and entering different passwords", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
await typeIntoField("New Password", testPassword);
|
|
|
|
await typeIntoField("Confirm new password", testPassword + "asd");
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should show an info about that", () => {
|
|
|
|
expect(screen.getByText("New passwords must match each other.")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and entering a new password", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
mocked(client.setPassword).mockRejectedValue({ httpStatus: 401 });
|
|
|
|
await typeIntoField("New Password", testPassword);
|
|
|
|
await typeIntoField("Confirm new password", testPassword);
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("and submitting it running into rate limiting", () => {
|
|
|
|
beforeEach(async () => {
|
|
|
|
mocked(client.setPassword).mockRejectedValue({
|
|
|
|
message: "rate limit reached",
|
|
|
|
httpStatus: 429,
|
|
|
|
data: {
|
|
|
|
retry_after_ms: (13 * 60 + 37) * 1000,
|
|
|
|
},
|
|
|
|
});
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Reset password"));
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should show the rate limit error message", () => {
|
|
|
|
expect(
|
|
|
|
screen.getByText("Too many attempts in a short time. Retry after 13:37."),
|
|
|
|
).toBeInTheDocument();
|
|
|
|
});
|
2023-01-19 08:03:48 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
describe("and confirm the email link and submitting the new password", () => {
|
|
|
|
beforeEach(async () => {
|
|
|
|
// fake link confirmed by resolving client.setPassword instead of raising an error
|
|
|
|
mocked(client.setPassword).mockResolvedValue({});
|
|
|
|
await click(screen.getByText("Reset password"));
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should send the new password (once)", () => {
|
|
|
|
expect(client.setPassword).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
type: "m.login.email.identity",
|
|
|
|
threepid_creds: {
|
|
|
|
client_secret: expect.any(String),
|
|
|
|
sid: testSid,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
testPassword,
|
|
|
|
false,
|
|
|
|
);
|
|
|
|
|
|
|
|
// be sure that the next attempt to set the password would have been sent
|
|
|
|
jest.advanceTimersByTime(3000);
|
|
|
|
// it should not retry to set the password
|
|
|
|
expect(client.setPassword).toHaveBeenCalledTimes(1);
|
|
|
|
});
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
describe("and submitting it", () => {
|
|
|
|
beforeEach(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Reset password"));
|
2023-03-24 19:39:24 +00:00
|
|
|
await waitEnoughCyclesForModal({
|
|
|
|
useFakeTimers: true,
|
|
|
|
});
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should send the new password and show the click validation link dialog", () => {
|
|
|
|
expect(client.setPassword).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
type: "m.login.email.identity",
|
|
|
|
threepid_creds: {
|
|
|
|
client_secret: expect.any(String),
|
|
|
|
sid: testSid,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
testPassword,
|
|
|
|
false,
|
|
|
|
);
|
|
|
|
expect(screen.getByText("Verify your email to continue")).toBeInTheDocument();
|
|
|
|
expect(screen.getByText(testEmail)).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
|
2022-12-06 09:01:25 +00:00
|
|
|
describe("and dismissing the dialog by clicking the background", () => {
|
|
|
|
beforeEach(async () => {
|
|
|
|
await act(async () => {
|
|
|
|
await userEvent.click(screen.getByTestId("dialog-background"), { delay: null });
|
|
|
|
});
|
2023-03-24 19:39:24 +00:00
|
|
|
await waitEnoughCyclesForModal({
|
|
|
|
useFakeTimers: true,
|
|
|
|
});
|
2022-12-06 09:01:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
itShouldCloseTheDialogAndShowThePasswordInput();
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("and dismissing the dialog", () => {
|
|
|
|
beforeEach(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByLabelText("Close dialog"));
|
2023-03-24 19:39:24 +00:00
|
|
|
await waitEnoughCyclesForModal({
|
|
|
|
useFakeTimers: true,
|
|
|
|
});
|
2022-12-06 09:01:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
itShouldCloseTheDialogAndShowThePasswordInput();
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and clicking »Re-enter email address«", () => {
|
2022-12-06 09:01:25 +00:00
|
|
|
beforeEach(async () => {
|
2023-01-18 07:25:03 +00:00
|
|
|
await click(screen.getByText("Re-enter email address"));
|
2023-03-24 19:39:24 +00:00
|
|
|
await waitEnoughCyclesForModal({
|
|
|
|
useFakeTimers: true,
|
|
|
|
});
|
2022-12-06 09:01:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should close the dialog and go back to the email input", () => {
|
|
|
|
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
|
|
|
|
expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-01-18 07:25:03 +00:00
|
|
|
describe("and validating the link from the mail", () => {
|
2022-11-22 06:58:37 +00:00
|
|
|
beforeEach(async () => {
|
|
|
|
mocked(client.setPassword).mockResolvedValue({});
|
|
|
|
// be sure the next set password attempt was sent
|
|
|
|
jest.advanceTimersByTime(3000);
|
|
|
|
// quad flush promises for the modal to disappear
|
|
|
|
await flushPromisesWithFakeTimers();
|
|
|
|
await flushPromisesWithFakeTimers();
|
|
|
|
await flushPromisesWithFakeTimers();
|
|
|
|
await flushPromisesWithFakeTimers();
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should display the confirm reset view and now show the dialog", () => {
|
|
|
|
expect(screen.queryByText("Your password has been reset.")).toBeInTheDocument();
|
|
|
|
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
2023-01-18 07:25:03 +00:00
|
|
|
|
|
|
|
describe("and clicking »Sign out of all devices« and »Reset password«", () => {
|
|
|
|
beforeEach(async () => {
|
|
|
|
await click(screen.getByText("Sign out of all devices"));
|
|
|
|
await click(screen.getByText("Reset password"));
|
2023-03-24 19:39:24 +00:00
|
|
|
await waitEnoughCyclesForModal({
|
|
|
|
useFakeTimers: true,
|
|
|
|
});
|
2023-01-18 07:25:03 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should show the sign out warning dialog", async () => {
|
|
|
|
expect(
|
|
|
|
screen.getByText(
|
|
|
|
"Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
|
|
|
|
),
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
|
|
|
// confirm dialog
|
|
|
|
await click(screen.getByText("Continue"));
|
|
|
|
|
|
|
|
// expect setPassword with logoutDevices = true
|
|
|
|
expect(client.setPassword).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
type: "m.login.email.identity",
|
|
|
|
threepid_creds: {
|
|
|
|
client_secret: expect.any(String),
|
|
|
|
sid: testSid,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
testPassword,
|
|
|
|
true,
|
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
2022-11-22 06:58:37 +00:00
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
2022-11-18 09:22:43 +00:00
|
|
|
});
|
|
|
|
});
|