diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index 56b51af50..3c39a1036 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -183,6 +183,7 @@ pub(crate) async fn get( "org.matrix.sessions_list".to_owned(), "org.matrix.session_view".to_owned(), "org.matrix.session_end".to_owned(), + "org.matrix.cross_signing_reset".to_owned(), ], }) } diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index d7807e066..17527b487 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -475,6 +475,9 @@ pub enum AccountAction { OrgMatrixSessionEnd { device_id: String }, #[serde(rename = "session_end")] SessionEnd { device_id: String }, + + #[serde(rename = "org.matrix.cross_signing_reset")] + OrgMatrixCrossSigningReset, } /// `GET /account/` diff --git a/frontend/src/components/ButtonLink.tsx b/frontend/src/components/ButtonLink.tsx new file mode 100644 index 000000000..4a03372eb --- /dev/null +++ b/frontend/src/components/ButtonLink.tsx @@ -0,0 +1,45 @@ +// 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 { LinkComponent, useLinkProps } from "@tanstack/react-router"; +import { Button } from "@vector-im/compound-web"; +import { forwardRef } from "react"; + +type Props = { + kind?: "primary" | "secondary" | "tertiary"; + size?: "sm" | "lg"; + Icon?: React.ComponentType>; + destructive?: boolean; +}; + +export const ButtonLink: LinkComponent = forwardRef< + HTMLAnchorElement, + Parameters[0] & Props +>(({ children, kind, size, destructive, Icon, ...props }, ref) => { + const linkProps = useLinkProps(props); + + return ( + + ); +}) as LinkComponent; diff --git a/frontend/src/components/Link/Link.tsx b/frontend/src/components/Link.tsx similarity index 65% rename from frontend/src/components/Link/Link.tsx rename to frontend/src/components/Link.tsx index 2d6de97f4..07a1d5b47 100644 --- a/frontend/src/components/Link/Link.tsx +++ b/frontend/src/components/Link.tsx @@ -14,24 +14,21 @@ 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 styles from "./Link.module.css"; +type Props = { + kind?: "primary" | "critical"; +}; -export const Link: LinkComponent = forwardRef< +export const Link: LinkComponent = forwardRef< HTMLAnchorElement, - Parameters[0] ->(({ children, ...props }, ref) => { - const { className, ...newProps } = useLinkProps(props); + Parameters[0] & Props +>(({ children, kind, ...props }, ref) => { + const linkProps = useLinkProps(props); return ( - + + {children} + ); -}) as LinkComponent; +}) as LinkComponent; diff --git a/frontend/src/components/Link/Link.module.css b/frontend/src/components/Link/Link.module.css deleted file mode 100644 index 757988dd8..000000000 --- a/frontend/src/components/Link/Link.module.css +++ /dev/null @@ -1,31 +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. - */ - -.link-button { - display: inline-block; - text-decoration: underline; - color: var(--cpd-color-text-primary); - font-weight: var(--cpd-font-weight-medium); - border-radius: var(--cpd-radius-pill-effect); - padding-inline: 0.25rem; -} - -.link-button:hover { - background: var(--cpd-color-gray-300); -} - -.link-button:active { - color: var(--cpd-color-text-on-solid-primary); -} diff --git a/frontend/src/components/Link/index.ts b/frontend/src/components/Link/index.ts deleted file mode 100644 index 7389ee401..000000000 --- a/frontend/src/components/Link/index.ts +++ /dev/null @@ -1,15 +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 { Link } from "./Link"; diff --git a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap index cb149f96f..3cbca6f8f 100644 --- a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap +++ b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap @@ -36,7 +36,7 @@ exports[` > renders a warning when there are unverified You have 2 unverified email addresses. = ({ userId }) => { - const { t } = useTranslation(); - const [result, allowReset] = useMutation(ALLOW_CROSS_SIGING_RESET_MUTATION); - - const onClick = (): void => { - allowReset({ userId }); - }; - - return ( - -

{t("frontend.reset_cross_signing.heading")}

- {!result.data && !result.error && ( - <> - - {t("frontend.reset_cross_signing.description")} - - - - )} - {result.data && ( - - {t("frontend.reset_cross_signing.success.description")} - - )} - {result.error && ( - - {t("frontend.reset_cross_signing.failure.description")} - - )} -
- ); -}; - -export default CrossSigningReset; diff --git a/frontend/src/components/UserSessionsOverview/__snapshots__/BrowserSessionsOverview.test.tsx.snap b/frontend/src/components/UserSessionsOverview/__snapshots__/BrowserSessionsOverview.test.tsx.snap index a7ef3a688..5a0600795 100644 --- a/frontend/src/components/UserSessionsOverview/__snapshots__/BrowserSessionsOverview.test.tsx.snap +++ b/frontend/src/components/UserSessionsOverview/__snapshots__/BrowserSessionsOverview.test.tsx.snap @@ -22,7 +22,7 @@ exports[`BrowserSessionsOverview > renders with sessions 1`] = `

; - -export type AllowCrossSigningResetMutation = { - __typename?: "Mutation"; - allowUserCrossSigningReset: { - __typename?: "AllowUserCrossSigningResetPayload"; - user?: { __typename?: "User"; id: string } | null; - }; -}; - export type UserEmailListQueryQueryVariables = Exact<{ userId: Scalars["ID"]["input"]; first?: InputMaybe; @@ -1698,7 +1686,9 @@ export type CurrentViewerQueryQueryVariables = Exact<{ [key: string]: never }>; export type CurrentViewerQueryQuery = { __typename?: "Query"; - viewer: { __typename: "Anonymous" } | { __typename: "User"; id: string }; + viewer: + | { __typename: "Anonymous"; id: string } + | { __typename: "User"; id: string }; }; export type DeviceRedirectQueryQueryVariables = Exact<{ @@ -1729,6 +1719,18 @@ export type VerifyEmailQueryQuery = { | null; }; +export type AllowCrossSigningResetMutationVariables = Exact<{ + userId: Scalars["ID"]["input"]; +}>; + +export type AllowCrossSigningResetMutation = { + __typename?: "Mutation"; + allowUserCrossSigningReset: { + __typename?: "AllowUserCrossSigningResetPayload"; + user?: { __typename?: "User"; id: string } | null; + }; +}; + export type CurrentViewerSessionQueryQueryVariables = Exact<{ [key: string]: never; }>; @@ -3160,75 +3162,6 @@ export const AddEmailDocument = { }, ], } as unknown as DocumentNode; -export const AllowCrossSigningResetDocument = { - kind: "Document", - definitions: [ - { - kind: "OperationDefinition", - operation: "mutation", - name: { kind: "Name", value: "AllowCrossSigningReset" }, - variableDefinitions: [ - { - kind: "VariableDefinition", - variable: { - kind: "Variable", - name: { kind: "Name", value: "userId" }, - }, - type: { - kind: "NonNullType", - type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, - }, - }, - ], - selectionSet: { - kind: "SelectionSet", - selections: [ - { - kind: "Field", - name: { kind: "Name", value: "allowUserCrossSigningReset" }, - arguments: [ - { - kind: "Argument", - name: { kind: "Name", value: "input" }, - value: { - kind: "ObjectValue", - fields: [ - { - kind: "ObjectField", - name: { kind: "Name", value: "userId" }, - value: { - kind: "Variable", - name: { kind: "Name", value: "userId" }, - }, - }, - ], - }, - }, - ], - selectionSet: { - kind: "SelectionSet", - selections: [ - { - kind: "Field", - name: { kind: "Name", value: "user" }, - selectionSet: { - kind: "SelectionSet", - selections: [ - { kind: "Field", name: { kind: "Name", value: "id" } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode< - AllowCrossSigningResetMutation, - AllowCrossSigningResetMutationVariables ->; export const UserEmailListQueryDocument = { kind: "Document", definitions: [ @@ -4576,7 +4509,7 @@ export const CurrentViewerQueryDocument = { kind: "InlineFragment", typeCondition: { kind: "NamedType", - name: { kind: "Name", value: "User" }, + name: { kind: "Name", value: "Node" }, }, selectionSet: { kind: "SelectionSet", @@ -4748,6 +4681,75 @@ export const VerifyEmailQueryDocument = { VerifyEmailQueryQuery, VerifyEmailQueryQueryVariables >; +export const AllowCrossSigningResetDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "mutation", + name: { kind: "Name", value: "AllowCrossSigningReset" }, + variableDefinitions: [ + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "userId" }, + }, + type: { + kind: "NonNullType", + type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "allowUserCrossSigningReset" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "input" }, + value: { + kind: "ObjectValue", + fields: [ + { + kind: "ObjectField", + name: { kind: "Name", value: "userId" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "userId" }, + }, + }, + ], + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "user" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + AllowCrossSigningResetMutation, + AllowCrossSigningResetMutationVariables +>; export const CurrentViewerSessionQueryDocument = { kind: "Document", definitions: [ diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 46ba91120..7357818c7 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -11,6 +11,7 @@ // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as ResetCrossSigningImport } from './routes/reset-cross-signing' import { Route as AccountImport } from './routes/_account' import { Route as AccountIndexImport } from './routes/_account.index' import { Route as DevicesIdImport } from './routes/devices.$id' @@ -22,6 +23,11 @@ import { Route as AccountSessionsIdImport } from './routes/_account.sessions.$id // Create/Update Routes +const ResetCrossSigningRoute = ResetCrossSigningImport.update({ + path: '/reset-cross-signing', + getParentRoute: () => rootRoute, +} as any) + const AccountRoute = AccountImport.update({ id: '/_account', getParentRoute: () => rootRoute, @@ -70,6 +76,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AccountImport parentRoute: typeof rootRoute } + '/reset-cross-signing': { + preLoaderRoute: typeof ResetCrossSigningImport + parentRoute: typeof rootRoute + } '/clients/$id': { preLoaderRoute: typeof ClientsIdImport parentRoute: typeof rootRoute @@ -110,6 +120,7 @@ export const routeTree = rootRoute.addChildren([ AccountSessionsBrowsersRoute, AccountSessionsIndexRoute, ]), + ResetCrossSigningRoute, ClientsIdRoute, DevicesIdRoute, EmailsIdVerifyRoute, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index d24c00d20..d93f87fe0 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -40,6 +40,9 @@ const actionSchema = z action: z.enum(["session_end", "org.matrix.session_end"]), device_id: z.string().optional(), }), + z.object({ + action: z.literal("org.matrix.cross_signing_reset"), + }), z.object({ action: z.undefined(), }), @@ -53,7 +56,7 @@ export const Route = createRootRouteWithContext<{ }>()({ validateSearch: (search): Action => actionSchema.parse(search), - beforeLoad: ({ search }) => { + beforeLoad({ search }) { switch (search.action) { case "profile": case "org.matrix.profile": @@ -80,6 +83,12 @@ export const Route = createRootRouteWithContext<{ params: { id: search.device_id }, }); throw redirect({ to: "/sessions" }); + + case "org.matrix.cross_signing_reset": + throw redirect({ + to: "/reset-cross-signing", + search: { deepLink: true }, + }); } }, diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 6a96a1085..f22db457b 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -13,14 +13,15 @@ // limitations under the License. import { createFileRoute, notFound } from "@tanstack/react-router"; +import IconKey from "@vector-im/compound-design-tokens/icons/key.svg?react"; 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 { ButtonLink } from "../components/ButtonLink"; 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"; @@ -71,7 +72,15 @@ function Index(): React.ReactElement { - + + + {t("frontend.reset_cross_signing.heading")} + ); diff --git a/frontend/src/routes/devices.$id.tsx b/frontend/src/routes/devices.$id.tsx index 68d0e0a04..0ef4e8159 100644 --- a/frontend/src/routes/devices.$id.tsx +++ b/frontend/src/routes/devices.$id.tsx @@ -23,7 +23,7 @@ const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ ` query CurrentViewerQuery { viewer { __typename - ... on User { + ... on Node { id } } diff --git a/frontend/src/routes/reset-cross-signing.tsx b/frontend/src/routes/reset-cross-signing.tsx new file mode 100644 index 000000000..a88f740fa --- /dev/null +++ b/frontend/src/routes/reset-cross-signing.tsx @@ -0,0 +1,139 @@ +// 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 IconArrowLeft from "@vector-im/compound-design-tokens/icons/arrow-left.svg?react"; +import IconKey from "@vector-im/compound-design-tokens/icons/key.svg?react"; +import { Alert, Button, Text } from "@vector-im/compound-web"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery } from "urql"; +import { z } from "zod"; + +import BlockList from "../components/BlockList"; +import { ButtonLink } from "../components/ButtonLink"; +import LoadingSpinner from "../components/LoadingSpinner"; +import PageHeading from "../components/PageHeading"; +import { graphql } from "../gql"; + +const searchSchema = z.object({ + deepLink: z.boolean().optional(), +}); + +type Search = z.infer; + +const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ ` + query CurrentViewerQuery { + viewer { + __typename + ... on Node { + id + } + } + } +`); + +const ALLOW_CROSS_SIGING_RESET_MUTATION = graphql(/* GraphQL */ ` + mutation AllowCrossSigningReset($userId: ID!) { + allowUserCrossSigningReset(input: { userId: $userId }) { + user { + id + } + } + } +`); + +export const Route = createFileRoute("/reset-cross-signing")({ + async loader({ context, abortController: { signal } }) { + const viewer = await context.client.query( + CURRENT_VIEWER_QUERY, + {}, + { fetchOptions: { signal } }, + ); + if (viewer.error) throw viewer.error; + if (viewer.data?.viewer.__typename !== "User") throw notFound(); + }, + + validateSearch: (search): Search => searchSchema.parse(search), + + component: ResetCrossSigning, +}); + +function ResetCrossSigning(): React.ReactNode { + const { deepLink } = Route.useSearch(); + const { t } = useTranslation(); + const [viewer] = useQuery({ query: CURRENT_VIEWER_QUERY }); + if (viewer.error) throw viewer.error; + if (viewer.data?.viewer.__typename !== "User") throw notFound(); + const userId = viewer.data.viewer.id; + + const [result, allowReset] = useMutation(ALLOW_CROSS_SIGING_RESET_MUTATION); + + const onClick = (): void => { + allowReset({ userId }); + }; + + return ( + + + + {!result.data && !result.error && ( + <> + + {t("frontend.reset_cross_signing.description")} + + + + )} + {result.data && ( + + {t("frontend.reset_cross_signing.success.description")} + + )} + {result.error && ( + + {t("frontend.reset_cross_signing.failure.description")} + + )} + + {!deepLink && ( + + {t("action.back")} + + )} + + ); +}