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,
|
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(),
|
||||||
|
@@ -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?;
|
||||||
|
@@ -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())
|
||||||
}
|
}
|
||||||
|
@@ -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();
|
||||||
|
@@ -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());
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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/*`
|
||||||
|
@@ -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 { 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";
|
||||||
|
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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";
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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";
|
||||||
|
@@ -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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@@ -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";
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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";
|
||||||
|
@@ -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";
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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]}
|
||||||
>
|
>
|
||||||
|
@@ -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";
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
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 { 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[] = [];
|
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 { 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,
|
||||||
|
Reference in New Issue
Block a user