Merge pull request #357 from robintown/ts-auth

TypeScriptify the auth directory
This commit is contained in:
Robin 2022-05-31 10:35:39 -04:00 committed by GitHub
commit 9444f43c72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 284 additions and 252 deletions

View file

@ -16,9 +16,6 @@ module.exports = {
"sourceType": "module", "sourceType": "module",
}, },
rules: { rules: {
// We break this rule in a few places: dial it back to a warning
// (and run with max warnings) to tolerate the existing code
"react-hooks/exhaustive-deps": ["warn"],
"jsx-a11y/media-has-caption": ["off"], "jsx-a11y/media-has-caption": ["off"],
}, },
overrides: [ overrides: [

View file

@ -8,7 +8,7 @@
"build-storybook": "build-storybook", "build-storybook": "build-storybook",
"prettier:check": "prettier -c src", "prettier:check": "prettier -c src",
"prettier:format": "prettier -w src", "prettier:format": "prettier -w src",
"lint:js": "eslint --max-warnings 2 src", "lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc" "lint:types": "tsc"
}, },
"dependencies": { "dependencies": {
@ -31,6 +31,7 @@
"@react-stately/tree": "^3.2.0", "@react-stately/tree": "^3.2.0",
"@sentry/react": "^6.13.3", "@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3", "@sentry/tracing": "^6.13.3",
"@types/grecaptcha": "^3.0.4",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"color-hash": "^2.0.1", "color-hash": "^2.0.1",

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2021 New Vector Ltd Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { import React, {
FC,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
@ -23,17 +24,59 @@ import React, {
useContext, useContext,
} from "react"; } from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ErrorView } from "./FullScreenView"; import { ErrorView } from "./FullScreenView";
import { initClient, defaultHomeserver } from "./matrix-utils"; import { initClient, defaultHomeserver } from "./matrix-utils";
const ClientContext = createContext(); declare global {
interface Window {
matrixclient: MatrixClient;
}
}
export function ClientProvider({ children }) { export interface Session {
user_id: string;
device_id: string;
access_token: string;
passwordlessUser: boolean;
tempPassword?: string;
}
const loadSession = (): Session => {
const data = localStorage.getItem("matrix-auth-store");
if (data) return JSON.parse(data);
return null;
};
const saveSession = (session: Session) =>
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
const clearSession = () => localStorage.removeItem("matrix-auth-store");
interface ClientState {
loading: boolean;
isAuthenticated: boolean;
isPasswordlessUser: boolean;
client: MatrixClient;
userName: string;
changePassword: (password: string) => Promise<void>;
logout: () => void;
setClient: (client: MatrixClient, session: Session) => void;
}
const ClientContext = createContext<ClientState>(null);
type ClientProviderState = Omit<
ClientState,
"changePassword" | "logout" | "setClient"
> & { error?: Error };
export const ClientProvider: FC = ({ children }) => {
const history = useHistory(); const history = useHistory();
const [ const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error }, { loading, isAuthenticated, isPasswordlessUser, client, userName, error },
setState, setState,
] = useState({ ] = useState<ClientProviderState>({
loading: true, loading: true,
isAuthenticated: false, isAuthenticated: false,
isPasswordlessUser: false, isPasswordlessUser: false,
@ -43,18 +86,16 @@ export function ClientProvider({ children }) {
}); });
useEffect(() => { useEffect(() => {
async function restore() { const restore = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try { try {
const authStore = localStorage.getItem("matrix-auth-store"); const session = loadSession();
if (authStore) { if (session) {
const { /* eslint-disable camelcase */
user_id, const { user_id, device_id, access_token, passwordlessUser } =
device_id, session;
access_token,
passwordlessUser,
tempPassword,
} = JSON.parse(authStore);
const client = await initClient({ const client = await initClient({
baseUrl: defaultHomeserver, baseUrl: defaultHomeserver,
@ -62,37 +103,26 @@ export function ClientProvider({ children }) {
userId: user_id, userId: user_id,
deviceId: device_id, deviceId: device_id,
}); });
/* eslint-enable camelcase */
localStorage.setItem( return { client, isPasswordlessUser: passwordlessUser };
"matrix-auth-store",
JSON.stringify({
user_id,
device_id,
access_token,
passwordlessUser,
tempPassword,
})
);
return { client, passwordlessUser };
} }
return { client: undefined }; return { client: undefined, isPasswordlessUser: false };
} catch (err) { } catch (err) {
console.error(err); console.error(err);
localStorage.removeItem("matrix-auth-store"); clearSession();
throw err; throw err;
} }
} };
restore() restore()
.then(({ client, passwordlessUser }) => { .then(({ client, isPasswordlessUser }) => {
setState({ setState({
client, client,
loading: false, loading: false,
isAuthenticated: !!client, isAuthenticated: Boolean(client),
isPasswordlessUser: !!passwordlessUser, isPasswordlessUser,
userName: client?.getUserIdLocalpart(), userName: client?.getUserIdLocalpart(),
}); });
}) })
@ -108,31 +138,23 @@ export function ClientProvider({ children }) {
}, []); }, []);
const changePassword = useCallback( const changePassword = useCallback(
async (password) => { async (password: string) => {
const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse( const { tempPassword, ...session } = loadSession();
localStorage.getItem("matrix-auth-store")
);
await client.setPassword( await client.setPassword(
{ {
type: "m.login.password", type: "m.login.password",
identifier: { identifier: {
type: "m.id.user", type: "m.id.user",
user: existingSession.user_id, user: session.user_id,
}, },
user: existingSession.user_id, user: session.user_id,
password: tempPassword, password: tempPassword,
}, },
password password
); );
localStorage.setItem( saveSession({ ...session, passwordlessUser: false });
"matrix-auth-store",
JSON.stringify({
...existingSession,
passwordlessUser: false,
})
);
setState({ setState({
client, client,
@ -146,23 +168,23 @@ export function ClientProvider({ children }) {
); );
const setClient = useCallback( const setClient = useCallback(
(newClient, session) => { (newClient: MatrixClient, session: Session) => {
if (client && client !== newClient) { if (client && client !== newClient) {
client.stopClient(); client.stopClient();
} }
if (newClient) { if (newClient) {
localStorage.setItem("matrix-auth-store", JSON.stringify(session)); saveSession(session);
setState({ setState({
client: newClient, client: newClient,
loading: false, loading: false,
isAuthenticated: true, isAuthenticated: true,
isPasswordlessUser: !!session.passwordlessUser, isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(), userName: newClient.getUserIdLocalpart(),
}); });
} else { } else {
localStorage.removeItem("matrix-auth-store"); clearSession();
setState({ setState({
client: undefined, client: undefined,
@ -177,29 +199,23 @@ export function ClientProvider({ children }) {
); );
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store"); clearSession();
window.location = "/"; history.push("/");
}, [history]); }, [history]);
useEffect(() => { useEffect(() => {
if (client) { if (client) {
const loadTime = Date.now(); const loadTime = Date.now();
const onToDeviceEvent = (event) => { const onToDeviceEvent = (event: MatrixEvent) => {
if (event.getType() !== "org.matrix.call_duplicate_session") { if (event.getType() !== "org.matrix.call_duplicate_session") return;
return;
}
const content = event.getContent(); const content = event.getContent();
if (content.session_id === client.getSessionId()) { if (content.session_id === client.getSessionId()) return;
return;
}
if (content.timestamp > loadTime) { if (content.timestamp > loadTime) {
if (client) { client?.stopClient();
client.stopClient();
}
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@ -210,7 +226,7 @@ export function ClientProvider({ children }) {
} }
}; };
client.on("toDeviceEvent", onToDeviceEvent); client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
client.sendToDevice("org.matrix.call_duplicate_session", { client.sendToDevice("org.matrix.call_duplicate_session", {
[client.getUserId()]: { [client.getUserId()]: {
@ -219,12 +235,12 @@ export function ClientProvider({ children }) {
}); });
return () => { return () => {
client.removeListener("toDeviceEvent", onToDeviceEvent); client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
}; };
} }
}, [client]); }, [client]);
const context = useMemo( const context = useMemo<ClientState>(
() => ({ () => ({
loading, loading,
isAuthenticated, isAuthenticated,
@ -258,8 +274,6 @@ export function ClientProvider({ children }) {
return ( return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider> <ClientContext.Provider value={context}>{children}</ClientContext.Provider>
); );
} };
export function useClient() { export const useClient = () => useContext(ClientContext);
return useContext(ClientContext);
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2021 New Vector Ltd Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,8 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useRef, useState, useMemo } from "react"; import React, {
FC,
FormEvent,
useCallback,
useRef,
useState,
useMemo,
} from "react";
import { useHistory, useLocation, Link } from "react-router-dom"; import { useHistory, useLocation, Link } from "react-router-dom";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@ -25,23 +33,23 @@ import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin"; import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export function LoginPage() { export const LoginPage: FC = () => {
usePageTitle("Login"); usePageTitle("Login");
const { setClient } = useClient(); const { setClient } = useClient();
const [_, login] = useInteractiveLogin(); const login = useInteractiveLogin();
const [homeserver, setHomeServer] = useState(defaultHomeserver); const homeserver = defaultHomeserver; // TODO: Make this configurable
const usernameRef = useRef(); const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef(); const passwordRef = useRef<HTMLInputElement>();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState<Error>();
// TODO: Handle hitting login page with authenticated client // TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback( const onSubmitLoginForm = useCallback(
(e) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@ -60,13 +68,13 @@ export function LoginPage() {
setLoading(false); setLoading(false);
}); });
}, },
[login, location, history, homeserver] [login, location, history, homeserver, setClient]
); );
const homeserverHost = useMemo(() => { const homeserverHost = useMemo(() => {
try { try {
return new URL(homeserver).host; return new URL(homeserver).host;
} catch (_error) { } catch (error) {
return defaultHomeserverHost; return defaultHomeserverHost;
} }
}, [homeserver]); }, [homeserver]);
@ -125,4 +133,4 @@ export function LoginPage() {
</div> </div>
</> </>
); );
} };

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2021 New Vector Ltd Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,10 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, {
FC,
FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { captureException } from "@sentry/react"; import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
@ -30,46 +38,35 @@ import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography"; import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export function RegisterPage() { export const RegisterPage: FC = () => {
usePageTitle("Register"); usePageTitle("Register");
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } = const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
useClient(); useClient();
const confirmPasswordRef = useRef(); const confirmPasswordRef = useRef<HTMLInputElement>();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const [registering, setRegistering] = useState(false); const [registering, setRegistering] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState<Error>();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback( const onSubmitRegisterForm = useCallback(
(e) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(e.target); const data = new FormData(e.target as HTMLFormElement);
const userName = data.get("userName"); const userName = data.get("userName") as string;
const password = data.get("password"); const password = data.get("password") as string;
const passwordConfirmation = data.get("passwordConfirmation"); const passwordConfirmation = data.get("passwordConfirmation") as string;
if (password !== passwordConfirmation) { if (password !== passwordConfirmation) return;
return;
}
async function submit() { const submit = async () => {
setRegistering(true); setRegistering(true);
let roomIds;
if (client && isPasswordlessUser) {
const groupCalls = client.groupCallEventHandler.groupCalls.values();
roomIds = Array.from(groupCalls).map(
(groupCall) => groupCall.room.roomId
);
}
const recaptchaResponse = await execute(); const recaptchaResponse = await execute();
const [newClient, session] = await register( const [newClient, session] = await register(
userName, userName,
@ -78,8 +75,11 @@ export function RegisterPage() {
recaptchaResponse recaptchaResponse
); );
if (roomIds) { if (client && isPasswordlessUser) {
for (const roomId of roomIds) { // Migrate the user's rooms
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
const roomId = groupCall.room.roomId;
try { try {
await newClient.joinRoom(roomId); await newClient.joinRoom(roomId);
} catch (error) { } catch (error) {
@ -95,11 +95,11 @@ export function RegisterPage() {
} }
setClient(newClient, session); setClient(newClient, session);
} };
submit() submit()
.then(() => { .then(() => {
if (location.state && location.state.from) { if (location.state?.from) {
history.push(location.state.from); history.push(location.state.from);
} else { } else {
history.push("/"); history.push("/");
@ -111,18 +111,23 @@ export function RegisterPage() {
reset(); reset();
}); });
}, },
[register, location, history, isPasswordlessUser, reset, execute, client] [
register,
location,
history,
isPasswordlessUser,
reset,
execute,
client,
setClient,
]
); );
useEffect(() => { useEffect(() => {
if (!confirmPasswordRef.current) {
return;
}
if (password && passwordConfirmation && password !== passwordConfirmation) { if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current.setCustomValidity("Passwords must match"); confirmPasswordRef.current?.setCustomValidity("Passwords must match");
} else { } else {
confirmPasswordRef.current.setCustomValidity(""); confirmPasswordRef.current?.setCustomValidity("");
} }
}, [password, passwordConfirmation]); }, [password, passwordConfirmation]);
@ -130,7 +135,7 @@ export function RegisterPage() {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) { if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
history.push("/"); history.push("/");
} }
}, [history, isAuthenticated, isPasswordlessUser, registering]); }, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
if (loading) { if (loading) {
return <LoadingView />; return <LoadingView />;
@ -218,4 +223,4 @@ export function RegisterPage() {
</div> </div>
</> </>
); );
} };

View file

@ -1,58 +0,0 @@
/*
Copyright 2022 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 matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { useState, useCallback } from "react";
import { initClient, defaultHomeserver } from "../matrix-utils";
export function useInteractiveLogin() {
const [state, setState] = useState({ loading: false });
const auth = useCallback(async (homeserver, username, password) => {
const authClient = matrix.createClient(homeserver);
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
busyChanged(loading) {
setState((prev) => ({ ...prev, loading }));
},
async doRequest(_auth, _background) {
return authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
});
},
});
const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth();
const session = { user_id, access_token, device_id };
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
return [client, session];
}, []);
return [state, auth];
}

View file

@ -0,0 +1,55 @@
/*
Copyright 2022 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 { useCallback } from "react";
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { initClient, defaultHomeserver } from "../matrix-utils";
export const useInteractiveLogin = () =>
useCallback(
async (homeserver: string, username: string, password: string) => {
const authClient = matrix.createClient(homeserver);
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
doRequest: () =>
authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
}),
});
/* eslint-disable camelcase */
const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth();
const session = { user_id, access_token, device_id };
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
/* eslint-enable camelcase */
return [client, session];
},
[]
);

View file

@ -14,56 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { initClient, defaultHomeserver } from "../matrix-utils"; import { initClient, defaultHomeserver } from "../matrix-utils";
import { Session } from "../ClientContext";
export function useInteractiveRegistration() { export const useInteractiveRegistration = (): [
const [state, setState] = useState({ string,
privacyPolicyUrl: null, string,
loading: false, (
}); username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
) => Promise<[MatrixClient, Session]>
] => {
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string>();
const [recaptchaKey, setRecaptchaKey] = useState<string>();
const authClientRef = useRef(); const authClient = useRef<MatrixClient>();
if (!authClient.current) {
authClient.current = matrix.createClient(defaultHomeserver);
}
useEffect(() => { useEffect(() => {
authClientRef.current = matrix.createClient(defaultHomeserver); authClient.current.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
authClientRef.current.registerRequest({}).catch((error) => { error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url
const privacyPolicyUrl = );
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url; setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key;
if (privacyPolicyUrl || recaptchaKey) {
setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey }));
}
}); });
}, []); }, []);
const register = useCallback( const register = useCallback(
async ( async (
username, username: string,
password, password: string,
displayName, displayName: string,
recaptchaResponse, recaptchaResponse: string,
passwordlessUser passwordlessUser?: boolean
) => { ): Promise<[MatrixClient, Session]> => {
const interactiveAuth = new InteractiveAuth({ const interactiveAuth = new InteractiveAuth({
matrixClient: authClientRef.current, matrixClient: authClient.current,
busyChanged(loading) { doRequest: (auth) =>
setState((prev) => ({ ...prev, loading })); authClient.current.registerRequest({
},
async doRequest(auth, _background) {
return authClientRef.current.registerRequest({
username, username,
password, password,
auth: auth || undefined, auth: auth || undefined,
}); }),
}, stateUpdated: (nextStage, status) => {
stateUpdated(nextStage, status) {
if (status.error) { if (status.error) {
throw new Error(error); throw new Error(status.error);
} }
if (nextStage === "m.login.terms") { if (nextStage === "m.login.terms") {
@ -79,6 +83,7 @@ export function useInteractiveRegistration() {
}, },
}); });
/* eslint-disable camelcase */
const { user_id, access_token, device_id } = const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth(); await interactiveAuth.attemptAuth();
@ -91,14 +96,19 @@ export function useInteractiveRegistration() {
await client.setDisplayName(displayName); await client.setDisplayName(displayName);
const session = { user_id, device_id, access_token, passwordlessUser }; const session: Session = {
user_id,
device_id,
access_token,
passwordlessUser,
};
/* eslint-enable camelcase */
if (passwordlessUser) { if (passwordlessUser) {
session.tempPassword = password; session.tempPassword = password;
} }
const user = client.getUser(client.getUserId()); const user = client.getUser(client.getUserId());
user.setRawDisplayName(displayName); user.setRawDisplayName(displayName);
user.setDisplayName(displayName); user.setDisplayName(displayName);
@ -107,5 +117,5 @@ export function useInteractiveRegistration() {
[] []
); );
return [state, register]; return [privacyPolicyUrl, recaptchaKey, register];
} };

View file

@ -14,52 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useEffect, useCallback, useRef, useState } from "react"; import { useEffect, useCallback, useRef, useState } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
declare global {
interface Window {
mxOnRecaptchaLoaded: () => void;
}
}
const RECAPTCHA_SCRIPT_URL = const RECAPTCHA_SCRIPT_URL =
"https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit"; "https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit";
export function useRecaptcha(sitekey) { interface RecaptchaPromiseRef {
resolve: (response: string) => void;
reject: (error: Error) => void;
}
export const useRecaptcha = (sitekey: string) => {
const [recaptchaId] = useState(() => randomString(16)); const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef(); const promiseRef = useRef<RecaptchaPromiseRef>();
useEffect(() => { useEffect(() => {
if (!sitekey) { if (!sitekey) return;
return;
}
const onRecaptchaLoaded = () => { const onRecaptchaLoaded = () => {
if (!document.getElementById(recaptchaId)) { if (!document.getElementById(recaptchaId)) return;
return;
}
window.grecaptcha.render(recaptchaId, { window.grecaptcha.render(recaptchaId, {
sitekey, sitekey,
size: "invisible", size: "invisible",
callback: (response) => { callback: (response: string) => promiseRef.current?.resolve(response),
if (promiseRef.current) { // eslint-disable-next-line @typescript-eslint/naming-convention
promiseRef.current.resolve(response); "error-callback": () => promiseRef.current?.reject(new Error()),
}
},
"error-callback": (error) => {
if (promiseRef.current) {
promiseRef.current.reject(error);
}
},
}); });
}; };
if ( if (typeof window.grecaptcha?.render === "function") {
typeof window.grecaptcha !== "undefined" &&
typeof window.grecaptcha.render === "function"
) {
onRecaptchaLoaded(); onRecaptchaLoaded();
} else { } else {
window.mxOnRecaptchaLoaded = onRecaptchaLoaded; window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) { if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
const scriptTag = document.createElement("script"); const scriptTag = document.createElement("script") as HTMLScriptElement;
scriptTag.src = RECAPTCHA_SCRIPT_URL; scriptTag.src = RECAPTCHA_SCRIPT_URL;
scriptTag.async = true; scriptTag.async = true;
document.body.appendChild(scriptTag); document.body.appendChild(scriptTag);
@ -80,7 +77,7 @@ export function useRecaptcha(sitekey) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => { const observer = new MutationObserver((mutationsList) => {
for (const item of mutationsList) { for (const item of mutationsList) {
if (item.target?.style?.visibility !== "visible") { if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
reject(new Error("Recaptcha dismissed")); reject(new Error("Recaptcha dismissed"));
observer.disconnect(); observer.disconnect();
return; return;
@ -101,7 +98,7 @@ export function useRecaptcha(sitekey) {
window.grecaptcha.execute(); window.grecaptcha.execute();
const iframe = document.querySelector( const iframe = document.querySelector<HTMLIFrameElement>(
'iframe[src*="recaptcha/api2/bframe"]' 'iframe[src*="recaptcha/api2/bframe"]'
); );
@ -111,13 +108,11 @@ export function useRecaptcha(sitekey) {
}); });
} }
}); });
}, [recaptchaId, sitekey]); }, [sitekey]);
const reset = useCallback(() => { const reset = useCallback(() => {
if (window.grecaptcha) { window.grecaptcha?.reset();
window.grecaptcha.reset(); }, []);
}
}, [recaptchaId]);
return { execute, reset, recaptchaId }; return { execute, reset, recaptchaId };
} };

View file

@ -39,7 +39,7 @@ export function UnauthenticatedView() {
const [callType, setCallType] = useState(CallType.Video); const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);

View file

@ -33,7 +33,7 @@ export function RoomAuthView() {
const { setClient } = useClient(); const { setClient } = useClient();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);

View file

@ -2876,6 +2876,11 @@
"@types/minimatch" "*" "@types/minimatch" "*"
"@types/node" "*" "@types/node" "*"
"@types/grecaptcha@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/grecaptcha/-/grecaptcha-3.0.4.tgz#3de601f3b0cd0298faf052dd5bd62aff64c2be2e"
integrity sha512-7l1Y8DTGXkx/r4pwU1nMVAR+yD/QC+MCHKXAyEX/7JZhwcN1IED09aZ9vCjjkcGdhSQiu/eJqcXInpl6eEEEwg==
"@types/hast@^2.0.0": "@types/hast@^2.0.0":
version "2.3.4" version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"