Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add error screens for connecting to the JWT service #2952

Draft
wants to merge 6 commits into
base: livekit
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,26 @@
},
"disconnected_banner": "Connectivity to the server has been lost.",
"error": {
"configuration_error": "Configuration error",
"configuration_error_description": "There is a configuration issues with the system. Please contact your administrator.",
"server_error": "Server error",
"server_error_description": "There is a server issue with the system. Please try again and contact your administrator if the problem persists.",
"network_error": "Network error",
"network_error_description": "There is a network issue with the system. Please check your network connection and try again. Alternatively try a different network if available.",
"call_not_found": "Call not found",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one</1>.</0>",
"connection_failed": "Connection failed",
"connection_failed_description": "Could not connect to the call. Check your network connection or try again later.",
"connection_lost": "Connection lost",
"connection_lost_description": "You were disconnected from the call.",
"connection_rejected_description": "Could not connect to the call. Try again later or contact your server admin if this persists.",
"e2ee_unsupported": "Incompatible browser",
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
"generic": "Something went wrong",
"generic_description": "Submitting debug logs will help us track down the problem.",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page."
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"show_details": "Show details"
},
"group_call_loader": {
"banned_body": "You have been banned from the room.",
Expand Down
229 changes: 225 additions & 4 deletions src/RichError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/

import { type FC, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
type ReactElement,
useCallback,
useState,
type FC,
type ReactNode,
} from "react";
import { Trans, useTranslation } from "react-i18next";
import {
ErrorIcon,
OfflineIcon,
PopOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";

import { ErrorView } from "./ErrorView";

Expand All @@ -22,8 +33,9 @@ export class RichError extends Error {
* The pretty, more helpful message to be shown on the error screen.
*/
public readonly richMessage: ReactNode,
cause?: unknown,
) {
super(message);
super(message, { cause });
}
}

Expand All @@ -46,3 +58,212 @@ export class OpenElsewhereError extends RichError {
super("App opened in another tab", <OpenElsewhere />);
}
}

interface ConfigurationErrorViewProps {
children?: ReactNode;
}

const ConfigurationErrorView: FC<ConfigurationErrorViewProps> = ({
children,
}) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);

return (
<ErrorView Icon={ErrorIcon} title={t("error.configuration_error")}>
<p>{t("error.configuration_error_description")}</p>
{showDetails ? (
children
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};

interface NetworkErrorViewProps {
children?: ReactNode;
}

const NetworkErrorView: FC<NetworkErrorViewProps> = ({ children }) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);

return (
<ErrorView Icon={OfflineIcon} title={t("error.network_error")}>
<p>{t("error.network_error_description")}</p>
{showDetails ? (
children
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};

interface ServerErrorViewProps {
children?: ReactNode;
}

const ServerErrorView: FC<ServerErrorViewProps> = ({ children }) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
const onShowDetailsClick = useCallback(() => setShowDetails(true), []);

return (
<ErrorView Icon={ErrorIcon} title={t("error.server_error")}>
<p>{t("error.server_error_description")}</p>
{showDetails ? (
children
) : (
<Button kind="tertiary" onClick={onShowDetailsClick}>
{t("error.show_details")}
</Button>
)}
</ErrorView>
);
};

export class ConfigurationError extends RichError {
public constructor(message: string, richMessage: ReactNode, cause?: unknown) {
super(
message,
<ConfigurationErrorView>{richMessage}</ConfigurationErrorView>,
cause,
);
}
}

export class NetworkError extends RichError {
public constructor(message: string, richMessage: ReactNode, cause?: unknown) {
super(message, <NetworkErrorView>{richMessage}</NetworkErrorView>, cause);
}
}

export class ServerError extends RichError {
public constructor(message: string, richMessage: ReactNode, cause?: unknown) {
super(message, <ServerErrorView>{richMessage}</ServerErrorView>, cause);
}
}

export class URLBuildingConfigurationError extends ConfigurationError {
public constructor(baseUrl: string, cause?: unknown) {
let message: string;
if (cause instanceof Error) {
message = cause.message;
} else {
message = "Unknown error";
}
super(
`Unable to build URL based on: ${baseUrl}`,
<Trans
i18nKey="error.invalid_url_details"
baseUrl={baseUrl}
message={message}
>
<p>
The URL derived from{" "}
<code>{{ baseUrl } as unknown as ReactElement}</code> is not valid:{" "}
<pre>{{ message } as unknown as ReactElement}</pre>
</p>
</Trans>,
cause,
);
}
}

export class ResourceNotFoundConfigurationError extends ConfigurationError {
public constructor(url: URL) {
super(
`The server returned a 404 response for: ${url.href}`,
<Trans i18nKey="error.resource_not_found_details" url={url.href}>
<p>
The request to{" "}
<code>{{ url: url.href } as unknown as ReactElement}</code> returned a{" "}
<code>404</code> response.
</p>
</Trans>,
);
}
}

export class UnexpectedResponseCodeError extends ServerError {
public constructor(url: URL, status: number, response: string) {
super(
`Received unexpected response code from ${url.href}: ${status}`,
<Trans
i18nKey="error.unexpected_response_code_details"
url={url.href}
status={status}
response={response}
>
<p>
The application received an unexpected response from{" "}
<code>{{ url } as unknown as ReactElement}</code>. It received status
code <code>{{ status } as unknown as ReactElement}</code>:{" "}
<pre>{{ response } as unknown as ReactElement}</pre>.
</p>
</Trans>,
);
}
}

export class FetchError extends ServerError {
public constructor(url: URL, cause: unknown) {
let message: string;
if (cause instanceof Error) {
message = cause.message;
} else {
message = "Unknown error";
}

super(
`Failed to connect to ${url.href}: ${message}`,
<Trans
i18nKey="error.fetch_error_details"
url={url.href}
message={message}
>
<p>
The application received an unexpected response from{" "}
<code>{{ url: url.href } as unknown as ReactElement}</code>. It
received status code{" "}
<code>{{ message } as unknown as ReactElement}</code>.
</p>
</Trans>,
);
}
}

export class InvalidServerResponseError extends ServerError {
public constructor(url: URL, cause: unknown) {
let message: string;
if (cause instanceof Error) {
message = cause.message;
} else {
message = "Unknown error";
}

super(
`Invalid response received from ${url.href}: ${message}`,
<Trans
i18nKey="error.invalid_server_response_error_details"
url={url.href}
message={message}
>
<p>
The server at{" "}
<code>{{ url: url.href } as unknown as ReactElement}</code> returned
an invalid response:{" "}
<pre>{{ message } as unknown as ReactElement}</pre>
</p>
</Trans>,
);
}
}
93 changes: 93 additions & 0 deletions src/livekit/openIDSFU.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/

import { expect, type MockInstance, test, vi } from "vitest";
import { type IOpenIDToken } from "matrix-js-sdk/src/client";
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc";

import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import {
AuthConnectionFailedError,
AuthConnectionRejectedError,
} from "../RichError";

async function withFetchSpy(
continuation: (fetchSpy: MockInstance<typeof fetch>) => Promise<void>,
): Promise<void> {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await continuation(fetchSpy);
} finally {
fetchSpy.mockRestore();
}
}

const mockClient: OpenIDClientParts = {
getOpenIdToken: async () => Promise.resolve({} as IOpenIDToken),
getDeviceId: () => "Device ID",
};
const mockFocus: LivekitFocus = {
type: "livekit",
livekit_alias: "LiveKit alias",
livekit_service_url: "LiveKit service URL",
};

test("getSFUConfigWithOpenID gets the JWT token", async () => {
await withFetchSpy(async (fetch) => {
fetch.mockResolvedValue({
ok: true,
json: async () =>
Promise.resolve({ jwt: "JWT token", url: "LiveKit URL" }),
} as Response);
expect(await getSFUConfigWithOpenID(mockClient, mockFocus)).toEqual({
jwt: "JWT token",
url: "LiveKit URL",
});
expect(fetch).toHaveBeenCalledWith("LiveKit service URL/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room: "LiveKit alias",
openid_token: {},
device_id: "Device ID",
}),
});
});
});

