-
-
-
- {/*
- In normal operation, the submit event should be `preventDefault()`ed.
- method = POST just prevents sending passwords in the query string,
- which could be logged, if for some reason the event handler fails.
- */}
- {unhandleableError && (
-
- {t("frontend.password_change.failure.description.unspecified")}
-
- )}
-
- {errorMsg !== undefined && (
-
- {errorMsg}
-
- )}
-
-
-
- {t("frontend.password_change.current_password_label")}
-
-
-
-
-
- {t("frontend.errors.field_required")}
-
-
- {mutation.data && mutation.data.status === "WRONG_PASSWORD" && (
-
- {t(
- "frontend.password_change.failure.description.wrong_password",
- )}
-
- )}
-
-
-
-
-
-
-
- {!!mutation.isPending && }
- {t("action.save")}
-
-
-
- {t("action.cancel")}
-
-
-
-
- );
-}
diff --git a/frontend/src/routes/password.change.index.tsx b/frontend/src/routes/password.change.index.tsx
index 2771c1fc6..749d0b90b 100644
--- a/frontend/src/routes/password.change.index.tsx
+++ b/frontend/src/routes/password.change.index.tsx
@@ -1,13 +1,46 @@
-// Copyright 2024 New Vector Ltd.
+// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
-import { queryOptions } from "@tanstack/react-query";
-import { createFileRoute } from "@tanstack/react-router";
+import {
+ queryOptions,
+ useMutation,
+ useSuspenseQuery,
+} from "@tanstack/react-query";
+import { createFileRoute, notFound, useRouter } from "@tanstack/react-router";
+import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
+import { Alert, Form } from "@vector-im/compound-web";
+import { type FormEvent, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { ButtonLink } from "../components/ButtonLink";
+import Layout from "../components/Layout";
+import LoadingSpinner from "../components/LoadingSpinner";
+import PageHeading from "../components/PageHeading";
+import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
+import Separator from "../components/Separator";
import { graphql } from "../gql";
import { graphqlRequest } from "../graphql";
+import { translateSetPasswordError } from "../i18n/password_changes";
+
+const CHANGE_PASSWORD_MUTATION = graphql(/* GraphQL */ `
+ mutation ChangePassword(
+ $userId: ID!
+ $oldPassword: String!
+ $newPassword: String!
+ ) {
+ setPassword(
+ input: {
+ userId: $userId
+ currentPassword: $oldPassword
+ newPassword: $newPassword
+ }
+ ) {
+ status
+ }
+ }
+`);
const QUERY = graphql(/* GraphQL */ `
query PasswordChange {
@@ -24,11 +57,150 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
-export const query = queryOptions({
+const query = queryOptions({
queryKey: ["passwordChange"],
queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }),
});
export const Route = createFileRoute("/password/change/")({
loader: ({ context }) => context.queryClient.ensureQueryData(query),
+ component: ChangePassword,
});
+
+function ChangePassword(): React.ReactNode {
+ const { t } = useTranslation();
+ const {
+ data: { viewer, siteConfig },
+ } = useSuspenseQuery(query);
+ const router = useRouter();
+ if (viewer.__typename !== "User") throw notFound();
+ const userId = viewer.id;
+
+ const currentPasswordRef = useRef(null);
+
+ const mutation = useMutation({
+ async mutationFn(formData: FormData) {
+ const oldPassword = formData.get("current_password") as string;
+ const newPassword = formData.get("new_password") as string;
+ const newPasswordAgain = formData.get("new_password_again") as string;
+
+ if (newPassword !== newPasswordAgain) {
+ throw new Error(
+ "passwords mismatch; this should be checked by the form",
+ );
+ }
+
+ const response = await graphqlRequest({
+ query: CHANGE_PASSWORD_MUTATION,
+ variables: {
+ userId,
+ oldPassword,
+ newPassword,
+ },
+ });
+
+ if (response.setPassword.status === "ALLOWED") {
+ router.navigate({ to: "/password/change/success" });
+ }
+
+ return response.setPassword;
+ },
+ });
+
+ const onSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ const formData = new FormData(event.currentTarget);
+ mutation.mutate(formData);
+ };
+
+ const unhandleableError = mutation.error !== null;
+
+ const errorMsg: string | undefined = translateSetPasswordError(
+ t,
+ mutation.data?.status,
+ );
+
+ return (
+
+
+
+
+
+ {/*
+ In normal operation, the submit event should be `preventDefault()`ed.
+ method = POST just prevents sending passwords in the query string,
+ which could be logged, if for some reason the event handler fails.
+ */}
+ {unhandleableError && (
+
+ {t("frontend.password_change.failure.description.unspecified")}
+
+ )}
+
+ {errorMsg !== undefined && (
+
+ {errorMsg}
+
+ )}
+
+
+
+ {t("frontend.password_change.current_password_label")}
+
+
+
+
+
+ {t("frontend.errors.field_required")}
+
+
+ {mutation.data && mutation.data.status === "WRONG_PASSWORD" && (
+
+ {t(
+ "frontend.password_change.failure.description.wrong_password",
+ )}
+
+ )}
+
+
+
+
+
+
+
+ {!!mutation.isPending && }
+ {t("action.save")}
+
+
+
+ {t("action.cancel")}
+
+
+
+
+ );
+}
diff --git a/frontend/src/routes/password.change.success.lazy.tsx b/frontend/src/routes/password.change.success.tsx
similarity index 85%
rename from frontend/src/routes/password.change.success.lazy.tsx
rename to frontend/src/routes/password.change.success.tsx
index 15357e0e0..368ca9b22 100644
--- a/frontend/src/routes/password.change.success.lazy.tsx
+++ b/frontend/src/routes/password.change.success.tsx
@@ -1,17 +1,17 @@
-// Copyright 2024 New Vector Ltd.
+// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
-import { createLazyFileRoute } from "@tanstack/react-router";
+import { createFileRoute } from "@tanstack/react-router";
import IconCheckCircle from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
import { useTranslation } from "react-i18next";
import { ButtonLink } from "../components/ButtonLink";
import Layout from "../components/Layout";
import PageHeading from "../components/PageHeading";
-export const Route = createLazyFileRoute("/password/change/success")({
+export const Route = createFileRoute("/password/change/success")({
component: ChangePasswordSuccess,
});
diff --git a/frontend/src/routes/password.recovery.index.lazy.tsx b/frontend/src/routes/password.recovery.index.lazy.tsx
deleted file mode 100644
index 96605056a..000000000
--- a/frontend/src/routes/password.recovery.index.lazy.tsx
+++ /dev/null
@@ -1,298 +0,0 @@
-// Copyright 2024, 2025 New Vector Ltd.
-// Copyright 2024 The Matrix.org Foundation C.I.C.
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-// Please see LICENSE in the repository root for full details.
-
-import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
-import {
- createLazyFileRoute,
- notFound,
- useNavigate,
- useSearch,
-} from "@tanstack/react-router";
-import IconErrorSolid from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
-import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
-import { Alert, Button, Form } from "@vector-im/compound-web";
-import type { FormEvent } from "react";
-import { useTranslation } from "react-i18next";
-import { ButtonLink } from "../components/ButtonLink";
-import Layout from "../components/Layout";
-import LoadingSpinner from "../components/LoadingSpinner";
-import PageHeading from "../components/PageHeading";
-import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
-import { type FragmentType, graphql, useFragment } from "../gql";
-import { graphqlRequest } from "../graphql";
-import { translateSetPasswordError } from "../i18n/password_changes";
-import { query } from "./password.recovery.index";
-
-const RECOVER_PASSWORD_MUTATION = graphql(/* GraphQL */ `
- mutation RecoverPassword($ticket: String!, $newPassword: String!) {
- setPasswordByRecovery(
- input: { ticket: $ticket, newPassword: $newPassword }
- ) {
- status
- }
- }
-`);
-
-const RESEND_EMAIL_MUTATION = graphql(/* GraphQL */ `
- mutation ResendRecoveryEmail($ticket: String!) {
- resendRecoveryEmail(input: { ticket: $ticket }) {
- status
- progressUrl
- }
- }
-`);
-
-const FRAGMENT = graphql(/* GraphQL */ `
- fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {
- username
- email
- }
-`);
-
-const SITE_CONFIG_FRAGMENT = graphql(/* GraphQL */ `
- fragment RecoverPassword_siteConfig on SiteConfig {
- ...PasswordCreationDoubleInput_siteConfig
- }
-`);
-
-const EmailConsumed: React.FC = () => {
- const { t } = useTranslation();
- return (
-
-
-
-
- {t("action.start_over")}
-
-
- );
-};
-
-const EmailExpired: React.FC<{
- userRecoveryTicket: FragmentType;
- ticket: string;
-}> = (props) => {
- const { t } = useTranslation();
- const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
-
- const mutation = useMutation({
- mutationFn: async ({ ticket }: { ticket: string }) => {
- const response = await graphqlRequest({
- query: RESEND_EMAIL_MUTATION,
- variables: {
- ticket,
- },
- });
-
- if (response.resendRecoveryEmail.status === "SENT") {
- if (!response.resendRecoveryEmail.progressUrl) {
- throw new Error("Unexpected response, missing progress URL");
- }
-
- // Redirect to the URL which confirms that the email was sent
- window.location.href = response.resendRecoveryEmail.progressUrl;
-
- // We await an infinite promise here, so that the mutation
- // doesn't resolve
- await new Promise(() => undefined);
- }
-
- return response.resendRecoveryEmail;
- },
- });
-
- const onClick = (event: React.MouseEvent): void => {
- event.preventDefault();
- mutation.mutate({ ticket: props.ticket });
- };
-
- return (
-
-
-
- {mutation.data?.status === "RATE_LIMITED" && (
-
- )}
-
-
-
-
- {t("action.start_over")}
-
-
- );
-};
-
-const EmailRecovery: React.FC<{
- siteConfig: FragmentType;
- userRecoveryTicket: FragmentType;
- ticket: string;
-}> = (props) => {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const siteConfig = useFragment(SITE_CONFIG_FRAGMENT, props.siteConfig);
- const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
-
- const mutation = useMutation({
- mutationFn: async ({
- ticket,
- form,
- }: { ticket: string; form: FormData }) => {
- const newPassword = form.get("new_password") as string;
- const newPasswordAgain = form.get("new_password_again") as string;
-
- if (newPassword !== newPasswordAgain) {
- throw new Error(
- "passwords mismatch; this should be checked by the form",
- );
- }
-
- const response = await graphqlRequest({
- query: RECOVER_PASSWORD_MUTATION,
- variables: {
- ticket,
- newPassword,
- },
- });
-
- if (response.setPasswordByRecovery.status === "ALLOWED") {
- // Redirect to the application root using a full page load
- // The MAS backend will then redirect to the login page
- // Unfortunately this won't work in dev mode (`npm run dev`)
- // as the backend isn't involved there.
- await navigate({ to: "/", reloadDocument: true });
- }
-
- return response.setPasswordByRecovery;
- },
- });
-
- const onSubmit = async (event: FormEvent): Promise => {
- event.preventDefault();
-
- const form = new FormData(event.currentTarget);
- mutation.mutate({ ticket: props.ticket, form });
- };
-
- const unhandleableError = mutation.error !== null;
-
- const errorMsg: string | undefined = translateSetPasswordError(
- t,
- mutation.data?.status,
- );
-
- return (
-
-
-
-
-
- {/*
- In normal operation, the submit event should be `preventDefault()`ed.
- method = POST just prevents sending passwords in the query string,
- which could be logged, if for some reason the event handler fails.
- */}
- {unhandleableError && (
-
- {t("frontend.password_change.failure.description.unspecified")}
-
- )}
-
- {errorMsg !== undefined && (
-
- {errorMsg}
-
- )}
-
-
-
-
-
-
- {!!mutation.isPending && }
- {t("action.save_and_continue")}
-
-
-
-
- );
-};
-
-export const Route = createLazyFileRoute("/password/recovery/")({
- component: RecoverPassword,
-});
-
-function RecoverPassword(): React.ReactNode {
- const { ticket } = useSearch({
- from: "/password/recovery/",
- });
- const {
- data: { siteConfig, userRecoveryTicket },
- } = useSuspenseQuery(query(ticket));
-
- if (!userRecoveryTicket) {
- throw notFound();
- }
-
- switch (userRecoveryTicket.status) {
- case "EXPIRED":
- return (
-
- );
- case "CONSUMED":
- return ;
- case "VALID":
- return (
-
- );
- default: {
- const exhaustiveCheck: never = userRecoveryTicket.status;
- throw new Error(`Unhandled case: ${exhaustiveCheck}`);
- }
- }
-}
diff --git a/frontend/src/routes/password.recovery.index.tsx b/frontend/src/routes/password.recovery.index.tsx
index 2efa5811f..87c4f9210 100644
--- a/frontend/src/routes/password.recovery.index.tsx
+++ b/frontend/src/routes/password.recovery.index.tsx
@@ -4,11 +4,57 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
+import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { queryOptions } from "@tanstack/react-query";
+import { useNavigate, useSearch } from "@tanstack/react-router";
import { createFileRoute, notFound } from "@tanstack/react-router";
+import IconErrorSolid from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
+import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
+import { Alert, Button, Form } from "@vector-im/compound-web";
+import type { FormEvent } from "react";
+import { useTranslation } from "react-i18next";
import * as v from "valibot";
+import { ButtonLink } from "../components/ButtonLink";
+import Layout from "../components/Layout";
+import LoadingSpinner from "../components/LoadingSpinner";
+import PageHeading from "../components/PageHeading";
+import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
+import { type FragmentType, useFragment } from "../gql";
import { graphql } from "../gql";
import { graphqlRequest } from "../graphql";
+import { translateSetPasswordError } from "../i18n/password_changes";
+
+const RECOVER_PASSWORD_MUTATION = graphql(/* GraphQL */ `
+ mutation RecoverPassword($ticket: String!, $newPassword: String!) {
+ setPasswordByRecovery(
+ input: { ticket: $ticket, newPassword: $newPassword }
+ ) {
+ status
+ }
+ }
+`);
+
+const RESEND_EMAIL_MUTATION = graphql(/* GraphQL */ `
+ mutation ResendRecoveryEmail($ticket: String!) {
+ resendRecoveryEmail(input: { ticket: $ticket }) {
+ status
+ progressUrl
+ }
+ }
+`);
+
+const FRAGMENT = graphql(/* GraphQL */ `
+ fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {
+ username
+ email
+ }
+`);
+
+const SITE_CONFIG_FRAGMENT = graphql(/* GraphQL */ `
+ fragment RecoverPassword_siteConfig on SiteConfig {
+ ...PasswordCreationDoubleInput_siteConfig
+ }
+`);
const QUERY = graphql(/* GraphQL */ `
query PasswordRecovery($ticket: String!) {
@@ -23,7 +69,7 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
-export const query = (ticket: string) =>
+const query = (ticket: string) =>
queryOptions({
queryKey: ["passwordRecovery", ticket],
queryFn: ({ signal }) =>
@@ -48,4 +94,244 @@ export const Route = createFileRoute("/password/recovery/")({
throw notFound();
}
},
+
+ component: RecoverPassword,
});
+
+const EmailConsumed: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ {t("action.start_over")}
+
+
+ );
+};
+
+const EmailExpired: React.FC<{
+ userRecoveryTicket: FragmentType;
+ ticket: string;
+}> = (props) => {
+ const { t } = useTranslation();
+ const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
+
+ const mutation = useMutation({
+ mutationFn: async ({ ticket }: { ticket: string }) => {
+ const response = await graphqlRequest({
+ query: RESEND_EMAIL_MUTATION,
+ variables: {
+ ticket,
+ },
+ });
+
+ if (response.resendRecoveryEmail.status === "SENT") {
+ if (!response.resendRecoveryEmail.progressUrl) {
+ throw new Error("Unexpected response, missing progress URL");
+ }
+
+ // Redirect to the URL which confirms that the email was sent
+ window.location.href = response.resendRecoveryEmail.progressUrl;
+
+ // We await an infinite promise here, so that the mutation
+ // doesn't resolve
+ await new Promise(() => undefined);
+ }
+
+ return response.resendRecoveryEmail;
+ },
+ });
+
+ const onClick = (event: React.MouseEvent): void => {
+ event.preventDefault();
+ mutation.mutate({ ticket: props.ticket });
+ };
+
+ return (
+
+
+
+ {mutation.data?.status === "RATE_LIMITED" && (
+
+ )}
+
+
+
+
+ {t("action.start_over")}
+
+
+ );
+};
+
+const EmailRecovery: React.FC<{
+ siteConfig: FragmentType;
+ userRecoveryTicket: FragmentType;
+ ticket: string;
+}> = (props) => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const siteConfig = useFragment(SITE_CONFIG_FRAGMENT, props.siteConfig);
+ const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
+
+ const mutation = useMutation({
+ mutationFn: async ({
+ ticket,
+ form,
+ }: {
+ ticket: string;
+ form: FormData;
+ }) => {
+ const newPassword = form.get("new_password") as string;
+ const newPasswordAgain = form.get("new_password_again") as string;
+
+ if (newPassword !== newPasswordAgain) {
+ throw new Error(
+ "passwords mismatch; this should be checked by the form",
+ );
+ }
+
+ const response = await graphqlRequest({
+ query: RECOVER_PASSWORD_MUTATION,
+ variables: {
+ ticket,
+ newPassword,
+ },
+ });
+
+ if (response.setPasswordByRecovery.status === "ALLOWED") {
+ // Redirect to the application root using a full page load
+ // The MAS backend will then redirect to the login page
+ // Unfortunately this won't work in dev mode (`npm run dev`)
+ // as the backend isn't involved there.
+ await navigate({ to: "/", reloadDocument: true });
+ }
+
+ return response.setPasswordByRecovery;
+ },
+ });
+
+ const onSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+
+ const form = new FormData(event.currentTarget);
+ mutation.mutate({ ticket: props.ticket, form });
+ };
+
+ const unhandleableError = mutation.error !== null;
+
+ const errorMsg: string | undefined = translateSetPasswordError(
+ t,
+ mutation.data?.status,
+ );
+
+ return (
+
+
+
+
+
+ {/*
+ In normal operation, the submit event should be `preventDefault()`ed.
+ method = POST just prevents sending passwords in the query string,
+ which could be logged, if for some reason the event handler fails.
+ */}
+ {unhandleableError && (
+
+ {t("frontend.password_change.failure.description.unspecified")}
+
+ )}
+
+ {errorMsg !== undefined && (
+
+ {errorMsg}
+
+ )}
+
+
+
+
+
+
+ {!!mutation.isPending && }
+ {t("action.save_and_continue")}
+
+
+
+
+ );
+};
+
+function RecoverPassword(): React.ReactNode {
+ const { ticket } = useSearch({
+ from: "/password/recovery/",
+ });
+ const {
+ data: { siteConfig, userRecoveryTicket },
+ } = useSuspenseQuery(query(ticket));
+
+ if (!userRecoveryTicket) {
+ throw notFound();
+ }
+
+ switch (userRecoveryTicket.status) {
+ case "EXPIRED":
+ return (
+
+ );
+ case "CONSUMED":
+ return ;
+ case "VALID":
+ return (
+
+ );
+ default: {
+ const exhaustiveCheck: never = userRecoveryTicket.status;
+ throw new Error(`Unhandled case: ${exhaustiveCheck}`);
+ }
+ }
+}
diff --git a/frontend/src/routes/sessions.$id.lazy.tsx b/frontend/src/routes/sessions.$id.lazy.tsx
deleted file mode 100644
index bc9524bf2..000000000
--- a/frontend/src/routes/sessions.$id.lazy.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright 2024 New Vector Ltd.
-// Copyright 2024 The Matrix.org Foundation C.I.C.
-//
-// SPDX-License-Identifier: AGPL-3.0-only
-// Please see LICENSE in the repository root for full details.
-
-import { useSuspenseQuery } from "@tanstack/react-query";
-import { createLazyFileRoute, notFound } from "@tanstack/react-router";
-import { Alert } from "@vector-im/compound-web";
-import { useTranslation } from "react-i18next";
-import Layout from "../components/Layout";
-import { Link } from "../components/Link";
-import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
-import CompatSessionDetail from "../components/SessionDetail/CompatSessionDetail";
-import OAuth2SessionDetail from "../components/SessionDetail/OAuth2SessionDetail";
-import { query } from "./sessions.$id";
-
-export const Route = createLazyFileRoute("/sessions/$id")({
- notFoundComponent: NotFound,
- component: SessionDetail,
-});
-
-function NotFound(): React.ReactElement {
- const { id } = Route.useParams();
- const { t } = useTranslation();
-
- return (
-
-
- {t("frontend.session_detail.alert.text")}
- {t("frontend.session_detail.alert.button")}
-
-
- );
-}
-
-function SessionDetail(): React.ReactElement {
- const { id } = Route.useParams();
- const {
- data: { node, viewerSession },
- } = useSuspenseQuery(query(id));
- if (!node) throw notFound();
-
- switch (node.__typename) {
- case "CompatSession":
- return (
-
-
-
- );
- case "Oauth2Session":
- return (
-
-
-
- );
- case "BrowserSession":
- return (
-
-
-
- );
- default:
- throw new Error("Unknown session type");
- }
-}
diff --git a/frontend/src/routes/sessions.$id.tsx b/frontend/src/routes/sessions.$id.tsx
index 9139aed31..6db9ecd79 100644
--- a/frontend/src/routes/sessions.$id.tsx
+++ b/frontend/src/routes/sessions.$id.tsx
@@ -1,11 +1,18 @@
-// Copyright 2024 New Vector Ltd.
+// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
-import { queryOptions } from "@tanstack/react-query";
-import { createFileRoute } from "@tanstack/react-router";
+import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Alert } from "@vector-im/compound-web";
+import { useTranslation } from "react-i18next";
+import Layout from "../components/Layout";
+import { Link } from "../components/Link";
+import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
+import CompatSessionDetail from "../components/SessionDetail/CompatSessionDetail";
+import OAuth2SessionDetail from "../components/SessionDetail/OAuth2SessionDetail";
import { graphql } from "../gql";
import { graphqlRequest } from "../graphql";
@@ -27,7 +34,7 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
-export const query = (id: string) =>
+const query = (id: string) =>
queryOptions({
queryKey: ["sessionDetail", id],
queryFn: ({ signal }) =>
@@ -37,4 +44,57 @@ export const query = (id: string) =>
export const Route = createFileRoute("/sessions/$id")({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(query(params.id)),
+ notFoundComponent: NotFound,
+ component: SessionDetail,
});
+
+function NotFound(): React.ReactElement {
+ const { id } = Route.useParams();
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t("frontend.session_detail.alert.text")}
+ {t("frontend.session_detail.alert.button")}
+
+
+ );
+}
+
+function SessionDetail(): React.ReactElement {
+ const { id } = Route.useParams();
+ const {
+ data: { node, viewerSession },
+ } = useSuspenseQuery(query(id));
+ if (!node) throw notFound();
+
+ switch (node.__typename) {
+ case "CompatSession":
+ return (
+
+
+
+ );
+ case "Oauth2Session":
+ return (
+
+
+
+ );
+ case "BrowserSession":
+ return (
+
+
+
+ );
+ default:
+ throw new Error("Unknown session type");
+ }
+}
diff --git a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap
index 06c4756ad..2b13c2cd4 100644
--- a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap
+++ b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap
@@ -43,6 +43,43 @@ exports[`Reset cross signing > renders the cancelled page 1`] = `
>
If you're signed out everywhere and don't remember your recovery code, you'll still need to reset your identity.