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,