test("getSFUConfigWithOpenID throws if connection fails", async () => {
await withFetchSpy(async (fetch) => {
fetch.mockRejectedValue(new Error("Connection failed"));
await expect(async () =>
getSFUConfigWithOpenID(mockClient, mockFocus),
).rejects.toThrowError(expect.any(AuthConnectionFailedError));

Check failure on line 69 in src/livekit/openIDSFU.test.ts

View workflow job for this annotation

GitHub Actions / Run vitest tests

src/livekit/openIDSFU.test.ts > getSFUConfigWithOpenID throws if connection fails

TypeError: any() expects to be passed a constructor function. Please pass one or use anything() to match any object. ❯ src/livekit/openIDSFU.test.ts:69:35 ❯ withFetchSpy src/livekit/openIDSFU.test.ts:23:11 ❯ src/livekit/openIDSFU.test.ts:65:9
});
});

test("getSFUConfigWithOpenID throws if endpoint is not found", async () => {
await withFetchSpy(async (fetch) => {
fetch.mockResolvedValue({ ok: false, status: 404 } as Response);
await expect(async () =>
getSFUConfigWithOpenID(mockClient, mockFocus),
).rejects.toThrowError(expect.any(AuthConnectionFailedError));

Check failure on line 78 in src/livekit/openIDSFU.test.ts

View workflow job for this annotation

GitHub Actions / Run vitest tests

src/livekit/openIDSFU.test.ts > getSFUConfigWithOpenID throws if endpoint is not found

TypeError: any() expects to be passed a constructor function. Please pass one or use anything() to match any object. ❯ src/livekit/openIDSFU.test.ts:78:35 ❯ withFetchSpy src/livekit/openIDSFU.test.ts:23:11 ❯ src/livekit/openIDSFU.test.ts:74:9
});
});

test("getSFUConfigWithOpenID throws if endpoint returns error", async () => {
await withFetchSpy(async (fetch) => {
fetch.mockResolvedValue({
ok: false,
status: 503,
text: async () => Promise.resolve("Internal server error"),
} as Response);
await expect(async () =>
getSFUConfigWithOpenID(mockClient, mockFocus),
).rejects.toThrowError(expect.any(AuthConnectionRejectedError));

Check failure on line 91 in src/livekit/openIDSFU.test.ts

View workflow job for this annotation

GitHub Actions / Run vitest tests

src/livekit/openIDSFU.test.ts > getSFUConfigWithOpenID throws if endpoint returns error

TypeError: any() expects to be passed a constructor function. Please pass one or use anything() to match any object. ❯ src/livekit/openIDSFU.test.ts:91:35 ❯ withFetchSpy src/livekit/openIDSFU.test.ts:23:11 ❯ src/livekit/openIDSFU.test.ts:83:9
});
});
Loading
Loading