You've already forked authentication-service
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:
@@ -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?
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
909
frontend/package-lock.json
generated
909
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
49
frontend/src/components/GenericError.module.css
Normal file
49
frontend/src/components/GenericError.module.css
Normal 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;
|
||||
}
|
||||
46
frontend/src/components/GenericError.tsx
Normal file
46
frontend/src/components/GenericError.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -5,6 +5,7 @@ exports[`LoadingScreen > render <LoadingScreen /> 1`] = `
|
||||
className="_loadingScreen_0642c6"
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
role="status"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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</>,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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 />`;
|
||||
|
||||
@@ -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 />`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "/" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
118
frontend/src/routeTree.gen.ts
Normal file
118
frontend/src/routeTree.gen.ts
Normal 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 */
|
||||
100
frontend/src/routes/__root.tsx
Normal file
100
frontend/src/routes/__root.tsx
Normal 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>
|
||||
),
|
||||
});
|
||||
74
frontend/src/routes/_account.index.tsx
Normal file
74
frontend/src/routes/_account.index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
frontend/src/routes/_account.sessions.$id.tsx
Normal file
82
frontend/src/routes/_account.sessions.$id.tsx
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
67
frontend/src/routes/_account.tsx
Normal file
67
frontend/src/routes/_account.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
80
frontend/src/routes/devices.$id.tsx
Normal file
80
frontend/src/routes/devices.$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
34
frontend/src/test-utils/router.tsx
Normal file
34
frontend/src/test-utils/router.tsx
Normal 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} />;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user