From 17f8dc4e00f7184b38220899392442e5b7af8179 Mon Sep 17 00:00:00 2001
From: Kerry
Date: Fri, 1 Sep 2023 21:42:50 +1200
Subject: [PATCH] Implement MSC2965 action parameter (#1673)
* redirect session_end action to session detail
* fix react key warning in oauth session detail
* move Route type to /routing
* test getRouteActionRedirection
* comment
* frontend: Split the routing-related stuff in multiple files under routing/
* frontend: Cover all the redirections defined by MSC2965
* frontend: fix test
* Make the backend keep query parameters through login to the /account/ interface
* Fix frontend tests & clippy lints
---------
Co-authored-by: Quentin Gliech
---
crates/handlers/src/lib.rs | 19 +-
.../handlers/src/views/account/emails/add.rs | 2 +-
.../src/views/account/emails/verify.rs | 4 +-
crates/handlers/src/views/account/password.rs | 2 +-
crates/handlers/src/views/app.rs | 8 +-
crates/handlers/src/views/reauth.rs | 2 +-
crates/handlers/src/views/shared.rs | 2 +-
crates/router/src/endpoints.rs | 53 +++-
frontend/src/Router.tsx | 273 ------------------
frontend/src/components/CompatSession.tsx | 2 +-
frontend/src/components/Layout.test.tsx | 2 +-
.../src/components/NavBar/NavBar.stories.tsx | 2 +-
.../components/NavItem/NavItem.stories.tsx | 2 +-
.../src/components/NavItem/NavItem.test.tsx | 2 +-
frontend/src/components/NavItem/NavItem.tsx | 2 +-
frontend/src/components/OAuth2Session.tsx | 2 +-
.../SessionDetail/OAuth2SessionDetail.tsx | 6 +-
.../SessionDetail/SessionDetail.tsx | 4 +-
.../UnverifiedEmailAlert.tsx | 5 +-
.../UnverifiedEmailAlert.test.tsx.snap | 2 +-
.../src/components/UserEmail/UserEmail.tsx | 2 +-
.../components/UserProfile/UserEmailList.tsx | 2 +-
.../UserSessionsOverview.stories.tsx | 2 +-
.../UserSessionsOverview.tsx | 6 +-
.../UserSessionsOverview.test.tsx.snap | 12 +-
.../components/VerifyEmail/VerifyEmail.tsx | 2 +-
frontend/src/main.tsx | 10 +-
.../Link.module.css} | 2 +-
frontend/src/routing/Link.tsx | 70 +++++
frontend/src/routing/Router.tsx | 86 ++++++
frontend/src/routing/actions.test.ts | 98 +++++++
frontend/src/routing/actions.ts | 76 +++++
frontend/src/routing/atoms.ts | 48 +++
frontend/src/routing/index.ts | 20 ++
.../routes.test.ts} | 4 +-
frontend/src/routing/routes.ts | 152 ++++++++++
frontend/src/test-utils/WithLocation.tsx | 2 +-
37 files changed, 662 insertions(+), 328 deletions(-)
delete mode 100644 frontend/src/Router.tsx
rename frontend/src/{Router.module.css => routing/Link.module.css} (99%)
create mode 100644 frontend/src/routing/Link.tsx
create mode 100644 frontend/src/routing/Router.tsx
create mode 100644 frontend/src/routing/actions.test.ts
create mode 100644 frontend/src/routing/actions.ts
create mode 100644 frontend/src/routing/atoms.ts
create mode 100644 frontend/src/routing/index.ts
rename frontend/src/{Router.test.tsx => routing/routes.test.ts} (97%)
create mode 100644 frontend/src/routing/routes.ts
diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs
index 2857b493..9fc65f4a 100644
--- a/crates/handlers/src/lib.rs
+++ b/crates/handlers/src/lib.rs
@@ -30,11 +30,11 @@
clippy::let_with_type_underscore,
)]
-use std::{convert::Infallible, time::Duration};
+use std::{borrow::Cow, convert::Infallible, time::Duration};
use axum::{
body::{Bytes, HttpBody},
- extract::{FromRef, FromRequestParts, OriginalUri, State},
+ extract::{FromRef, FromRequestParts, OriginalUri, RawQuery, State},
http::Method,
response::{Html, IntoResponse},
routing::{get, on, post, MethodFilter},
@@ -265,6 +265,7 @@ where
)
}
+#[allow(clippy::too_many_lines)]
pub fn human_router(templates: Templates) -> Router
where
B: HttpBody + Send + 'static,
@@ -286,7 +287,19 @@ where
{
Router::new()
// XXX: hard-coded redirect from /account to /account/
- .route("/account", get(|| async { mas_router::Account.go() }))
+ .route(
+ "/account",
+ get(|RawQuery(query): RawQuery| async {
+ let route = mas_router::Account::route();
+ let destination = if let Some(query) = query {
+ Cow::Owned(format!("{route}?{query}"))
+ } else {
+ Cow::Borrowed(route)
+ };
+
+ axum::response::Redirect::to(&destination)
+ }),
+ )
.route(mas_router::Account::route(), get(self::views::app::get))
.route(
mas_router::AccountWildcard::route(),
diff --git a/crates/handlers/src/views/account/emails/add.rs b/crates/handlers/src/views/account/emails/add.rs
index c8bc5da3..85a43a60 100644
--- a/crates/handlers/src/views/account/emails/add.rs
+++ b/crates/handlers/src/views/account/emails/add.rs
@@ -128,7 +128,7 @@ pub(crate) async fn post(
next.go()
} else {
- query.go_next_or_default(&mas_router::Account)
+ query.go_next_or_default(&mas_router::Account::default())
};
repo.save().await?;
diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs
index 44f48903..c447aff0 100644
--- a/crates/handlers/src/views/account/emails/verify.rs
+++ b/crates/handlers/src/views/account/emails/verify.rs
@@ -73,7 +73,7 @@ pub(crate) async fn get(
if user_email.confirmed_at.is_some() {
// This email was already verified, skip
- let destination = query.go_next_or_default(&mas_router::Account);
+ let destination = query.go_next_or_default(&mas_router::Account::default());
return Ok((cookie_jar, destination).into_response());
}
@@ -145,6 +145,6 @@ pub(crate) async fn post(
repo.save().await?;
- let destination = query.go_next_or_default(&mas_router::Account);
+ let destination = query.go_next_or_default(&mas_router::Account::default());
Ok((cookie_jar, destination).into_response())
}
diff --git a/crates/handlers/src/views/account/password.rs b/crates/handlers/src/views/account/password.rs
index f21b5743..9fb95d89 100644
--- a/crates/handlers/src/views/account/password.rs
+++ b/crates/handlers/src/views/account/password.rs
@@ -55,7 +55,7 @@ pub(crate) async fn get(
) -> Result {
// If the password manager is disabled, we can go back to the account page.
if !password_manager.is_enabled() {
- return Ok(mas_router::Account.go().into_response());
+ return Ok(mas_router::Account::default().go().into_response());
}
let (session_info, cookie_jar) = cookie_jar.session_info();
diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs
index 45507215..2c6af220 100644
--- a/crates/handlers/src/views/app.rs
+++ b/crates/handlers/src/views/app.rs
@@ -13,7 +13,7 @@
// limitations under the License.
use axum::{
- extract::State,
+ extract::{Query, State},
response::{Html, IntoResponse},
};
use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt};
@@ -24,17 +24,19 @@ use mas_templates::{AppContext, Templates};
#[tracing::instrument(name = "handlers.views.app.get", skip_all, err)]
pub async fn get(
State(templates): State,
+ action: Option>,
mut repo: BoxRepository,
cookie_jar: CookieJar,
) -> Result {
let (session_info, cookie_jar) = cookie_jar.session_info();
let session = session_info.load_session(&mut repo).await?;
+ let action = action.map(|Query(a)| a);
- // TODO: keep the full path
+ // TODO: keep the full path, not just the action
if session.is_none() {
return Ok((
cookie_jar,
- mas_router::Login::and_then(PostAuthAction::ManageAccount).go(),
+ mas_router::Login::and_then(PostAuthAction::manage_account(action)).go(),
)
.into_response());
}
diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs
index 62ceeb5e..018a04a3 100644
--- a/crates/handlers/src/views/reauth.rs
+++ b/crates/handlers/src/views/reauth.rs
@@ -52,7 +52,7 @@ pub(crate) async fn get(
) -> Result {
if !password_manager.is_enabled() {
// XXX: do something better here
- return Ok(mas_router::Account.go().into_response());
+ return Ok(mas_router::Account::default().go().into_response());
}
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs
index 2ec1bdda..385cba40 100644
--- a/crates/handlers/src/views/shared.rs
+++ b/crates/handlers/src/views/shared.rs
@@ -88,7 +88,7 @@ impl OptionalPostAuthAction {
PostAuthContextInner::LinkUpstream { provider, link }
}
- PostAuthAction::ManageAccount => PostAuthContextInner::ManageAccount,
+ PostAuthAction::ManageAccount { .. } => PostAuthContextInner::ManageAccount,
};
Ok(Some(PostAuthContext {
diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs
index 5cd13010..563d00f5 100644
--- a/crates/router/src/endpoints.rs
+++ b/crates/router/src/endpoints.rs
@@ -20,11 +20,20 @@ pub use crate::traits::*;
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "snake_case", tag = "next")]
pub enum PostAuthAction {
- ContinueAuthorizationGrant { id: Ulid },
- ContinueCompatSsoLogin { id: Ulid },
+ ContinueAuthorizationGrant {
+ id: Ulid,
+ },
+ ContinueCompatSsoLogin {
+ id: Ulid,
+ },
ChangePassword,
- LinkUpstream { id: Ulid },
- ManageAccount,
+ LinkUpstream {
+ id: Ulid,
+ },
+ ManageAccount {
+ #[serde(flatten)]
+ action: Option,
+ },
}
impl PostAuthAction {
@@ -43,13 +52,21 @@ impl PostAuthAction {
PostAuthAction::LinkUpstream { id }
}
+ #[must_use]
+ pub const fn manage_account(action: Option) -> Self {
+ PostAuthAction::ManageAccount { action }
+ }
+
pub fn go_next(&self) -> axum::response::Redirect {
match self {
Self::ContinueAuthorizationGrant { id } => ContinueAuthorizationGrant(*id).go(),
Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(),
Self::ChangePassword => AccountPassword.go(),
Self::LinkUpstream { id } => UpstreamOAuth2Link::new(*id).go(),
- Self::ManageAccount => Account.go(),
+ Self::ManageAccount { action } => Account {
+ action: action.clone(),
+ }
+ .go(),
}
}
}
@@ -406,12 +423,32 @@ impl AccountAddEmail {
}
}
+/// Actions parameters as defined by MSC2965
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case", tag = "action")]
+pub enum AccountAction {
+ Profile,
+ SessionsList,
+ SessionView { device_id: String },
+ SessionEnd { device_id: String },
+}
+
/// `GET /account/`
#[derive(Default, Debug, Clone)]
-pub struct Account;
+pub struct Account {
+ action: Option,
+}
-impl SimpleRoute for Account {
- const PATH: &'static str = "/account/";
+impl Route for Account {
+ type Query = AccountAction;
+
+ fn route() -> &'static str {
+ "/account/"
+ }
+
+ fn query(&self) -> Option<&Self::Query> {
+ self.action.as_ref()
+ }
}
/// `GET /account/*`
diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx
deleted file mode 100644
index 0931bc87..00000000
--- a/frontend/src/Router.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-// Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import { atom, useAtomValue, useSetAtom } from "jotai";
-import { atomWithLocation } from "jotai-location";
-import { lazy, Suspense, useTransition } from "react";
-
-import styles from "./Router.module.css";
-import Layout from "./components/Layout";
-import LoadingSpinner from "./components/LoadingSpinner";
-
-type Location = {
- pathname?: string;
- searchParams?: URLSearchParams;
-};
-
-type ProfileRoute = { type: "profile" };
-type SessionOverviewRoute = { type: "sessions-overview" };
-type SessionDetailRoute = { type: "session"; id: string };
-type OAuth2ClientRoute = { type: "client"; id: string };
-type OAuth2SessionList = { type: "oauth2-session-list" };
-type BrowserSessionRoute = { type: "browser-session"; id: string };
-type BrowserSessionListRoute = { type: "browser-session-list" };
-type CompatSessionListRoute = { type: "compat-session-list" };
-type VerifyEmailRoute = { type: "verify-email"; id: string };
-type UnknownRoute = { type: "unknown"; segments: string[] };
-
-export type Route =
- | SessionOverviewRoute
- | SessionDetailRoute
- | ProfileRoute
- | OAuth2ClientRoute
- | OAuth2SessionList
- | BrowserSessionRoute
- | BrowserSessionListRoute
- | CompatSessionListRoute
- | VerifyEmailRoute
- | UnknownRoute;
-
-const routeToSegments = (route: Route): string[] => {
- switch (route.type) {
- case "profile":
- return [];
- case "sessions-overview":
- return ["sessions-overview"];
- case "session":
- return ["session", route.id];
- case "verify-email":
- return ["emails", route.id, "verify"];
- case "client":
- return ["clients", route.id];
- case "browser-session-list":
- return ["browser-sessions"];
- case "browser-session":
- return ["browser-sessions", route.id];
- case "oauth2-session-list":
- return ["oauth2-sessions"];
- case "compat-session-list":
- return ["compat-sessions"];
- case "unknown":
- return route.segments;
- }
-};
-
-const P = Symbol();
-type PatternItem = string | typeof P;
-
-// Returns true if the segments match the pattern, where P is a parameter
-const segmentMatches = (
- segments: string[],
- ...pattern: PatternItem[]
-): boolean => {
- // Quick check to see if the lengths match
- if (segments.length !== pattern.length) return false;
-
- // Check each segment
- for (let i = 0; i < segments.length; i++) {
- // If the pattern is P, then it's a parameter and we can skip it
- if (pattern[i] === P) continue;
- // Otherwise, check that the segment matches the pattern
- if (segments[i] !== pattern[i]) return false;
- }
-
- return true;
-};
-
-export const segmentsToRoute = (segments: string[]): Route => {
- const matches = (...pattern: PatternItem[]): boolean =>
- segmentMatches(segments, ...pattern);
-
- // Special case for the home page
- if (segments.length === 0 || (segments.length === 1 && segments[0] === "")) {
- return { type: "profile" };
- }
-
- if (matches("sessions-overview")) {
- return { type: "sessions-overview" };
- }
-
- if (matches("browser-sessions")) {
- return { type: "browser-session-list" };
- }
-
- if (matches("oauth2-sessions")) {
- return { type: "oauth2-session-list" };
- }
-
- if (matches("compat-sessions")) {
- return { type: "compat-session-list" };
- }
-
- if (matches("emails", P, "verify")) {
- return { type: "verify-email", id: segments[1] };
- }
-
- if (matches("clients", P)) {
- return { type: "client", id: segments[1] };
- }
-
- if (matches("browser-sessions", P)) {
- return { type: "browser-session", id: segments[1] };
- }
-
- if (matches("session", P)) {
- return { type: "session", id: segments[1] };
- }
-
- return { type: "unknown", segments };
-};
-
-const routeToPath = (route: Route): string =>
- routeToSegments(route)
- .map((part) => encodeURIComponent(part))
- .join("/");
-
-export const appConfigAtom = atom(
- typeof window !== "undefined" ? window.APP_CONFIG : { root: "/" },
-);
-
-const pathToRoute = (path: string): Route => {
- const segments = path.split("/").map(decodeURIComponent);
- return segmentsToRoute(segments);
-};
-
-const locationToRoute = (root: string, location: Location): Route => {
- if (!location.pathname || !location.pathname.startsWith(root)) {
- throw new Error(`Invalid location ${location.pathname}`);
- }
-
- const path = location.pathname.slice(root.length);
- return pathToRoute(path);
-};
-
-export const locationAtom = atomWithLocation();
-export const routeAtom = atom(
- (get) => {
- const location = get(locationAtom);
- const config = get(appConfigAtom);
- return locationToRoute(config.root, location);
- },
- (get, set, value: Route) => {
- const appConfig = get(appConfigAtom);
- set(locationAtom, {
- pathname: appConfig.root + routeToPath(value),
- });
- },
-);
-
-const SessionsOverview = lazy(() => import("./pages/SessionsOverview"));
-const SessionDetail = lazy(() => import("./pages/SessionDetail"));
-const Profile = lazy(() => import("./pages/Profile"));
-const OAuth2Client = lazy(() => import("./pages/OAuth2Client"));
-const BrowserSession = lazy(() => import("./pages/BrowserSession"));
-const BrowserSessionList = lazy(() => import("./pages/BrowserSessionList"));
-const CompatSessionList = lazy(() => import("./pages/CompatSessionList"));
-const OAuth2SessionList = lazy(() => import("./pages/OAuth2SessionList"));
-const VerifyEmail = lazy(() => import("./pages/VerifyEmail"));
-
-const InnerRouter: React.FC = () => {
- const route = useAtomValue(routeAtom);
-
- switch (route.type) {
- case "profile":
- return ;
- case "sessions-overview":
- return ;
- case "session":
- return ;
- case "oauth2-session-list":
- return ;
- case "browser-session-list":
- return ;
- case "compat-session-list":
- return ;
- case "client":
- return ;
- case "browser-session":
- return ;
- case "verify-email":
- return ;
- case "unknown":
- return <>Unknown route {JSON.stringify(route.segments)}>;
- }
-};
-
-const Router: React.FC = () => (
-
- }>
-
-
-
-);
-
-// Filter out clicks with modifiers or that have been prevented
-const shouldHandleClick = (e: React.MouseEvent): boolean =>
- !e.defaultPrevented &&
- e.button === 0 &&
- !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
-
-export const Link: React.FC<
- {
- route: Route;
- // adds button-like styling to link element
- kind?: "button";
- } & React.HTMLProps
-> = ({ route, children, kind, className, ...props }) => {
- const config = useAtomValue(appConfigAtom);
- const path = routeToPath(route);
- const fullUrl = config.root + path;
- const setRoute = useSetAtom(routeAtom);
-
- // TODO: we should probably have more user control over this
- const [isPending, startTransition] = useTransition();
-
- const classNames = [
- kind === "button" ? styles.linkButton : "",
- className,
- ].join("");
-
- return (
- {
- // Only handle left clicks without modifiers
- if (!shouldHandleClick(e)) {
- return;
- }
-
- e.preventDefault();
- startTransition(() => {
- setRoute(route);
- });
- }}
- className={classNames}
- {...props}
- >
- {isPending ? "Loading..." : children}
-
- );
-};
-
-export default Router;
diff --git a/frontend/src/components/CompatSession.tsx b/frontend/src/components/CompatSession.tsx
index be7aa1f8..781db00b 100644
--- a/frontend/src/components/CompatSession.tsx
+++ b/frontend/src/components/CompatSession.tsx
@@ -18,8 +18,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql";
import { useTransition } from "react";
-import { Link } from "../Router";
import { FragmentType, graphql, useFragment } from "../gql";
+import { Link } from "../routing";
import { Session } from "./Session";
diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx
index d7719834..3076bd93 100644
--- a/frontend/src/components/Layout.test.tsx
+++ b/frontend/src/components/Layout.test.tsx
@@ -20,8 +20,8 @@ import { useHydrateAtoms } from "jotai/utils";
import { Suspense } from "react";
import { describe, expect, it, vi, afterAll, beforeEach } from "vitest";
-import { appConfigAtom, locationAtom } from "../Router";
import { currentUserIdAtom, GqlResult } from "../atoms";
+import { appConfigAtom, locationAtom } from "../routing";
import Layout from "./Layout";
diff --git a/frontend/src/components/NavBar/NavBar.stories.tsx b/frontend/src/components/NavBar/NavBar.stories.tsx
index 97360f03..ca5a0c0e 100644
--- a/frontend/src/components/NavBar/NavBar.stories.tsx
+++ b/frontend/src/components/NavBar/NavBar.stories.tsx
@@ -16,7 +16,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
-import { appConfigAtom, locationAtom } from "../../Router";
+import { appConfigAtom, locationAtom } from "../../routing";
import NavItem, { ExternalLink } from "../NavItem";
import NavBar from "./NavBar";
diff --git a/frontend/src/components/NavItem/NavItem.stories.tsx b/frontend/src/components/NavItem/NavItem.stories.tsx
index 08c74e75..7c20c930 100644
--- a/frontend/src/components/NavItem/NavItem.stories.tsx
+++ b/frontend/src/components/NavItem/NavItem.stories.tsx
@@ -16,7 +16,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
-import { appConfigAtom, locationAtom } from "../../Router";
+import { appConfigAtom, locationAtom } from "../../routing";
import NavItem from "./NavItem";
diff --git a/frontend/src/components/NavItem/NavItem.test.tsx b/frontend/src/components/NavItem/NavItem.test.tsx
index 894c06dc..0df6ed25 100644
--- a/frontend/src/components/NavItem/NavItem.test.tsx
+++ b/frontend/src/components/NavItem/NavItem.test.tsx
@@ -20,7 +20,7 @@ import { useHydrateAtoms } from "jotai/utils";
import { create } from "react-test-renderer";
import { beforeEach, describe, expect, it } from "vitest";
-import { appConfigAtom, locationAtom } from "../../Router";
+import { appConfigAtom, locationAtom } from "../../routing";
import NavItem from "./NavItem";
diff --git a/frontend/src/components/NavItem/NavItem.tsx b/frontend/src/components/NavItem/NavItem.tsx
index c11db566..d21a83dd 100644
--- a/frontend/src/components/NavItem/NavItem.tsx
+++ b/frontend/src/components/NavItem/NavItem.tsx
@@ -15,7 +15,7 @@
import classNames from "classnames";
import { useAtomValue } from "jotai";
-import { Link, Route, routeAtom } from "../../Router";
+import { routeAtom, Link, Route } from "../../routing";
import styles from "./NavItem.module.css";
diff --git a/frontend/src/components/OAuth2Session.tsx b/frontend/src/components/OAuth2Session.tsx
index dd461db6..3fbb8958 100644
--- a/frontend/src/components/OAuth2Session.tsx
+++ b/frontend/src/components/OAuth2Session.tsx
@@ -18,8 +18,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql";
import { useTransition } from "react";
-import { Link } from "../Router";
import { FragmentType, graphql, useFragment } from "../gql";
+import { Link } from "../routing";
import { getDeviceIdFromScope } from "../utils/deviceIdFromScope";
// import LoadingSpinner from "./LoadingSpinner/LoadingSpinner";
diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx
index 8a34cd9d..8419ed6b 100644
--- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx
+++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx
@@ -1,4 +1,4 @@
-// Copyright 2022 The Matrix.org Foundation C.I.C.
+// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -65,8 +65,8 @@ const OAuth2SessionDetail: React.FC = ({ session }) => {
value: (
<>
{scopes.map((scope) => (
-
- {scope}
+
+ {scope}
))}
>
diff --git a/frontend/src/components/SessionDetail/SessionDetail.tsx b/frontend/src/components/SessionDetail/SessionDetail.tsx
index 71323181..c3e3db97 100644
--- a/frontend/src/components/SessionDetail/SessionDetail.tsx
+++ b/frontend/src/components/SessionDetail/SessionDetail.tsx
@@ -18,8 +18,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useMemo } from "react";
-import { Link } from "../../Router";
-import { graphql } from "../../gql/gql";
+import { graphql } from "../../gql";
+import { Link } from "../../routing";
import CompatSessionDetail from "./CompatSessionDetail";
import OAuth2SessionDetail from "./OAuth2SessionDetail";
diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx
index fe3581e0..c9b7b63f 100644
--- a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx
+++ b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx
@@ -15,9 +15,8 @@
import { Alert } from "@vector-im/compound-web";
import { useState } from "react";
-import { Link } from "../../Router";
-import { FragmentType, useFragment } from "../../gql/fragment-masking";
-import { graphql } from "../../gql/gql";
+import { FragmentType, useFragment, graphql } from "../../gql";
+import { Link } from "../../routing";
import styles from "./UnverifiedEmailAlert.module.css";
diff --git a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap
index 24cbccf2..a4cf44b3 100644
--- a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap
+++ b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap
@@ -34,7 +34,7 @@ exports[` > renders a warning when there are unverified
unverified email address(es).
Review and verify
diff --git a/frontend/src/components/UserEmail/UserEmail.tsx b/frontend/src/components/UserEmail/UserEmail.tsx
index 852f459a..814f7ee3 100644
--- a/frontend/src/components/UserEmail/UserEmail.tsx
+++ b/frontend/src/components/UserEmail/UserEmail.tsx
@@ -19,8 +19,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql";
import { useTransition } from "react";
-import { Link } from "../../Router";
import { FragmentType, graphql, useFragment } from "../../gql";
+import { Link } from "../../routing";
import styles from "./UserEmail.module.css";
diff --git a/frontend/src/components/UserProfile/UserEmailList.tsx b/frontend/src/components/UserProfile/UserEmailList.tsx
index 2a247de6..98a793e6 100644
--- a/frontend/src/components/UserProfile/UserEmailList.tsx
+++ b/frontend/src/components/UserProfile/UserEmailList.tsx
@@ -18,7 +18,6 @@ import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react";
-import { routeAtom } from "../../Router";
import { graphql } from "../../gql";
import { PageInfo } from "../../gql/graphql";
import {
@@ -27,6 +26,7 @@ import {
FIRST_PAGE,
Pagination,
} from "../../pagination";
+import { routeAtom } from "../../routing";
import BlockList from "../BlockList";
import PaginationControls from "../PaginationControls";
import UserEmail from "../UserEmail";
diff --git a/frontend/src/components/UserSessionsOverview/UserSessionsOverview.stories.tsx b/frontend/src/components/UserSessionsOverview/UserSessionsOverview.stories.tsx
index 57114d12..b7bb3e03 100644
--- a/frontend/src/components/UserSessionsOverview/UserSessionsOverview.stories.tsx
+++ b/frontend/src/components/UserSessionsOverview/UserSessionsOverview.stories.tsx
@@ -16,8 +16,8 @@ import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
-import { appConfigAtom, locationAtom } from "../../Router";
import { makeFragmentData } from "../../gql";
+import { appConfigAtom, locationAtom } from "../../routing";
import { FRAGMENT as EMAIL_FRAGMENT } from "../UserEmail";
import UserSessionsOverview, { FRAGMENT } from "./UserSessionsOverview";
diff --git a/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx b/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx
index 73756bdd..4a3bfab4 100644
--- a/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx
+++ b/frontend/src/components/UserSessionsOverview/UserSessionsOverview.tsx
@@ -14,10 +14,10 @@
import { Body, H3, H6 } from "@vector-im/compound-web";
-import { Link } from "../../Router";
import { FragmentType, graphql, useFragment } from "../../gql";
-import Block from "../Block/Block";
-import BlockList from "../BlockList/BlockList";
+import { Link } from "../../routing";
+import Block from "../Block";
+import BlockList from "../BlockList";
import styles from "./UserSessionsOverview.module.css";
diff --git a/frontend/src/components/UserSessionsOverview/__snapshots__/UserSessionsOverview.test.tsx.snap b/frontend/src/components/UserSessionsOverview/__snapshots__/UserSessionsOverview.test.tsx.snap
index 15a09b84..736aa3ba 100644
--- a/frontend/src/components/UserSessionsOverview/__snapshots__/UserSessionsOverview.test.tsx.snap
+++ b/frontend/src/components/UserSessionsOverview/__snapshots__/UserSessionsOverview.test.tsx.snap
@@ -30,7 +30,7 @@ exports[`UserSessionsOverview > render a with sessions
@@ -58,7 +58,7 @@ exports[`UserSessionsOverview > render a with sessions
@@ -86,7 +86,7 @@ exports[`UserSessionsOverview > render a with sessions
@@ -126,7 +126,7 @@ exports[`UserSessionsOverview > render an simple 1`] =
@@ -154,7 +154,7 @@ exports[`UserSessionsOverview > render an simple 1`] =
@@ -182,7 +182,7 @@ exports[`UserSessionsOverview > render an simple 1`] =
diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.tsx b/frontend/src/components/VerifyEmail/VerifyEmail.tsx
index e2400354..c9341e43 100644
--- a/frontend/src/components/VerifyEmail/VerifyEmail.tsx
+++ b/frontend/src/components/VerifyEmail/VerifyEmail.tsx
@@ -27,8 +27,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql";
import { useEffect, useRef, useTransition } from "react";
-import { routeAtom } from "../../Router";
import { FragmentType, graphql, useFragment } from "../../gql";
+import { routeAtom } from "../../routing";
import styles from "./VerifyEmail.module.css";
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index c9bc03f7..33f931db 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -17,9 +17,11 @@ import { DevTools } from "jotai-devtools";
import { Suspense, StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import Router from "./Router";
import { HydrateAtoms } from "./atoms";
+import Layout from "./components/Layout";
import LoadingScreen from "./components/LoadingScreen";
+import LoadingSpinner from "./components/LoadingSpinner";
+import { Router } from "./routing";
import "./main.css";
createRoot(document.getElementById("root") as HTMLElement).render(
@@ -28,7 +30,11 @@ createRoot(document.getElementById("root") as HTMLElement).render(
{import.meta.env.DEV && }
}>
-
+
+ }>
+
+
+
diff --git a/frontend/src/Router.module.css b/frontend/src/routing/Link.module.css
similarity index 99%
rename from frontend/src/Router.module.css
rename to frontend/src/routing/Link.module.css
index ca470e41..757988dd 100644
--- a/frontend/src/Router.module.css
+++ b/frontend/src/routing/Link.module.css
@@ -28,4 +28,4 @@
.link-button:active {
color: var(--cpd-color-text-on-solid-primary);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/routing/Link.tsx b/frontend/src/routing/Link.tsx
new file mode 100644
index 00000000..bcfff2fe
--- /dev/null
+++ b/frontend/src/routing/Link.tsx
@@ -0,0 +1,70 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useAtomValue, useSetAtom } from "jotai";
+import { useTransition } from "react";
+
+import styles from "./Link.module.css";
+import { appConfigAtom, routeAtom } from "./atoms";
+import { Route, routeToPath } from "./routes";
+
+// Filter out clicks with modifiers or that have been prevented
+const shouldHandleClick = (e: React.MouseEvent): boolean =>
+ !e.defaultPrevented &&
+ e.button === 0 &&
+ !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
+
+const Link: React.FC<
+ {
+ route: Route;
+ // adds button-like styling to link element
+ kind?: "button";
+ } & React.HTMLProps
+> = ({ route, children, kind, className, ...props }) => {
+ const config = useAtomValue(appConfigAtom);
+ const path = routeToPath(route);
+ const fullUrl = config.root + path;
+ const setRoute = useSetAtom(routeAtom);
+
+ // TODO: we should probably have more user control over this
+ const [isPending, startTransition] = useTransition();
+
+ const classNames = [
+ kind === "button" ? styles.linkButton : "",
+ className,
+ ].join("");
+
+ return (
+ {
+ // Only handle left clicks without modifiers
+ if (!shouldHandleClick(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ startTransition(() => {
+ setRoute(route);
+ });
+ }}
+ className={classNames}
+ {...props}
+ >
+ {isPending ? "Loading..." : children}
+
+ );
+};
+
+export default Link;
diff --git a/frontend/src/routing/Router.tsx b/frontend/src/routing/Router.tsx
new file mode 100644
index 00000000..43d20df8
--- /dev/null
+++ b/frontend/src/routing/Router.tsx
@@ -0,0 +1,86 @@
+// Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useAtom, useAtomValue } from "jotai";
+import { lazy, useEffect } from "react";
+
+import LoadingSpinner from "../components/LoadingSpinner";
+
+import { getRouteActionRedirection } from "./actions";
+import { locationAtom, routeAtom } from "./atoms";
+import type { Route } from "./routes";
+
+/**
+ * Check for actions in URL query params requiring a redirect
+ * Get route from path
+ * @returns Route
+ */
+const useRouteWithRedirect = (): [Route, boolean] => {
+ const location = useAtomValue(locationAtom);
+ const redirect = getRouteActionRedirection(location);
+
+ const [route, setRoute] = useAtom(routeAtom);
+
+ useEffect(() => {
+ if (redirect) {
+ setRoute(redirect.route, redirect.searchParams);
+ }
+ }, [redirect, setRoute]);
+
+ const redirecting = !!redirect;
+ return [route, redirecting];
+};
+
+const SessionsOverview = lazy(() => import("../pages/SessionsOverview"));
+const SessionDetail = lazy(() => import("../pages/SessionDetail"));
+const Profile = lazy(() => import("../pages/Profile"));
+const OAuth2Client = lazy(() => import("../pages/OAuth2Client"));
+const BrowserSession = lazy(() => import("../pages/BrowserSession"));
+const BrowserSessionList = lazy(() => import("../pages/BrowserSessionList"));
+const CompatSessionList = lazy(() => import("../pages/CompatSessionList"));
+const OAuth2SessionList = lazy(() => import("../pages/OAuth2SessionList"));
+const VerifyEmail = lazy(() => import("../pages/VerifyEmail"));
+
+const Router: React.FC = () => {
+ const [route, redirecting] = useRouteWithRedirect();
+
+ if (redirecting) {
+ return ;
+ }
+
+ switch (route.type) {
+ case "profile":
+ return ;
+ case "sessions-overview":
+ return ;
+ case "session":
+ return ;
+ case "oauth2-session-list":
+ return ;
+ case "browser-session-list":
+ return ;
+ case "compat-session-list":
+ return ;
+ case "client":
+ return ;
+ case "browser-session":
+ return ;
+ case "verify-email":
+ return ;
+ case "unknown":
+ return <>Unknown route {JSON.stringify(route.segments)}>;
+ }
+};
+
+export default Router;
diff --git a/frontend/src/routing/actions.test.ts b/frontend/src/routing/actions.test.ts
new file mode 100644
index 00000000..b6956031
--- /dev/null
+++ b/frontend/src/routing/actions.test.ts
@@ -0,0 +1,98 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { it, expect, describe } from "vitest";
+
+import { getRouteActionRedirection } from "./actions";
+
+describe("getRouteActionRedirection()", () => {
+ it("no redirect when location has no searchParams", () => {
+ expect(getRouteActionRedirection({ pathname: "/account/" })).toBeNull();
+ });
+
+ it("no redirect when location has empty searchParams", () => {
+ expect(
+ getRouteActionRedirection({
+ pathname: "/account/",
+ searchParams: new URLSearchParams(),
+ }),
+ ).toBeNull();
+ });
+
+ it("no redirect when location has an unknown action in search params", () => {
+ expect(
+ getRouteActionRedirection({
+ pathname: "/account/",
+ searchParams: new URLSearchParams("?action=test"),
+ }),
+ ).toBeNull();
+ });
+
+ it("redirects to session detail when location has a action=session_end", () => {
+ const searchParams = new URLSearchParams();
+ searchParams.set("action", "session_end");
+ searchParams.set("device_id", "test-device-id");
+ searchParams.set("something_else", "should-remain");
+ expect(
+ getRouteActionRedirection({ pathname: "/account/", searchParams }),
+ ).toEqual({
+ route: {
+ type: "session",
+ id: "test-device-id",
+ },
+ searchParams: new URLSearchParams("?something_else=should-remain"),
+ });
+ });
+
+ it("redirects to session detail when location has a action=session_view", () => {
+ const searchParams = new URLSearchParams();
+ searchParams.set("action", "session_view");
+ searchParams.set("device_id", "test-device-id");
+ expect(
+ getRouteActionRedirection({ pathname: "/account/", searchParams }),
+ ).toEqual({
+ route: {
+ type: "session",
+ id: "test-device-id",
+ },
+ searchParams: new URLSearchParams(),
+ });
+ });
+
+ it("redirects to sessions overview when location has a action=sessions_list", () => {
+ const searchParams = new URLSearchParams();
+ searchParams.set("action", "sessions_list");
+ expect(
+ getRouteActionRedirection({ pathname: "/account/", searchParams }),
+ ).toEqual({
+ route: {
+ type: "sessions-overview",
+ },
+ searchParams: new URLSearchParams(),
+ });
+ });
+
+ it("redirects to profile when location has a action=profile", () => {
+ const searchParams = new URLSearchParams();
+ searchParams.set("action", "profile");
+ expect(
+ getRouteActionRedirection({ pathname: "/account/", searchParams }),
+ ).toEqual({
+ route: {
+ type: "profile",
+ },
+ searchParams: new URLSearchParams(),
+ });
+ });
+});
diff --git a/frontend/src/routing/actions.ts b/frontend/src/routing/actions.ts
new file mode 100644
index 00000000..81a97fa6
--- /dev/null
+++ b/frontend/src/routing/actions.ts
@@ -0,0 +1,76 @@
+/* Copyright 2023 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Location, Route } from "./routes";
+
+// As defined by MSC2965
+// https://github.com/sandhose/matrix-doc/blob/msc/sandhose/oidc-discovery/proposals/2965-oidc-discovery.md#account-management-url-parameters
+enum RouteAction {
+ EndSession = "session_end",
+ ViewSession = "session_view",
+ ListSessions = "sessions_list",
+ Profile = "profile",
+}
+
+export const getRouteActionRedirection = (
+ location: Location,
+): null | {
+ route: Route;
+ searchParams?: URLSearchParams;
+} => {
+ // Clone the search params so we can modify them
+ const searchParams = new URLSearchParams(location.searchParams?.toString());
+ const action = searchParams.get("action");
+ const deviceId = searchParams.get("device_id");
+ searchParams.delete("action");
+ searchParams.delete("device_id");
+
+ let route: Route;
+ switch (action) {
+ case RouteAction.EndSession:
+ route = {
+ type: "session",
+ id: deviceId || "",
+ };
+ break;
+
+ case RouteAction.ViewSession:
+ route = {
+ type: "session",
+ id: deviceId || "",
+ };
+ break;
+
+ case RouteAction.ListSessions:
+ route = {
+ type: "sessions-overview",
+ };
+ break;
+
+ case RouteAction.Profile:
+ route = {
+ type: "profile",
+ };
+ break;
+
+ default:
+ return null;
+ }
+
+ return {
+ route,
+ searchParams,
+ };
+};
diff --git a/frontend/src/routing/atoms.ts b/frontend/src/routing/atoms.ts
new file mode 100644
index 00000000..c336f0e8
--- /dev/null
+++ b/frontend/src/routing/atoms.ts
@@ -0,0 +1,48 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { atom } from "jotai";
+import { atomWithLocation } from "jotai-location";
+
+import { Location, pathToRoute, Route, routeToPath } from "./routes";
+
+export const appConfigAtom = atom(
+ typeof window !== "undefined" ? window.APP_CONFIG : { root: "/" },
+);
+
+const locationToRoute = (root: string, location: Location): Route => {
+ if (!location.pathname || !location.pathname.startsWith(root)) {
+ throw new Error(`Invalid location ${location.pathname}`);
+ }
+
+ const path = location.pathname.slice(root.length);
+ return pathToRoute(path);
+};
+
+export const locationAtom = atomWithLocation();
+
+export const routeAtom = atom(
+ (get) => {
+ const location = get(locationAtom);
+ const config = get(appConfigAtom);
+ return locationToRoute(config.root, location);
+ },
+ (get, set, value: Route, searchParams?: URLSearchParams) => {
+ const appConfig = get(appConfigAtom);
+ set(locationAtom, {
+ pathname: appConfig.root + routeToPath(value),
+ searchParams,
+ });
+ },
+);
diff --git a/frontend/src/routing/index.ts b/frontend/src/routing/index.ts
new file mode 100644
index 00000000..36432ae6
--- /dev/null
+++ b/frontend/src/routing/index.ts
@@ -0,0 +1,20 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export { default as Router } from "./Router";
+export { default as Link } from "./Link";
+export type { Route, Location } from "./routes";
+export { pathToRoute, routeToPath } from "./routes";
+export { getRouteActionRedirection } from "./actions";
+export { routeAtom, locationAtom, appConfigAtom } from "./atoms";
diff --git a/frontend/src/Router.test.tsx b/frontend/src/routing/routes.test.ts
similarity index 97%
rename from frontend/src/Router.test.tsx
rename to frontend/src/routing/routes.test.ts
index fdb60f84..2b3d16c5 100644
--- a/frontend/src/Router.test.tsx
+++ b/frontend/src/routing/routes.test.ts
@@ -14,9 +14,9 @@
import { describe, it, expect } from "vitest";
-import { segmentsToRoute } from "./Router";
+import { segmentsToRoute } from "./routes";
-describe("Router", () => {
+describe("routes", () => {
describe("segmentsToRoute", () => {
it("returns profile for route with no segments", () => {
const segments: string[] = [];
diff --git a/frontend/src/routing/routes.ts b/frontend/src/routing/routes.ts
new file mode 100644
index 00000000..8fed53e9
--- /dev/null
+++ b/frontend/src/routing/routes.ts
@@ -0,0 +1,152 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export type Location = Readonly<{
+ pathname?: string;
+ searchParams?: URLSearchParams;
+}>;
+
+export type Segments = Readonly;
+
+// Converts a list of segments to a path
+const segmentsToPath = (segments: Segments): string =>
+ segments.map((part) => encodeURIComponent(part)).join("/");
+
+// Converts a path to a list of segments
+const pathToSegments = (path: string): Segments =>
+ path.split("/").map(decodeURIComponent);
+
+type ProfileRoute = Readonly<{ type: "profile" }>;
+type SessionOverviewRoute = Readonly<{ type: "sessions-overview" }>;
+type SessionDetailRoute = Readonly<{ type: "session"; id: string }>;
+type OAuth2ClientRoute = Readonly<{ type: "client"; id: string }>;
+type OAuth2SessionList = Readonly<{ type: "oauth2-session-list" }>;
+type BrowserSessionRoute = Readonly<{ type: "browser-session"; id: string }>;
+type BrowserSessionListRoute = Readonly<{ type: "browser-session-list" }>;
+type CompatSessionListRoute = Readonly<{ type: "compat-session-list" }>;
+type VerifyEmailRoute = Readonly<{ type: "verify-email"; id: string }>;
+type UnknownRoute = Readonly<{ type: "unknown"; segments: Segments }>;
+
+export type Route =
+ | SessionOverviewRoute
+ | SessionDetailRoute
+ | ProfileRoute
+ | OAuth2ClientRoute
+ | OAuth2SessionList
+ | BrowserSessionRoute
+ | BrowserSessionListRoute
+ | CompatSessionListRoute
+ | VerifyEmailRoute
+ | UnknownRoute;
+
+// Converts a route to a path
+export const routeToPath = (route: Route): string =>
+ segmentsToPath(routeToSegments(route));
+
+// Converts a path to a route
+export const pathToRoute = (path: string): Route =>
+ segmentsToRoute(pathToSegments(path));
+
+// Converts a route to a list of segments
+export const routeToSegments = (route: Route): Segments => {
+ switch (route.type) {
+ case "profile":
+ return [];
+ case "sessions-overview":
+ return ["sessions-overview"];
+ case "session":
+ return ["session", route.id];
+ case "verify-email":
+ return ["emails", route.id, "verify"];
+ case "client":
+ return ["clients", route.id];
+ case "browser-session-list":
+ return ["browser-sessions"];
+ case "browser-session":
+ return ["browser-sessions", route.id];
+ case "oauth2-session-list":
+ return ["oauth2-sessions"];
+ case "compat-session-list":
+ return ["compat-sessions"];
+ case "unknown":
+ return route.segments;
+ }
+};
+
+const P = Symbol();
+type PatternItem = string | typeof P;
+
+// Returns true if the segments match the pattern, where P is a parameter
+const segmentMatches = (
+ segments: Segments,
+ ...pattern: PatternItem[]
+): boolean => {
+ // Quick check to see if the lengths match
+ if (segments.length !== pattern.length) return false;
+
+ // Check each segment
+ for (let i = 0; i < segments.length; i++) {
+ // If the pattern is P, then it's a parameter and we can skip it
+ if (pattern[i] === P) continue;
+ // Otherwise, check that the segment matches the pattern
+ if (segments[i] !== pattern[i]) return false;
+ }
+
+ return true;
+};
+
+// Converts a list of segments to a route
+export const segmentsToRoute = (segments: Segments): Route => {
+ const matches = (...pattern: PatternItem[]): boolean =>
+ segmentMatches(segments, ...pattern);
+
+ // Special case for the home page
+ if (matches() || matches("")) {
+ return { type: "profile" };
+ }
+
+ if (matches("sessions-overview")) {
+ return { type: "sessions-overview" };
+ }
+
+ if (matches("browser-sessions")) {
+ return { type: "browser-session-list" };
+ }
+
+ if (matches("oauth2-sessions")) {
+ return { type: "oauth2-session-list" };
+ }
+
+ if (matches("compat-sessions")) {
+ return { type: "compat-session-list" };
+ }
+
+ if (matches("emails", P, "verify")) {
+ return { type: "verify-email", id: segments[1] };
+ }
+
+ if (matches("clients", P)) {
+ return { type: "client", id: segments[1] };
+ }
+
+ if (matches("browser-sessions", P)) {
+ return { type: "browser-session", id: segments[1] };
+ }
+
+ if (matches("session", P)) {
+ return { type: "session", id: segments[1] };
+ }
+
+ return { type: "unknown", segments };
+};
diff --git a/frontend/src/test-utils/WithLocation.tsx b/frontend/src/test-utils/WithLocation.tsx
index 9cd30eae..49f12ac4 100644
--- a/frontend/src/test-utils/WithLocation.tsx
+++ b/frontend/src/test-utils/WithLocation.tsx
@@ -17,7 +17,7 @@
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
-import { appConfigAtom, locationAtom } from "../Router";
+import { appConfigAtom, locationAtom } from "../routing";
const HydrateLocation: React.FC> = ({
children,