From 2649d6682550e43daa1fca9d2fdc9c6a330151aa Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 17 Jul 2024 09:24:42 +0200 Subject: [PATCH] Code-split the password channge page --- frontend/src/routeTree.gen.ts | 4 +- .../src/routes/password.change.index.lazy.tsx | 343 ++++++++++++++++++ frontend/src/routes/password.change.index.tsx | 306 +--------------- 3 files changed, 347 insertions(+), 306 deletions(-) create mode 100644 frontend/src/routes/password.change.index.lazy.tsx diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 59b6c03b5..1ad4fc19e 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -53,7 +53,9 @@ const ClientsIdRoute = ClientsIdImport.update({ const PasswordChangeIndexRoute = PasswordChangeIndexImport.update({ path: '/password/change/', getParentRoute: () => rootRoute, -} as any) +} as any).lazy(() => + import('./routes/password.change.index.lazy').then((d) => d.Route), +) const AccountSessionsIndexRoute = AccountSessionsIndexImport.update({ path: '/sessions/', diff --git a/frontend/src/routes/password.change.index.lazy.tsx b/frontend/src/routes/password.change.index.lazy.tsx new file mode 100644 index 000000000..75734f9e2 --- /dev/null +++ b/frontend/src/routes/password.change.index.lazy.tsx @@ -0,0 +1,343 @@ +// 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, + notFound, + useRouter, +} from "@tanstack/react-router"; +import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid"; +import { Alert, Form, Progress, Separator } from "@vector-im/compound-web"; +import { + FormEvent, + useDeferredValue, + useEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery } from "urql"; + +import BlockList from "../components/BlockList"; +import { ButtonLink } from "../components/ButtonLink"; +import Layout from "../components/Layout"; +import LoadingSpinner from "../components/LoadingSpinner"; +import PageHeading from "../components/PageHeading"; +import { graphql } from "../gql"; +import { SetPasswordStatus } from "../gql/graphql"; +import { + PasswordComplexity, + estimatePasswordComplexity, +} from "../utils/password_complexity"; + +const QUERY = graphql(/* GraphQL */ ` + query PasswordChangeQuery { + viewer { + __typename + ... on Node { + id + } + } + + siteConfig { + id + minimumPasswordComplexity + } + } +`); + +const CHANGE_PASSWORD_MUTATION = graphql(/* GraphQL */ ` + mutation ChangePassword( + $userId: ID! + $oldPassword: String! + $newPassword: String! + ) { + setPassword( + input: { + userId: $userId + currentPassword: $oldPassword + newPassword: $newPassword + } + ) { + status + } + } +`); + +export const Route = createLazyFileRoute("/password/change/")({ + component: ChangePassword, +}); + +const usePasswordComplexity = (password: string): PasswordComplexity => { + const { t } = useTranslation(); + const [result, setResult] = useState({ + score: 0, + scoreText: t("frontend.password_strength.placeholder"), + improvementsText: [], + }); + const deferredPassword = useDeferredValue(password); + + useEffect(() => { + if (deferredPassword === "") { + setResult({ + score: 0, + scoreText: t("frontend.password_strength.placeholder"), + improvementsText: [], + }); + } else { + estimatePasswordComplexity(deferredPassword, t).then((response) => + setResult(response), + ); + } + }, [deferredPassword, t]); + + return result; +}; + +function ChangePassword(): React.ReactNode { + const { t } = useTranslation(); + const [queryResult] = useQuery({ query: QUERY }); + const router = useRouter(); + if (queryResult.error) throw queryResult.error; + if (queryResult.data?.viewer.__typename !== "User") throw notFound(); + const userId = queryResult.data.viewer.id; + const minPasswordComplexity = + queryResult.data.siteConfig.minimumPasswordComplexity; + + const currentPasswordRef = useRef(null); + const newPasswordRef = useRef(null); + const newPasswordAgainRef = useRef(null); + const [newPassword, setNewPassword] = useState(""); + + const [result, changePassword] = useMutation(CHANGE_PASSWORD_MUTATION); + + const onSubmit = async (event: FormEvent): Promise => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const oldPassword = formData.get("current_password") as string; + 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({ userId, oldPassword, newPassword }); + + if (response.data?.setPassword.status === SetPasswordStatus.Allowed) { + router.navigate({ to: "/password/change/success" }); + } + }; + + const unhandleableError = result.error !== undefined; + + const errorMsg: string | undefined = ((): string | undefined => { + switch (result.data?.setPassword.status) { + case SetPasswordStatus.NoCurrentPassword: + return t( + "frontend.password_change.failure.description.no_current_password", + ); + case SetPasswordStatus.PasswordChangesDisabled: + return t( + "frontend.password_change.failure.description.password_changes_disabled", + ); + + case SetPasswordStatus.WrongPassword: + case SetPasswordStatus.InvalidNewPassword: + // These cases are shown as inline errors in the form itself. + return undefined; + + case SetPasswordStatus.Allowed: + case undefined: + return undefined; + + default: + throw new Error( + `unexpected error when changing password: ${result.data!.setPassword.status}`, + ); + } + })(); + + const passwordComplexity = usePasswordComplexity(newPassword); + let passwordStrengthTint; + if (newPassword === "") { + passwordStrengthTint = undefined; + } else { + passwordStrengthTint = ["red", "red", "orange", "lime", "green"][ + passwordComplexity.score + ] as "red" | "orange" | "lime" | "green" | undefined; + } + + 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} + + )} + + + + {t("frontend.password_change.current_password_label")} + + + + + + {t("frontend.errors.field_required")} + + + {result.data && + result.data.setPassword.status === + SetPasswordStatus.WrongPassword && ( + + {t( + "frontend.password_change.failure.description.wrong_password", + )} + + )} + + + + + + + {t("frontend.password_change.new_password_label")} + + + + newPasswordAgainRef.current!.value && + newPasswordAgainRef.current!.reportValidity() + } + onChange={(e) => setNewPassword(e.target.value)} + /> + + passwordComplexity.scoreText} + tint={passwordStrengthTint} + max={4} + value={passwordComplexity.score} + /> + + {passwordComplexity.improvementsText.map((suggestion) => ( + {suggestion} + ))} + + {passwordComplexity.score < minPasswordComplexity && ( + true}> + {t("frontend.password_strength.too_weak")} + + )} + + + {t("frontend.errors.field_required")} + + + {result.data && + result.data.setPassword.status === + SetPasswordStatus.InvalidNewPassword && ( + + {t( + "frontend.password_change.failure.description.invalid_new_password", + )} + + )} + + + + {/* + TODO This field has validation defects, + some caused by Radix-UI upstream bugs. + https://github.com/matrix-org/matrix-authentication-service/issues/2855 + */} + + {t("frontend.password_change.new_password_again_label")} + + + + + + {t("frontend.errors.field_required")} + + + v !== form.get("new_password")} + > + {t("frontend.password_change.passwords_no_match")} + + + + {t("frontend.password_change.passwords_match")} + + + + + {!!result.fetching && } + {t("action.save")} + + + + {t("action.cancel")} + + + + + ); +} diff --git a/frontend/src/routes/password.change.index.tsx b/frontend/src/routes/password.change.index.tsx index b76d72cf4..7b5d91a65 100644 --- a/frontend/src/routes/password.change.index.tsx +++ b/frontend/src/routes/password.change.index.tsx @@ -12,30 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createFileRoute, notFound, useRouter } from "@tanstack/react-router"; -import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid"; -import { Alert, Form, Progress, Separator } from "@vector-im/compound-web"; -import { - FormEvent, - useDeferredValue, - useEffect, - useRef, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; -import { useMutation, useQuery } from "urql"; +import { createFileRoute, notFound } from "@tanstack/react-router"; -import BlockList from "../components/BlockList"; -import { ButtonLink } from "../components/ButtonLink"; -import Layout from "../components/Layout"; -import LoadingSpinner from "../components/LoadingSpinner"; -import PageHeading from "../components/PageHeading"; import { graphql } from "../gql"; -import { SetPasswordStatus } from "../gql/graphql"; -import { - PasswordComplexity, - estimatePasswordComplexity, -} from "../utils/password_complexity"; const QUERY = graphql(/* GraphQL */ ` query PasswordChangeQuery { @@ -53,24 +32,6 @@ const QUERY = graphql(/* GraphQL */ ` } `); -const CHANGE_PASSWORD_MUTATION = graphql(/* GraphQL */ ` - mutation ChangePassword( - $userId: ID! - $oldPassword: String! - $newPassword: String! - ) { - setPassword( - input: { - userId: $userId - currentPassword: $oldPassword - newPassword: $newPassword - } - ) { - status - } - } -`); - export const Route = createFileRoute("/password/change/")({ async loader({ context, abortController: { signal } }) { const queryResult = await context.client.query( @@ -81,269 +42,4 @@ export const Route = createFileRoute("/password/change/")({ if (queryResult.error) throw queryResult.error; if (queryResult.data?.viewer.__typename !== "User") throw notFound(); }, - - component: ChangePassword, }); - -const usePasswordComplexity = (password: string): PasswordComplexity => { - const { t } = useTranslation(); - const [result, setResult] = useState({ - score: 0, - scoreText: t("frontend.password_strength.placeholder"), - improvementsText: [], - }); - const deferredPassword = useDeferredValue(password); - - useEffect(() => { - if (deferredPassword === "") { - setResult({ - score: 0, - scoreText: t("frontend.password_strength.placeholder"), - improvementsText: [], - }); - } else { - estimatePasswordComplexity(deferredPassword, t).then((response) => - setResult(response), - ); - } - }, [deferredPassword, t]); - - return result; -}; - -function ChangePassword(): React.ReactNode { - const { t } = useTranslation(); - const [queryResult] = useQuery({ query: QUERY }); - const router = useRouter(); - if (queryResult.error) throw queryResult.error; - if (queryResult.data?.viewer.__typename !== "User") throw notFound(); - const userId = queryResult.data.viewer.id; - const minPasswordComplexity = - queryResult.data.siteConfig.minimumPasswordComplexity; - - const currentPasswordRef = useRef(null); - const newPasswordRef = useRef(null); - const newPasswordAgainRef = useRef(null); - const [newPassword, setNewPassword] = useState(""); - - const [result, changePassword] = useMutation(CHANGE_PASSWORD_MUTATION); - - const onSubmit = async (event: FormEvent): Promise => { - event.preventDefault(); - - const formData = new FormData(event.currentTarget); - - const oldPassword = formData.get("current_password") as string; - 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({ userId, oldPassword, newPassword }); - - if (response.data?.setPassword.status === SetPasswordStatus.Allowed) { - router.navigate({ to: "/password/change/success" }); - } - }; - - const unhandleableError = result.error !== undefined; - - const errorMsg: string | undefined = ((): string | undefined => { - switch (result.data?.setPassword.status) { - case SetPasswordStatus.NoCurrentPassword: - return t( - "frontend.password_change.failure.description.no_current_password", - ); - case SetPasswordStatus.PasswordChangesDisabled: - return t( - "frontend.password_change.failure.description.password_changes_disabled", - ); - - case SetPasswordStatus.WrongPassword: - case SetPasswordStatus.InvalidNewPassword: - // These cases are shown as inline errors in the form itself. - return undefined; - - case SetPasswordStatus.Allowed: - case undefined: - return undefined; - - default: - throw new Error( - `unexpected error when changing password: ${result.data!.setPassword.status}`, - ); - } - })(); - - const passwordComplexity = usePasswordComplexity(newPassword); - let passwordStrengthTint; - if (newPassword === "") { - passwordStrengthTint = undefined; - } else { - passwordStrengthTint = ["red", "red", "orange", "lime", "green"][ - passwordComplexity.score - ] as "red" | "orange" | "lime" | "green" | undefined; - } - - 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} - - )} - - - - {t("frontend.password_change.current_password_label")} - - - - - - {t("frontend.errors.field_required")} - - - {result.data && - result.data.setPassword.status === - SetPasswordStatus.WrongPassword && ( - - {t( - "frontend.password_change.failure.description.wrong_password", - )} - - )} - - - - - - - {t("frontend.password_change.new_password_label")} - - - - newPasswordAgainRef.current!.value && - newPasswordAgainRef.current!.reportValidity() - } - onChange={(e) => setNewPassword(e.target.value)} - /> - - passwordComplexity.score < minPasswordComplexity} - > - {t("frontend.password_strength.too_weak")} - - - passwordComplexity.scoreText} - tint={passwordStrengthTint} - max={4} - value={passwordComplexity.score} - /> - - {passwordComplexity.improvementsText.map((suggestion) => ( - {suggestion} - ))} - - - {t("frontend.errors.field_required")} - - - {result.data && - result.data.setPassword.status === - SetPasswordStatus.InvalidNewPassword && ( - - {t( - "frontend.password_change.failure.description.invalid_new_password", - )} - - )} - - - - {/* - TODO This field has validation defects, - some caused by Radix-UI upstream bugs. - https://github.com/matrix-org/matrix-authentication-service/issues/2855 - */} - - {t("frontend.password_change.new_password_again_label")} - - - - - - {t("frontend.errors.field_required")} - - - v !== form.get("new_password")} - > - {t("frontend.password_change.passwords_no_match")} - - - - {t("frontend.password_change.passwords_match")} - - - - - {!!result.fetching && } - {t("action.save")} - - - - {t("action.cancel")} - - - - - ); -}