1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-29 22:01:14 +03:00

frontend: password recovery

This commit is contained in:
Olivier 'reivilibre
2024-07-24 15:22:30 +01:00
committed by reivilibre
parent 6613f4547a
commit a6d46d90ca
6 changed files with 250 additions and 2 deletions

View File

@ -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": {

View File

@ -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.
*/

View File

@ -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<VerifyEmailQueryQuery, VerifyEmailQueryQueryVariables>;
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<PasswordChangeQueryQuery, PasswordChangeQueryQueryVariables>;
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<ChangePasswordMutation, ChangePasswordMutationVariables>;
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<PasswordRecoveryQueryQuery, PasswordRecoveryQueryQueryVariables>;
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<RecoverPasswordMutation, RecoverPasswordMutationVariables>;
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>;

View File

@ -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"
}
}
}

View File

@ -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<HTMLFormElement>): Promise<void> => {
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 (
<Layout>
<BlockList>
<PageHeading
Icon={IconLockSolid}
title={t("frontend.password_reset.title")}
subtitle={t("frontend.password_change.subtitle")}
/>
<Form.Root onSubmit={onSubmit} method="POST">
{/*
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 && (
<Alert
type="critical"
title={t("frontend.password_change.failure.title")}
>
{t("frontend.password_change.failure.description.unspecified")}
</Alert>
)}
{errorMsg !== undefined && (
<Alert
type="critical"
title={t("frontend.password_change.failure.title")}
>
{errorMsg}
</Alert>
)}
<PasswordCreationDoubleInput
siteConfig={siteConfig}
forceShowNewPasswordInvalid={
(result.data &&
result.data.setPasswordByRecovery.status ==
SetPasswordStatus.InvalidNewPassword) ||
false
}
/>
<Form.Submit kind="primary" disabled={result.fetching}>
{!!result.fetching && <LoadingSpinner inline />}
{t("action.save_and_continue")}
</Form.Submit>
</Form.Root>
</BlockList>
</Layout>
);
}

View File

@ -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;
},
});