Skip to content

Commit df10107

Browse files
odelcroimcalinghee
andauthored
Feat(register): add password complexity and hints (#68)
* add specific component to test in template * adapt password form fields name use include_asset, add noscript fix password tests fix lint fix unit test dupplicate PasswordCreationDoubleInput to use custom names and labels fix lint add exception in knip for external components rename component to PasswordDoubleInput update html forceShowNewPasswordInvalid": false * update autocomplete field * deactivate english dictionnary fix lint update translation * update translations --------- Co-authored-by: mcalinghee <[email protected]>
1 parent 472feea commit df10107

File tree

12 files changed

+420
-182
lines changed

12 files changed

+420
-182
lines changed

frontend/knip.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export default {
1111
"src/gql/*",
1212
"src/routeTree.gen.ts",
1313
".storybook/locales.ts",
14+
"src/external/**",
15+
1416
"tchap/**", //:tchap: add tchap folder
1517
],
1618
ignoreDependencies: [

frontend/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"loading": "Loading…",
3030
"next": "Next",
3131
"password": "Password",
32+
"password_confirm": "",
3233
"previous": "Previous",
3334
"saved": "Saved",
3435
"saving": "Saving…"

frontend/src/external/mount.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { TooltipProvider } from "@vector-im/compound-web";
3+
import { createElement, StrictMode, Suspense } from "react";
4+
import ReactDOM from "react-dom/client";
5+
import { I18nextProvider } from "react-i18next";
6+
import ErrorBoundary from "../components/ErrorBoundary";
7+
import i18n, { setupI18n } from "../i18n";
8+
import "../shared.css";
9+
10+
setupI18n();
11+
12+
export function mountWithProviders(
13+
selector: string,
14+
Component: React.ComponentType<any>,
15+
defaultProps?: Record<string, unknown>,
16+
) {
17+
try {
18+
const el = document.querySelector(selector);
19+
if (!el) throw new Error(`can not find ${selector} in DOM`);
20+
const propsJSON = el.getAttribute("data-props") || "{}";
21+
const parsedProps = JSON.parse(propsJSON);
22+
const props = { ...(defaultProps ?? {}), ...(parsedProps ?? {}) };
23+
const queryClient = new QueryClient();
24+
ReactDOM.createRoot(el).render(
25+
<StrictMode>
26+
<QueryClientProvider client={queryClient}>
27+
<ErrorBoundary>
28+
<TooltipProvider>
29+
<Suspense
30+
fallback={<div>{`Chargement du composant ${selector}…`}</div>}
31+
>
32+
<I18nextProvider i18n={i18n}>
33+
{createElement(Component, props)}
34+
</I18nextProvider>
35+
</Suspense>
36+
</TooltipProvider>
37+
</ErrorBoundary>
38+
</QueryClientProvider>
39+
</StrictMode>,
40+
);
41+
} catch (err) {
42+
console.error(`Cannot mount component on ${selector}:`, err);
43+
}
44+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright 2024, 2025 New Vector Ltd.
2+
// Copyright 2024 The Matrix.org Foundation C.I.C.
3+
//
4+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
// Please see LICENSE files in the repository root for full details.
6+
7+
import { Form, Progress } from "@vector-im/compound-web";
8+
import { useDeferredValue, useEffect, useRef, useState } from "react";
9+
import { useTranslation } from "react-i18next";
10+
11+
import { type FragmentType, graphql, useFragment } from "../../gql";
12+
import type { PasswordComplexity } from "../../utils/password_complexity";
13+
14+
const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
15+
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
16+
id
17+
minimumPasswordComplexity
18+
}
19+
`);
20+
21+
// This will load the password complexity module lazily,
22+
// so that it doesn't block the initial render and can be code-split
23+
const loadPromise = import("../../utils/password_complexity").then(
24+
({ estimatePasswordComplexity }) => estimatePasswordComplexity,
25+
);
26+
27+
const usePasswordComplexity = (password: string): PasswordComplexity => {
28+
const { t } = useTranslation();
29+
const [result, setResult] = useState<PasswordComplexity>({
30+
score: 0,
31+
scoreText: t("frontend.password_strength.placeholder"),
32+
improvementsText: [],
33+
});
34+
const deferredPassword = useDeferredValue(password);
35+
36+
useEffect(() => {
37+
if (deferredPassword === "") {
38+
setResult({
39+
score: 0,
40+
scoreText: t("frontend.password_strength.placeholder"),
41+
improvementsText: [],
42+
});
43+
} else {
44+
loadPromise
45+
.then((estimatePasswordComplexity) =>
46+
estimatePasswordComplexity(deferredPassword, t),
47+
)
48+
.then((response) => setResult(response));
49+
}
50+
}, [deferredPassword, t]);
51+
52+
return result;
53+
};
54+
55+
//:tchap: this compoenent has been duplicated from src/frontend/components/PasswordCreationDoubleInput
56+
//:tchap: it should be reusing this component with possibilities to customize names and labels
57+
export default function PasswordCreationDoubleInput({
58+
siteConfig,
59+
forceShowNewPasswordInvalid,
60+
}: {
61+
siteConfig: FragmentType<typeof CONFIG_FRAGMENT>;
62+
forceShowNewPasswordInvalid: boolean;
63+
}): React.ReactElement {
64+
const { t } = useTranslation();
65+
const { minimumPasswordComplexity } = useFragment(
66+
CONFIG_FRAGMENT,
67+
siteConfig,
68+
);
69+
70+
const newPasswordRef = useRef<HTMLInputElement>(null);
71+
const newPasswordAgainRef = useRef<HTMLInputElement>(null);
72+
const [newPassword, setNewPassword] = useState("");
73+
74+
const passwordComplexity = usePasswordComplexity(newPassword);
75+
let passwordStrengthTint: "red" | "orange" | "lime" | "green" | undefined;
76+
if (newPassword === "") {
77+
passwordStrengthTint = undefined;
78+
} else {
79+
passwordStrengthTint = (["red", "red", "orange", "lime", "green"] as const)[
80+
passwordComplexity.score
81+
];
82+
}
83+
84+
return (
85+
<>
86+
<Form.Field name="password">
87+
<Form.Label>{t("common.password")}</Form.Label>
88+
89+
<Form.PasswordControl
90+
required
91+
autoComplete="new-password"
92+
ref={newPasswordRef}
93+
onBlur={() =>
94+
newPasswordAgainRef.current?.value &&
95+
newPasswordAgainRef.current?.reportValidity()
96+
}
97+
onChange={(e) => setNewPassword(e.target.value)}
98+
/>
99+
100+
<Progress
101+
size="sm"
102+
getValueLabel={() => passwordComplexity.scoreText}
103+
tint={passwordStrengthTint}
104+
max={4}
105+
value={passwordComplexity.score}
106+
/>
107+
108+
{passwordComplexity.improvementsText.map((suggestion) => (
109+
<Form.HelpMessage key={suggestion}>{suggestion}</Form.HelpMessage>
110+
))}
111+
112+
{passwordComplexity.score < minimumPasswordComplexity && (
113+
<Form.ErrorMessage match={() => true}>
114+
{t("frontend.password_strength.too_weak")}
115+
</Form.ErrorMessage>
116+
)}
117+
118+
<Form.ErrorMessage match="valueMissing">
119+
{t("frontend.errors.field_required")}
120+
</Form.ErrorMessage>
121+
122+
{forceShowNewPasswordInvalid && (
123+
<Form.ErrorMessage>
124+
{t(
125+
"frontend.password_change.failure.description.invalid_new_password",
126+
)}
127+
</Form.ErrorMessage>
128+
)}
129+
</Form.Field>
130+
131+
<Form.Field name="password_confirm">
132+
{/*
133+
TODO This field has validation defects,
134+
some caused by Radix-UI upstream bugs.
135+
https://github.com/matrix-org/matrix-authentication-service/issues/2855
136+
*/}
137+
<Form.Label>{t("common.password_confirm")}</Form.Label>
138+
139+
<Form.PasswordControl
140+
required
141+
ref={newPasswordAgainRef}
142+
autoComplete="new-password"
143+
/>
144+
145+
<Form.ErrorMessage match="valueMissing">
146+
{t("frontend.errors.field_required")}
147+
</Form.ErrorMessage>
148+
149+
<Form.ErrorMessage match={(v, form) => v !== form.get("password")}>
150+
{t("frontend.password_change.passwords_no_match")}
151+
</Form.ErrorMessage>
152+
153+
<Form.SuccessMessage match="valid">
154+
{t("frontend.password_change.passwords_match")}
155+
</Form.SuccessMessage>
156+
</Form.Field>
157+
</>
158+
);
159+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
2+
import { Form } from "@vector-im/compound-web";
3+
import { graphql } from "../../gql";
4+
import { graphqlRequest } from "../../graphql";
5+
import { mountWithProviders } from "../mount";
6+
import "../../shared.css";
7+
import PasswordCreationDoubleInput from "./PasswordCreationDoubleInput";
8+
9+
const HTML_ID = "#password-double-input";
10+
11+
const QUERY = graphql(/* GraphQL */ `
12+
query PasswordChange {
13+
viewer {
14+
__typename
15+
... on Node {
16+
id
17+
}
18+
}
19+
20+
siteConfig {
21+
...PasswordCreationDoubleInput_siteConfig
22+
}
23+
}
24+
`);
25+
26+
const query = queryOptions({
27+
queryKey: ["passwordChange"],
28+
queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }),
29+
});
30+
31+
type PasswordDoubleInputProps = { forceShowNewPasswordInvalid: boolean };
32+
33+
function PasswordDoubleInput({
34+
forceShowNewPasswordInvalid,
35+
}: PasswordDoubleInputProps) {
36+
const {
37+
data: { siteConfig },
38+
} = useSuspenseQuery(query);
39+
return (
40+
// Form.Root is needed because Form.Field requires to be included into a Form
41+
// asChild allows to replace Form.Root component by the child, the <form> used is in the password.html
42+
<Form.Root asChild>
43+
<div>
44+
<PasswordCreationDoubleInput
45+
siteConfig={siteConfig}
46+
forceShowNewPasswordInvalid={forceShowNewPasswordInvalid}
47+
/>
48+
</div>
49+
</Form.Root>
50+
);
51+
}
52+
53+
// Allow mounting under either the new specific id or the legacy #view
54+
mountWithProviders(HTML_ID, PasswordDoubleInput);

frontend/src/gql/gql.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Documents = {
4949
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc,
5050
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc,
5151
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc,
52+
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": typeof types.PasswordChangeDocument,
5253
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument,
5354
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": typeof types.PlanManagementTabDocument,
5455
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\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 }\n": typeof types.BrowserSessionListDocument,
@@ -62,7 +63,6 @@ type Documents = {
6263
"\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": typeof types.DoVerifyEmailDocument,
6364
"\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": typeof types.ResendEmailAuthenticationCodeDocument,
6465
"\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 types.ChangePasswordDocument,
65-
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": typeof types.PasswordChangeDocument,
6666
"\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": typeof types.RecoverPasswordDocument,
6767
"\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n": typeof types.ResendRecoveryEmailDocument,
6868
"\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n": typeof types.RecoverPassword_UserRecoveryTicketFragmentDoc,
@@ -106,6 +106,7 @@ const documents: Documents = {
106106
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc,
107107
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc,
108108
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc,
109+
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeDocument,
109110
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument,
110111
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": types.PlanManagementTabDocument,
111112
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\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 }\n": types.BrowserSessionListDocument,
@@ -119,7 +120,6 @@ const documents: Documents = {
119120
"\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": types.DoVerifyEmailDocument,
120121
"\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": types.ResendEmailAuthenticationCodeDocument,
121122
"\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,
122-
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeDocument,
123123
"\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": types.RecoverPasswordDocument,
124124
"\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n": types.ResendRecoveryEmailDocument,
125125
"\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n": types.RecoverPassword_UserRecoveryTicketFragmentDoc,
@@ -265,6 +265,10 @@ export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteCo
265265
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
266266
*/
267267
export function graphql(source: "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n"): typeof import('./graphql').BrowserSessionsOverview_UserFragmentDoc;
268+
/**
269+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
270+
*/
271+
export function graphql(source: "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n"): typeof import('./graphql').PasswordChangeDocument;
268272
/**
269273
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
270274
*/
@@ -317,10 +321,6 @@ export function graphql(source: "\n mutation ResendEmailAuthenticationCode($id:
317321
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
318322
*/
319323
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 import('./graphql').ChangePasswordDocument;
320-
/**
321-
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
322-
*/
323-
export function graphql(source: "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n"): typeof import('./graphql').PasswordChangeDocument;
324324
/**
325325
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
326326
*/

0 commit comments

Comments
 (0)