diff --git a/src/auth/LoginPage.tsx b/src/auth/LoginPage.tsx index da20a86b..e23ab3d8 100644 --- a/src/auth/LoginPage.tsx +++ b/src/auth/LoginPage.tsx @@ -1,85 +1,63 @@ /* Copyright 2021-2024 New Vector Ltd. +Copyright 2026 Sunbeam Studios. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type FormEvent, useCallback, useRef, useState } from "react"; +import { type FC, useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import { Trans, useTranslation } from "react-i18next"; +import { useTranslation } from "react-i18next"; import { Button } from "@vector-im/compound-web"; import Logo from "../icons/LogoLarge.svg?react"; import { useClient } from "../ClientContext"; -import { FieldRow, InputField, ErrorMessage } from "../input/Input"; +import { FieldRow, ErrorMessage } from "../input/Input"; import styles from "./LoginPage.module.css"; -import { useInteractiveLogin } from "./useInteractiveLogin"; +import { useSSORedirect, useSSOCallback } from "./useSSOLogin"; import { usePageTitle } from "../usePageTitle"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { Config } from "../config/Config"; -import { Link } from "../button/Link"; export const LoginPage: FC = () => { const { t } = useTranslation(); usePageTitle(t("login_title")); const { client, setClient } = useClient(); - const login = useInteractiveLogin(client); - const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable - const usernameRef = useRef(null); - const passwordRef = useRef(null); + const homeserver = Config.defaultHomeserverUrl(); const navigate = useNavigate(); const location = useLocation(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); + const ssoRedirect = useSSORedirect(); - // TODO: Handle hitting login page with authenticated client + // Handle SSO callback (redirected back with ?loginToken=) + const { loading, error, session } = useSSOCallback(homeserver, client); - const onSubmitLoginForm = useCallback( - (e: FormEvent) => { - e.preventDefault(); - setLoading(true); + useEffect(() => { + if (!session || !setClient) return; - if (!homeserver || !usernameRef.current || !passwordRef.current) { - setError(Error("Login parameters are undefined")); - setLoading(false); - return; - } + const [newClient, newSession] = session; + setClient(newClient, newSession); - login(homeserver, usernameRef.current.value, passwordRef.current.value) - .then(async ([client, session]) => { - if (!setClient) { - return; - } + const locationState = location.state; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (locationState && locationState.from) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + void navigate(locationState.from); + } else { + void navigate("/"); + } + PosthogAnalytics.instance.eventLogin.track(); + }, [session, setClient, navigate, location]); - setClient(client, session); + const onClickSSO = (): void => { + if (homeserver) { + ssoRedirect(homeserver); + } + }; - const locationState = location.state; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (locationState && locationState.from) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await navigate(locationState.from); - } else { - await navigate("/"); - } - PosthogAnalytics.instance.eventLogin.track(); - }) - .catch((error) => { - setError(error); - setLoading(false); - }); - }, - [login, location, navigate, homeserver, setClient], - ); - // we need to limit the length of the homserver name to not cover the whole loginview input with the string. - let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25); - shortendHomeserverName = - shortendHomeserverName?.length !== Config.defaultServerName()?.length - ? shortendHomeserverName + "..." - : shortendHomeserverName; return ( <>
@@ -89,54 +67,20 @@ export const LoginPage: FC = () => {

{t("log_in")}

{t("login_subheading")}

-
+ {error && ( - + - - - - {error && ( - - - - )} - - - -
-
-
-

{t("login_auth_links_prompt")}

-

- - Create an account - {" Or "} - Access as a guest - -

+ )} + + +
diff --git a/src/auth/useSSOLogin.ts b/src/auth/useSSOLogin.ts new file mode 100644 index 00000000..bfff8fd7 --- /dev/null +++ b/src/auth/useSSOLogin.ts @@ -0,0 +1,96 @@ +/* +Copyright 2026 Sunbeam Studios. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useCallback, useEffect, useState } from "react"; +import { createClient, type MatrixClient } from "matrix-js-sdk"; + +import { initClient } from "../utils/matrix"; +import { type Session } from "../ClientContext"; + +/** + * Initiates SSO login by redirecting the user to the homeserver's SSO endpoint. + * The homeserver will redirect to the OIDC provider, and on success redirect + * back to the given callbackUrl with a ?loginToken= parameter. + */ +export function useSSORedirect(): (homeserver: string) => void { + return useCallback((homeserver: string) => { + const callbackUrl = `${window.location.origin}/login`; + const ssoUrl = `${homeserver}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(callbackUrl)}`; + window.location.href = ssoUrl; + }, []); +} + +/** + * Completes SSO login by exchanging a loginToken (from the SSO callback URL) + * for a full Matrix session via m.login.token. + */ +export function useSSOCallback( + homeserver: string | undefined, + oldClient?: MatrixClient, +): { + loading: boolean; + error: Error | undefined; + session: [MatrixClient, Session] | undefined; +} { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [session, setSession] = useState<[MatrixClient, Session]>(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const loginToken = params.get("loginToken"); + + if (!loginToken || !homeserver) return; + + // Clear the loginToken from the URL to prevent re-use on refresh. + const cleanUrl = window.location.pathname; + window.history.replaceState({}, "", cleanUrl); + + setLoading(true); + + (async (): Promise => { + try { + const authClient = createClient({ baseUrl: homeserver }); + + const response = await authClient.login("m.login.token", { + token: loginToken, + }); + + const { user_id, access_token, device_id } = response; + + // Sign out the old client if one exists, to avoid crypto session confusion. + await oldClient?.logout(true); + + const client = await initClient( + { + baseUrl: homeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }, + false, + ); + + setSession([ + client, + { + user_id, + access_token, + device_id, + passwordlessUser: false, + }, + ]); + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + setLoading(false); + } + })(); + }, [homeserver, oldClient]); + + return { loading, error, session }; +}