You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-07 17:03:01 +03:00
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 <quenting@element.io>
This commit is contained in:
@@ -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<S, B>(templates: Templates) -> Router<S, B>
|
||||
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(),
|
||||
|
@@ -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?;
|
||||
|
@@ -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())
|
||||
}
|
||||
|
@@ -55,7 +55,7 @@ pub(crate) async fn get(
|
||||
) -> Result<Response, FancyError> {
|
||||
// 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();
|
||||
|
@@ -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<Templates>,
|
||||
action: Option<Query<mas_router::AccountAction>>,
|
||||
mut repo: BoxRepository,
|
||||
cookie_jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, FancyError> {
|
||||
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());
|
||||
}
|
||||
|
@@ -52,7 +52,7 @@ pub(crate) async fn get(
|
||||
) -> Result<Response, FancyError> {
|
||||
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);
|
||||
|
@@ -88,7 +88,7 @@ impl OptionalPostAuthAction {
|
||||
PostAuthContextInner::LinkUpstream { provider, link }
|
||||
}
|
||||
|
||||
PostAuthAction::ManageAccount => PostAuthContextInner::ManageAccount,
|
||||
PostAuthAction::ManageAccount { .. } => PostAuthContextInner::ManageAccount,
|
||||
};
|
||||
|
||||
Ok(Some(PostAuthContext {
|
||||
|
@@ -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<AccountAction>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PostAuthAction {
|
||||
@@ -43,13 +52,21 @@ impl PostAuthAction {
|
||||
PostAuthAction::LinkUpstream { id }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn manage_account(action: Option<AccountAction>) -> 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<AccountAction>,
|
||||
}
|
||||
|
||||
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/*`
|
||||
|
@@ -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<AppConfig>(
|
||||
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 <Profile />;
|
||||
case "sessions-overview":
|
||||
return <SessionsOverview />;
|
||||
case "session":
|
||||
return <SessionDetail deviceId={route.id} />;
|
||||
case "oauth2-session-list":
|
||||
return <OAuth2SessionList />;
|
||||
case "browser-session-list":
|
||||
return <BrowserSessionList />;
|
||||
case "compat-session-list":
|
||||
return <CompatSessionList />;
|
||||
case "client":
|
||||
return <OAuth2Client id={route.id} />;
|
||||
case "browser-session":
|
||||
return <BrowserSession id={route.id} />;
|
||||
case "verify-email":
|
||||
return <VerifyEmail id={route.id} />;
|
||||
case "unknown":
|
||||
return <>Unknown route {JSON.stringify(route.segments)}</>;
|
||||
}
|
||||
};
|
||||
|
||||
const Router: React.FC = () => (
|
||||
<Layout>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<InnerRouter />
|
||||
</Suspense>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
// 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<HTMLAnchorElement>
|
||||
> = ({ 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 (
|
||||
<a
|
||||
href={fullUrl}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
// Only handle left clicks without modifiers
|
||||
if (!shouldHandleClick(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
setRoute(route);
|
||||
});
|
||||
}}
|
||||
className={classNames}
|
||||
{...props}
|
||||
>
|
||||
{isPending ? "Loading..." : children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|
@@ -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";
|
||||
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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";
|
||||
|
@@ -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<Props> = ({ session }) => {
|
||||
value: (
|
||||
<>
|
||||
{scopes.map((scope) => (
|
||||
<p>
|
||||
<code key={scope}>{scope}</code>
|
||||
<p key={scope}>
|
||||
<code>{scope}</code>
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -34,7 +34,7 @@ exports[`<UnverifiedEmailAlert /> > renders a warning when there are unverified
|
||||
unverified email address(es).
|
||||
|
||||
<a
|
||||
class="_linkButton_4f14fc"
|
||||
class="_linkButton_b80ad8"
|
||||
href="/"
|
||||
>
|
||||
Review and verify
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -30,7 +30,7 @@ exports[`UserSessionsOverview > render a <UserSessionsOverview /> with sessions
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="_linkButton_4f14fc"
|
||||
className="_linkButton_b80ad8"
|
||||
href="/browser-sessions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
@@ -58,7 +58,7 @@ exports[`UserSessionsOverview > render a <UserSessionsOverview /> with sessions
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="_linkButton_4f14fc"
|
||||
className="_linkButton_b80ad8"
|
||||
href="/oauth2-sessions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
@@ -86,7 +86,7 @@ exports[`UserSessionsOverview > render a <UserSessionsOverview /> with sessions
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="_linkButton_4f14fc"
|
||||
className="_linkButton_b80ad8"
|
||||
href="/compat-sessions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
@@ -126,7 +126,7 @@ exports[`UserSessionsOverview > render an simple <UserSessionsOverview /> 1`] =
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="_linkButton_4f14fc"
|
||||
className="_linkButton_b80ad8"
|
||||
href="/browser-sessions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
@@ -154,7 +154,7 @@ exports[`UserSessionsOverview > render an simple <UserSessionsOverview /> 1`] =
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="_linkButton_4f14fc"
|
||||
className="_linkButton_b80ad8"
|
||||
href="/oauth2-sessions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
@@ -182,7 +182,7 @@ exports[`UserSessionsOverview > render an simple <UserSessionsOverview /> 1`] =
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="_linkButton_4f14fc"
|
||||
className="_linkButton_b80ad8"
|
||||
href="/compat-sessions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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,8 +30,12 @@ createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
{import.meta.env.DEV && <DevTools />}
|
||||
<HydrateAtoms>
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Layout>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Router />
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</Suspense>
|
||||
</HydrateAtoms>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
|
70
frontend/src/routing/Link.tsx
Normal file
70
frontend/src/routing/Link.tsx
Normal file
@@ -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<HTMLAnchorElement>
|
||||
> = ({ 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 (
|
||||
<a
|
||||
href={fullUrl}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
// Only handle left clicks without modifiers
|
||||
if (!shouldHandleClick(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
setRoute(route);
|
||||
});
|
||||
}}
|
||||
className={classNames}
|
||||
{...props}
|
||||
>
|
||||
{isPending ? "Loading..." : children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default Link;
|
86
frontend/src/routing/Router.tsx
Normal file
86
frontend/src/routing/Router.tsx
Normal file
@@ -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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
switch (route.type) {
|
||||
case "profile":
|
||||
return <Profile />;
|
||||
case "sessions-overview":
|
||||
return <SessionsOverview />;
|
||||
case "session":
|
||||
return <SessionDetail deviceId={route.id} />;
|
||||
case "oauth2-session-list":
|
||||
return <OAuth2SessionList />;
|
||||
case "browser-session-list":
|
||||
return <BrowserSessionList />;
|
||||
case "compat-session-list":
|
||||
return <CompatSessionList />;
|
||||
case "client":
|
||||
return <OAuth2Client id={route.id} />;
|
||||
case "browser-session":
|
||||
return <BrowserSession id={route.id} />;
|
||||
case "verify-email":
|
||||
return <VerifyEmail id={route.id} />;
|
||||
case "unknown":
|
||||
return <>Unknown route {JSON.stringify(route.segments)}</>;
|
||||
}
|
||||
};
|
||||
|
||||
export default Router;
|
98
frontend/src/routing/actions.test.ts
Normal file
98
frontend/src/routing/actions.test.ts
Normal file
@@ -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(),
|
||||
});
|
||||
});
|
||||
});
|
76
frontend/src/routing/actions.ts
Normal file
76
frontend/src/routing/actions.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
48
frontend/src/routing/atoms.ts
Normal file
48
frontend/src/routing/atoms.ts
Normal file
@@ -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<AppConfig>(
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
20
frontend/src/routing/index.ts
Normal file
20
frontend/src/routing/index.ts
Normal file
@@ -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";
|
@@ -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[] = [];
|
152
frontend/src/routing/routes.ts
Normal file
152
frontend/src/routing/routes.ts
Normal file
@@ -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<string[]>;
|
||||
|
||||
// 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 };
|
||||
};
|
@@ -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<React.PropsWithChildren<{ path: string }>> = ({
|
||||
children,
|
||||
|
Reference in New Issue
Block a user