1
0
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:
Kerry
2023-09-01 21:42:50 +12:00
committed by GitHub
parent be5b527403
commit 17f8dc4e00
37 changed files with 662 additions and 328 deletions

View File

@@ -30,11 +30,11 @@
clippy::let_with_type_underscore, clippy::let_with_type_underscore,
)] )]
use std::{convert::Infallible, time::Duration}; use std::{borrow::Cow, convert::Infallible, time::Duration};
use axum::{ use axum::{
body::{Bytes, HttpBody}, body::{Bytes, HttpBody},
extract::{FromRef, FromRequestParts, OriginalUri, State}, extract::{FromRef, FromRequestParts, OriginalUri, RawQuery, State},
http::Method, http::Method,
response::{Html, IntoResponse}, response::{Html, IntoResponse},
routing::{get, on, post, MethodFilter}, 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> pub fn human_router<S, B>(templates: Templates) -> Router<S, B>
where where
B: HttpBody + Send + 'static, B: HttpBody + Send + 'static,
@@ -286,7 +287,19 @@ where
{ {
Router::new() Router::new()
// XXX: hard-coded redirect from /account to /account/ // 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::Account::route(), get(self::views::app::get))
.route( .route(
mas_router::AccountWildcard::route(), mas_router::AccountWildcard::route(),

View File

@@ -128,7 +128,7 @@ pub(crate) async fn post(
next.go() next.go()
} else { } else {
query.go_next_or_default(&mas_router::Account) query.go_next_or_default(&mas_router::Account::default())
}; };
repo.save().await?; repo.save().await?;

View File

@@ -73,7 +73,7 @@ pub(crate) async fn get(
if user_email.confirmed_at.is_some() { if user_email.confirmed_at.is_some() {
// This email was already verified, skip // 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()); return Ok((cookie_jar, destination).into_response());
} }
@@ -145,6 +145,6 @@ pub(crate) async fn post(
repo.save().await?; 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()) Ok((cookie_jar, destination).into_response())
} }

View File

@@ -55,7 +55,7 @@ pub(crate) async fn get(
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
// If the password manager is disabled, we can go back to the account page. // If the password manager is disabled, we can go back to the account page.
if !password_manager.is_enabled() { 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(); let (session_info, cookie_jar) = cookie_jar.session_info();

View File

@@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
use axum::{ use axum::{
extract::State, extract::{Query, State},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt}; 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)] #[tracing::instrument(name = "handlers.views.app.get", skip_all, err)]
pub async fn get( pub async fn get(
State(templates): State<Templates>, State(templates): State<Templates>,
action: Option<Query<mas_router::AccountAction>>,
mut repo: BoxRepository, mut repo: BoxRepository,
cookie_jar: CookieJar, cookie_jar: CookieJar,
) -> Result<impl IntoResponse, FancyError> { ) -> Result<impl IntoResponse, FancyError> {
let (session_info, cookie_jar) = cookie_jar.session_info(); let (session_info, cookie_jar) = cookie_jar.session_info();
let session = session_info.load_session(&mut repo).await?; 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() { if session.is_none() {
return Ok(( return Ok((
cookie_jar, cookie_jar,
mas_router::Login::and_then(PostAuthAction::ManageAccount).go(), mas_router::Login::and_then(PostAuthAction::manage_account(action)).go(),
) )
.into_response()); .into_response());
} }

View File

@@ -52,7 +52,7 @@ pub(crate) async fn get(
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
if !password_manager.is_enabled() { if !password_manager.is_enabled() {
// XXX: do something better here // 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); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);

View File

@@ -88,7 +88,7 @@ impl OptionalPostAuthAction {
PostAuthContextInner::LinkUpstream { provider, link } PostAuthContextInner::LinkUpstream { provider, link }
} }
PostAuthAction::ManageAccount => PostAuthContextInner::ManageAccount, PostAuthAction::ManageAccount { .. } => PostAuthContextInner::ManageAccount,
}; };
Ok(Some(PostAuthContext { Ok(Some(PostAuthContext {

View File

@@ -20,11 +20,20 @@ pub use crate::traits::*;
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "snake_case", tag = "next")] #[serde(rename_all = "snake_case", tag = "next")]
pub enum PostAuthAction { pub enum PostAuthAction {
ContinueAuthorizationGrant { id: Ulid }, ContinueAuthorizationGrant {
ContinueCompatSsoLogin { id: Ulid }, id: Ulid,
},
ContinueCompatSsoLogin {
id: Ulid,
},
ChangePassword, ChangePassword,
LinkUpstream { id: Ulid }, LinkUpstream {
ManageAccount, id: Ulid,
},
ManageAccount {
#[serde(flatten)]
action: Option<AccountAction>,
},
} }
impl PostAuthAction { impl PostAuthAction {
@@ -43,13 +52,21 @@ impl PostAuthAction {
PostAuthAction::LinkUpstream { id } 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 { pub fn go_next(&self) -> axum::response::Redirect {
match self { match self {
Self::ContinueAuthorizationGrant { id } => ContinueAuthorizationGrant(*id).go(), Self::ContinueAuthorizationGrant { id } => ContinueAuthorizationGrant(*id).go(),
Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(), Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(),
Self::ChangePassword => AccountPassword.go(), Self::ChangePassword => AccountPassword.go(),
Self::LinkUpstream { id } => UpstreamOAuth2Link::new(*id).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/` /// `GET /account/`
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Account; pub struct Account {
action: Option<AccountAction>,
}
impl SimpleRoute for Account { impl Route for Account {
const PATH: &'static str = "/account/"; type Query = AccountAction;
fn route() -> &'static str {
"/account/"
}
fn query(&self) -> Option<&Self::Query> {
self.action.as_ref()
}
} }
/// `GET /account/*` /// `GET /account/*`

View File

@@ -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;

View File

@@ -18,8 +18,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useTransition } from "react"; import { useTransition } from "react";
import { Link } from "../Router";
import { FragmentType, graphql, useFragment } from "../gql"; import { FragmentType, graphql, useFragment } from "../gql";
import { Link } from "../routing";
import { Session } from "./Session"; import { Session } from "./Session";

View File

@@ -20,8 +20,8 @@ import { useHydrateAtoms } from "jotai/utils";
import { Suspense } from "react"; import { Suspense } from "react";
import { describe, expect, it, vi, afterAll, beforeEach } from "vitest"; import { describe, expect, it, vi, afterAll, beforeEach } from "vitest";
import { appConfigAtom, locationAtom } from "../Router";
import { currentUserIdAtom, GqlResult } from "../atoms"; import { currentUserIdAtom, GqlResult } from "../atoms";
import { appConfigAtom, locationAtom } from "../routing";
import Layout from "./Layout"; import Layout from "./Layout";

View File

@@ -16,7 +16,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai"; import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils"; import { useHydrateAtoms } from "jotai/utils";
import { appConfigAtom, locationAtom } from "../../Router"; import { appConfigAtom, locationAtom } from "../../routing";
import NavItem, { ExternalLink } from "../NavItem"; import NavItem, { ExternalLink } from "../NavItem";
import NavBar from "./NavBar"; import NavBar from "./NavBar";

View File

@@ -16,7 +16,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai"; import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils"; import { useHydrateAtoms } from "jotai/utils";
import { appConfigAtom, locationAtom } from "../../Router"; import { appConfigAtom, locationAtom } from "../../routing";
import NavItem from "./NavItem"; import NavItem from "./NavItem";

View File

@@ -20,7 +20,7 @@ import { useHydrateAtoms } from "jotai/utils";
import { create } from "react-test-renderer"; import { create } from "react-test-renderer";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { appConfigAtom, locationAtom } from "../../Router"; import { appConfigAtom, locationAtom } from "../../routing";
import NavItem from "./NavItem"; import NavItem from "./NavItem";

View File

@@ -15,7 +15,7 @@
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Link, Route, routeAtom } from "../../Router"; import { routeAtom, Link, Route } from "../../routing";
import styles from "./NavItem.module.css"; import styles from "./NavItem.module.css";

View File

@@ -18,8 +18,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useTransition } from "react"; import { useTransition } from "react";
import { Link } from "../Router";
import { FragmentType, graphql, useFragment } from "../gql"; import { FragmentType, graphql, useFragment } from "../gql";
import { Link } from "../routing";
import { getDeviceIdFromScope } from "../utils/deviceIdFromScope"; import { getDeviceIdFromScope } from "../utils/deviceIdFromScope";
// import LoadingSpinner from "./LoadingSpinner/LoadingSpinner"; // import LoadingSpinner from "./LoadingSpinner/LoadingSpinner";

View File

@@ -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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with 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: ( value: (
<> <>
{scopes.map((scope) => ( {scopes.map((scope) => (
<p> <p key={scope}>
<code key={scope}>{scope}</code> <code>{scope}</code>
</p> </p>
))} ))}
</> </>

View File

@@ -18,8 +18,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql"; import { atomWithQuery } from "jotai-urql";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "../../Router"; import { graphql } from "../../gql";
import { graphql } from "../../gql/gql"; import { Link } from "../../routing";
import CompatSessionDetail from "./CompatSessionDetail"; import CompatSessionDetail from "./CompatSessionDetail";
import OAuth2SessionDetail from "./OAuth2SessionDetail"; import OAuth2SessionDetail from "./OAuth2SessionDetail";

View File

@@ -15,9 +15,8 @@
import { Alert } from "@vector-im/compound-web"; import { Alert } from "@vector-im/compound-web";
import { useState } from "react"; import { useState } from "react";
import { Link } from "../../Router"; import { FragmentType, useFragment, graphql } from "../../gql";
import { FragmentType, useFragment } from "../../gql/fragment-masking"; import { Link } from "../../routing";
import { graphql } from "../../gql/gql";
import styles from "./UnverifiedEmailAlert.module.css"; import styles from "./UnverifiedEmailAlert.module.css";

View File

@@ -34,7 +34,7 @@ exports[`<UnverifiedEmailAlert /> > renders a warning when there are unverified
unverified email address(es). unverified email address(es).
<a <a
class="_linkButton_4f14fc" class="_linkButton_b80ad8"
href="/" href="/"
> >
Review and verify Review and verify

View File

@@ -19,8 +19,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useTransition } from "react"; import { useTransition } from "react";
import { Link } from "../../Router";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import { Link } from "../../routing";
import styles from "./UserEmail.module.css"; import styles from "./UserEmail.module.css";

View File

@@ -18,7 +18,6 @@ import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql"; import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react"; import { useTransition } from "react";
import { routeAtom } from "../../Router";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
import { PageInfo } from "../../gql/graphql"; import { PageInfo } from "../../gql/graphql";
import { import {
@@ -27,6 +26,7 @@ import {
FIRST_PAGE, FIRST_PAGE,
Pagination, Pagination,
} from "../../pagination"; } from "../../pagination";
import { routeAtom } from "../../routing";
import BlockList from "../BlockList"; import BlockList from "../BlockList";
import PaginationControls from "../PaginationControls"; import PaginationControls from "../PaginationControls";
import UserEmail from "../UserEmail"; import UserEmail from "../UserEmail";

View File

@@ -16,8 +16,8 @@ import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai"; import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils"; import { useHydrateAtoms } from "jotai/utils";
import { appConfigAtom, locationAtom } from "../../Router";
import { makeFragmentData } from "../../gql"; import { makeFragmentData } from "../../gql";
import { appConfigAtom, locationAtom } from "../../routing";
import { FRAGMENT as EMAIL_FRAGMENT } from "../UserEmail"; import { FRAGMENT as EMAIL_FRAGMENT } from "../UserEmail";
import UserSessionsOverview, { FRAGMENT } from "./UserSessionsOverview"; import UserSessionsOverview, { FRAGMENT } from "./UserSessionsOverview";

View File

@@ -14,10 +14,10 @@
import { Body, H3, H6 } from "@vector-im/compound-web"; import { Body, H3, H6 } from "@vector-im/compound-web";
import { Link } from "../../Router";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import Block from "../Block/Block"; import { Link } from "../../routing";
import BlockList from "../BlockList/BlockList"; import Block from "../Block";
import BlockList from "../BlockList";
import styles from "./UserSessionsOverview.module.css"; import styles from "./UserSessionsOverview.module.css";

View File

@@ -30,7 +30,7 @@ exports[`UserSessionsOverview > render a <UserSessionsOverview /> with sessions
</p> </p>
</div> </div>
<a <a
className="_linkButton_4f14fc" className="_linkButton_b80ad8"
href="/browser-sessions" href="/browser-sessions"
onClick={[Function]} onClick={[Function]}
> >
@@ -58,7 +58,7 @@ exports[`UserSessionsOverview > render a <UserSessionsOverview /> with sessions
</p> </p>
</div> </div>
<a <a
className="_linkButton_4f14fc" className="_linkButton_b80ad8"
href="/oauth2-sessions" href="/oauth2-sessions"
onClick={[Function]} onClick={[Function]}
> >
@@ -86,7 +86,7 @@ exports[`UserSessionsOverview > render a <UserSessionsOverview /> with sessions
</p> </p>
</div> </div>
<a <a
className="_linkButton_4f14fc" className="_linkButton_b80ad8"
href="/compat-sessions" href="/compat-sessions"
onClick={[Function]} onClick={[Function]}
> >
@@ -126,7 +126,7 @@ exports[`UserSessionsOverview > render an simple <UserSessionsOverview /> 1`] =
</p> </p>
</div> </div>
<a <a
className="_linkButton_4f14fc" className="_linkButton_b80ad8"
href="/browser-sessions" href="/browser-sessions"
onClick={[Function]} onClick={[Function]}
> >
@@ -154,7 +154,7 @@ exports[`UserSessionsOverview > render an simple <UserSessionsOverview /> 1`] =
</p> </p>
</div> </div>
<a <a
className="_linkButton_4f14fc" className="_linkButton_b80ad8"
href="/oauth2-sessions" href="/oauth2-sessions"
onClick={[Function]} onClick={[Function]}
> >
@@ -182,7 +182,7 @@ exports[`UserSessionsOverview > render an simple <UserSessionsOverview /> 1`] =
</p> </p>
</div> </div>
<a <a
className="_linkButton_4f14fc" className="_linkButton_b80ad8"
href="/compat-sessions" href="/compat-sessions"
onClick={[Function]} onClick={[Function]}
> >

View File

@@ -27,8 +27,8 @@ import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useEffect, useRef, useTransition } from "react"; import { useEffect, useRef, useTransition } from "react";
import { routeAtom } from "../../Router";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import { routeAtom } from "../../routing";
import styles from "./VerifyEmail.module.css"; import styles from "./VerifyEmail.module.css";

View File

@@ -17,9 +17,11 @@ import { DevTools } from "jotai-devtools";
import { Suspense, StrictMode } from "react"; import { Suspense, StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import Router from "./Router";
import { HydrateAtoms } from "./atoms"; import { HydrateAtoms } from "./atoms";
import Layout from "./components/Layout";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
import LoadingSpinner from "./components/LoadingSpinner";
import { Router } from "./routing";
import "./main.css"; import "./main.css";
createRoot(document.getElementById("root") as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(
@@ -28,7 +30,11 @@ createRoot(document.getElementById("root") as HTMLElement).render(
{import.meta.env.DEV && <DevTools />} {import.meta.env.DEV && <DevTools />}
<HydrateAtoms> <HydrateAtoms>
<Suspense fallback={<LoadingScreen />}> <Suspense fallback={<LoadingScreen />}>
<Router /> <Layout>
<Suspense fallback={<LoadingSpinner />}>
<Router />
</Suspense>
</Layout>
</Suspense> </Suspense>
</HydrateAtoms> </HydrateAtoms>
</Provider> </Provider>

View File

@@ -28,4 +28,4 @@
.link-button:active { .link-button:active {
color: var(--cpd-color-text-on-solid-primary); color: var(--cpd-color-text-on-solid-primary);
} }

View 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;

View 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;

View 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(),
});
});
});

View 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,
};
};

View 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,
});
},
);

View 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";

View File

@@ -14,9 +14,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { segmentsToRoute } from "./Router"; import { segmentsToRoute } from "./routes";
describe("Router", () => { describe("routes", () => {
describe("segmentsToRoute", () => { describe("segmentsToRoute", () => {
it("returns profile for route with no segments", () => { it("returns profile for route with no segments", () => {
const segments: string[] = []; const segments: string[] = [];

View 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 };
};

View File

@@ -17,7 +17,7 @@
import { Provider } from "jotai"; import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils"; import { useHydrateAtoms } from "jotai/utils";
import { appConfigAtom, locationAtom } from "../Router"; import { appConfigAtom, locationAtom } from "../routing";
const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({ const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
children, children,