diff --git a/frontend/locales/en.json b/frontend/locales/en.json index e72ae3bd..79bf8ee9 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -6,7 +6,8 @@ "close": "Close", "continue": "Continue", "edit": "Edit", - "save": "Save" + "save": "Save", + "save_and_continue": "Save and continue" }, "branding": { "privacy_policy": { @@ -135,6 +136,9 @@ }, "title": "Change your password" }, + "password_reset": { + "title": "Reset your password" + }, "password_strength": { "placeholder": "Password strength", "score": { diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index e13a9015..ad51ac71 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -55,6 +55,8 @@ const documents = { "\n query VerifyEmailQuery($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n": types.VerifyEmailQueryDocument, "\n query PasswordChangeQuery {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeQueryDocument, "\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": types.ChangePasswordDocument, + "\n query PasswordRecoveryQuery {\n siteConfig {\n id\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordRecoveryQueryDocument, + "\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": types.RecoverPasswordDocument, "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n": types.AllowCrossSigningResetDocument, }; @@ -240,6 +242,14 @@ export function graphql(source: "\n query PasswordChangeQuery {\n viewer {\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 mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n"): (typeof documents)["\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\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 PasswordRecoveryQuery {\n siteConfig {\n id\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n"): (typeof documents)["\n query PasswordRecoveryQuery {\n siteConfig {\n id\n ...PasswordCreationDoubleInput_siteConfig\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 mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n"): (typeof documents)["\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 1e03597d..01b7d805 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1694,6 +1694,22 @@ export type ChangePasswordMutationVariables = Exact<{ export type ChangePasswordMutation = { __typename?: 'Mutation', setPassword: { __typename?: 'SetPasswordPayload', status: SetPasswordStatus } }; +export type PasswordRecoveryQueryQueryVariables = Exact<{ [key: string]: never; }>; + + +export type PasswordRecoveryQueryQuery = { __typename?: 'Query', siteConfig: ( + { __typename?: 'SiteConfig', id: string } + & { ' $fragmentRefs'?: { 'PasswordCreationDoubleInput_SiteConfigFragment': PasswordCreationDoubleInput_SiteConfigFragment } } + ) }; + +export type RecoverPasswordMutationVariables = Exact<{ + ticket: Scalars['String']['input']; + newPassword: Scalars['String']['input']; +}>; + + +export type RecoverPasswordMutation = { __typename?: 'Mutation', setPasswordByRecovery: { __typename?: 'SetPasswordPayload', status: SetPasswordStatus } }; + export type AllowCrossSigningResetMutationVariables = Exact<{ userId: Scalars['ID']['input']; }>; @@ -1743,4 +1759,6 @@ export const DeviceRedirectQueryDocument = {"kind":"Document","definitions":[{"k export const VerifyEmailQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"VerifyEmailQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserEmail_verifyEmail"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserEmail_verifyEmail"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]} as unknown as DocumentNode; export const PasswordChangeQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PasswordChangeQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Node"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"siteConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PasswordCreationDoubleInput_siteConfig"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PasswordCreationDoubleInput_siteConfig"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SiteConfig"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"minimumPasswordComplexity"}}]}}]} as unknown as DocumentNode; export const ChangePasswordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ChangePassword"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"oldPassword"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setPassword"},"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"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"currentPassword"},"value":{"kind":"Variable","name":{"kind":"Name","value":"oldPassword"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"newPassword"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; +export const PasswordRecoveryQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PasswordRecoveryQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"siteConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"PasswordCreationDoubleInput_siteConfig"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PasswordCreationDoubleInput_siteConfig"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SiteConfig"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"minimumPasswordComplexity"}}]}}]} as unknown as DocumentNode; +export const RecoverPasswordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RecoverPassword"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ticket"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setPasswordByRecovery"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"ticket"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ticket"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"newPassword"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newPassword"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} 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; \ No newline at end of file diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 1ad4fc19..4ccc4604 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AccountImport } from './routes/_account' import { Route as AccountIndexImport } from './routes/_account.index' import { Route as DevicesSplatImport } from './routes/devices.$' import { Route as ClientsIdImport } from './routes/clients.$id' +import { Route as PasswordRecoveryIndexImport } from './routes/password.recovery.index' import { Route as PasswordChangeIndexImport } from './routes/password.change.index' import { Route as AccountSessionsIndexImport } from './routes/_account.sessions.index' import { Route as PasswordChangeSuccessImport } from './routes/password.change.success' @@ -50,6 +51,13 @@ const ClientsIdRoute = ClientsIdImport.update({ getParentRoute: () => rootRoute, } as any) +const PasswordRecoveryIndexRoute = PasswordRecoveryIndexImport.update({ + path: '/password/recovery/', + getParentRoute: () => rootRoute, +} as any).lazy(() => + import('./routes/password.recovery.index.lazy').then((d) => d.Route), +) + const PasswordChangeIndexRoute = PasswordChangeIndexImport.update({ path: '/password/change/', getParentRoute: () => rootRoute, @@ -163,6 +171,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PasswordChangeIndexImport parentRoute: typeof rootRoute } + '/password/recovery/': { + id: '/password/recovery/' + path: '/password/recovery' + fullPath: '/password/recovery' + preLoaderRoute: typeof PasswordRecoveryIndexImport + parentRoute: typeof rootRoute + } } } @@ -181,6 +196,7 @@ export const routeTree = rootRoute.addChildren({ EmailsIdVerifyRoute, PasswordChangeSuccessRoute, PasswordChangeIndexRoute, + PasswordRecoveryIndexRoute, }) /* prettier-ignore-end */ @@ -197,7 +213,8 @@ export const routeTree = rootRoute.addChildren({ "/devices/$", "/emails/$id/verify", "/password/change/success", - "/password/change/" + "/password/change/", + "/password/recovery/" ] }, "/_account": { @@ -242,6 +259,9 @@ export const routeTree = rootRoute.addChildren({ }, "/password/change/": { "filePath": "password.change.index.tsx" + }, + "/password/recovery/": { + "filePath": "password.recovery.index.tsx" } } } diff --git a/frontend/src/routes/password.recovery.index.lazy.tsx b/frontend/src/routes/password.recovery.index.lazy.tsx new file mode 100644 index 00000000..b4020466 --- /dev/null +++ b/frontend/src/routes/password.recovery.index.lazy.tsx @@ -0,0 +1,155 @@ +// 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 { + createLazyFileRoute, + useRouter, + useSearch, +} from "@tanstack/react-router"; +import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid"; +import { Alert, Form } from "@vector-im/compound-web"; +import { FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery } from "urql"; + +import BlockList from "../components/BlockList"; +import Layout from "../components/Layout"; +import LoadingSpinner from "../components/LoadingSpinner"; +import PageHeading from "../components/PageHeading"; +import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput"; +import { graphql } from "../gql"; +import { SetPasswordStatus } from "../gql/graphql"; +import { translateSetPasswordError } from "../i18n/password_changes"; + +const QUERY = graphql(/* GraphQL */ ` + query PasswordRecoveryQuery { + siteConfig { + id + ...PasswordCreationDoubleInput_siteConfig + } + } +`); + +const RECOVER_PASSWORD_MUTATION = graphql(/* GraphQL */ ` + mutation RecoverPassword($ticket: String!, $newPassword: String!) { + setPasswordByRecovery( + input: { ticket: $ticket, newPassword: $newPassword } + ) { + status + } + } +`); + +export const Route = createLazyFileRoute("/password/recovery/")({ + component: RecoverPassword, +}); + +function RecoverPassword(): React.ReactNode { + const { t } = useTranslation(); + const { ticket } = useSearch({ + from: "/password/recovery/", + }); + const [queryResult] = useQuery({ query: QUERY }); + const router = useRouter(); + if (queryResult.error) throw queryResult.error; + const siteConfig = queryResult.data?.siteConfig; + if (!siteConfig) throw Error(); // This should never happen + + const [result, changePassword] = useMutation(RECOVER_PASSWORD_MUTATION); + + const onSubmit = async (event: FormEvent): Promise => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const newPassword = formData.get("new_password") as string; + const newPasswordAgain = formData.get("new_password_again") as string; + + if (newPassword !== newPasswordAgain) { + throw new Error("passwords mismatch; this should be checked by the form"); + } + + const response = await changePassword({ ticket, newPassword }); + + if ( + response.data?.setPasswordByRecovery.status === SetPasswordStatus.Allowed + ) { + // Redirect to the application root using a full page load + // The MAS backend will then redirect to the login page + // Unfortunately this won't work in dev mode (`npm run dev`) + // as the backend isn't involved there. + const location = router.buildLocation({ to: "/" }); + window.location.href = location.href; + } + }; + + const unhandleableError = result.error !== undefined; + + const errorMsg: string | undefined = translateSetPasswordError( + t, + result.data?.setPasswordByRecovery.status, + ); + + return ( + + + + + + {/* + In normal operation, the submit event should be `preventDefault()`ed. + method = POST just prevents sending passwords in the query string, + which could be logged, if for some reason the event handler fails. + */} + {unhandleableError && ( + + {t("frontend.password_change.failure.description.unspecified")} + + )} + + {errorMsg !== undefined && ( + + {errorMsg} + + )} + + + + + {!!result.fetching && } + {t("action.save_and_continue")} + + + + + ); +} diff --git a/frontend/src/routes/password.recovery.index.tsx b/frontend/src/routes/password.recovery.index.tsx new file mode 100644 index 00000000..9ee5afd2 --- /dev/null +++ b/frontend/src/routes/password.recovery.index.tsx @@ -0,0 +1,41 @@ +// 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 } from "@tanstack/react-router"; + +import { graphql } from "../gql"; + +const QUERY = graphql(/* GraphQL */ ` + query PasswordRecoveryQuery { + siteConfig { + id + ...PasswordCreationDoubleInput_siteConfig + } + } +`); + +export const Route = createFileRoute("/password/recovery/")({ + validateSearch: (search) => + search as { + ticket: string; + }, + async loader({ context, abortController: { signal } }) { + const queryResult = await context.client.query( + QUERY, + {}, + { fetchOptions: { signal } }, + ); + if (queryResult.error) throw queryResult.error; + }, +});