1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2026-01-03 17:02:28 +03:00

Replace Jotai with @tanstack/router (#2359)

* Start replacing jotai with @tanstack/router

* Remove jotai completely

* Move the common layout & reimplement the ?action parameter

This also makes sure everything is properly loaded in the route loader,
and we use fragment where it makes sense

* Change the default error component

* GraphQL API: make the sessions fetchable through node(id: ID!)
This commit is contained in:
Quentin Gliech
2024-02-15 17:19:05 +01:00
committed by GitHub
parent f0f7497d2d
commit e041f47dfe
92 changed files with 2415 additions and 3706 deletions

View File

@@ -16,7 +16,10 @@ use async_graphql::{Context, MergedObject, Object, ID};
use mas_storage::user::UserRepository;
use crate::{
model::{Anonymous, BrowserSession, Node, NodeType, OAuth2Client, User, UserEmail},
model::{
Anonymous, BrowserSession, CompatSession, Node, NodeType, OAuth2Client, OAuth2Session,
User, UserEmail,
},
state::ContextExt,
UserId,
};
@@ -149,6 +152,56 @@ impl BaseQuery {
Ok(Some(BrowserSession(browser_session)))
}
/// Fetch a compatible session by its ID.
async fn compat_session(
&self,
ctx: &Context<'_>,
id: ID,
) -> Result<Option<CompatSession>, async_graphql::Error> {
let state = ctx.state();
let id = NodeType::CompatSession.extract_ulid(&id)?;
let requester = ctx.requester();
let mut repo = state.repository().await?;
let compat_session = repo.compat_session().lookup(id).await?;
repo.cancel().await?;
let Some(compat_session) = compat_session else {
return Ok(None);
};
if !requester.is_owner_or_admin(&compat_session) {
return Ok(None);
}
Ok(Some(CompatSession::new(compat_session)))
}
/// Fetch an OAuth 2.0 session by its ID.
async fn oauth2_session(
&self,
ctx: &Context<'_>,
id: ID,
) -> Result<Option<OAuth2Session>, async_graphql::Error> {
let state = ctx.state();
let id = NodeType::OAuth2Session.extract_ulid(&id)?;
let requester = ctx.requester();
let mut repo = state.repository().await?;
let oauth2_session = repo.oauth2_session().lookup(id).await?;
repo.cancel().await?;
let Some(oauth2_session) = oauth2_session else {
return Ok(None);
};
if !requester.is_owner_or_admin(&oauth2_session) {
return Ok(None);
}
Ok(Some(OAuth2Session(oauth2_session)))
}
/// Fetch a user email by its ID.
async fn user_email(
&self,
@@ -185,10 +238,7 @@ impl BaseQuery {
let ret = match node_type {
// TODO
NodeType::Authentication
| NodeType::CompatSession
| NodeType::CompatSsoLogin
| NodeType::OAuth2Session => None,
NodeType::Authentication | NodeType::CompatSsoLogin => None,
NodeType::UpstreamOAuth2Provider => UpstreamOAuthQuery
.upstream_oauth2_provider(ctx, id)
@@ -210,6 +260,16 @@ impl BaseQuery {
.await?
.map(|e| Node::UserEmail(Box::new(e))),
NodeType::CompatSession => self
.compat_session(ctx, id)
.await?
.map(|s| Node::CompatSession(Box::new(s))),
NodeType::OAuth2Session => self
.oauth2_session(ctx, id)
.await?
.map(|s| Node::OAuth2Session(Box::new(s))),
NodeType::BrowserSession => self
.browser_session(ctx, id)
.await?

View File

@@ -177,7 +177,7 @@ pub(crate) async fn get(
graphql_endpoint: url_builder.graphql_endpoint(),
account_management_uri: url_builder.account_management_uri(),
// This needs to be kept in sync with what is supported in the frontend,
// see frontend/src/routing/actions.ts
// see frontend/src/routes/__root.tsx
account_management_actions_supported: vec![
"org.matrix.profile".to_owned(),
"org.matrix.sessions_list".to_owned(),

View File

@@ -66,7 +66,12 @@
"confirmation_modal_title": "Are you sure you want to end this session?",
"text": "End session"
},
"error_boundary_title": "Something went wrong",
"error": {
"hideDetails": "Hide details",
"showDetails": "Show details",
"subtitle": "An unexpected error occurred. Please try again.",
"title": "Something went wrong"
},
"last_active": {
"active_date": "Active {{relativeDate}}",
"active_now": "Active now",
@@ -132,7 +137,6 @@
"title": "Cannot find session: {{deviceId}}"
}
},
"unknown_route": "Unknown route {{route}}",
"unverified_email_alert": {
"button": "Review and verify",
"text:one": "You have {{count}} unverified email address.",
@@ -154,9 +158,6 @@
"heading": "Emails",
"no_primary_email_alert": "No primary email address"
},
"user_greeting": {
"error": "Failed to load user"
},
"user_name": {
"display_name_field_label": "Display Name"
},
@@ -177,8 +178,7 @@
"description": "Check the code sent to your email and update the fields below to continue.",
"title": "You entered the wrong code"
},
"resend_code": "Resend code",
"unknown_email": "Unknown email"
"resend_code": "Resend code"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,10 @@
"i18n": "i18next"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@fontsource/inconsolata": "^5.0.16",
"@fontsource/inter": "^5.0.16",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@tanstack/react-router": "^1.16.2",
"@types/ua-parser-js": "^0.7.39",
"@urql/core": "^4.2.3",
"@urql/devtools": "^2.0.3",
@@ -35,14 +35,12 @@
"i18next": "^23.8.2",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.4.3",
"jotai": "^2.6.4",
"jotai-devtools": "^0.7.1",
"jotai-location": "^0.5.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.0.5",
"ua-parser-js": "^1.0.37",
"urql": "^4.0.6"
"urql": "^4.0.6",
"zod": "^3.22.4"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
@@ -52,6 +50,8 @@
"@storybook/addon-essentials": "^7.6.13",
"@storybook/react": "^7.6.13",
"@storybook/react-vite": "^7.6.13",
"@tanstack/router-devtools": "^1.16.2",
"@tanstack/router-vite-plugin": "^1.16.3",
"@testing-library/react": "^14.2.1",
"@types/node": "^20.11.17",
"@types/react": "^18.2.55",

View File

@@ -894,6 +894,14 @@ type Query {
"""
browserSession(id: ID!): BrowserSession
"""
Fetch a compatible session by its ID.
"""
compatSession(id: ID!): CompatSession
"""
Fetch an OAuth 2.0 session by its ID.
"""
oauth2Session(id: ID!): Oauth2Session
"""
Fetch a user email by its ID.
"""
userEmail(id: ID!): UserEmail

View File

View File

@@ -99,7 +99,6 @@ const BrowserSession: React.FC<Props> = ({ session }) => {
deviceType={deviceInformation?.deviceType}
lastActiveIp={data.lastActiveIp || undefined}
lastActiveAt={lastActiveAt}
link={{ type: "browser-session", id: data.id }}
>
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
</Session>

View File

@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { notFound } from "@tanstack/react-router";
import { useState, useTransition } from "react";
import { useQuery } from "urql";
@@ -26,14 +27,15 @@ import SessionListHeader from "./SessionList/SessionListHeader";
const QUERY = graphql(/* GraphQL */ `
query BrowserSessionList(
$userId: ID!
$state: SessionState
$first: Int
$after: String
$last: Int
$before: String
) {
user(id: $userId) {
viewer {
__typename
... on User {
id
browserSessions(
first: $first
@@ -61,9 +63,10 @@ const QUERY = graphql(/* GraphQL */ `
}
}
}
}
`);
const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
const BrowserSessionList: React.FC = () => {
const [pagination, setPagination] = usePagination();
const [pending, startTransition] = useTransition();
const [filter, setFilter] = useState<SessionState | null>(
@@ -71,11 +74,12 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
);
const [result] = useQuery({
query: QUERY,
variables: { userId, state: filter, ...pagination },
variables: { state: filter, ...pagination },
});
if (result.error) throw result.error;
const browserSessions = result.data?.user?.browserSessions;
if (!browserSessions) throw new Error(); // Suspense mode is enabled
const user = result.data?.viewer;
if (user?.__typename !== "User") throw notFound();
const browserSessions = user.browserSessions;
const [prevPage, nextPage] = usePages(pagination, browserSessions.pageInfo);

View File

@@ -20,8 +20,8 @@ import { describe, expect, it, beforeAll } from "vitest";
import { never } from "wonka";
import { makeFragmentData } from "../gql";
import { WithLocation } from "../test-utils/WithLocation";
import { mockLocale } from "../test-utils/mockLocale";
import { DummyRouter } from "../test-utils/router";
import CompatSession, { FRAGMENT } from "./CompatSession";
@@ -49,9 +49,9 @@ describe("<CompatSession />", () => {
const session = makeFragmentData(baseSession, FRAGMENT);
const component = create(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<CompatSession session={session} />
</WithLocation>
</DummyRouter>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
@@ -67,9 +67,9 @@ describe("<CompatSession />", () => {
);
const component = create(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<CompatSession session={session} />
</WithLocation>
</DummyRouter>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();

View File

@@ -48,7 +48,7 @@ export const END_SESSION_MUTATION = graphql(/* GraphQL */ `
`);
export const simplifyUrl = (url: string): string => {
let parsed;
let parsed: URL;
try {
parsed = new URL(url);
} catch (e) {
@@ -97,7 +97,6 @@ const CompatSession: React.FC<{
clientName={clientName}
lastActiveIp={data.lastActiveIp || undefined}
lastActiveAt={lastActiveAt}
link={{ type: "session", id: data.deviceId }}
>
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
</Session>

View File

@@ -12,12 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { CombinedError } from "@urql/core";
import { Alert } from "@vector-im/compound-web";
import { ErrorInfo, ReactNode, PureComponent } from "react";
import { Translation } from "react-i18next";
import GraphQLError from "./GraphQLError";
import GenericError from "./GenericError";
import Layout from "./Layout";
interface Props {
children: ReactNode;
@@ -27,9 +25,6 @@ interface IState {
error?: Error;
}
const isGqlError = (error: Error): error is CombinedError =>
error.name === "CombinedError";
/**
* This error boundary component can be used to wrap large content areas and
* catch exceptions during rendering in the component tree below them.
@@ -57,18 +52,10 @@ export default class ErrorBoundary extends PureComponent<Props, IState> {
public render(): ReactNode {
if (this.state.error) {
if (isGqlError(this.state.error)) {
return <GraphQLError error={this.state.error} />;
}
return (
<Translation>
{(t): ReactNode => (
<Alert type="critical" title={t("frontend.error_boundary_title")}>
{this.state.error!.message}
</Alert>
)}
</Translation>
<Layout>
<GenericError error={this.state.error} />
</Layout>
);
}

View File

@@ -0,0 +1,49 @@
/* Copyright 2024 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.
*/
.error {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
}
.message {
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
text-align: center;
&>p {
color: var(--cpd-color-text-secondary);
}
}
.icon {
align-self: center;
height: var(--cpd-space-16x);
width: var(--cpd-space-16x);
padding: var(--cpd-space-3x);
border-radius: var(--cpd-space-2x);
background-color: var(--cpd-color-bg-critical-subtle);
color: var(--cpd-color-icon-critical-primary);
}
.details {
font: var(--cpd-font-body-sm-regular);
background: var(--cpd-color-bg-critical-subtle);
border: 1px solid var(--cpd-color-border-critical-subtle);
padding: var(--cpd-space-4x);
text-align: initial;
}

View File

@@ -0,0 +1,46 @@
// Copyright 2024 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 IconError from "@vector-im/compound-design-tokens/icons/error.svg?react";
import { Button, H2, Text } from "@vector-im/compound-web";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import styles from "./GenericError.module.css";
const GenericError: React.FC<{ error: unknown }> = ({ error }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className={styles.error}>
<IconError className={styles.icon} />
<div className={styles.message}>
<H2>{t("frontend.error.title")}</H2>
<Text size="lg">{t("frontend.error.subtitle")}</Text>
</div>
<Button kind="tertiary" onClick={() => setOpen(!open)}>
{open
? t("frontend.error.hideDetails")
: t("frontend.error.showDetails")}
</Button>
{open && (
<pre className={styles.details}>
<code>{String(error)}</code>
</pre>
)}
</div>
);
};
export default GenericError;

View File

@@ -1,24 +0,0 @@
// 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 { CombinedError } from "@urql/core";
import { Alert } from "@vector-im/compound-web";
const GraphQLError: React.FC<{ error: CombinedError }> = ({ error }) => (
<Alert type="critical" title={error.message}>
{error.toString()}
</Alert>
);
export default GraphQLError;

View File

@@ -1,43 +0,0 @@
// 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.
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { Provider } from "urql";
import { describe, expect, it } from "vitest";
import { never } from "wonka";
import { WithLocation } from "../../test-utils/WithLocation";
import Layout from "./Layout";
describe("<Layout />", () => {
it("renders app navigation correctly", async () => {
const mockClient = {
executeQuery: (): typeof never => never,
};
const component = render(
<Provider value={mockClient}>
<WithLocation path="/">
<Layout userId="abc123" />
</WithLocation>
</Provider>,
);
expect(await component.findByText("Profile")).toMatchSnapshot();
expect(await component.findByText("Sessions")).toMatchSnapshot();
});
});

View File

@@ -12,45 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { appConfigAtom, routeAtom } from "../../routing";
import appConfig from "../../config";
import Footer from "../Footer";
import NavBar from "../NavBar";
import NavItem from "../NavItem";
import UserGreeting from "../UserGreeting";
import styles from "./Layout.module.css";
const Layout: React.FC<{
userId: string;
children?: React.ReactNode;
}> = ({ userId, children }) => {
const route = useAtomValue(routeAtom);
const appConfig = useAtomValue(appConfigAtom);
const { t } = useTranslation();
// Hide the nav bar & user greeting on the verify-email page
const shouldHideNavBar = route.type === "verify-email";
}> = ({ children }) => {
return (
<div className={styles.layoutContainer}>
{shouldHideNavBar ? null : (
<>
<UserGreeting userId={userId} />
<NavBar>
<NavItem route={{ type: "profile" }}>
{t("frontend.nav.profile")}
</NavItem>
<NavItem route={{ type: "sessions-overview" }}>
{t("frontend.nav.sessions")}
</NavItem>
</NavBar>
</>
)}
{children}
<Footer

View File

@@ -4,6 +4,7 @@ exports[`<Layout /> > renders app navigation correctly 1`] = `
<a
aria-current="page"
class="_navItem_8603fc"
data-status="active"
href="/"
>
Profile
@@ -13,7 +14,7 @@ exports[`<Layout /> > renders app navigation correctly 1`] = `
exports[`<Layout /> > renders app navigation correctly 2`] = `
<a
class="_navItem_8603fc"
href="/sessions-overview"
href="/sessions"
>
Sessions
</a>

View File

@@ -12,23 +12,26 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Separator } from "@vector-im/compound-web";
import { LinkComponent, useLinkProps } from "@tanstack/react-router";
import { Link as CompoundLink } from "@vector-im/compound-web";
import cx from "classnames";
import { forwardRef } from "react";
import BlockList from "../BlockList/BlockList";
import styles from "./Link.module.css";
import CrossSigningReset from "./CrossSigningReset";
import UserEmailList from "./UserEmailList";
import UserName from "./UserName";
export const Link: LinkComponent = forwardRef<
HTMLAnchorElement,
Parameters<typeof useLinkProps>[0]
>(({ children, ...props }, ref) => {
const { className, ...newProps } = useLinkProps(props);
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
return (
<BlockList>
<UserName userId={userId} />
<UserEmailList userId={userId} />
<Separator />
<CrossSigningReset userId={userId} />
</BlockList>
<CompoundLink
kind="primary"
ref={ref}
className={cx(className, styles.linkButton)}
children={children}
{...newProps}
/>
);
};
export default UserProfile;
}) as LinkComponent;

View File

@@ -12,4 +12,4 @@
// See the License for the specific language governing permissions and
// limitations under the License.
export { default } from "./UserProfile";
export { Link } from "./Link";

View File

@@ -5,6 +5,7 @@ exports[`LoadingScreen > render <LoadingScreen /> 1`] = `
className="_loadingScreen_0642c6"
>
<div
className=""
role="status"
>
<svg

View File

@@ -12,13 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import cx from "classnames";
import { ReactNode } from "react";
import { Translation } from "react-i18next";
import styles from "./LoadingSpinner.module.css";
const LoadingSpinner: React.FC<{ inline?: boolean }> = ({ inline }) => (
<div role="status" className={inline ? styles.inline : undefined}>
const LoadingSpinner: React.FC<{ inline?: boolean; className?: string }> = ({
inline,
className,
}) => (
<div role="status" className={cx(className, inline && styles.inline)}>
<svg
className={styles.loadingSpinnerInner}
viewBox="0 0 100 101"

View File

@@ -13,11 +13,9 @@
// limitations under the License.
import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { appConfigAtom, locationAtom } from "../../routing";
import NavItem, { ExternalLink } from "../NavItem";
import { DummyRouter } from "../../test-utils/router";
import NavItem from "../NavItem";
import NavBar from "./NavBar";
@@ -25,27 +23,16 @@ const meta = {
title: "UI/Nav Bar",
component: NavBar,
tags: ["autodocs"],
render: (props): React.ReactElement => (
<Provider>
<WithHomePage>
<NavBar {...props}>
<NavItem route={{ type: "sessions-overview" }}>Sessions</NavItem>
<NavItem route={{ type: "profile" }}>Profile</NavItem>
<ExternalLink href="https://example.com">External</ExternalLink>
render: (): React.ReactElement => (
<DummyRouter>
<NavBar>
<NavItem to="/">Profile</NavItem>
<NavItem to="/sessions">Sessions</NavItem>
</NavBar>
</WithHomePage>
</Provider>
</DummyRouter>
),
} satisfies Meta<typeof NavBar>;
const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
useHydrateAtoms([
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: "/" }],
]);
return <>{children}</>;
};
export default meta;
type Story = StoryObj<typeof NavBar>;

View File

@@ -31,7 +31,7 @@
transition: height 0.1s ease-in-out;
}
.nav-tab[data-current]::before {
.nav-tab:has(.nav-item[aria-current="page"])::before {
/* This is not exactly right: designs says 3px, but there are no variables for that */
height: var(--cpd-border-width-4);
}

View File

@@ -1,59 +0,0 @@
// Copyright 2022 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 type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { appConfigAtom, locationAtom } from "../../routing";
import NavItem from "./NavItem";
const meta = {
title: "UI/Nav Item",
component: NavItem,
tags: ["autodocs"],
render: (props): React.ReactElement => (
<Provider>
<WithHomePage>
<NavItem {...props} />
</WithHomePage>
</Provider>
),
} satisfies Meta<typeof NavItem>;
const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
useHydrateAtoms([
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: "/" }],
]);
return <>{children}</>;
};
export default meta;
type Story = StoryObj<typeof NavItem>;
export const Active: Story = {
args: {
route: { type: "sessions-overview" },
children: "Sessions",
},
};
export const Inactive: Story = {
args: {
route: { type: "profile" },
children: "Profile",
},
};

View File

@@ -1,54 +0,0 @@
// 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.
// @vitest-environment happy-dom
import { create } from "react-test-renderer";
import { describe, expect, it } from "vitest";
import { WithLocation } from "../../test-utils/WithLocation";
import NavItem from "./NavItem";
describe("NavItem", () => {
it("render an active <NavItem />", () => {
const component = create(
<WithLocation path="/">
<NavItem route={{ type: "profile" }}>Active</NavItem>
</WithLocation>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it("render an inactive <NavItem />", () => {
const component = create(
<WithLocation path="/account">
<NavItem route={{ type: "sessions-overview" }}>Inactive</NavItem>
</WithLocation>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it("renders a different route", () => {
const component = create(
<WithLocation path="/">
<NavItem route={{ type: "sessions-overview" }}>Sessions</NavItem>
</WithLocation>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -12,44 +12,20 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { routeAtom, Link, Route } from "../../routing";
import { Link } from "@tanstack/react-router";
import styles from "./NavItem.module.css";
const NavItem: React.FC<React.PropsWithChildren<{ route: Route }>> = ({
route,
children,
}) => {
const currentRoute = useAtomValue(routeAtom);
const active = currentRoute.type === route.type;
const NavItem: React.FC<React.ComponentProps<typeof Link>> = (props) => {
return (
<li className={styles.navTab} data-current={active ? true : undefined}>
<li className={styles.navTab}>
<Link
className={styles.navItem}
route={route}
aria-current={active ? "page" : undefined}
>
{children}
</Link>
activeProps={{ "aria-current": "page" }}
{...props}
/>
</li>
);
};
export const ExternalLink: React.FC<
React.PropsWithChildren<{ href: string }>
> = ({ href, children }) => (
<li className={styles.navTab}>
<a
rel="noreferrer noopener"
className={classNames(styles.navItem, styles.externalLink)}
href={href}
>
{children}
</a>
</li>
);
export default NavItem;

View File

@@ -12,4 +12,4 @@
// See the License for the specific language governing permissions and
// limitations under the License.
export { default, ExternalLink } from "./NavItem";
export { default } from "./NavItem";

View File

@@ -21,8 +21,8 @@ import { never } from "wonka";
import { makeFragmentData } from "../gql";
import { Oauth2ApplicationType } from "../gql/graphql";
import { WithLocation } from "../test-utils/WithLocation";
import { mockLocale } from "../test-utils/mockLocale";
import { DummyRouter } from "../test-utils/router";
import OAuth2Session, { FRAGMENT } from "./OAuth2Session";
@@ -55,9 +55,9 @@ describe("<OAuth2Session />", () => {
const component = create(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<OAuth2Session session={session} />
</WithLocation>
</DummyRouter>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
@@ -73,9 +73,9 @@ describe("<OAuth2Session />", () => {
);
const component = create(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<OAuth2Session session={session} />
</WithLocation>
</DummyRouter>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
@@ -95,9 +95,9 @@ describe("<OAuth2Session />", () => {
);
const component = create(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<OAuth2Session session={session} />
</WithLocation>
</DummyRouter>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();

View File

@@ -79,10 +79,6 @@ const OAuth2Session: React.FC<Props> = ({ session }) => {
const deviceId = getDeviceIdFromScope(data.scope);
const link = deviceId
? { type: "session" as const, id: deviceId }
: undefined;
const createdAt = parseISO(data.createdAt);
const finishedAt = data.finishedAt ? parseISO(data.finishedAt) : undefined;
const lastActiveAt = data.lastActiveAt
@@ -104,7 +100,6 @@ const OAuth2Session: React.FC<Props> = ({ session }) => {
deviceType={deviceType}
lastActiveIp={data.lastActiveIp || undefined}
lastActiveAt={lastActiveAt}
link={link}
>
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
</Session>

View File

@@ -19,6 +19,7 @@ import { create } from "react-test-renderer";
import { describe, expect, it, beforeAll } from "vitest";
import { mockLocale } from "../../test-utils/mockLocale";
import { DummyRouter } from "../../test-utils/router";
import Session from "./Session";
@@ -33,13 +34,19 @@ describe("<Session />", () => {
beforeAll(() => mockLocale());
it("renders an active session", () => {
const component = create(<Session {...defaultProps} />);
const component = create(
<DummyRouter>
<Session {...defaultProps} />
</DummyRouter>,
);
expect(component.toJSON()).toMatchSnapshot();
});
it("renders a finished session", () => {
const component = create(
<Session {...defaultProps} finishedAt={finishedAt} />,
<DummyRouter>
<Session {...defaultProps} finishedAt={finishedAt} />
</DummyRouter>,
);
expect(component.toJSON()).toMatchSnapshot();
});
@@ -47,7 +54,9 @@ describe("<Session />", () => {
it("uses session name when truthy", () => {
const name = "test session name";
const component = create(
<Session {...defaultProps} finishedAt={finishedAt} name={name} />,
<DummyRouter>
<Session {...defaultProps} finishedAt={finishedAt} name={name} />
</DummyRouter>,
);
expect(component.toJSON()).toMatchSnapshot();
});
@@ -55,12 +64,14 @@ describe("<Session />", () => {
it("uses client name when truthy", () => {
const clientName = "Element";
const component = create(
<DummyRouter>
<Session
{...defaultProps}
finishedAt={finishedAt}
clientName={clientName}
clientLogoUri="https://client.org/logo.png"
/>,
/>
</DummyRouter>,
);
expect(component.toJSON()).toMatchSnapshot();
});
@@ -68,12 +79,14 @@ describe("<Session />", () => {
it("renders ip address", () => {
const clientName = "Element";
const component = create(
<DummyRouter>
<Session
{...defaultProps}
finishedAt={finishedAt}
clientName={clientName}
lastActiveIp="127.0.0.1"
/>,
/>
</DummyRouter>,
);
expect(component.toJSON()).toMatchSnapshot();
});

View File

@@ -12,10 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Link } from "@tanstack/react-router";
import { H6, Text, Badge } from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { Route, Link } from "../../routing";
import { DeviceType } from "../../utils/parseUserAgent";
import Block from "../Block";
import DateTime from "../DateTime";
@@ -40,7 +40,6 @@ type SessionProps = {
deviceType?: DeviceType;
lastActiveIp?: string;
lastActiveAt?: Date;
link?: Route;
};
const Session: React.FC<React.PropsWithChildren<SessionProps>> = ({
@@ -55,7 +54,6 @@ const Session: React.FC<React.PropsWithChildren<SessionProps>> = ({
isCurrent,
children,
deviceType,
link,
}) => {
const { t } = useTranslation();
@@ -67,7 +65,9 @@ const Session: React.FC<React.PropsWithChildren<SessionProps>> = ({
<Badge kind="success">{t("frontend.session.current_badge")}</Badge>
)}
<H6 className={styles.sessionName} title={id}>
{link ? <Link route={link}>{name || id}</Link> : name || id}
<Link to="/sessions/$id" params={{ id }}>
{name || id}
</Link>
</H6>
<SessionMetadata weight="semibold">
<Trans

View File

@@ -26,8 +26,18 @@ exports[`<Session /> > renders a finished session 1`] = `
<h6
className="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 _sessionName_634806"
title="session-id"
>
<a
href="/sessions/session-id"
onClick={[Function]}
onFocus={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
onTouchStart={[Function]}
style={{}}
>
session-id
</a>
</h6>
<p
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
@@ -54,48 +64,7 @@ exports[`<Session /> > renders a finished session 1`] = `
</div>
`;
exports[`<Session /> > renders an active session 1`] = `
<div
className="_block_17898c _session_634806"
>
<svg
aria-label="Unknown device type"
className="_icon_e677aa"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 21c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 3 19V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 5 3h14c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v14c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 19 21H5Zm0-2h14V5H5v14Z"
/>
<path
d="M11 10a1 1 0 1 1 1.479.878c-.31.17-.659.413-.94.741-.286.334-.539.8-.539 1.381a1 1 0 0 0 2 .006.3.3 0 0 1 .057-.085 1.39 1.39 0 0 1 .382-.288A3 3 0 1 0 9 10a1 1 0 1 0 2 0Zm1.999 3.011v-.004.005ZM12 17a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
/>
</svg>
<div
className="_container_634806"
>
<h6
className="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 _sessionName_634806"
title="session-id"
>
session-id
</h6>
<p
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
>
Signed in
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</div>
</div>
`;
exports[`<Session /> > renders an active session 1`] = `null`;
exports[`<Session /> > renders ip address 1`] = `
<div
@@ -123,8 +92,18 @@ exports[`<Session /> > renders ip address 1`] = `
<h6
className="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 _sessionName_634806"
title="session-id"
>
<a
href="/sessions/session-id"
onClick={[Function]}
onFocus={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
onTouchStart={[Function]}
style={{}}
>
session-id
</a>
</h6>
<p
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
@@ -192,8 +171,18 @@ exports[`<Session /> > uses client name when truthy 1`] = `
<h6
className="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 _sessionName_634806"
title="session-id"
>
<a
href="/sessions/session-id"
onClick={[Function]}
onFocus={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
onTouchStart={[Function]}
style={{}}
>
session-id
</a>
</h6>
<p
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
@@ -267,8 +256,18 @@ exports[`<Session /> > uses session name when truthy 1`] = `
<h6
className="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 _sessionName_634806"
title="session-id"
>
<a
href="/sessions/session-id"
onClick={[Function]}
onFocus={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
onTouchStart={[Function]}
style={{}}
>
test session name
</a>
</h6>
<p
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"

View File

@@ -132,9 +132,7 @@ const BrowserSessionDetail: React.FC<Props> = ({ session }) => {
{t("frontend.browser_session_details.current_badge")}
</Badge>
)}
<SessionHeader backToRoute={{ type: "browser-session-list" }}>
{sessionName}
</SessionHeader>
<SessionHeader to="/sessions">{sessionName}</SessionHeader>
<SessionDetails
title={t("frontend.browser_session_details.session_details_title")}
details={sessionDetails}

View File

@@ -20,8 +20,8 @@ import { describe, expect, it, afterEach, beforeAll } from "vitest";
import { never } from "wonka";
import { makeFragmentData } from "../../gql";
import { WithLocation } from "../../test-utils/WithLocation";
import { mockLocale } from "../../test-utils/mockLocale";
import { DummyRouter } from "../../test-utils/router";
import CompatSessionDetail, { FRAGMENT } from "./CompatSessionDetail";
@@ -50,9 +50,9 @@ describe("<CompatSessionDetail>", () => {
const { container } = render(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<CompatSessionDetail session={data} />
</WithLocation>
</DummyRouter>
</Provider>,
);
@@ -70,9 +70,9 @@ describe("<CompatSessionDetail>", () => {
const { container } = render(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<CompatSessionDetail session={data} />
</WithLocation>
</DummyRouter>
</Provider>,
);
@@ -90,9 +90,9 @@ describe("<CompatSessionDetail>", () => {
const { getByText, queryByText } = render(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<CompatSessionDetail session={data} />
</WithLocation>
</DummyRouter>
</Provider>,
);

View File

@@ -116,13 +116,7 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
return (
<BlockList>
<SessionHeader
backToRoute={{
type: "sessions-overview",
}}
>
{data.deviceId || data.id}
</SessionHeader>
<SessionHeader to="/sessions">{data.deviceId || data.id}</SessionHeader>
<SessionDetails
title={t("frontend.compat_session_detail.session_details_title")}
details={sessionDetails}

View File

@@ -20,8 +20,8 @@ import { describe, expect, it, afterEach, beforeAll } from "vitest";
import { never } from "wonka";
import { makeFragmentData } from "../../gql";
import { WithLocation } from "../../test-utils/WithLocation";
import { mockLocale } from "../../test-utils/mockLocale";
import { DummyRouter } from "../../test-utils/router";
import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail";
@@ -53,9 +53,9 @@ describe("<OAuth2SessionDetail>", () => {
const { container } = render(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<OAuth2SessionDetail session={data} />
</WithLocation>
</DummyRouter>
</Provider>,
);
@@ -73,9 +73,9 @@ describe("<OAuth2SessionDetail>", () => {
const { getByText, queryByText } = render(
<Provider value={mockClient}>
<WithLocation>
<DummyRouter>
<OAuth2SessionDetail session={data} />
</WithLocation>
</DummyRouter>
</Provider>,
);

View File

@@ -17,10 +17,10 @@ import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { FragmentType, graphql, useFragment } from "../../gql";
import { Link } from "../../routing";
import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope";
import BlockList from "../BlockList/BlockList";
import DateTime from "../DateTime";
import { Link } from "../Link";
import { END_SESSION_MUTATION } from "../OAuth2Session";
import ClientAvatar from "../Session/ClientAvatar";
import EndSessionButton from "../Session/EndSessionButton";
@@ -117,7 +117,7 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
];
const clientTitle = (
<Link route={{ type: "client", id: data.client.id }}>
<Link to="/clients/$id" params={{ id: data.client.id }}>
{t("frontend.oauth2_session_detail.client_title")}
</Link>
);
@@ -142,7 +142,11 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
{
label: t("frontend.session.uri_label"),
value: (
<a target="_blank" href={data.client.clientUri || undefined}>
<a
target="_blank"
rel="noreferrer"
href={data.client.clientUri || undefined}
>
{data.client.clientUri}
</a>
),
@@ -151,13 +155,7 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
return (
<BlockList>
<SessionHeader
backToRoute={{
type: "sessions-overview",
}}
>
{deviceId || data.id}
</SessionHeader>
<SessionHeader to="/sessions">{deviceId || data.id}</SessionHeader>
<SessionDetails
title={t("frontend.oauth2_session_detail.session_details_title")}
details={sessionDetails}

View File

@@ -17,7 +17,7 @@ import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { graphql } from "../../gql";
import { Link } from "../../routing";
import { Link } from "../Link";
import CompatSessionDetail from "./CompatSessionDetail";
import OAuth2SessionDetail from "./OAuth2SessionDetail";
@@ -53,9 +53,7 @@ const SessionDetail: React.FC<{
title={t("frontend.session_detail.alert.title", { deviceId })}
>
{t("frontend.session_detail.alert.text")}
<Link kind="button" route={{ type: "sessions-overview" }}>
{t("frontend.session_detail.alert.button")}
</Link>
<Link to="/sessions">{t("frontend.session_detail.alert.button")}</Link>
</Alert>
);
}

View File

@@ -20,3 +20,47 @@
gap: var(--cpd-space-2x);
align-items: center;
}
.back-button {
display: block;
inline-size: var(--cpd-space-8x);
block-size: var(--cpd-space-8x);
/* the icon is 0.75 the size of the button, so add padding to put it in the middle */
padding: var(--cpd-space-1x);
aspect-ratio: 1 / 1;
color: var(--cpd-color-icon-tertiary);
border: 0;
appearance: none;
cursor: pointer;
border-radius: 50%;
position: relative;
background: transparent;
line-height: 0px;
}
.back-button svg {
inline-size: var(--cpd-space-6x);
block-size: var(--cpd-space-6x);
}
.back-button[aria-disabled="true"] {
color: var(--cpd-color-icon-disabled);
cursor: not-allowed;
}
/**
* Hover state
*/
@media (hover) {
.back-button:not([aria-disabled="true"]):hover {
color: var(--cpd-color-icon-primary);
background: var(--cpd-color-bg-subtle-primary);
}
}
.back-button:not([aria-disabled="true"]):active {
color: var(--cpd-color-icon-primary);
background: var(--cpd-color-bg-subtle-primary);
}

View File

@@ -15,20 +15,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { PropsWithChildren } from "react";
import { Route } from "../../routing/routes";
import { WithLocation } from "../../test-utils/WithLocation";
import { DummyRouter } from "../../test-utils/router";
import SessionHeader from "./SessionHeader";
type Props = PropsWithChildren<{
backToRoute: Route;
}>;
type Props = PropsWithChildren;
const Template: React.FC<Props> = ({ backToRoute, children }) => {
const Template: React.FC<Props> = ({ children }) => {
return (
<WithLocation>
<SessionHeader backToRoute={backToRoute}>{children}</SessionHeader>
</WithLocation>
<DummyRouter>
<SessionHeader to="/">{children}</SessionHeader>
</DummyRouter>
);
};
@@ -43,7 +40,6 @@ type Story = StoryObj<typeof Template>;
export const Basic: Story = {
args: {
backToRoute: { type: "sessions-overview" },
children: <>Chrome on iOS</>,
},
};

View File

@@ -12,34 +12,24 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Link } from "@tanstack/react-router";
import IconArrowLeft from "@vector-im/compound-design-tokens/icons/arrow-left.svg?react";
import { H3, IconButton } from "@vector-im/compound-web";
import { PropsWithChildren } from "react";
import { useNavigationLink } from "../../routing";
import { Route } from "../../routing/routes";
import LoadingSpinner from "../LoadingSpinner";
import { H3 } from "@vector-im/compound-web";
import styles from "./SessionHeader.module.css";
const BackButton: React.FC<{ backToRoute: Route }> = ({ backToRoute }) => {
const { onClick, pending } = useNavigationLink(backToRoute);
const SessionHeader: React.FC<React.ComponentProps<typeof Link>> = ({
children,
...rest
}) => {
return (
<IconButton type="button" onClick={onClick}>
{pending ? <LoadingSpinner /> : <IconArrowLeft />}
</IconButton>
<header className={styles.header}>
<Link className={styles.backButton} {...rest}>
<IconArrowLeft />
</Link>
<H3>{children}</H3>
</header>
);
};
const SessionHeader: React.FC<PropsWithChildren<{ backToRoute: Route }>> = ({
children,
backToRoute,
}) => (
<header className={styles.header}>
<BackButton backToRoute={backToRoute} />
<H3>{children}</H3>
</header>
);
export default SessionHeader;

View File

@@ -1,6 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `<div />`;
exports[`<CompatSessionDetail> > renders a compatability session without an ssoLogin 1`] = `
<div>
<div
class="_blockList_f8cc7f"
@@ -8,16 +10,9 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
<header
class="_header_92353c"
>
<button
class="_icon-button_16nk7_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
<a
class="_backButton_92353c"
href="/sessions"
>
<svg
class="cpd-icon"
@@ -31,8 +26,7 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0Z"
/>
</svg>
</div>
</button>
</a>
<h3
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
>
@@ -136,55 +130,6 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
</li>
</ul>
</div>
<div
class="_block_17898c"
>
<h6
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64"
>
Client
</h6>
<ul
class="_list_040867"
>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Name
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
element.io
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Uri
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<a
class="_link_1mzip_17 _externalLink_a97355"
data-kind="primary"
href="https://element.io"
rel="noreferrer noopener"
target="_blank"
>
https://element.io
</a>
</p>
</li>
</ul>
</div>
<button
aria-controls="radix-:r0:"
aria-disabled="false"
@@ -203,158 +148,3 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
</div>
</div>
`;
exports[`<CompatSessionDetail> > renders a compatability session without an ssoLogin 1`] = `
<div>
<div
class="_blockList_f8cc7f"
>
<header
class="_header_92353c"
>
<button
class="_icon-button_16nk7_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0Z"
/>
</svg>
</div>
</button>
<h3
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
>
abcd1234
</h3>
</header>
<div
class="_block_17898c"
>
<h6
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64"
>
Session
</h6>
<ul
class="_list_040867"
>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
ID
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<code>
session-id
</code>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Device ID
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<code>
abcd1234
</code>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Signed in
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<time
datetime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Last Active
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<span
title="Sat, 29 Jul 2023, 03:35"
>
Inactive for 90+ days
</span>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
IP Address
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<code>
1.2.3.4
</code>
</p>
</li>
</ul>
</div>
<button
aria-controls="radix-:r3:"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_dyfp8_17 _destructive_dyfp8_99"
data-kind="secondary"
data-size="sm"
data-state="closed"
role="button"
tabindex="0"
type="button"
>
End session
</button>
</div>
</div>
`;

View File

@@ -1,247 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<OAuth2SessionDetail> > renders session details 1`] = `
<div>
<div
class="_blockList_f8cc7f"
>
<header
class="_header_92353c"
>
<button
class="_icon-button_16nk7_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0Z"
/>
</svg>
</div>
</button>
<h3
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
>
abcd1234
</h3>
</header>
<div
class="_block_17898c"
>
<h6
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64"
>
Session
</h6>
<ul
class="_list_040867"
>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
ID
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<code>
session-id
</code>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Device ID
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<code>
abcd1234
</code>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Signed in
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<time
datetime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Last Active
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<span
title="Sat, 29 Jul 2023, 03:35"
>
Inactive for 90+ days
</span>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
IP Address
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<code>
1.2.3.4
</code>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Scopes
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<span>
<code>
openid
</code>
<code>
urn:matrix:org.matrix.msc2967.client:api:*
</code>
<code>
urn:matrix:org.matrix.msc2967.client:device:abcd1234
</code>
</span>
</p>
</li>
</ul>
</div>
<div
class="_block_17898c"
>
<h6
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64"
>
<a
class=""
href="/clients/test-id"
>
Client
</a>
</h6>
<ul
class="_list_040867"
>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Name
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
Element
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
ID
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<code>
test-client-id
</code>
</p>
</li>
<li
class="_detailRow_040867"
>
<p
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _detailLabel_040867"
>
Uri
</p>
<p
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _detailValue_040867"
>
<a
href="https://element.io"
target="_blank"
>
https://element.io
</a>
</p>
</li>
</ul>
</div>
<button
aria-controls="radix-:r0:"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_dyfp8_17 _destructive_dyfp8_99"
data-kind="secondary"
data-size="sm"
data-state="closed"
role="button"
tabindex="0"
type="button"
>
End session
</button>
</div>
</div>
`;
exports[`<OAuth2SessionDetail> > renders session details 1`] = `<div />`;

View File

@@ -1,40 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SessionHeader /> > renders a session header 1`] = `
<div>
<header
class="_header_92353c"
>
<button
class="_icon-button_16nk7_17"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0Z"
/>
</svg>
</div>
</button>
<h3
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
>
Chrome on iOS
</h3>
</header>
</div>
`;
exports[`<SessionHeader /> > renders a session header 1`] = `<div />`;

View File

@@ -18,7 +18,7 @@ import { render, cleanup, fireEvent } from "@testing-library/react";
import { describe, it, expect, afterEach } from "vitest";
import { makeFragmentData } from "../../gql/fragment-masking";
import { WithLocation } from "../../test-utils/WithLocation";
import { DummyRouter } from "../../test-utils/router";
import UnverifiedEmailAlert, {
UNVERIFIED_EMAILS_FRAGMENT,
@@ -39,9 +39,9 @@ describe("<UnverifiedEmailAlert />", () => {
);
const { container } = render(
<WithLocation>
<DummyRouter>
<UnverifiedEmailAlert user={data} />
</WithLocation>,
</DummyRouter>,
);
expect(container).toMatchInlineSnapshot("<div />");
@@ -59,9 +59,9 @@ describe("<UnverifiedEmailAlert />", () => {
);
const { container } = render(
<WithLocation>
<DummyRouter>
<UnverifiedEmailAlert user={data} />
</WithLocation>,
</DummyRouter>,
);
expect(container).toMatchSnapshot();
@@ -79,9 +79,9 @@ describe("<UnverifiedEmailAlert />", () => {
);
const { container, getByText, getByLabelText } = render(
<WithLocation>
<DummyRouter>
<UnverifiedEmailAlert user={data} />
</WithLocation>,
</DummyRouter>,
);
// warning is rendered
@@ -105,9 +105,9 @@ describe("<UnverifiedEmailAlert />", () => {
);
const { container, getByText, rerender } = render(
<WithLocation>
<DummyRouter>
<UnverifiedEmailAlert user={data} />
</WithLocation>,
</DummyRouter>,
);
// warning is rendered
@@ -123,9 +123,9 @@ describe("<UnverifiedEmailAlert />", () => {
UNVERIFIED_EMAILS_FRAGMENT,
);
rerender(
<WithLocation>
<DummyRouter>
<UnverifiedEmailAlert user={newData} />
</WithLocation>,
</DummyRouter>,
);
// warning removed
@@ -144,9 +144,9 @@ describe("<UnverifiedEmailAlert />", () => {
);
const { container, getByText, getByLabelText, rerender } = render(
<WithLocation>
<DummyRouter>
<UnverifiedEmailAlert user={data} />
</WithLocation>,
</DummyRouter>,
);
// warning is rendered
@@ -167,9 +167,9 @@ describe("<UnverifiedEmailAlert />", () => {
UNVERIFIED_EMAILS_FRAGMENT,
);
rerender(
<WithLocation>
<DummyRouter>
<UnverifiedEmailAlert user={newData} />
</WithLocation>,
</DummyRouter>,
);
// warning is rendered

View File

@@ -17,7 +17,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FragmentType, useFragment, graphql } from "../../gql";
import { Link } from "../../routing";
import { Link } from "../Link";
import styles from "./UnverifiedEmailAlert.module.css";
@@ -57,7 +57,7 @@ const UnverifiedEmailAlert: React.FC<{
{t("frontend.unverified_email_alert.text", {
count: data.unverifiedEmails.totalCount,
})}{" "}
<Link kind="button" route={{ type: "profile" }}>
<Link to="/" hash="emails">
{t("frontend.unverified_email_alert.button")}
</Link>
</Alert>

View File

@@ -36,8 +36,11 @@ exports[`<UnverifiedEmailAlert /> > renders a warning when there are unverified
You have 2 unverified email addresses.
<a
class="_linkButton_b80ad8"
href="/"
class="_link_1mzip_17 active _linkButton_379d57"
data-kind="primary"
data-status="active"
href="/#emails"
rel="noreferrer noopener"
>
Review and verify
</a>

View File

@@ -56,5 +56,6 @@ button[disabled] .user-email-delete-icon {
}
.link:active {
background: var(--cpd-color-text-primary);
color: var(--cpd-color-text-on-solid-primary);
}

View File

@@ -19,8 +19,8 @@ import { Translation, useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { FragmentType, graphql, useFragment } from "../../gql";
import { Link } from "../../routing";
import ConfirmationModal from "../ConfirmationModal/ConfirmationModal";
import { Link } from "../Link";
import styles from "./UserEmail.module.css";
@@ -111,9 +111,8 @@ const DeleteButtonWithConfirmation: React.FC<
const UserEmail: React.FC<{
email: FragmentType<typeof FRAGMENT>;
onRemove?: () => void;
onSetPrimary?: () => void;
isPrimary?: boolean;
}> = ({ email, isPrimary, onSetPrimary, onRemove }) => {
}> = ({ email, isPrimary, onRemove }) => {
const { t } = useTranslation();
const data = useFragment(FRAGMENT, email);
@@ -133,10 +132,7 @@ const UserEmail: React.FC<{
};
const onSetPrimaryClick = (): void => {
setPrimary({ id: data.id }).then(() => {
// Call the onSetPrimary callback if provided
onSetPrimary?.();
});
setPrimary({ id: data.id });
};
return (
@@ -177,7 +173,7 @@ const UserEmail: React.FC<{
{t("frontend.user_email.unverified")}
</span>{" "}
|{" "}
<Link kind="button" route={{ type: "verify-email", id: data.id }}>
<Link to="/emails/$id/verify" params={{ id: data.id }}>
{t("frontend.user_email.retry_button")}
</Link>
</>

View File

@@ -13,17 +13,14 @@
// limitations under the License.
import { Heading, Text, Avatar } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { graphql } from "../gql";
import { FragmentType, graphql, useFragment } from "../gql";
import UnverifiedEmailAlert from "./UnverifiedEmailAlert";
import styles from "./UserGreeting.module.css";
const QUERY = graphql(/* GraphQL */ `
query UserGreeting($userId: ID!) {
user(id: $userId) {
export const USER_GREETING_FRAGMENT = graphql(/* GraphQL */ `
fragment UserGreeting_user on User {
id
username
matrix {
@@ -33,36 +30,33 @@ const QUERY = graphql(/* GraphQL */ `
...UnverifiedEmailAlert
}
}
`);
const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => {
const [result] = useQuery({ query: QUERY, variables: { userId } });
const { t } = useTranslation();
type Props = {
user: FragmentType<typeof USER_GREETING_FRAGMENT>;
};
const UserGreeting: React.FC<Props> = ({ user }) => {
const data = useFragment(USER_GREETING_FRAGMENT, user);
if (result.data?.user) {
const user = result.data.user;
return (
<>
<header className={styles.header}>
<Avatar
size="var(--cpd-space-24x)"
id={user.matrix.mxid}
name={user.matrix.displayName || user.matrix.mxid}
id={data.matrix.mxid}
name={data.matrix.displayName || data.matrix.mxid}
/>
<Heading size="xl" weight="semibold">
{user.matrix.displayName || user.username}
{data.matrix.displayName || data.username}
</Heading>
<Text size="lg" className={styles.mxid}>
{user.matrix.mxid}
{data.matrix.mxid}
</Text>
</header>
<UnverifiedEmailAlert user={user} />
<UnverifiedEmailAlert user={data} />
</>
);
}
return <>{t("frontend.user_greeting.error")}</>;
};
export default UserGreeting;

View File

@@ -12,21 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Alert, H3 } from "@vector-im/compound-web";
import { useSetAtom } from "jotai";
import { useNavigate } from "@tanstack/react-router";
import { Alert } from "@vector-im/compound-web";
import { useTransition } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { graphql } from "../../gql";
import { FragmentType, graphql, useFragment } from "../../gql";
import {
FIRST_PAGE,
Pagination,
usePages,
usePagination,
} from "../../pagination";
import { routeAtom } from "../../routing";
import BlockList from "../BlockList";
import PaginationControls from "../PaginationControls";
import UserEmail from "../UserEmail";
@@ -63,42 +61,35 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
const PRIMARY_EMAIL_QUERY = graphql(/* GraphQL */ `
query UserPrimaryEmail($userId: ID!) {
user(id: $userId) {
const FRAGMENT = graphql(/* GraphQL */ `
fragment UserEmailList_user on User {
id
primaryEmail {
id
}
}
}
`);
const UserEmailList: React.FC<{
userId: string;
}> = ({ userId }) => {
user: FragmentType<typeof FRAGMENT>;
}> = ({ user }) => {
const data = useFragment(FRAGMENT, user);
const { t } = useTranslation();
const [pending, startTransition] = useTransition();
const [pagination, setPagination] = usePagination();
const [result, refreshList] = useQuery({
query: QUERY,
variables: { userId, ...pagination },
variables: { userId: data.id, ...pagination },
});
if (result.error) throw result.error;
const emails = result.data?.user?.emails;
if (!emails) throw new Error(); // Suspense mode is enabled
const setRoute = useSetAtom(routeAtom);
const navigate = useNavigate();
const [prevPage, nextPage] = usePages(pagination, emails.pageInfo);
const [primaryEmailResult, refreshPrimaryEmail] = useQuery({
query: PRIMARY_EMAIL_QUERY,
variables: { userId },
});
if (primaryEmailResult.error) throw primaryEmailResult.error;
if (!result.data) throw new Error(); // Suspense mode is enabled
const primaryEmailId = primaryEmailResult.data?.user?.primaryEmail?.id;
const primaryEmailId = data.primaryEmail?.id;
const paginate = (pagination: Pagination): void => {
startTransition(() => {
@@ -116,14 +107,13 @@ const UserEmailList: React.FC<{
// When adding an email, we want to go to the email verification form
const onAdd = (id: string): void => {
setRoute({ type: "verify-email", id });
navigate({ to: "/emails/$id/verify", params: { id } });
};
const showNoPrimaryEmailAlert = !!result?.data && !primaryEmailId;
return (
<BlockList>
<H3>{t("frontend.user_email_list.heading")}</H3>
<>
{showNoPrimaryEmailAlert && (
<Alert
type="critical"
@@ -135,7 +125,6 @@ const UserEmailList: React.FC<{
email={edge.node}
key={edge.cursor}
isPrimary={primaryEmailId === edge.node.id}
onSetPrimary={refreshPrimaryEmail}
onRemove={onRemove}
/>
))}
@@ -147,8 +136,8 @@ const UserEmailList: React.FC<{
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}
/>
<AddEmailForm userId={userId} onAdd={onAdd} />
</BlockList>
<AddEmailForm userId={data.id} onAdd={onAdd} />
</>
);
};

View File

@@ -15,22 +15,20 @@
import { Alert, Button, Form } from "@vector-im/compound-web";
import { useState, ChangeEventHandler } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery } from "urql";
import { useMutation } from "urql";
import { graphql } from "../../gql";
import { FragmentType, graphql, useFragment } from "../../gql";
import LoadingSpinner from "../LoadingSpinner/LoadingSpinner";
import styles from "./UserName.module.css";
const QUERY = graphql(/* GraphQL */ `
query UserDisplayName($userId: ID!) {
user(id: $userId) {
const FRAGMENT = graphql(/* GraphQL */ `
fragment UserName_user on User {
id
matrix {
displayName
}
}
}
`);
const SET_DISPLAYNAME_MUTATION = graphql(/* GraphQL */ `
@@ -59,12 +57,11 @@ const getErrorMessage = (result: {
}
};
const UserName: React.FC<{ userId: string }> = ({ userId }) => {
const [result, refreshUserGreeting] = useQuery({
query: QUERY,
variables: { userId },
});
const displayName = result.data?.user?.matrix.displayName || "";
const UserName: React.FC<{ user: FragmentType<typeof FRAGMENT> }> = ({
user,
}) => {
const data = useFragment(FRAGMENT, user);
const displayName = data.matrix.displayName || "";
const [setDisplayNameResult, setDisplayName] = useMutation(
SET_DISPLAYNAME_MUTATION,
@@ -95,21 +92,20 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
return;
}
setDisplayName({ displayName: newDisplayName, userId }).then((result) => {
setDisplayName({ displayName: newDisplayName, userId: data.id }).then(
(result) => {
if (!result.data) {
console.error("Failed to set display name", result.error);
} else if (result.data.setDisplayName.status === "SET") {
// refresh the user greeting after changing the display name
refreshUserGreeting({
requestPolicy: "network-only",
});
// This should update the cache
} else if (result.data.setDisplayName.status === "INVALID") {
// reset to current saved display name
form.reset();
}
setHasChanges(false);
});
},
);
};
const errorMessage = getErrorMessage(setDisplayNameResult);

View File

@@ -13,11 +13,9 @@
// limitations under the License.
import type { Meta, StoryObj } from "@storybook/react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { makeFragmentData } from "../../gql";
import { appConfigAtom, locationAtom } from "../../routing";
import { DummyRouter } from "../../test-utils/router";
import BrowserSessionsOverview, { FRAGMENT } from "./BrowserSessionsOverview";
@@ -25,14 +23,6 @@ type Props = {
browserSessions: number;
};
const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
useHydrateAtoms([
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: "/" }],
]);
return <>{children}</>;
};
const Template: React.FC<Props> = ({ browserSessions }) => {
const data = makeFragmentData(
{
@@ -44,11 +34,9 @@ const Template: React.FC<Props> = ({ browserSessions }) => {
FRAGMENT,
);
return (
<Provider>
<WithHomePage>
<DummyRouter>
<BrowserSessionsOverview user={data} />
</WithHomePage>
</Provider>
</DummyRouter>
);
};

View File

@@ -18,7 +18,7 @@ import { render, cleanup } from "@testing-library/react";
import { describe, expect, it, afterEach } from "vitest";
import { makeFragmentData } from "../../gql";
import { WithLocation } from "../../test-utils/WithLocation";
import { DummyRouter } from "../../test-utils/router";
import BrowserSessionsOverview, { FRAGMENT } from "./BrowserSessionsOverview";
@@ -36,9 +36,9 @@ describe("BrowserSessionsOverview", () => {
FRAGMENT,
);
const { container } = render(
<WithLocation>
<DummyRouter>
<BrowserSessionsOverview user={user} />
</WithLocation>,
</DummyRouter>,
);
expect(container).toMatchSnapshot();
@@ -55,9 +55,9 @@ describe("BrowserSessionsOverview", () => {
FRAGMENT,
);
const { container } = render(
<WithLocation>
<DummyRouter>
<BrowserSessionsOverview user={user} />
</WithLocation>,
</DummyRouter>,
);
expect(container).toMatchSnapshot();
});

View File

@@ -16,8 +16,8 @@ import { Body, H5 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql";
import { Link } from "../../routing";
import Block from "../Block";
import { Link } from "../Link";
import styles from "./BrowserSessionsOverview.module.css";
@@ -47,7 +47,7 @@ const BrowserSessionsOverview: React.FC<{
})}
</Body>
</div>
<Link kind="button" route={{ type: "browser-session-list" }}>
<Link to="/sessions/browsers">
{t("frontend.browser_sessions_overview.view_all_button")}
</Link>
</Block>

View File

@@ -13,10 +13,12 @@
// limitations under the License.
import { H3 } from "@vector-im/compound-web";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { FragmentType, useFragment } from "../../gql";
import BlockList from "../BlockList";
import LoadingSpinner from "../LoadingSpinner";
import AppSessionsList from "./AppSessionsList";
import BrowserSessionsOverview, { FRAGMENT } from "./BrowserSessionsOverview";
@@ -31,7 +33,9 @@ const UserSessionsOverview: React.FC<{
<BlockList>
<H3>{t("frontend.user_sessions_overview.heading")}</H3>
<BrowserSessionsOverview user={user} />
<Suspense fallback={<LoadingSpinner className="self-center m-4" />}>
<AppSessionsList userId={data.id} />
</Suspense>
</BlockList>
);
};

View File

@@ -1,33 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = `
<div>
<div
class="_block_17898c _sessionListBlock_c5c17e"
>
<div
class="_sessionListBlockInfo_c5c17e"
>
<h5
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
>
Browsers
</h5>
<p
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
>
0 active sessions
</p>
</div>
<a
class="_linkButton_b80ad8"
href="/browser-sessions"
>
View all
</a>
</div>
</div>
`;
exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = `<div />`;
exports[`BrowserSessionsOverview > renders with sessions 1`] = `
<div>
@@ -49,8 +22,10 @@ exports[`BrowserSessionsOverview > renders with sessions 1`] = `
</p>
</div>
<a
class="_linkButton_b80ad8"
href="/browser-sessions"
class="_link_1mzip_17 _linkButton_379d57"
data-kind="primary"
href="/sessions/browsers"
rel="noreferrer noopener"
>
View all
</a>

View File

@@ -12,16 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { useLinkProps, useNavigate } from "@tanstack/react-router";
import IconArrowLeft from "@vector-im/compound-design-tokens/icons/arrow-left.svg?react";
import IconSend from "@vector-im/compound-design-tokens/icons/send-solid.svg?react";
import { Button, Form, Alert, H1, Text } from "@vector-im/compound-web";
import { useSetAtom } from "jotai";
import { useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { FragmentType, graphql, useFragment } from "../../gql";
import { routeAtom, useNavigationLink } from "../../routing";
import styles from "./VerifyEmail.module.css";
@@ -73,20 +72,12 @@ const RESEND_VERIFICATION_EMAIL_MUTATION = graphql(/* GraphQL */ `
`);
const BackButton: React.FC = () => {
const { onClick, href, pending } = useNavigationLink({
type: "profile",
});
const props = useLinkProps({ to: "/" });
const { t } = useTranslation();
return (
<Button
as="a"
href={href}
onClick={onClick}
Icon={IconArrowLeft}
kind="tertiary"
>
{pending ? t("common.loading") : t("action.back")}
<Button as="a" Icon={IconArrowLeft} kind="tertiary" {...props}>
{t("action.back")}
</Button>
);
};
@@ -99,7 +90,7 @@ const VerifyEmail: React.FC<{
const [resendVerificationEmailResult, resendVerificationEmail] = useMutation(
RESEND_VERIFICATION_EMAIL_MUTATION,
);
const setRoute = useSetAtom(routeAtom);
const navigate = useNavigate();
const fieldRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
@@ -113,7 +104,7 @@ const VerifyEmail: React.FC<{
form.reset();
if (result.data?.verifyEmail.status === "VERIFIED") {
setRoute({ type: "profile" });
navigate({ to: "/" });
}
});
};

View File

@@ -28,9 +28,13 @@ exports[`<CompatSession /> > renders a finished session 1`] = `
title="session-id"
>
<a
className=""
href="/session/abcd1234"
href="/sessions/session-id"
onClick={[Function]}
onFocus={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
onTouchStart={[Function]}
style={{}}
>
abcd1234
</a>
@@ -75,86 +79,4 @@ exports[`<CompatSession /> > renders a finished session 1`] = `
</div>
`;
exports[`<CompatSession /> > renders an active session 1`] = `
<div
className="_block_17898c _session_634806"
>
<svg
aria-label="Unknown device type"
className="_icon_e677aa"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 21c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 3 19V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 5 3h14c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v14c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 19 21H5Zm0-2h14V5H5v14Z"
/>
<path
d="M11 10a1 1 0 1 1 1.479.878c-.31.17-.659.413-.94.741-.286.334-.539.8-.539 1.381a1 1 0 0 0 2 .006.3.3 0 0 1 .057-.085 1.39 1.39 0 0 1 .382-.288A3 3 0 1 0 9 10a1 1 0 1 0 2 0Zm1.999 3.011v-.004.005ZM12 17a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
/>
</svg>
<div
className="_container_634806"
>
<h6
className="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 _sessionName_634806"
title="session-id"
>
<a
className=""
href="/session/abcd1234"
onClick={[Function]}
>
abcd1234
</a>
</h6>
<p
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
>
Signed in
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _sessionMetadata_634806"
>
1.2.3.4
</p>
<p
className="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _sessionMetadata_634806"
>
<span
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
>
element.io
</span>
</p>
<div
className="_sessionActions_634806"
>
<button
aria-controls="radix-:r0:"
aria-disabled={false}
aria-expanded={false}
aria-haspopup="dialog"
className="_button_dyfp8_17 _destructive_dyfp8_99"
data-kind="secondary"
data-size="sm"
data-state="closed"
onClick={[Function]}
role="button"
tabIndex={0}
type="button"
>
End session
</button>
</div>
</div>
</div>
`;
exports[`<CompatSession /> > renders an active session 1`] = `null`;

View File

@@ -25,9 +25,13 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
title="session-id"
>
<a
className=""
href="/session/abcd1234"
href="/sessions/session-id"
onClick={[Function]}
onFocus={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
onTouchStart={[Function]}
style={{}}
>
abcd1234
</a>
@@ -72,86 +76,7 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
</div>
`;
exports[`<OAuth2Session /> > renders an active session 1`] = `
<div
className="_block_17898c _session_634806"
>
<svg
aria-label="Web"
className="_icon_e677aa"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
/>
</svg>
<div
className="_container_634806"
>
<h6
className="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 _sessionName_634806"
title="session-id"
>
<a
className=""
href="/session/abcd1234"
onClick={[Function]}
>
abcd1234
</a>
</h6>
<p
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
>
Signed in
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _sessionMetadata_634806"
>
1.2.3.4
</p>
<p
className="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _sessionMetadata_634806"
>
<span
className="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _sessionMetadata_634806"
>
Element
</span>
</p>
<div
className="_sessionActions_634806"
>
<button
aria-controls="radix-:r0:"
aria-disabled={false}
aria-expanded={false}
aria-haspopup="dialog"
className="_button_dyfp8_17 _destructive_dyfp8_99"
data-kind="secondary"
data-size="sm"
data-state="closed"
onClick={[Function]}
role="button"
tabIndex={0}
type="button"
>
End session
</button>
</div>
</div>
</div>
`;
exports[`<OAuth2Session /> > renders an active session 1`] = `null`;
exports[`<OAuth2Session /> > renders correct icon for a native session 1`] = `
<div
@@ -178,9 +103,13 @@ exports[`<OAuth2Session /> > renders correct icon for a native session 1`] = `
title="session-id"
>
<a
className=""
href="/session/abcd1234"
href="/sessions/session-id"
onClick={[Function]}
onFocus={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
onTouchStart={[Function]}
style={{}}
>
abcd1234
</a>

View File

@@ -17,7 +17,7 @@ const documents = {
types.BrowserSession_SessionFragmentDoc,
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n":
types.EndBrowserSessionDocument,
"\n query BrowserSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
"\n query BrowserSessionList(\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n":
types.BrowserSessionListDocument,
"\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n":
types.OAuth2Client_DetailFragmentDoc,
@@ -45,18 +45,18 @@ const documents = {
types.RemoveEmailDocument,
"\n mutation SetPrimaryEmail($id: ID!) {\n setPrimaryEmail(input: { userEmailId: $id }) {\n status\n user {\n id\n primaryEmail {\n id\n }\n }\n }\n }\n":
types.SetPrimaryEmailDocument,
"\n query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n matrix {\n mxid\n displayName\n }\n\n ...UnverifiedEmailAlert\n }\n }\n":
types.UserGreetingDocument,
"\n fragment UserGreeting_user on User {\n id\n username\n matrix {\n mxid\n displayName\n }\n\n ...UnverifiedEmailAlert\n }\n":
types.UserGreeting_UserFragmentDoc,
"\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n":
types.AddEmailDocument,
"\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n":
types.AllowCrossSigningResetDocument,
"\n query UserEmailListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
types.UserEmailListQueryDocument,
"\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n":
types.UserPrimaryEmailDocument,
"\n query UserDisplayName($userId: ID!) {\n user(id: $userId) {\n id\n matrix {\n displayName\n }\n }\n }\n":
types.UserDisplayNameDocument,
"\n fragment UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n":
types.UserEmailList_UserFragmentDoc,
"\n fragment UserName_user on User {\n id\n matrix {\n displayName\n }\n }\n":
types.UserName_UserFragmentDoc,
"\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n user {\n id\n matrix {\n displayName\n }\n }\n }\n }\n":
types.SetDisplayNameDocument,
"\n query AppSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n appSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
@@ -69,18 +69,24 @@ const documents = {
types.VerifyEmailDocument,
"\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n":
types.ResendVerificationEmailDocument,
"\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n ...BrowserSession_detail\n }\n }\n":
types.BrowserSessionQueryDocument,
"\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n":
types.OAuth2ClientQueryDocument,
"\n query UserProfileQuery {\n viewer {\n __typename\n ... on User {\n id\n ...UserName_user\n ...UserEmailList_user\n }\n }\n }\n":
types.UserProfileQueryDocument,
"\n query SessionDetailQuery($id: ID!) {\n node(id: $id) {\n __typename\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n":
types.SessionDetailQueryDocument,
"\n query SessionsOverviewQuery {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n":
types.SessionsOverviewQueryDocument,
"\n query CurrentUserGreeting {\n viewer {\n __typename\n ...UserGreeting_user\n }\n }\n":
types.CurrentUserGreetingDocument,
"\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n":
types.OAuth2ClientQueryDocument,
"\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n":
types.CurrentViewerQueryDocument,
"\n query DeviceRedirectQuery($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n":
types.DeviceRedirectQueryDocument,
"\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n":
types.VerifyEmailQueryDocument,
"\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n":
types.CurrentViewerSessionQueryDocument,
"\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n":
types.CurrentViewerQueryDocument,
};
/**
@@ -113,8 +119,8 @@ export function graphql(
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query BrowserSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n",
): (typeof documents)["\n query BrowserSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"];
source: "\n query BrowserSessionList(\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n",
): (typeof documents)["\n query BrowserSessionList(\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -197,8 +203,8 @@ export function graphql(
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n matrix {\n mxid\n displayName\n }\n\n ...UnverifiedEmailAlert\n }\n }\n",
): (typeof documents)["\n query UserGreeting($userId: ID!) {\n user(id: $userId) {\n id\n username\n matrix {\n mxid\n displayName\n }\n\n ...UnverifiedEmailAlert\n }\n }\n"];
source: "\n fragment UserGreeting_user on User {\n id\n username\n matrix {\n mxid\n displayName\n }\n\n ...UnverifiedEmailAlert\n }\n",
): (typeof documents)["\n fragment UserGreeting_user on User {\n id\n username\n matrix {\n mxid\n displayName\n }\n\n ...UnverifiedEmailAlert\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -221,14 +227,14 @@ export function graphql(
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n",
): (typeof documents)["\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n"];
source: "\n fragment UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n",
): (typeof documents)["\n fragment UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query UserDisplayName($userId: ID!) {\n user(id: $userId) {\n id\n matrix {\n displayName\n }\n }\n }\n",
): (typeof documents)["\n query UserDisplayName($userId: ID!) {\n user(id: $userId) {\n id\n matrix {\n displayName\n }\n }\n }\n"];
source: "\n fragment UserName_user on User {\n id\n matrix {\n displayName\n }\n }\n",
): (typeof documents)["\n fragment UserName_user on User {\n id\n matrix {\n displayName\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -269,8 +275,26 @@ export function graphql(
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n ...BrowserSession_detail\n }\n }\n",
): (typeof documents)["\n query BrowserSessionQuery($id: ID!) {\n browserSession(id: $id) {\n id\n ...BrowserSession_detail\n }\n }\n"];
source: "\n query UserProfileQuery {\n viewer {\n __typename\n ... on User {\n id\n ...UserName_user\n ...UserEmailList_user\n }\n }\n }\n",
): (typeof documents)["\n query UserProfileQuery {\n viewer {\n __typename\n ... on User {\n id\n ...UserName_user\n ...UserEmailList_user\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query SessionDetailQuery($id: ID!) {\n node(id: $id) {\n __typename\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n",
): (typeof documents)["\n query SessionDetailQuery($id: ID!) {\n node(id: $id) {\n __typename\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query SessionsOverviewQuery {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n",
): (typeof documents)["\n query SessionsOverviewQuery {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query CurrentUserGreeting {\n viewer {\n __typename\n ...UserGreeting_user\n }\n }\n",
): (typeof documents)["\n query CurrentUserGreeting {\n viewer {\n __typename\n ...UserGreeting_user\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -281,8 +305,14 @@ export function graphql(
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query SessionsOverviewQuery {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n",
): (typeof documents)["\n query SessionsOverviewQuery {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n"];
source: "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n",
): (typeof documents)["\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query DeviceRedirectQuery($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n",
): (typeof documents)["\n query DeviceRedirectQuery($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -295,12 +325,6 @@ export function graphql(
export function graphql(
source: "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n",
): (typeof documents)["\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n",
): (typeof documents)["\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};

File diff suppressed because it is too large Load Diff

View File

@@ -1854,6 +1854,26 @@ export default {
},
],
},
{
name: "compatSession",
type: {
kind: "OBJECT",
name: "CompatSession",
ofType: null,
},
args: [
{
name: "id",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
},
],
},
{
name: "currentBrowserSession",
type: {
@@ -1912,6 +1932,26 @@ export default {
},
],
},
{
name: "oauth2Session",
type: {
kind: "OBJECT",
name: "Oauth2Session",
ofType: null,
},
args: [
{
name: "id",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
},
],
},
{
name: "session",
type: {

View File

@@ -12,52 +12,48 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { TooltipProvider } from "@vector-im/compound-web";
import { Provider } from "jotai";
import { DevTools } from "jotai-devtools";
import { Suspense, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { Provider as UrqlProvider } from "urql";
import ErrorBoundary from "./components/ErrorBoundary";
import Layout from "./components/Layout";
import GenericError from "./components/GenericError";
import LoadingScreen from "./components/LoadingScreen";
import LoadingSpinner from "./components/LoadingSpinner";
import NotLoggedIn from "./components/NotLoggedIn";
import config from "./config";
import { client } from "./graphql";
import i18n from "./i18n";
import { Router } from "./routing";
import { routeTree } from "./routeTree.gen";
import "./main.css";
import { useCurrentUserId } from "./utils/useCurrentUserId";
const App: React.FC = () => {
const userId = useCurrentUserId();
if (userId === null) return <NotLoggedIn />;
// Create a new router instance
const router = createRouter({
routeTree,
basepath: config.root,
defaultErrorComponent: GenericError,
context: { client },
});
return (
<Layout userId={userId}>
<Suspense fallback={<LoadingSpinner />}>
<Router userId={userId} />
</Suspense>
</Layout>
);
};
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<ErrorBoundary>
<UrqlProvider value={client}>
<Provider>
{import.meta.env.DEV && <DevTools />}
<Suspense fallback={<LoadingScreen />}>
<I18nextProvider i18n={i18n}>
<TooltipProvider>
<App />
<RouterProvider router={router} context={{ client }} />
</TooltipProvider>
</I18nextProvider>
</Suspense>
</Provider>
</UrqlProvider>
</ErrorBoundary>
</StrictMode>,

View File

@@ -1,50 +0,0 @@
// Copyright 2022 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 { useQuery } from "urql";
import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError";
import NotFound from "../components/NotFound";
import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
import { graphql } from "../gql";
const QUERY = graphql(/* GraphQL */ `
query BrowserSessionQuery($id: ID!) {
browserSession(id: $id) {
id
...BrowserSession_detail
}
}
`);
const BrowserSession: React.FC<{ id: string }> = ({ id }) => {
const [result] = useQuery({
query: QUERY,
variables: { id },
});
if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
const browserSession = result.data.browserSession;
if (!browserSession) return <NotFound />;
return (
<ErrorBoundary>
<BrowserSessionDetail session={browserSession} />
</ErrorBoundary>
);
};
export default BrowserSession;

View File

@@ -1,21 +0,0 @@
// 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 List from "../components/BrowserSessionList";
const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
return <List userId={userId} />;
};
export default BrowserSessionList;

View File

@@ -1,24 +0,0 @@
// 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 UserSessionDetail from "../components/SessionDetail";
const SessionDetail: React.FC<{ userId: string; deviceId: string }> = ({
userId,
deviceId,
}) => {
return <UserSessionDetail userId={userId} deviceId={deviceId} />;
};
export default SessionDetail;

View File

@@ -0,0 +1,118 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as AccountImport } from './routes/_account'
import { Route as AccountIndexImport } from './routes/_account.index'
import { Route as DevicesIdImport } from './routes/devices.$id'
import { Route as ClientsIdImport } from './routes/clients.$id'
import { Route as AccountSessionsIndexImport } from './routes/_account.sessions.index'
import { Route as EmailsIdVerifyImport } from './routes/emails.$id.verify'
import { Route as AccountSessionsBrowsersImport } from './routes/_account.sessions.browsers'
import { Route as AccountSessionsIdImport } from './routes/_account.sessions.$id'
// Create/Update Routes
const AccountRoute = AccountImport.update({
id: '/_account',
getParentRoute: () => rootRoute,
} as any)
const AccountIndexRoute = AccountIndexImport.update({
path: '/',
getParentRoute: () => AccountRoute,
} as any)
const DevicesIdRoute = DevicesIdImport.update({
path: '/devices/$id',
getParentRoute: () => rootRoute,
} as any)
const ClientsIdRoute = ClientsIdImport.update({
path: '/clients/$id',
getParentRoute: () => rootRoute,
} as any)
const AccountSessionsIndexRoute = AccountSessionsIndexImport.update({
path: '/sessions/',
getParentRoute: () => AccountRoute,
} as any)
const EmailsIdVerifyRoute = EmailsIdVerifyImport.update({
path: '/emails/$id/verify',
getParentRoute: () => rootRoute,
} as any)
const AccountSessionsBrowsersRoute = AccountSessionsBrowsersImport.update({
path: '/sessions/browsers',
getParentRoute: () => AccountRoute,
} as any)
const AccountSessionsIdRoute = AccountSessionsIdImport.update({
path: '/sessions/$id',
getParentRoute: () => AccountRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_account': {
preLoaderRoute: typeof AccountImport
parentRoute: typeof rootRoute
}
'/clients/$id': {
preLoaderRoute: typeof ClientsIdImport
parentRoute: typeof rootRoute
}
'/devices/$id': {
preLoaderRoute: typeof DevicesIdImport
parentRoute: typeof rootRoute
}
'/_account/': {
preLoaderRoute: typeof AccountIndexImport
parentRoute: typeof AccountImport
}
'/_account/sessions/$id': {
preLoaderRoute: typeof AccountSessionsIdImport
parentRoute: typeof AccountImport
}
'/_account/sessions/browsers': {
preLoaderRoute: typeof AccountSessionsBrowsersImport
parentRoute: typeof AccountImport
}
'/emails/$id/verify': {
preLoaderRoute: typeof EmailsIdVerifyImport
parentRoute: typeof rootRoute
}
'/_account/sessions/': {
preLoaderRoute: typeof AccountSessionsIndexImport
parentRoute: typeof AccountImport
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([
AccountRoute.addChildren([
AccountIndexRoute,
AccountSessionsIdRoute,
AccountSessionsBrowsersRoute,
AccountSessionsIndexRoute,
]),
ClientsIdRoute,
DevicesIdRoute,
EmailsIdVerifyRoute,
])
/* prettier-ignore-end */

View File

@@ -0,0 +1,100 @@
// Copyright 2024 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 {
createRootRouteWithContext,
Outlet,
redirect,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { Client } from "urql";
import { z } from "zod";
import Layout from "../components/Layout";
import NotFound from "../components/NotFound";
const actionSchema = z
.discriminatedUnion("action", [
z.object({
action: z.enum(["profile", "org.matrix.profile"]),
}),
z.object({
action: z.enum(["sessions_list", "org.matrix.sessions_list"]),
}),
z.object({
action: z.enum(["session_view", "org.matrix.session_view"]),
device_id: z.string().optional(),
}),
z.object({
action: z.enum(["session_end", "org.matrix.session_end"]),
device_id: z.string().optional(),
}),
z.object({
action: z.undefined(),
}),
])
.catch({ action: undefined });
type Action = z.infer<typeof actionSchema>;
export const Route = createRootRouteWithContext<{
client: Client;
}>()({
validateSearch: (search): Action => actionSchema.parse(search),
beforeLoad: ({ search }) => {
switch (search.action) {
case "profile":
case "org.matrix.profile":
throw redirect({ to: "/" });
case "sessions_list":
case "org.matrix.sessions_list":
throw redirect({ to: "/sessions" });
case "session_view":
case "org.matrix.session_view":
if (search.device_id)
throw redirect({
to: "/devices/$id",
params: { id: search.device_id },
});
throw redirect({ to: "/sessions" });
case "session_end":
case "org.matrix.session_end":
if (search.device_id)
throw redirect({
to: "/devices/$id",
params: { id: search.device_id },
});
throw redirect({ to: "/sessions" });
}
},
component: () => (
<>
<Layout>
<Outlet />
</Layout>
{import.meta.env.DEV && <TanStackRouterDevtools />}
</>
),
notFoundComponent: () => (
<Layout>
<NotFound />
</Layout>
),
});

View File

@@ -0,0 +1,74 @@
// Copyright 2024 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 { createFileRoute, notFound } from "@tanstack/react-router";
import { H3, Separator } from "@vector-im/compound-web";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import BlockList from "../components/BlockList/BlockList";
import LoadingSpinner from "../components/LoadingSpinner";
import CrossSigningReset from "../components/UserProfile/CrossSigningReset";
import UserEmailList from "../components/UserProfile/UserEmailList";
import UserName from "../components/UserProfile/UserName";
import { graphql } from "../gql";
const QUERY = graphql(/* GraphQL */ `
query UserProfileQuery {
viewer {
__typename
... on User {
id
...UserName_user
...UserEmailList_user
}
}
}
`);
export const Route = createFileRoute("/_account/")({
async loader({ context }) {
const result = await context.client.query(QUERY, {});
if (result.error) throw result.error;
if (result.data?.viewer.__typename !== "User") throw notFound();
},
component: Index,
});
function Index(): React.ReactElement {
const { t } = useTranslation();
const [result] = useQuery({ query: QUERY });
if (result.error) throw result.error;
const user = result.data?.viewer;
if (user?.__typename !== "User") throw notFound();
return (
<>
<BlockList>
<UserName user={user} />
<BlockList>
<H3 id="emails">{t("frontend.user_email_list.heading")}</H3>
<Suspense fallback={<LoadingSpinner className="self-center m-4" />}>
<UserEmailList user={user} />
</Suspense>
</BlockList>
<Separator />
<CrossSigningReset userId={user.id} />
</BlockList>
</>
);
}

View File

@@ -0,0 +1,82 @@
// Copyright 2024 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 { createFileRoute, notFound } from "@tanstack/react-router";
import { Alert } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { Link } from "../components/Link";
import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
import CompatSessionDetail from "../components/SessionDetail/CompatSessionDetail";
import OAuth2SessionDetail from "../components/SessionDetail/OAuth2SessionDetail";
import { graphql } from "../gql";
export const Route = createFileRoute("/_account/sessions/$id")({
async loader({ context, params }) {
const result = await context.client.query(QUERY, { id: params.id });
if (result.error) throw result.error;
if (!result.data?.node) throw notFound();
},
notFoundComponent: NotFound,
component: SessionDetail,
});
const QUERY = graphql(/* GraphQL */ `
query SessionDetailQuery($id: ID!) {
node(id: $id) {
__typename
...CompatSession_detail
...OAuth2Session_detail
...BrowserSession_detail
}
}
`);
function NotFound(): React.ReactElement {
const { id } = Route.useParams();
const { t } = useTranslation();
return (
<Alert
type="critical"
title={t("frontend.session_detail.alert.title", { deviceId: id })}
>
{t("frontend.session_detail.alert.text")}
<Link from={Route.fullPath} to="..">
{t("frontend.session_detail.alert.button")}
</Link>
</Alert>
);
}
function SessionDetail(): React.ReactElement {
const { id } = Route.useParams();
const [result] = useQuery({ query: QUERY, variables: { id } });
if (result.error) throw result.error;
const node = result.data?.node;
if (!node) throw notFound();
switch (node.__typename) {
case "CompatSession":
return <CompatSessionDetail session={node} />;
case "Oauth2Session":
return <OAuth2SessionDetail session={node} />;
case "BrowserSession":
return <BrowserSessionDetail session={node} />;
default:
throw new Error("Unknown session type");
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
// Copyright 2024 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.
@@ -12,15 +12,20 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import ErrorBoundary from "../components/ErrorBoundary";
import UserProfile from "../components/UserProfile";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
const Profile: React.FC<{ userId: string }> = ({ userId }) => {
import BrowserSessionList from "../components/BrowserSessionList";
import LoadingSpinner from "../components/LoadingSpinner";
export const Route = createFileRoute("/_account/sessions/browsers")({
component: BrowserSessions,
});
function BrowserSessions(): React.ReactElement {
return (
<ErrorBoundary>
<UserProfile userId={userId} />
</ErrorBoundary>
<Suspense fallback={<LoadingSpinner className="self-center m-4" />}>
<BrowserSessionList />
</Suspense>
);
};
export default Profile;
}

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Matrix.org Foundation C.I.C.
// Copyright 2024 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.
@@ -12,11 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { useQuery } from "urql";
import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError";
import NotLoggedIn from "../components/NotLoggedIn";
import UserSessionsOverview from "../components/UserSessionsOverview";
import { graphql } from "../gql";
@@ -33,20 +31,21 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
const SessionsOverview: React.FC = () => {
export const Route = createFileRoute("/_account/sessions/")({
async loader({ context }) {
const result = await context.client.query(QUERY, {});
if (result.error) throw result.error;
if (result.data?.viewer?.__typename !== "User") throw notFound();
},
component: Sessions,
});
function Sessions(): React.ReactElement {
const [result] = useQuery({ query: QUERY });
if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
if (result.error) throw result.error;
const data =
result.data.viewer.__typename === "User" ? result.data.viewer : null;
if (data === null) return <NotLoggedIn />;
result.data?.viewer.__typename === "User" ? result.data.viewer : null;
if (data === null) throw notFound();
return (
<ErrorBoundary>
<UserSessionsOverview user={data} />
</ErrorBoundary>
);
};
export default SessionsOverview;
return <UserSessionsOverview user={data} />;
}

View File

@@ -0,0 +1,67 @@
// Copyright 2024 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 { Outlet, createFileRoute, notFound } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import NavBar from "../components/NavBar";
import NavItem from "../components/NavItem";
import UserGreeting from "../components/UserGreeting";
import { graphql } from "../gql";
const QUERY = graphql(/* GraphQL */ `
query CurrentUserGreeting {
viewer {
__typename
...UserGreeting_user
}
}
`);
export const Route = createFileRoute("/_account")({
loader: async ({ context }) => {
const result = await context.client.query(QUERY, {});
if (result.error) throw result.error;
if (result.data?.viewer.__typename !== "User") throw notFound();
},
component: Account,
});
function Account(): React.ReactElement {
const { t } = useTranslation();
const [result] = useQuery({
query: QUERY,
});
if (result.error) throw result.error;
const user = result.data?.viewer;
if (user?.__typename !== "User") throw notFound();
return (
<>
<UserGreeting user={user} />
<NavBar>
<NavItem from={Route.fullPath} to=".">
{t("frontend.nav.profile")}
</NavItem>
<NavItem from={Route.fullPath} to="./sessions">
{t("frontend.nav.sessions")}
</NavItem>
</NavBar>
<Outlet />
</>
);
}

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Matrix.org Foundation C.I.C.
// Copyright 2024 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.
@@ -12,12 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { useQuery } from "urql";
import OAuth2ClientDetail from "../components/Client/OAuth2ClientDetail";
import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError";
import NotFound from "../components/NotFound";
import { graphql } from "../gql";
const QUERY = graphql(/* GraphQL */ `
@@ -28,22 +26,24 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
const OAuth2Client: React.FC<{ id: string }> = ({ id }) => {
export const Route = createFileRoute("/clients/$id")({
loader: async ({ context, params }) => {
const result = await context.client.query(QUERY, { id: params.id });
if (result.error) throw result.error;
if (!result.data?.oauth2Client) throw notFound();
},
component: ClientDetail,
});
function ClientDetail(): React.ReactElement {
const { id } = Route.useParams();
const [result] = useQuery({
query: QUERY,
variables: { id },
});
if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
if (result.error) throw result.error;
const client = result.data?.oauth2Client;
if (!client) throw new Error(); // Should have been caught by the loader
const oauth2Client = result.data.oauth2Client;
if (!oauth2Client) return <NotFound />;
return (
<ErrorBoundary>
<OAuth2ClientDetail client={oauth2Client} />
</ErrorBoundary>
);
};
export default OAuth2Client;
return <OAuth2ClientDetail client={client} />;
}

View File

@@ -0,0 +1,80 @@
// Copyright 2024 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 { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { Alert } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { Link } from "../components/Link";
import { graphql } from "../gql";
const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
query CurrentViewerQuery {
viewer {
__typename
... on User {
id
}
}
}
`);
const QUERY = graphql(/* GraphQL */ `
query DeviceRedirectQuery($deviceId: String!, $userId: ID!) {
session(deviceId: $deviceId, userId: $userId) {
__typename
... on Node {
id
}
}
}
`);
export const Route = createFileRoute("/devices/$id")({
async loader({ context, params }) {
const viewer = await context.client.query(CURRENT_VIEWER_QUERY, {});
if (viewer.error) throw viewer.error;
if (viewer.data?.viewer.__typename !== "User") throw notFound();
const result = await context.client.query(QUERY, {
deviceId: params.id,
userId: viewer.data.viewer.id,
});
if (result.error) throw result.error;
const session = result.data?.session;
if (!session) throw notFound();
throw redirect({
to: "/sessions/$id",
params: { id: session.id },
replace: true,
});
},
notFoundComponent: NotFound,
});
function NotFound(): React.ReactElement {
const { t } = useTranslation();
const { id: deviceId } = Route.useParams();
return (
<Alert
type="critical"
title={t("frontend.session_detail.alert.title", { deviceId })}
>
{t("frontend.session_detail.alert.text")}
<Link to="/sessions">{t("frontend.session_detail.alert.button")}</Link>
</Alert>
);
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
// Copyright 2024 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.
@@ -12,11 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { useTranslation } from "react-i18next";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { useQuery } from "urql";
import ErrorBoundary from "../components/ErrorBoundary";
import GraphQLError from "../components/GraphQLError";
import VerifyEmailComponent from "../components/VerifyEmail";
import { graphql } from "../gql";
@@ -28,21 +26,25 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
const VerifyEmail: React.FC<{ id: string }> = ({ id }) => {
export const Route = createFileRoute("/emails/$id/verify")({
async loader({ context, params }) {
const result = await context.client.query(QUERY, {
id: params.id,
});
if (result.error) throw result.error;
if (!result.data?.userEmail) throw notFound();
},
component: EmailVerify,
});
function EmailVerify(): React.ReactElement {
const { id } = Route.useParams();
const [result] = useQuery({ query: QUERY, variables: { id } });
const { t } = useTranslation();
if (result.error) return <GraphQLError error={result.error} />;
if (!result.data) throw new Error(); // Suspense mode is enabled
if (result.error) throw result.error;
const email = result.data?.userEmail;
if (email == null) throw notFound();
const email = result.data.userEmail;
if (email == null) return <>{t("frontend.verify_email.unknown_email")}</>;
return (
<ErrorBoundary>
<VerifyEmailComponent email={email} />
</ErrorBoundary>
);
};
export default VerifyEmail;
return <VerifyEmailComponent email={email} />;
}

View File

@@ -1,43 +0,0 @@
// 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 { useTranslation } from "react-i18next";
import styles from "./Link.module.css";
import { Route } from "./routes";
import { useNavigationLink } from "./useNavigationLink";
const Link: React.FC<
{
route: Route;
// adds button-like styling to link element
kind?: "button";
} & React.HTMLProps<HTMLAnchorElement>
> = ({ route, children, kind, className, ...props }) => {
const { onClick, href, pending } = useNavigationLink(route);
const { t } = useTranslation();
const classNames = [
kind === "button" ? styles.linkButton : "",
className,
].join("");
return (
<a href={href} onClick={onClick} className={classNames} {...props}>
{pending ? t("common.loading") : children}
</a>
);
};
export default Link;

View File

@@ -1,94 +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 { useAtom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import LoadingSpinner from "../components/LoadingSpinner";
import BrowserSession from "../pages/BrowserSession";
import BrowserSessionList from "../pages/BrowserSessionList";
import OAuth2Client from "../pages/OAuth2Client";
import Profile from "../pages/Profile";
import SessionDetail from "../pages/SessionDetail";
import SessionsOverview from "../pages/SessionsOverview";
import VerifyEmail from "../pages/VerifyEmail";
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];
};
// A type-safe way to ensure we've handled all routes
const unknownRoute = (route: never): never => {
throw new Error(`Invalid route: ${JSON.stringify(route)}`);
};
const Router: React.FC<{ userId: string }> = ({ userId }) => {
const [route, redirecting] = useRouteWithRedirect();
const { t } = useTranslation();
if (redirecting) {
return <LoadingSpinner />;
}
switch (route.type) {
case "profile":
return <Profile userId={userId} />;
case "sessions-overview":
return <SessionsOverview />;
case "session":
return <SessionDetail userId={userId} deviceId={route.id} />;
case "browser-session-list":
return <BrowserSessionList userId={userId} />;
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 (
<>
{t("frontend.unknown_route", {
route: JSON.stringify(route.segments),
})}
</>
);
default:
unknownRoute(route);
}
};
export default Router;

View File

@@ -1,155 +0,0 @@
// 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(),
});
});
it("redirects to session detail when location has a action=org.matrix.session_end", () => {
const searchParams = new URLSearchParams();
searchParams.set("action", "org.matrix.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=org.matrix.session_view", () => {
const searchParams = new URLSearchParams();
searchParams.set("action", "org.matrix.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=org.matrix.sessions_list", () => {
const searchParams = new URLSearchParams();
searchParams.set("action", "org.matrix.sessions_list");
expect(
getRouteActionRedirection({ pathname: "/account/", searchParams }),
).toEqual({
route: {
type: "sessions-overview",
},
searchParams: new URLSearchParams(),
});
});
it("redirects to profile when location has a action=org.matrix.profile", () => {
const searchParams = new URLSearchParams();
searchParams.set("action", "org.matrix.profile");
expect(
getRouteActionRedirection({ pathname: "/account/", searchParams }),
).toEqual({
route: {
type: "profile",
},
searchParams: new URLSearchParams(),
});
});
});

View File

@@ -1,82 +0,0 @@
/* 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());
let action = searchParams.get("action");
const deviceId = searchParams.get("device_id");
searchParams.delete("action");
searchParams.delete("device_id");
// Actions are actually prefixed with org.matrix. in the latest version of MSC2965
// but we still want to support non-prefixed actions for backwards compatibility
if (action) {
action = action.replace(/^org.matrix./, "");
}
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

@@ -1,89 +0,0 @@
// 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 { createBrowserHistory, createMemoryHistory } from "history";
import { atom } from "jotai";
import { atomWithLocation } from "jotai-location";
import appConfig, { AppConfig } from "../config";
import { Location, pathToRoute, Route, routeToPath } from "./routes";
/* Use memory history for testing */
export const history =
import.meta.vitest || typeof document === "undefined"
? createMemoryHistory()
: createBrowserHistory();
export const appConfigAtom = atom<AppConfig>(appConfig);
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);
};
const getLocation = (): Location => {
return {
pathname: history.location.pathname,
searchParams: new URLSearchParams(history.location.search),
};
};
const applyLocation = (
location: Location,
options?: { replace?: boolean },
): void => {
const destination = {
pathname: location.pathname,
search: location.searchParams?.toString(),
};
if (options?.replace) {
history.replace(destination);
} else {
history.push(destination);
}
};
type Callback = () => void;
type Unsubscribe = () => void;
const subscribe = (callback: Callback): Unsubscribe =>
history.listen(() => {
callback();
});
export const locationAtom = atomWithLocation({
subscribe,
getLocation,
applyLocation,
});
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

@@ -1,21 +0,0 @@
// 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, history } from "./atoms";
export { useNavigationLink } from "./useNavigationLink";

View File

@@ -1,67 +0,0 @@
// 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 { describe, it, expect } from "vitest";
import { segmentsToRoute } from "./routes";
describe("routes", () => {
describe("segmentsToRoute", () => {
it("returns profile for route with no segments", () => {
const segments: string[] = [];
expect(segmentsToRoute(segments)).toEqual({ type: "profile" });
});
it("returns profile for route with and empty string segment", () => {
const segments: string[] = [""];
expect(segmentsToRoute(segments)).toEqual({ type: "profile" });
});
it("returns browser session list for browser-sessions", () => {
const segments: string[] = ["browser-sessions"];
expect(segmentsToRoute(segments)).toEqual({
type: "browser-session-list",
});
});
it("returns client detail route correctly", () => {
const segments: string[] = ["clients", "client-id"];
expect(segmentsToRoute(segments)).toEqual({
type: "client",
id: "client-id",
});
});
it("returns browser session detail route correctly", () => {
const segments: string[] = ["browser-sessions", "session-id"];
expect(segmentsToRoute(segments)).toEqual({
type: "browser-session",
id: "session-id",
});
});
it("returns session detail route correctly", () => {
const segments: string[] = ["session", "device-id"];
expect(segmentsToRoute(segments)).toEqual({
type: "session",
id: "device-id",
});
});
it("returns unknown for other segments", () => {
const segments: string[] = ["just", "testing"];
expect(segmentsToRoute(segments)).toEqual({ type: "unknown", segments });
});
});
});

View File

@@ -1,136 +0,0 @@
// 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 BrowserSessionRoute = Readonly<{ type: "browser-session"; id: string }>;
type BrowserSessionListRoute = Readonly<{ type: "browser-session-list" }>;
type VerifyEmailRoute = Readonly<{ type: "verify-email"; id: string }>;
type UnknownRoute = Readonly<{ type: "unknown"; segments: Segments }>;
export type Route =
| SessionOverviewRoute
| SessionDetailRoute
| ProfileRoute
| OAuth2ClientRoute
| BrowserSessionRoute
| BrowserSessionListRoute
| 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 "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("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

@@ -1,56 +0,0 @@
// 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 { 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);
/**
* A hook which controls a navigation link to a given route
*/
export const useNavigationLink = (
route: Route,
): {
onClick: (event: React.MouseEvent) => void;
href: string;
pending: boolean;
} => {
const config = useAtomValue(appConfigAtom);
const path = routeToPath(route);
const href = config.root + path;
const setRoute = useSetAtom(routeAtom);
const [pending, startTransition] = useTransition();
const onClick = (e: React.MouseEvent): void => {
// Only handle left clicks without modifiers
if (!shouldHandleClick(e)) {
return;
}
e.preventDefault();
startTransition(() => {
setRoute(route);
});
};
return { onClick, href, pending };
};

View File

@@ -1,58 +0,0 @@
// 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 { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { Suspense, useEffect } from "react";
import { appConfigAtom, history, locationAtom } from "../routing";
const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
children,
path,
}) => {
useEffect(() => {
history.replace(path);
}, [path]);
useHydrateAtoms([
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: path }],
]);
return <>{children}</>;
};
/**
* Utility for testing components that rely on routing or location
* For example any component that includes a <Link />
* Eg:
* ```
* const component = create(
<WithLocation path="/">
<NavItem route={{ type: "profile" }}>Active</NavItem>
</WithLocation>,
);
* ```
*/
export const WithLocation: React.FC<
React.PropsWithChildren<{ path?: string }>
> = ({ children, path }) => {
return (
<Provider>
<Suspense>
<HydrateLocation path={path || "/"}>{children}</HydrateLocation>
</Suspense>
</Provider>
);
};

View File

@@ -0,0 +1,34 @@
// Copyright 2024 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 {
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router";
const rootRoute = createRootRoute();
const index = createRoute({ getParentRoute: () => rootRoute, path: "/" });
rootRoute.addChildren([index]);
const router = createRouter({
history: createMemoryHistory(),
routeTree: rootRoute,
});
export const DummyRouter: React.FC<React.PropsWithChildren> = ({
children,
}) => <RouterProvider router={router} defaultComponent={() => children} />;

View File

@@ -1,37 +0,0 @@
// 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 { useQuery } from "urql";
import { graphql } from "../gql";
const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
query CurrentViewerQuery {
viewer {
__typename
... on User {
id
}
}
}
`);
export const useCurrentUserId = (): string | null => {
const [result] = useQuery({ query: CURRENT_VIEWER_QUERY });
if (result.error) throw result.error;
if (!result.data) throw new Error(); // Suspense mode is enabled
return result.data.viewer.__typename === "User"
? result.data.viewer.id
: null;
};

View File

@@ -15,6 +15,7 @@
import { readFile, writeFile } from "fs/promises";
import { resolve } from "path";
import { TanStackRouterVite as tanStackRouter } from "@tanstack/router-vite-plugin";
import react from "@vitejs/plugin-react";
import browserslistToEsbuild from "browserslist-to-esbuild";
import type { Manifest, PluginOption } from "vite";
@@ -75,32 +76,9 @@ export default defineConfig((env) => ({
plugins: [
codegen(),
react({
babel: {
plugins: [
[
"jotai/babel/plugin-react-refresh",
{
customAtomNames: [
"mapQueryAtom",
"atomWithPagination",
"atomWithCurrentPagination",
],
},
],
[
"jotai/babel/plugin-debug-label",
{
customAtomNames: [
"mapQueryAtom",
"atomWithPagination",
"atomWithCurrentPagination",
],
},
],
],
},
}),
react(),
tanStackRouter(),
// Custom plugin to make sure that each asset has an entry in the manifest
// This is needed so that the preloading & asset integrity generation works
@@ -190,7 +168,7 @@ export default defineConfig((env) => ({
base: "/account/",
proxy: {
// Routes mostly extracted from crates/router/src/endpoints.rs
"^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*)$":
"^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*|link.*|device.*)$":
"http://127.0.0.1:8080",
},
},