refactor login lorm

This commit is contained in:
Aine
2026-05-31 12:42:55 +01:00
parent bab94407a7
commit 2d35c2ca39
24 changed files with 1173 additions and 513 deletions
+35
View File
@@ -2370,3 +2370,38 @@ SPDX-FileCopyrightText = [
"2026 Nikita Chernyi <https://etke.cc>",
]
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ["src/components/login/types.ts"]
SPDX-FileCopyrightText = [
"2026 Nikita Chernyi <https://etke.cc>",
]
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ["src/components/login/useLoginProbe.ts"]
SPDX-FileCopyrightText = [
"2026 Nikita Chernyi <https://etke.cc>",
]
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ["src/components/login/urls.ts"]
SPDX-FileCopyrightText = [
"2026 Nikita Chernyi <https://etke.cc>",
]
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ["src/components/login/LoginFormSections.tsx"]
SPDX-FileCopyrightText = [
"2026 Nikita Chernyi <https://etke.cc>",
]
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ["src/components/login/LoginButtons.tsx"]
SPDX-FileCopyrightText = [
"2026 Nikita Chernyi <https://etke.cc>",
]
SPDX-License-Identifier = "Apache-2.0"
+1 -1
View File
@@ -35,7 +35,7 @@ import { useMatch } from "react-router-dom";
import { setDataProviderNotifier } from "../../providers/data";
import { AdminClientConfigItems } from "../users/AdminClientConfigItems";
import Footer from "./Footer";
import { LoginMethod } from "../../pages/LoginPage";
import { LoginMethod } from "../../components/login/types";
import { ServerProcessResponse, ServerStatusResponse } from "../../providers/types";
import { useServerVersions } from "../../providers/serverVersion";
import { ClearConfig } from "../../utils/config";
+25 -1
View File
@@ -142,10 +142,18 @@ const LoginFormBox = styled(Box, {
marginBottom: "2rem",
},
},
// Reserve the logo's footprint and centre vertically, so swapping to the
// (much smaller) loading spinner on submit doesn't collapse the avatar row
// and jump the whole card upward.
[`& .avatar`]: {
margin: "1.5rem 1rem 1rem",
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "80px",
[theme.breakpoints.up("sm")]: {
minHeight: "120px",
},
},
[`& .icon`]: {
backgroundColor: theme.palette.grey[500],
@@ -162,8 +170,24 @@ const LoginFormBox = styled(Box, {
[`& .form`]: {
padding: "0 1.5rem 1.5rem 1.5rem",
},
// Buttons live inside .form (which already supplies the 1.5rem horizontal
// padding), so .actions adds none of its own — otherwise the buttons sit
// inset from the inputs above them and the column looks misaligned.
[`& .actions`]: {
padding: "0 1.5rem 1.5rem 1.5rem",
padding: 0,
marginTop: "0.5rem",
},
// Probe status line (resolving / unreachable / incompatible / suppress).
// Colour is set per-message on the Typography (error vs secondary), so it is
// deliberately omitted here to avoid overriding the error variants.
[`& .serverState`]: {
fontSize: "0.85rem",
marginLeft: "0.5rem",
// Padding, not margin: it stays inside the Collapse's measured height box,
// so the status line animates in/out cleanly instead of snapping at the
// first frame of the transition.
paddingTop: "0.25rem",
paddingBottom: "0.75rem",
},
[`& .serverVersion`]: {
color: theme.palette.text.secondary,
+100
View File
@@ -0,0 +1,100 @@
import { Button, CardActions } from "@mui/material";
import { useLogin, useTranslate } from "react-admin";
import createLogger from "../../utils/logger";
import { LoginMethod, ProbeState } from "./types";
const log = createLogger("login-buttons");
interface LoginButtonsProps {
probeState: ProbeState;
loginMethod: LoginMethod;
loading: boolean;
}
/**
* The login action buttons. The password Sign-in renders whenever the
* credentials tab is active but stays disabled until a probe has resolved a
* server that accepts password auth — so it can never submit before the server's
* capabilities are known. SSO and OIDC buttons appear only once their capability
* is confirmed on a resolved server.
*/
export const LoginButtons = ({ probeState, loginMethod, loading }: LoginButtonsProps) => {
const translate = useTranslate();
const login = useLogin();
const handleSSO = () => {
if (probeState.tag !== "ready") {
return;
}
const { ssoBaseUrl } = probeState.caps;
localStorage.setItem("sso_base_url", ssoBaseUrl);
// Return to the bare login page after SSO — origin + pathname only, matching
// handleOIDC. The full href would leak any query params (and a racing
// loginToken) to the homeserver's SSO endpoint via the redirectUrl.
const redirectUrl = window.location.origin + window.location.pathname;
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(
redirectUrl
)}`;
window.location.href = ssoFullUrl;
};
const handleOIDC = () => {
if (probeState.tag !== "ready") {
return;
}
log.debug("OIDC login initiated", { baseUrl: probeState.url });
login({
base_url: probeState.url,
clientUrl: window.location.origin + window.location.pathname,
authMetadata: probeState.caps.authMetadata,
});
};
if (loginMethod === "accessToken") {
return (
<CardActions className="actions">
<Button variant="contained" type="submit" color="primary" disabled={loading} fullWidth>
{translate("ra.auth.sign_in")}
</Button>
</CardActions>
);
}
const ready = probeState.tag === "ready";
const caps = ready ? probeState.caps : null;
// Show the password Sign-in until a resolved server is known NOT to accept
// password — then hide it, so OIDC-only servers don't show a permanently-dead
// control (better a11y than a never-enabling disabled button).
const showSignIn = !ready || !!caps?.password;
const signInDisabled = loading || !caps || !caps.password || caps.suppressPassword;
return (
<CardActions className="actions" sx={{ flexDirection: "column", gap: 1, "& > :not(:first-of-type)": { ml: 0 } }}>
{showSignIn && (
<Button variant="contained" type="submit" color="primary" disabled={signInDisabled} fullWidth>
{probeState.tag === "resolving"
? translate("ketesa.auth.server_state.checking")
: translate("ra.auth.sign_in")}
</Button>
)}
{/* Suppress SSO only when OIDC is the live alternative (caps.oidc): a server
that asks to suppress password but advertises no usable OIDC issuer would
otherwise leave the card with no actionable button at all — SSO is the
fallback path there. */}
{caps && caps.sso && (!caps.suppressPassword || !caps.oidc) && (
<Button variant="contained" color="secondary" onClick={handleSSO} disabled={loading} fullWidth>
{translate("ketesa.auth.sso_sign_in")}
</Button>
)}
{caps && caps.oidc && (
// Only when a usable issuer is confirmed (caps.oidc) — a server can claim
// suppressPassword without a valid issuer, and handleOIDC needs the metadata.
<Button variant="contained" color="secondary" onClick={handleOIDC} disabled={loading} fullWidth>
{translate("ketesa.auth.oidc_sign_in")}
</Button>
)}
</CardActions>
);
};
+320
View File
@@ -0,0 +1,320 @@
import { useEffect, useRef } from "react";
import { Box, Collapse, Tab, Tabs, Typography } from "@mui/material";
import { PasswordInput, required, SelectInput, TextInput, useTranslate } from "react-admin";
import { useFormContext } from "react-hook-form";
import { getWellKnownUrl, isValidBaseUrl, splitMxid } from "../../providers/matrix";
import { GetConfig } from "../../utils/config";
import { LoginMethod, ProbeState } from "./types";
import { prependDefaultProtocol } from "./urls";
import { UseLoginProbe } from "./useLoginProbe";
interface LoginFormSectionsProps {
formData: { base_url?: string; username?: string };
probeState: ProbeState;
loginMethod: LoginMethod;
setLoginMethod: (method: LoginMethod) => void;
loading: boolean;
restrictBaseUrlSingle: string | null;
restrictBaseUrlMultiple: string[] | null;
baseUrlChoices: string[];
start: UseLoginProbe["start"];
}
/**
* The login form body: the credentials/access-token tabs, the homeserver URL
* field, the server-state hints, and the username/password (or access-token)
* inputs. The username/password inputs render whenever the credentials tab is
* active — never gated on the probe result — so they are present in the DOM
* regardless of probe timing. That is the keyboard-trap fix: Tab always reaches
* them. They are merely disabled once a resolved server is known not to accept
* password auth (and stay enabled while resolving, for autofill compatibility).
*/
export const LoginFormSections = ({
formData,
probeState,
loginMethod,
setLoginMethod,
loading,
restrictBaseUrlSingle,
restrictBaseUrlMultiple,
baseUrlChoices,
start,
}: LoginFormSectionsProps) => {
const translate = useTranslate();
const form = useFormContext();
const hasInitializedUrlParams = useRef(false);
const wellKnownControllerRef = useRef<AbortController | null>(null);
useEffect(() => () => wellKnownControllerRef.current?.abort(), []);
const validateBaseUrl = (value: string) => {
if (!value.match(/^(https?):\/\//)) {
return translate("ketesa.auth.protocol_error");
} else if (!isValidBaseUrl(value)) {
return translate("ketesa.auth.url_error");
}
return undefined;
};
const handleUsernameChange = async () => {
if (formData.base_url || restrictBaseUrlSingle) {
return;
}
// If the username is a full MXID, derive the homeserver from its domain.
const domain = splitMxid(formData.username ?? "")?.domain;
if (domain) {
const wellKnownDiscovery = GetConfig().wellKnownDiscovery ?? true;
let url: string;
if (wellKnownDiscovery) {
// Abort an earlier in-flight lookup and bail if this one is cancelled on
// unmount, so we never setValue on a dead form.
wellKnownControllerRef.current?.abort();
const controller = new AbortController();
wellKnownControllerRef.current = controller;
url = await getWellKnownUrl(domain, controller.signal);
if (controller.signal.aborted) {
return;
}
} else {
url = `https://${domain}`;
}
if (!restrictBaseUrlMultiple || restrictBaseUrlMultiple.includes(url)) {
form.setValue("base_url", url, { shouldValidate: true, shouldDirty: true });
start(url);
}
}
};
const handleBaseUrlBlurOrChange = (event?: { target?: { value?: string } }) => {
// onChange passes the event; onBlur falls back to the current form value.
let value = event?.target?.value || formData.base_url;
if (!value) {
return;
}
if (!value.match(/^https?:\/\//)) {
value = prependDefaultProtocol(value);
if (!restrictBaseUrlMultiple && !restrictBaseUrlSingle) {
form.setValue("base_url", value, { shouldValidate: true, shouldDirty: true });
}
}
form.trigger("base_url");
// Only sync the field to the well-known-resolved url when the user owns the
// field (free-text mode); fixed/choice modes keep their configured value.
const onResolved =
restrictBaseUrlMultiple || restrictBaseUrlSingle
? undefined
: (nextUrl: string) => form.setValue("base_url", nextUrl, { shouldValidate: true, shouldDirty: true });
start(value, onResolved);
};
useEffect(() => {
if (hasInitializedUrlParams.current) return;
hasInitializedUrlParams.current = true;
// Defer to ensure the form is initialized before seeding from URL params.
const timer = setTimeout(() => {
const params = new URLSearchParams(window.location.search);
const hostname = window.location.hostname;
const username = params.get("username");
const password = params.get("password");
const accessToken = params.get("accessToken");
let serverURL = params.get("server");
if (username) {
form.setValue("username", username);
}
if (hostname === "localhost" || hostname === "127.0.0.1") {
if (password) {
form.setValue("password", password);
}
if (accessToken) {
setLoginMethod("accessToken");
form.setValue("accessToken", accessToken);
}
}
if (serverURL) {
if (!serverURL.match(/^(http|https):\/\//)) {
serverURL = prependDefaultProtocol(serverURL);
}
form.setValue("base_url", serverURL, { shouldValidate: true, shouldDirty: true });
const onResolved =
restrictBaseUrlMultiple || restrictBaseUrlSingle
? undefined
: (nextUrl: string) => form.setValue("base_url", nextUrl, { shouldValidate: true, shouldDirty: true });
start(serverURL, onResolved);
}
}, 0);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-time URL-param seeding on mount
}, []);
// Disable inputs when a resolved server will not accept password sign-in —
// either it advertises no password flow, or it asks OIDC-aware clients to
// suppress password (suppressPassword). This mirrors the Sign-in button's
// disabled logic, so the "password isn't available" notice never sits above
// a still-usable field.
// Error states (unreachable/incompatible) keep inputs enabled so the user can
// correct the URL without the fields dropping out of tab order — disabling
// them there would reintroduce a narrower version of the keyboard trap.
const inputsDisabled =
loading || (probeState.tag === "ready" && (!probeState.caps.password || probeState.caps.suppressPassword));
// When the credential fields go disabled, clear any stale required-validation
// errors left from an earlier interaction (e.g. the user focused username,
// blurred it empty, then entered a server that does not accept password) —
// otherwise a greyed-out field keeps showing a red "required" message.
useEffect(() => {
if (inputsDisabled) {
form.clearErrors(["username", "password"]);
}
// form (react-hook-form's methods) is stable in identity; only re-run when
// the disabled state toggles, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputsDisabled]);
const serverVersionText =
probeState.tag === "ready" && probeState.caps.serverVersion
? `${translate("ketesa.auth.server_version")} ${probeState.caps.serverVersion}`
: "";
const matrixVersionsText =
probeState.tag === "ready" && probeState.caps.matrixVersions.length > 0
? `${translate("ketesa.auth.supports_specs")} ${probeState.caps.matrixVersions.join(", ")}`
: "";
// Retain the last advertised flows so the "incompatible" message can animate
// out smoothly — the Collapse keeps its child mounted through the exit
// transition, by which point probeState no longer carries advertisedFlows.
const lastFlowsRef = useRef("");
if (probeState.tag === "incompatible") {
lastFlowsRef.current = probeState.advertisedFlows.join(", ");
}
return (
<>
<Tabs
value={loginMethod}
onChange={(_, newValue) => setLoginMethod(newValue as LoginMethod)}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
>
<Tab label={translate("ketesa.auth.credentials")} value="credentials" />
<Tab label={translate("ketesa.auth.access_token")} value="accessToken" />
</Tabs>
<Box>
{restrictBaseUrlMultiple && (
<SelectInput
source="base_url"
label="ketesa.auth.base_url"
select={true}
autoComplete="url"
fullWidth
{...(loading ? { disabled: true } : {})}
onChange={handleBaseUrlBlurOrChange}
validate={[required(), validateBaseUrl]}
choices={baseUrlChoices}
/>
)}
{!restrictBaseUrlSingle && !restrictBaseUrlMultiple && (
<TextInput
source="base_url"
label="ketesa.auth.base_url"
autoComplete="url"
fullWidth
{...(loading ? { disabled: true } : {})}
resettable={true}
validate={[required(), validateBaseUrl]}
onBlur={handleBaseUrlBlurOrChange}
/>
)}
</Box>
{/* One persistent aria-live region wraps the animated status messages, so a
screen reader announces every probe-state change — including the same
error twice in a row — reliably. Per-message role/aria-live is dropped
to avoid nested live regions reading the text twice. */}
<Box aria-live="polite">
<Collapse in={probeState.tag === "resolving"} unmountOnExit>
<Typography className="serverState" color="text.secondary" sx={{ wordBreak: "break-word" }}>
{translate("ketesa.auth.server_state.resolving")}
</Typography>
</Collapse>
<Collapse in={probeState.tag === "unreachable"} unmountOnExit>
<Typography className="serverState" color="error" sx={{ wordBreak: "break-word" }}>
{translate("ketesa.auth.server_state.unreachable")}
</Typography>
</Collapse>
<Collapse in={probeState.tag === "incompatible"} unmountOnExit>
<Typography className="serverState" color="error" sx={{ wordBreak: "break-word" }}>
{translate("ketesa.auth.server_state.incompatible", { flows: lastFlowsRef.current })}
</Typography>
</Collapse>
<Collapse in={probeState.tag === "ready" && probeState.caps.suppressPassword} unmountOnExit>
<Typography className="serverState" color="text.secondary" sx={{ wordBreak: "break-word" }}>
{translate("ketesa.auth.server_state.suppress_password_notice")}
</Typography>
</Collapse>
</Box>
{loginMethod === "credentials" && (
<>
<Box>
<TextInput
source="username"
label="ra.auth.username"
autoComplete="username"
fullWidth
onBlur={handleUsernameChange}
resettable
validate={required()}
{...(inputsDisabled ? { disabled: true } : {})}
/>
</Box>
<Box>
<PasswordInput
source="password"
label="ra.auth.password"
type="password"
autoComplete="current-password"
fullWidth
{...(inputsDisabled ? { disabled: true } : {})}
resettable
validate={required()}
/>
</Box>
</>
)}
{loginMethod === "accessToken" && (
<Box>
<TextInput
source="accessToken"
label="ketesa.auth.access_token"
fullWidth
{...(loading ? { disabled: true } : {})}
resettable
validate={required()}
/>
</Box>
)}
<Collapse in={!!serverVersionText || !!matrixVersionsText} unmountOnExit>
<Box>
{serverVersionText && (
<Typography className="serverVersion" sx={{ wordBreak: "break-word" }}>
{serverVersionText}
</Typography>
)}
{matrixVersionsText && (
<Typography className="matrixVersions" sx={{ wordBreak: "break-word" }}>
{matrixVersionsText}
</Typography>
)}
</Box>
</Collapse>
</>
);
};
+53
View File
@@ -0,0 +1,53 @@
import { AuthMetadata } from "../../providers/matrix";
/** Which sign-in surface the form is showing. */
export type LoginMethod = "credentials" | "accessToken";
/**
* Capabilities a homeserver advertises, derived from the login flows,
* feature versions, and OIDC auth metadata once a probe resolves.
*
* password and sso are independent: a Synapse + SSO deployment advertises
* both, so they are NOT mutually exclusive (the spec allows their coexistence).
*/
export interface ServerCapabilities {
password: boolean;
sso: boolean;
oidc: boolean;
oidcIssuer: string | null;
/**
* OR of the three "OIDC-aware clients should suppress password" signals:
* org.matrix.msc3824.delegated_oidc_compatibility, delegated_oidc_compatibility,
* and oauth_aware_preferred (Matrix v1.18 stable).
*/
suppressPassword: boolean;
serverVersion: string;
matrixVersions: string[];
authMetadata: AuthMetadata | null;
ssoBaseUrl: string;
}
/**
* The probe lifecycle as a discriminated union. Input visibility is driven by
* loginMethod, never by which tag is active, so the form is never gone from the
* DOM mid-probe (the keyboard-trap fix). Each non-idle tag carries the url it
* describes, for the view layer; staleness is handled in useLoginProbe via the
* AbortController, not here.
*
* incompatible is distinct from unreachable: the server answered cleanly but
* advertises only auth methods Ketesa cannot drive — advertisedFlows carries
* them so the error copy can name them.
*/
export type ProbeState =
| { tag: "idle" }
| { tag: "resolving"; url: string }
| { tag: "ready"; url: string; caps: ServerCapabilities }
| { tag: "incompatible"; url: string; advertisedFlows: string[] }
| { tag: "unreachable"; url: string };
export type ProbeAction =
| { type: "START"; url: string }
| { type: "RESOLVED"; url: string; caps: ServerCapabilities }
| { type: "INCOMPATIBLE"; url: string; advertisedFlows: string[] }
| { type: "UNREACHABLE"; url: string }
| { type: "RESET" };
+48
View File
@@ -0,0 +1,48 @@
/**
* Pure homeserver-URL helpers shared by the login page and its form sections.
* They live in their own module so both can import them without a circular
* dependency through LoginPage.
*/
/**
* Returns true when the issuer string is a well-formed HTTP(S) URL
* with no query string or fragment (per RFC 8414 §2).
* Does not enforce https — that is a deployment policy, not a format rule.
* (MAS rejects http: issuers in production via its own config; local-dev MAS
* runs over http:, so format validation must accept it.)
*/
export const isValidIssuer = (issuer: string): boolean => {
try {
const { protocol, search, hash } = new URL(issuer);
return (protocol === "https:" || protocol === "http:") && search === "" && hash === "";
} catch {
return false;
}
};
/**
* Pick the default protocol for a homeserver the user typed without one:
* http for localhost / loopback, https for everything else.
*/
export const getDefaultProtocolForHomeserverInput = (value: string): "http" | "https" => {
const normalizedValue = value.trim().replace(/\/+$/g, "");
if (
/^(localhost|127\.0\.0\.1)(:\d{1,5})?$/i.test(normalizedValue) ||
/^::1$/i.test(normalizedValue) ||
/^\[::1\](:\d{1,5})?$/i.test(normalizedValue)
) {
return "http";
}
return "https";
};
/** Prepend the default protocol when the user typed a bare host. */
export const prependDefaultProtocol = (value: string): string => {
if (value.match(/^https?:\/\//)) {
return value;
}
return `${getDefaultProtocolForHomeserverInput(value)}://${value}`;
};
+210
View File
@@ -0,0 +1,210 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import { getServerVersion } from "../../providers/data/synapse";
import {
AuthMetadata,
getAuthMetadata,
getSupportedFeatures,
getSupportedLoginFlows,
isValidBaseUrl,
resolveBaseUrlWithWellKnown,
} from "../../providers/matrix";
import { GetConfig, SetExternalAuthProvider } from "../../utils/config";
import createLogger from "../../utils/logger";
import { ProbeAction, ProbeState, ServerCapabilities } from "./types";
import { isValidIssuer } from "./urls";
const log = createLogger("login-probe");
/** Per-flow flags that mark a server as delegating auth to OIDC. */
const OIDC_DELEGATION_FLAGS = ["org.matrix.msc3824.delegated_oidc_compatibility", "delegated_oidc_compatibility"];
/** Per-flow flags that ask OIDC-aware clients to suppress password sign-in (v1.18 adds oauth_aware_preferred). */
const SUPPRESS_PASSWORD_FLAGS = [...OIDC_DELEGATION_FLAGS, "oauth_aware_preferred"];
/** A login flow object with its type plus arbitrary advertised flags. */
interface LoginFlow {
type: string;
[key: string]: unknown;
}
/**
* Staleness is handled in start() via the AbortController signal, so the reducer
* only transitions on the action tag. START carries the raw url (for instant
* "resolving" feedback) and RESOLVED carries the well-known-resolved url, so the
* two urls legitimately differ — a url-matching guard here would reject the
* resolved result.
*/
function probeReducer(state: ProbeState, action: ProbeAction): ProbeState {
switch (action.type) {
case "START":
return { tag: "resolving", url: action.url };
case "RESOLVED":
return { tag: "ready", url: action.url, caps: action.caps };
case "INCOMPATIBLE":
return { tag: "incompatible", url: action.url, advertisedFlows: action.advertisedFlows };
case "UNREACHABLE":
return { tag: "unreachable", url: action.url };
case "RESET":
return { tag: "idle" };
default:
return state;
}
}
/**
* Derive the advertised capabilities from a resolved probe. password, sso, and
* oidc are computed independently — a Synapse + SSO deployment advertises both
* password and sso, and the spec allows their coexistence.
*
* OIDC counts as usable only with a present, well-formed issuer: a delegated-OIDC
* signal pointing at a missing or malformed issuer is a misconfigured server, not
* a flow we can drive (handleOIDC needs the auth metadata).
*/
function deriveCapabilities(
url: string,
flows: LoginFlow[],
authMetadata: AuthMetadata | null,
serverVersion: string,
matrixVersions: string[]
): ServerCapabilities {
const password = flows.some(f => f.type === "m.login.password");
const sso = flows.some(f => f.type === "m.login.sso");
const hasDelegatedOIDC = flows.some(f => f.type === "m.login.sso" && OIDC_DELEGATION_FLAGS.some(flag => !!f[flag]));
const suppressPassword = flows.some(f => f.type === "m.login.sso" && SUPPRESS_PASSWORD_FLAGS.some(flag => !!f[flag]));
const oidcUsable =
(hasDelegatedOIDC || authMetadata?.issuer != null) && authMetadata != null && isValidIssuer(authMetadata.issuer);
const meta = oidcUsable && authMetadata ? authMetadata : null;
return {
password,
sso,
oidc: oidcUsable,
oidcIssuer: meta ? meta.issuer : null,
suppressPassword,
serverVersion,
matrixVersions,
authMetadata: meta,
ssoBaseUrl: sso ? url : "",
};
}
export interface UseLoginProbe {
state: ProbeState;
/**
* Probe a homeserver. rawUrl is resolved via well-known (when enabled); if the
* resolved url differs, onResolved is called so the caller can sync its form
* field. A previous in-flight probe is aborted first.
*/
start: (rawUrl: string, onResolved?: (resolvedUrl: string) => void) => void;
/** Cancel any in-flight probe and return to idle. */
abort: () => void;
}
/**
* Owns the homeserver-probe lifecycle: one AbortController-managed probe at a
* time, the result reduced into a ProbeState the login form renders from. Input
* visibility never depends on probe resolution timing, which is what fixes the
* keyboard trap. An optional initialUrl fires one probe on mount (replacing the
* previous on-mount effect for restrictBaseUrlSingle / restored base_url).
*/
export function useLoginProbe(initialUrl?: string): UseLoginProbe {
const [state, dispatch] = useReducer(probeReducer, { tag: "idle" });
const controllerRef = useRef<AbortController | null>(null);
const start = useCallback((rawUrl: string, onResolved?: (resolvedUrl: string) => void) => {
if (!rawUrl) {
return;
}
if (!isValidBaseUrl(rawUrl)) {
// Invalid input clears the probe; the form's own validator shows the error.
controllerRef.current?.abort();
controllerRef.current = null;
dispatch({ type: "RESET" });
return;
}
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const { signal } = controller;
dispatch({ type: "START", url: rawUrl });
void (async () => {
let resolvedUrl = rawUrl;
try {
const wellKnownDiscovery = GetConfig().wellKnownDiscovery ?? true;
resolvedUrl = wellKnownDiscovery ? await resolveBaseUrlWithWellKnown(rawUrl, signal) : rawUrl;
if (signal.aborted) {
return;
}
if (resolvedUrl !== rawUrl) {
onResolved?.(resolvedUrl);
}
const [featuresR, flowsR, metaR, versionR] = await Promise.allSettled([
getSupportedFeatures(resolvedUrl, signal),
getSupportedLoginFlows(resolvedUrl, signal),
getAuthMetadata(resolvedUrl, signal),
getServerVersion(resolvedUrl, signal),
]);
if (signal.aborted) {
return;
}
// getAuthMetadata resolves to null on failure rather than rejecting, so
// reachability is judged by the three probes that reject on network error.
const reachable =
featuresR.status === "fulfilled" || flowsR.status === "fulfilled" || versionR.status === "fulfilled";
if (!reachable) {
dispatch({ type: "UNREACHABLE", url: resolvedUrl });
return;
}
const flows: LoginFlow[] = flowsR.status === "fulfilled" && Array.isArray(flowsR.value) ? flowsR.value : [];
const authMetadata = metaR.status === "fulfilled" ? metaR.value : null;
const serverVersion = versionR.status === "fulfilled" ? versionR.value : "";
// features.versions is an untyped server response; Array.isArray guards the shape.
const matrixVersions: string[] =
featuresR.status === "fulfilled" && Array.isArray(featuresR.value?.versions)
? (featuresR.value.versions as string[])
: [];
const caps = deriveCapabilities(resolvedUrl, flows, authMetadata, serverVersion, matrixVersions);
if (!caps.password && !caps.sso && !caps.oidc) {
dispatch({ type: "INCOMPATIBLE", url: resolvedUrl, advertisedFlows: flows.map(f => f.type) });
return;
}
// Mark whether the deployment authenticates externally so the auth provider
// drives the right path. Set unconditionally (not only when true) so a later
// password-only server is not left stuck on a previous server's OIDC path.
SetExternalAuthProvider(caps.oidc);
dispatch({ type: "RESOLVED", url: resolvedUrl, caps });
} catch (error) {
if (signal.aborted) {
return;
}
log.error("server probe failed", error);
dispatch({ type: "UNREACHABLE", url: resolvedUrl });
}
})();
}, []);
const abort = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
dispatch({ type: "RESET" });
}, []);
useEffect(() => {
if (initialUrl) {
start(initialUrl);
}
return () => controllerRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only probe; start is stable
}, []);
return { state, start, abort };
}
+9
View File
@@ -15,8 +15,17 @@ const common: Record<string, any> = {
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
url_error: "Keine gültige Matrix Server URL",
sso_sign_in: "Anmeldung mit SSO",
oidc_sign_in: "Anmeldung mit OIDC",
credentials: "Anmeldedaten",
access_token: "Zugriffstoken",
server_state: {
resolving: "Serverfähigkeiten werden geprüft…",
unreachable: "Dieser Server ist nicht erreichbar. Bitte überprüfen Sie die URL und versuchen Sie es erneut.",
incompatible: "Dieser Server bietet Anmeldemethoden an, die Ketesa nicht unterstützt: %{flows}",
suppress_password_notice:
"Dieser Server erfordert den OAuth-Ablauf die Anmeldung mit Passwort ist nicht verfügbar.",
checking: "Server wird geprüft…",
},
logout_access_token_dialog: {
title: "Sie verwenden ein bestehendes Matrix-Zugriffstoken.",
content:
+8
View File
@@ -15,8 +15,16 @@ const common = {
protocol_error: "URL has to start with 'http://' or 'https://'",
url_error: "Not a valid Matrix server URL",
sso_sign_in: "Sign in with SSO",
oidc_sign_in: "Sign in with OIDC",
credentials: "Credentials",
access_token: "Access token",
server_state: {
resolving: "Checking server capabilities…",
unreachable: "Couldn't reach this server. Check the URL and try again.",
incompatible: "This server advertises sign-in methods Ketesa doesn't support: %{flows}",
suppress_password_notice: "This server requires the OAuth flow — password sign-in isn't available.",
checking: "Checking server…",
},
logout_access_token_dialog: {
title: "You are using an existing Matrix access token.",
content:
+8
View File
@@ -14,8 +14,16 @@ const common: Record<string, any> = {
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
url_error: "آدرس وارد شده یک سرور معتبر نیست",
sso_sign_in: "با SSO وارد شوید",
oidc_sign_in: "با OIDC وارد شوید",
credentials: "اعتبارنامه",
access_token: "توکن دسترسی",
server_state: {
resolving: "در حال بررسی قابلیت‌های سرور…",
unreachable: "امکان دسترسی به این سرور وجود ندارد. URL را بررسی کنید و دوباره تلاش کنید.",
incompatible: "این سرور روش‌های ورودی را اعلام می‌کند که Ketesa پشتیبانی نمی‌کند: %{flows}",
suppress_password_notice: "این سرور نیازمند فرایند OAuth است — ورود با رمز عبور در دسترس نیست.",
checking: "در حال بررسی سرور…",
},
supports_specs: "پشتیبانی از مشخصات Matrix",
logout_access_token_dialog: {
title: "شما در حال استفاده از یک توکن دسترسی موجود Matrix هستید.",
+9
View File
@@ -14,8 +14,17 @@ const common: Record<string, any> = {
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
url_error: "L'URL du serveur Matrix n'est pas valide",
sso_sign_in: "Se connecter avec l'authentification unique",
oidc_sign_in: "Se connecter avec OIDC",
credentials: "Identifiants",
access_token: "Token d'accès",
server_state: {
resolving: "Vérification des capacités du serveur…",
unreachable: "Impossible de joindre ce serveur. Vérifiez l'URL et réessayez.",
incompatible: "Ce serveur propose des méthodes de connexion que Ketesa ne prend pas en charge : %{flows}",
suppress_password_notice:
"Ce serveur requiert le flux OAuth — la connexion par mot de passe n'est pas disponible.",
checking: "Vérification du serveur…",
},
supports_specs: "prend en charge les spécifications Matrix",
logout_access_token_dialog: {
title: "Vous utilisez un token d'accès Matrix existant.",
+9 -1
View File
@@ -13,9 +13,17 @@ const common: Record<string, any> = {
username_error: "Per favore inserisca un ID utente completo: '@utente:dominio'",
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
url_error: "URL del server Matrix non valido",
sso_sign_in: "Accedi con SSO",
sso_sign_in: "Acceda con SSO",
oidc_sign_in: "Acceda con OIDC",
credentials: "Credenziali",
access_token: "Token di accesso",
server_state: {
resolving: "Verifica delle funzionalità del server in corso…",
unreachable: "Impossibile raggiungere questo server. Verifichi l'URL e riprovi.",
incompatible: "Questo server pubblicizza metodi di accesso non supportati da Ketesa: %{flows}",
suppress_password_notice: "Questo server richiede il flusso OAuth — l'accesso con password non è disponibile.",
checking: "Verifica del server in corso…",
},
supports_specs: "supporta le specifiche Matrix",
logout_access_token_dialog: {
title: "Sta utilizzando un token di accesso Matrix esistente.",
+9
View File
@@ -15,8 +15,17 @@ const common: Record<string, any> = {
protocol_error: "URLの先頭には「http://」または「https://」を置いてください",
url_error: "正しいMatrixのサーバーのURLではありません",
sso_sign_in: "シングルサインオン",
oidc_sign_in: "OIDCでサインイン",
credentials: "認証情報",
access_token: "アクセストークン",
server_state: {
resolving: "サーバーの機能を確認しています…",
unreachable: "このサーバーに接続できませんでした。URLを確認してもう一度お試しください。",
incompatible: "このサーバーは、Ketesaが対応していないサインイン方式を提供しています: %{flows}",
suppress_password_notice:
"このサーバーはOAuthフローが必要なため、パスワードでのサインインはご利用いただけません。",
checking: "サーバーを確認しています…",
},
logout_access_token_dialog: {
title: "既存のMatrixアクセストークンが使われています。",
content:
+9
View File
@@ -15,8 +15,17 @@ const common: Record<string, any> = {
protocol_error: "O URL deve começar com 'http://' ou 'https://'",
url_error: "URL de servidor Matrix inválido",
sso_sign_in: "Entrar com SSO",
oidc_sign_in: "Entrar com OIDC",
credentials: "Credenciais",
access_token: "Token de acesso",
server_state: {
resolving: "A verificar as capacidades do servidor…",
unreachable: "Não foi possível contactar este servidor. Verifique o URL e tente novamente.",
incompatible: "Este servidor anuncia métodos de autenticação que o Ketesa não suporta: %{flows}",
suppress_password_notice:
"Este servidor requer o fluxo OAuth — a autenticação por palavra-passe não está disponível.",
checking: "A verificar o servidor…",
},
logout_access_token_dialog: {
title: "Está a utilizar um token de acesso Matrix existente.",
content:
+8
View File
@@ -15,8 +15,16 @@ const common: Record<string, any> = {
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
url_error: "Неверный адрес сервера Matrix",
sso_sign_in: "Вход через SSO",
oidc_sign_in: "Вход через OIDC",
credentials: "Учетные данные",
access_token: "Токен доступа",
server_state: {
resolving: "Проверка возможностей сервера…",
unreachable: "Не удалось подключиться к серверу. Проверьте URL и попробуйте снова.",
incompatible: "Этот сервер предлагает методы входа, которые Ketesa не поддерживает: %{flows}",
suppress_password_notice: "Этот сервер требует входа через OAuth — вход по паролю недоступен.",
checking: "Проверка сервера…",
},
logout_access_token_dialog: {
title: "Вы используете существующий токен доступа Matrix.",
content:
+8
View File
@@ -12,8 +12,16 @@ export interface SynapseTranslationMessages extends TranslationMessages {
protocol_error: string;
url_error: string;
sso_sign_in: string;
oidc_sign_in: string;
credentials: string;
access_token: string;
server_state: {
resolving: string;
unreachable: string;
incompatible: string;
suppress_password_notice: string;
checking: string;
};
logout_access_token_dialog: {
title: string;
content: string;
+8
View File
@@ -15,8 +15,16 @@ const common: Record<string, any> = {
protocol_error: "URL повинен починатися з 'http://' або 'https://'",
url_error: "Недійсна URL-адреса сервера Matrix",
sso_sign_in: "Вхід через SSO",
oidc_sign_in: "Вхід через OIDC",
credentials: "Облікові дані",
access_token: "Токен доступу",
server_state: {
resolving: "Перевірка можливостей сервера…",
unreachable: "Не вдалося зʼєднатися з цим сервером. Перевірте URL і спробуйте ще раз.",
incompatible: "Цей сервер пропонує методи входу, які Ketesa не підтримує: %{flows}",
suppress_password_notice: "Цей сервер вимагає потоку OAuth — вхід за паролем недоступний.",
checking: "Перевірка сервера…",
},
logout_access_token_dialog: {
title: "Ви використовуєте існуючий токен доступу Matrix.",
content:
+8
View File
@@ -14,8 +14,16 @@ const common: Record<string, any> = {
protocol_error: "URL 需要以'http://'或'https://'作为起始",
url_error: "不是一个有效的 Matrix 服务器地址",
sso_sign_in: "使用 SSO 登录",
oidc_sign_in: "使用 OIDC 登录",
credentials: "凭证",
access_token: "访问令牌",
server_state: {
resolving: "正在检查服务器功能…",
unreachable: "无法连接到此服务器。请检查 URL 后重试。",
incompatible: "此服务器公布了 Ketesa 不支持的登录方式:%{flows}",
suppress_password_notice: "此服务器需要使用 OAuth 流程登录,密码登录不可用。",
checking: "正在检查服务器…",
},
supports_specs: "支持 Matrix 规范",
logout_access_token_dialog: {
title: "您正在使用现有的 Matrix 访问令牌。",
+232 -50
View File
@@ -1,13 +1,79 @@
import { act, render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import polyglotI18nProvider from "ra-i18n-polyglot";
import { AdminContext } from "react-admin";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import LoginPage, { getDefaultProtocolForHomeserverInput, isValidIssuer } from "./LoginPage";
import LoginPage from "./LoginPage";
import { getDefaultProtocolForHomeserverInput, isValidIssuer } from "../components/login/urls";
import { AppContext } from "../Context";
import englishMessages from "../i18n/en";
import { getServerVersion } from "../providers/data/synapse";
import { getAuthMetadata, getSupportedFeatures, getSupportedLoginFlows } from "../providers/matrix";
// Mock only the network-touching probe functions; keep the rest of each module
// (isValidBaseUrl, splitMxid, etc.) intact for the components that use them.
vi.mock("../providers/matrix", async importOriginal => {
const actual = await importOriginal<typeof import("../providers/matrix")>();
return {
...actual,
resolveBaseUrlWithWellKnown: vi.fn(async (url: string) => url),
getSupportedFeatures: vi.fn(),
getSupportedLoginFlows: vi.fn(),
getAuthMetadata: vi.fn(),
};
});
vi.mock("../providers/data/synapse", async importOriginal => {
const actual = await importOriginal<typeof import("../providers/data/synapse")>();
return {
...actual,
getServerVersion: vi.fn(),
};
});
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
const welcomeText = englishMessages.ketesa.auth.welcome.replace("%{name}", "Ketesa");
const auth = englishMessages.ketesa.auth;
const signIn = englishMessages.ra.auth.sign_in;
const mockFeatures = getSupportedFeatures as Mock;
const mockFlows = getSupportedLoginFlows as Mock;
const mockAuthMetadata = getAuthMetadata as Mock;
const mockServerVersion = getServerVersion as Mock;
beforeEach(() => {
vi.clearAllMocks();
// Default: a reachable password-only server.
mockFeatures.mockResolvedValue({ versions: ["v1.11"] });
mockFlows.mockResolvedValue([{ type: "m.login.password" }]);
mockAuthMetadata.mockResolvedValue(null);
mockServerVersion.mockResolvedValue("1.100.0");
});
const renderSingleRestrict = (url = "https://matrix.example.com") =>
render(
<AppContext.Provider
value={{
restrictBaseUrl: url,
asManagedUsers: [],
menu: [],
corsCredentials: "include",
externalAuthProvider: false,
}}
>
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
const renderUnrestricted = () =>
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
describe("isValidIssuer", () => {
it.each([
@@ -35,7 +101,7 @@ describe("isValidIssuer", () => {
});
});
describe("LoginForm", () => {
describe("getDefaultProtocolForHomeserverInput", () => {
it.each([
["localhost", "http"],
["localhost:8008", "http"],
@@ -48,57 +114,48 @@ describe("LoginForm", () => {
])("selects %s for %s homeserver inputs", (input, expectedProtocol) => {
expect(getDefaultProtocolForHomeserverInput(input)).toBe(expectedProtocol);
});
});
it("renders with no restriction to homeserver", async () => {
await act(async () => {
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
});
describe("LoginPage rendering", () => {
it("renders with no restriction to homeserver", () => {
renderUnrestricted();
screen.getByText(welcomeText);
screen.getByRole("combobox", { name: "" }); // Language selector
// Base URL input should be visible and editable
const baseUrlInput = screen.getByRole("textbox", {
name: englishMessages.ketesa.auth.base_url,
});
const baseUrlInput = screen.getByRole("textbox", { name: auth.base_url });
expect(baseUrlInput.className.split(" ")).not.toContain("Mui-readOnly");
// Username and password fields are not visible until server info is checked
// and supportPassAuth is determined
});
it("renders with single restricted homeserver", () => {
render(
<AppContext.Provider
value={{
restrictBaseUrl: "https://matrix.example.com",
asManagedUsers: [],
menu: [],
corsCredentials: "include",
externalAuthProvider: false,
}}
>
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
it("renders the username/password fields immediately, before any probe (keyboard-trap fix)", () => {
// The inputs must be in the DOM regardless of probe state — that is the
// WCAG 2.1.2 keyboard-trap fix. They are no longer gated on an async result.
renderUnrestricted();
expect(screen.getByLabelText(/username/i)).toBeEnabled();
expect(screen.getByLabelText(/password/i, { selector: "input" })).toBeEnabled();
});
it("Tab from the homeserver URL reaches the username field (no focus trap)", async () => {
const user = userEvent.setup();
renderUnrestricted();
const baseUrl = screen.getByRole("textbox", { name: auth.base_url });
baseUrl.focus();
expect(baseUrl).toHaveFocus();
await user.tab();
expect(screen.getByLabelText(/username/i)).toHaveFocus();
});
it("renders with single restricted homeserver", async () => {
renderSingleRestrict();
screen.getByText(welcomeText);
screen.getByRole("combobox", { name: "" }); // Language selector
// Base URL field should not be visible when single restricted homeserver is set
expect(() =>
screen.getByRole("textbox", {
name: englishMessages.ketesa.auth.base_url,
})
).toThrow();
// Username and password fields are not visible until server info is checked
// and supportPassAuth is determined
// Base URL field is hidden for a single fixed homeserver.
expect(() => screen.getByRole("textbox", { name: auth.base_url })).toThrow();
});
it("renders with multiple restricted homeservers", async () => {
it("renders the base URL combobox for multiple restricted homeservers", () => {
render(
<AppContext.Provider
value={{
@@ -116,12 +173,137 @@ describe("LoginForm", () => {
);
screen.getByText(welcomeText);
screen.getByRole("combobox", { name: "" }); // Language selector
// Base URL field should be visible as a combobox when multiple restricted homeservers are set
screen.getByRole("combobox", {
name: englishMessages.ketesa.auth.base_url,
});
// Username and password fields are not visible until server info is checked
// and supportPassAuth is determined
screen.getByRole("combobox", { name: auth.base_url });
});
});
describe("LoginPage server probe — capability matrix", () => {
it("password-only server: password Sign-in shown, no SSO/OIDC", async () => {
mockFlows.mockResolvedValue([{ type: "m.login.password" }]);
renderSingleRestrict();
await waitFor(() => expect(screen.getByRole("button", { name: signIn })).toBeEnabled());
expect(screen.queryByRole("button", { name: auth.sso_sign_in })).toBeNull();
expect(screen.queryByRole("button", { name: auth.oidc_sign_in })).toBeNull();
});
it("password + SSO server: both password Sign-in and SSO button shown", async () => {
mockFlows.mockResolvedValue([{ type: "m.login.password" }, { type: "m.login.sso" }]);
renderSingleRestrict();
expect(await screen.findByRole("button", { name: auth.sso_sign_in })).toBeEnabled();
expect(screen.getByRole("button", { name: signIn })).toBeEnabled();
});
it("SSO with oauth_aware_preferred + valid issuer (suppress password): OIDC button + notice, no password Sign-in", async () => {
mockFlows.mockResolvedValue([{ type: "m.login.sso", oauth_aware_preferred: true }]);
mockAuthMetadata.mockResolvedValue({
issuer: "https://mas.example.com",
authorization_endpoint: "https://mas.example.com/authorize",
token_endpoint: "https://mas.example.com/token",
});
renderSingleRestrict();
expect(await screen.findByRole("button", { name: auth.oidc_sign_in })).toBeEnabled();
expect(screen.getByText(auth.server_state.suppress_password_notice)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: signIn })).toBeNull();
});
it("suppress_password without a usable issuer: notice shown, but NO OIDC button (no broken flow)", async () => {
// A server can advertise oauth_aware_preferred without a valid auth_metadata
// issuer (misconfigured or malicious). The OIDC button must NOT render — it
// would fire handleOIDC with a null issuer. Only caps.oidc (valid issuer) shows it.
mockFlows.mockResolvedValue([{ type: "m.login.sso", oauth_aware_preferred: true }]);
mockAuthMetadata.mockResolvedValue(null);
renderSingleRestrict();
expect(await screen.findByText(auth.server_state.suppress_password_notice)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: auth.oidc_sign_in })).toBeNull();
});
it("OIDC-only server (no flows, auth_metadata issuer present): OIDC button, no password Sign-in", async () => {
mockFlows.mockResolvedValue([]);
mockAuthMetadata.mockResolvedValue({
issuer: "https://mas.example.com",
authorization_endpoint: "https://mas.example.com/authorize",
token_endpoint: "https://mas.example.com/token",
});
renderSingleRestrict();
expect(await screen.findByRole("button", { name: auth.oidc_sign_in })).toBeEnabled();
expect(screen.queryByRole("button", { name: signIn })).toBeNull();
});
it("incompatible server (reachable, no usable auth): shows the incompatible notice naming flows", async () => {
mockFlows.mockResolvedValue([{ type: "m.login.token" }]);
mockAuthMetadata.mockResolvedValue(null);
renderSingleRestrict();
const notice = await screen.findByText(/doesn't support/i);
expect(notice).toHaveTextContent("m.login.token");
});
it("unreachable server (all probes reject): shows the unreachable notice", async () => {
mockFeatures.mockRejectedValue(new Error("network"));
mockFlows.mockRejectedValue(new Error("network"));
mockAuthMetadata.mockResolvedValue(null);
mockServerVersion.mockRejectedValue(new Error("network"));
renderSingleRestrict();
expect(await screen.findByText(auth.server_state.unreachable)).toBeInTheDocument();
});
});
describe("LoginPage server probe — resolving state", () => {
it("keeps inputs enabled and disables Sign-in (with 'Checking server…') while resolving", async () => {
// Never-resolving probes keep the form in the resolving state.
mockFeatures.mockReturnValue(new Promise(() => undefined));
mockFlows.mockReturnValue(new Promise(() => undefined));
mockAuthMetadata.mockReturnValue(new Promise(() => undefined));
mockServerVersion.mockReturnValue(new Promise(() => undefined));
renderSingleRestrict();
const checking = await screen.findByRole("button", { name: auth.server_state.checking });
expect(checking).toBeDisabled();
// Inputs remain enabled during resolving (autofill compatibility + no trap).
expect(screen.getByLabelText(/username/i)).toBeEnabled();
expect(screen.getByLabelText(/password/i, { selector: "input" })).toBeEnabled();
});
});
describe("LoginPage server probe — staleness guard", () => {
it("a superseded probe never overwrites the fresh one (last URL wins)", async () => {
const user = userEvent.setup();
// url1's other three probes (features, authMetadata, serverVersion) resolve immediately via
// the beforeEach defaults; only url1's flows is held open below. So url1's Promise.allSettled
// DOES settle when we release resolveStaleFlows — and the signal.aborted guard in start() is
// the sole reason url1's late result is discarded. Remove that guard and this test fails.
let resolveStaleFlows: (value: unknown) => void = () => undefined;
mockFlows
.mockImplementationOnce(() => new Promise(resolve => (resolveStaleFlows = resolve))) // url1 — held open
.mockResolvedValue([{ type: "m.login.sso" }]); // url2 — resolves immediately to SSO-only
mockAuthMetadata.mockResolvedValueOnce(null).mockResolvedValue({
issuer: "https://mas.example.com",
authorization_endpoint: "https://mas.example.com/authorize",
token_endpoint: "https://mas.example.com/token",
});
renderUnrestricted();
const baseUrl = screen.getByRole("textbox", { name: auth.base_url });
await user.type(baseUrl, "https://stale.example.com");
await user.tab(); // start(url1) — flows held pending
await user.clear(baseUrl);
await user.type(baseUrl, "https://fresh.example.com");
await user.tab(); // start(url2) — aborts url1, resolves to SSO
// url2 wins: SSO button appears, no password Sign-in.
expect(await screen.findByRole("button", { name: auth.sso_sign_in })).toBeInTheDocument();
// Now let url1 resolve late with password — it must be discarded (signal aborted).
resolveStaleFlows([{ type: "m.login.password" }]);
await new Promise(resolve => queueMicrotask(() => resolve(null)));
expect(screen.getByRole("button", { name: auth.sso_sign_in })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: signIn })).toBeNull();
});
});
+38 -448
View File
@@ -1,55 +1,29 @@
import TranslateIcon from "@mui/icons-material/Translate";
import {
Avatar,
Box,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
Tab,
Tabs,
Typography,
} from "@mui/material";
import { useState, useEffect, useRef } from "react";
import { Avatar, Box, Card, CircularProgress, MenuItem, Select } from "@mui/material";
import { useState, useEffect } from "react";
import {
Form,
FormDataConsumer,
Notification,
required,
useLogin,
useNotify,
useLocaleState,
useTranslate,
PasswordInput,
TextInput,
SelectInput,
useLocales,
} from "react-admin";
import { useFormContext } from "react-hook-form";
import { useAppContext } from "../Context";
import { EtkeAttribution } from "../components/etke.cc/EtkeAttribution";
import { useInstanceConfig } from "../components/etke.cc/InstanceConfig";
import { getServerVersion } from "../providers/data/synapse";
import {
getSupportedFeatures,
getWellKnownUrl,
isValidBaseUrl,
splitMxid,
getSupportedLoginFlows,
getAuthMetadata,
resolveBaseUrlWithWellKnown,
} from "../providers/matrix";
import { GetConfig, SetExternalAuthProvider } from "../utils/config";
import createLogger from "../utils/logger";
import { Footer, LoginFormBox } from "../components/layout";
import { LoginButtons } from "../components/login/LoginButtons";
import { LoginFormSections } from "../components/login/LoginFormSections";
import { LoginMethod } from "../components/login/types";
import { useLoginProbe } from "../components/login/useLoginProbe";
import createLogger from "../utils/logger";
const log = createLogger("login");
export type LoginMethod = "credentials" | "accessToken";
/**
* Get restricted base URL(s) from app context
* @returns tuple of (single URL or null, array of URLs or null)
@@ -94,47 +68,10 @@ function useRestrictedBaseUrl(): [string | null, string[] | null] {
return [null, null];
}
export const getDefaultProtocolForHomeserverInput = (value: string): "http" | "https" => {
const normalizedValue = value.trim().replace(/\/+$/g, "");
if (
/^(localhost|127\.0\.0\.1)(:\d{1,5})?$/i.test(normalizedValue) ||
/^::1$/i.test(normalizedValue) ||
/^\[::1\](:\d{1,5})?$/i.test(normalizedValue)
) {
return "http";
}
return "https";
};
const prependDefaultProtocol = (value: string): string => {
if (value.match(/^https?:\/\//)) {
return value;
}
return `${getDefaultProtocolForHomeserverInput(value)}://${value}`;
};
/**
* Returns true when the issuer string is a well-formed HTTP(S) URL
* with no query string or fragment (per RFC 8414 §2).
* Does not enforce https — that is a deployment policy, not a format rule.
*/
export const isValidIssuer = (issuer: string): boolean => {
try {
const { protocol, search, hash } = new URL(issuer);
return (protocol === "https:" || protocol === "http:") && search === "" && hash === "";
} catch {
return false;
}
};
const LoginPage = () => {
const login = useLogin();
const notify = useNotify();
const [restrictBaseUrlSingle, restrictBaseUrlMultiple] = useRestrictedBaseUrl();
const wellKnownDiscovery = GetConfig().wellKnownDiscovery ?? true;
const baseUrlChoices = restrictBaseUrlMultiple ? restrictBaseUrlMultiple : [];
const localStorageBaseUrl = localStorage.getItem("base_url");
let base_url = restrictBaseUrlSingle
@@ -149,29 +86,15 @@ const LoginPage = () => {
}
}
const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(false);
const [locale, setLocale] = useLocaleState();
const locales = useLocales();
const translate = useTranslate();
const hasInitializedUrlParams = useRef(false);
const [authMetadata, setAuthMetadata] = useState({});
const [oidcVisible, setOIDCVisible] = useState(true);
const [oidcUrl, setOIDCUrl] = useState("");
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const [baseUrl, setBaseUrl] = useState(base_url || "");
const [resolvedBaseUrl, setResolvedBaseUrl] = useState(base_url || "");
const loginToken = new URLSearchParams(window.location.search).get("loginToken");
const [loginMethod, setLoginMethod] = useState<LoginMethod>("credentials");
const [serverVersion, setServerVersion] = useState("");
const [matrixVersions, setMatrixVersions] = useState("");
const loginToken = new URLSearchParams(window.location.search).get("loginToken");
const initialBaseUrl = useRef(base_url);
useEffect(() => {
if (initialBaseUrl.current) {
resolveAndCheckServerInfo(initialBaseUrl.current as string);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- resolveAndCheckServerInfo is stable within the mount
// base_url, when present, seeds one probe on mount (restrictBaseUrlSingle or a
// restored localStorage value). The probe lifecycle lives entirely in the hook.
const { state: probeState, start } = useLoginProbe(base_url || undefined);
useEffect(() => {
if (!loginToken) {
@@ -204,24 +127,20 @@ const LoginPage = () => {
}
}, [loginToken, login]);
const validateBaseUrl = (value: string) => {
if (!value.match(/^(https?):\/\//)) {
return translate("ketesa.auth.protocol_error");
} else if (!isValidBaseUrl(value)) {
return translate("ketesa.auth.url_error");
} else {
return undefined;
}
};
const handleSubmit = auth => {
const handleSubmit = (auth: { base_url?: string; [key: string]: unknown }) => {
setLoading(true);
const cleanUrl = window.location.href.replace(window.location.search, "");
window.history.replaceState({}, "", cleanUrl);
// Strip the query string (mirrors the loginToken handler's URL handling):
// String.replace would mangle the URL if the search string recurred elsewhere.
const cleanUrl = new URL(window.location.toString());
cleanUrl.search = "";
window.history.replaceState({}, "", cleanUrl.toString());
// When a probe has resolved, submit against its well-known-resolved url;
// otherwise fall through to whatever the form holds.
const resolvedBaseUrl = probeState.tag === "ready" ? probeState.url : auth.base_url;
const authWithResolved = {
...auth,
base_url: resolvedBaseUrl || auth.base_url,
base_url: resolvedBaseUrl,
};
login(authWithResolved).catch(error => {
@@ -237,125 +156,6 @@ const LoginPage = () => {
});
};
const handleSSO = () => {
localStorage.setItem("sso_base_url", ssoBaseUrl);
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(
window.location.href
)}`;
window.location.href = ssoFullUrl;
};
const handleOIDC = () => {
log.debug("OIDC login initiated", { baseUrl });
login({
base_url: baseUrl,
clientUrl: window.location.origin + window.location.pathname,
authMetadata: authMetadata,
});
};
const checkServerInfo = async (url: string) => {
if (!isValidBaseUrl(url)) {
setServerVersion("");
setMatrixVersions("");
setOIDCUrl("");
setBaseUrl("");
setResolvedBaseUrl("");
setSupportPassAuth(false);
return;
}
try {
const serverVersion = await getServerVersion(url);
setServerVersion(`${translate("ketesa.auth.server_version")} ${serverVersion}`);
} catch {
setServerVersion("");
}
try {
const features = await getSupportedFeatures(url);
setMatrixVersions(`${translate("ketesa.auth.supports_specs")} ${features.versions.join(", ")}`);
} catch {
setMatrixVersions("");
}
// Probe login flows and auth_metadata in parallel.
// auth_metadata (/_matrix/client/v1/auth_metadata) works even when /v3/login is disabled by MAS.
const [loginFlowsResult, authMetadataResult] = await Promise.allSettled([
getSupportedLoginFlows(url),
getAuthMetadata(url),
]);
const loginFlows = loginFlowsResult.status === "fulfilled" ? loginFlowsResult.value : [];
const authMetadata = authMetadataResult.status === "fulfilled" ? authMetadataResult.value : null;
const supportPass = loginFlows.find(f => f.type === "m.login.password") !== undefined;
const supportSSO = loginFlows.find(f => f.type === "m.login.sso") !== undefined;
const hasDelegatedOIDC =
supportSSO &&
!!loginFlows.find(
f =>
f.type === "m.login.sso" &&
(f["org.matrix.msc3824.delegated_oidc_compatibility"] || f["delegated_oidc_compatibility"])
);
// Either the MSC3824 delegated_oidc flag OR a valid auth_metadata issuer triggers the OIDC path.
// MAS servers typically disable /v3/login (returning 404), so auth_metadata is the only signal.
if (hasDelegatedOIDC || authMetadata?.issuer) {
if (!authMetadata || !isValidIssuer(authMetadata.issuer)) {
// auth_metadata missing or issuer has an unsupported scheme — server misconfigured
setSupportPassAuth(false);
setSSOBaseUrl("");
setOIDCUrl("");
setOIDCVisible(false);
setBaseUrl("");
setResolvedBaseUrl("");
return;
}
setBaseUrl(url);
setResolvedBaseUrl(url);
SetExternalAuthProvider(true);
setSSOBaseUrl("");
setAuthMetadata(authMetadata);
setOIDCUrl(authMetadata.issuer);
setSupportPassAuth(false);
} else if (loginFlows.length > 0) {
// Standard login flows — no delegated OIDC
setBaseUrl(url);
setResolvedBaseUrl(url);
setSupportPassAuth(supportPass);
setSSOBaseUrl(supportSSO ? url : "");
setOIDCVisible(false);
setOIDCUrl("");
setAuthMetadata({});
} else {
// Both probes failed — unknown or unreachable server
setSupportPassAuth(false);
setSSOBaseUrl("");
setOIDCUrl("");
setOIDCVisible(false);
setBaseUrl("");
setResolvedBaseUrl("");
}
};
const resolveAndCheckServerInfo = async (url: string, updateFormValue?: (nextUrl: string) => void) => {
if (!url) {
return;
}
if (!isValidBaseUrl(url)) {
checkServerInfo(url);
return;
}
const resolvedUrl = wellKnownDiscovery ? await resolveBaseUrlWithWellKnown(url) : url;
if (resolvedUrl !== url && updateFormValue) {
updateFormValue(resolvedUrl);
}
checkServerInfo(resolvedUrl);
};
const icfg = useInstanceConfig();
let welcomeTo = "Ketesa";
let logoUrl = "./images/logo.webp";
@@ -370,196 +170,6 @@ const LoginPage = () => {
backgroundUrl = icfg.background_url;
}
const UserData = ({ formData }) => {
const form = useFormContext();
const handleUsernameChange = async () => {
if (formData.base_url || restrictBaseUrlSingle) {
return;
}
// check if username is a full qualified userId then set base_url accordingly
const domain = splitMxid(formData.username)?.domain;
if (domain) {
const url = wellKnownDiscovery ? await getWellKnownUrl(domain) : `https://${domain}`;
if (!restrictBaseUrlMultiple || restrictBaseUrlMultiple.includes(url)) {
form.setValue("base_url", url, {
shouldValidate: true,
shouldDirty: true,
});
setResolvedBaseUrl(url);
checkServerInfo(url);
}
}
};
const handleBaseUrlBlurOrChange = event => {
// Get the value either from the event (onChange) or from formData (onBlur)
let value = event?.target?.value || formData.base_url;
if (!value) {
return;
}
if (!value.match(/^https?:\/\//)) {
value = prependDefaultProtocol(value);
if (!restrictBaseUrlMultiple && !restrictBaseUrlSingle) {
form.setValue("base_url", value, {
shouldValidate: true,
shouldDirty: true,
});
}
}
// Trigger validation only when user finishes typing/selecting
form.trigger("base_url");
const updateFormValue =
restrictBaseUrlMultiple || restrictBaseUrlSingle
? undefined
: (nextUrl: string) =>
form.setValue("base_url", nextUrl, {
shouldValidate: true,
shouldDirty: true,
});
resolveAndCheckServerInfo(value, updateFormValue);
};
useEffect(() => {
if (hasInitializedUrlParams.current) return;
hasInitializedUrlParams.current = true;
// Defer to ensure form is initialized
const timer = setTimeout(() => {
const params = new URLSearchParams(window.location.search);
const hostname = window.location.hostname;
const username = params.get("username");
const password = params.get("password");
const accessToken = params.get("accessToken");
let serverURL = params.get("server");
if (username) {
form.setValue("username", username);
}
if (hostname === "localhost" || hostname === "127.0.0.1") {
if (password) {
form.setValue("password", password);
}
if (accessToken) {
setLoginMethod("accessToken");
form.setValue("accessToken", accessToken);
}
}
if (serverURL) {
const isFullUrl = serverURL.match(/^(http|https):\/\//);
if (!isFullUrl) {
serverURL = prependDefaultProtocol(serverURL);
}
form.setValue("base_url", serverURL, {
shouldValidate: true,
shouldDirty: true,
});
const updateFormValue =
restrictBaseUrlMultiple || restrictBaseUrlSingle
? undefined
: (nextUrl: string) =>
form.setValue("base_url", nextUrl, {
shouldValidate: true,
shouldDirty: true,
});
resolveAndCheckServerInfo(serverURL, updateFormValue);
}
}, 0);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<Tabs
value={loginMethod}
onChange={(_, newValue) => setLoginMethod(newValue as LoginMethod)}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
>
<Tab label={translate("ketesa.auth.credentials")} value="credentials" />
<Tab label={translate("ketesa.auth.access_token")} value="accessToken" />
</Tabs>
<Box>
{restrictBaseUrlMultiple && (
<SelectInput
source="base_url"
label="ketesa.auth.base_url"
select={true}
autoComplete="url"
{...(loading ? { disabled: true } : {})}
onChange={handleBaseUrlBlurOrChange}
validate={[required(), validateBaseUrl]}
choices={baseUrlChoices}
/>
)}
{!restrictBaseUrlSingle && !restrictBaseUrlMultiple && (
<TextInput
source="base_url"
label="ketesa.auth.base_url"
autoComplete="url"
{...(loading ? { disabled: true } : {})}
resettable={true}
validate={[required(), validateBaseUrl]}
onBlur={handleBaseUrlBlurOrChange}
/>
)}
</Box>
{loginMethod === "credentials" && supportPassAuth && (
<>
<Box>
<TextInput
source="username"
label="ra.auth.username"
autoComplete="username"
onBlur={handleUsernameChange}
resettable
validate={required()}
{...(loading || !supportPassAuth ? { disabled: true } : {})}
/>
</Box>
<Box>
<PasswordInput
source="password"
label="ra.auth.password"
type="password"
autoComplete="current-password"
{...(loading || !supportPassAuth ? { disabled: true } : {})}
resettable
validate={required()}
/>
</Box>
</>
)}
{loginMethod === "accessToken" && (
<Box>
<TextInput
source="accessToken"
label="ketesa.auth.access_token"
{...(loading ? { disabled: true } : {})}
resettable
validate={required()}
/>
</Box>
)}
<Typography className="serverVersion" sx={{ wordBreak: "break-word" }}>
{serverVersion}
</Typography>
<Typography className="matrixVersions" sx={{ wordBreak: "break-word" }}>
{matrixVersions}
</Typography>
</>
);
};
return (
<Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onBlur">
<LoginFormBox backgroundUrl={backgroundUrl}>
@@ -573,7 +183,7 @@ const LoginPage = () => {
<Card className="card">
<Box className="avatar">
{loading ? (
<CircularProgress size={25} thickness={2} />
<CircularProgress size={80} thickness={3} />
) : (
<Avatar sx={{ width: { xs: "80px", sm: "120px" }, height: { xs: "80px", sm: "120px" } }} src={logoUrl} />
)}
@@ -594,42 +204,22 @@ const LoginPage = () => {
{translate("ketesa.auth.description")}
</Box>
<Box className="form">
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
{loginMethod === "credentials" && (
<CardActions
className="actions"
sx={{ flexDirection: "column", gap: 1, "& > :not(:first-of-type)": { ml: 0 } }}
>
{supportPassAuth && (
<Button variant="contained" type="submit" color="primary" disabled={loading} fullWidth>
{translate("ra.auth.sign_in")}
</Button>
)}
{ssoBaseUrl !== "" && (
<Button variant="contained" color="secondary" onClick={handleSSO} disabled={loading} fullWidth>
{translate("ketesa.auth.sso_sign_in")}
</Button>
)}
{(oidcVisible || oidcUrl !== "") && (
<Button
variant="contained"
color="secondary"
onClick={handleOIDC}
disabled={loading || oidcUrl === ""}
fullWidth
>
{translate("ra.auth.sign_in")}
</Button>
)}
</CardActions>
)}
{loginMethod === "accessToken" && (
<CardActions className="actions">
<Button variant="contained" type="submit" color="primary" disabled={loading} fullWidth>
{translate("ra.auth.sign_in")}
</Button>
</CardActions>
)}
<FormDataConsumer>
{({ formData }) => (
<LoginFormSections
formData={formData}
probeState={probeState}
loginMethod={loginMethod}
setLoginMethod={setLoginMethod}
loading={loading}
restrictBaseUrlSingle={restrictBaseUrlSingle}
restrictBaseUrlMultiple={restrictBaseUrlMultiple}
baseUrlChoices={baseUrlChoices}
start={start}
/>
)}
</FormDataConsumer>
<LoginButtons probeState={probeState} loginMethod={loginMethod} loading={loading} />
</Box>
<Box
sx={{
+5 -2
View File
@@ -25,8 +25,11 @@ import { normalizeTS } from "../../utils/date";
/**
* Get Synapse server version via /_synapse/admin/v1/server_version
*/
export const getServerVersion = async (baseUrl: string): Promise<string> => {
const response = await fetchUtils.fetchJson(`${baseUrl}/_synapse/admin/v1/server_version`, { method: "GET" });
export const getServerVersion = async (baseUrl: string, signal?: AbortSignal): Promise<string> => {
const response = await fetchUtils.fetchJson(`${baseUrl}/_synapse/admin/v1/server_version`, {
method: "GET",
signal,
});
return response.json.server_version;
};
+10 -10
View File
@@ -22,7 +22,7 @@ export const isValidBaseUrl = (baseUrl: unknown): boolean =>
* Resolve a base URL using /.well-known/matrix/client if present.
* Falls back to the provided URL if lookup fails or is invalid.
*/
export const resolveBaseUrlWithWellKnown = async (baseUrl: string): Promise<string> => {
export const resolveBaseUrlWithWellKnown = async (baseUrl: string, signal?: AbortSignal): Promise<string> => {
if (!baseUrl) return baseUrl;
const cleaned = baseUrl.replace(/\/+$/g, "");
let origin: string;
@@ -34,7 +34,7 @@ export const resolveBaseUrlWithWellKnown = async (baseUrl: string): Promise<stri
const wellKnownUrl = `${origin}/.well-known/matrix/client`;
try {
const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET", signal });
const wkBaseUrl = response.json?.["m.homeserver"]?.base_url;
if (typeof wkBaseUrl === "string" && wkBaseUrl.trim() !== "") {
const resolved = wkBaseUrl.replace(/\/+$/g, "");
@@ -53,10 +53,10 @@ export const resolveBaseUrlWithWellKnown = async (baseUrl: string): Promise<stri
* @param domain the domain part of an MXID
* @returns homeserver base URL
*/
export const getWellKnownUrl = async (domain: string) => {
export const getWellKnownUrl = async (domain: string, signal?: AbortSignal) => {
const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
try {
const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET", signal });
return response.json["m.homeserver"].base_url;
} catch {
// if there is no .well-known entry, return the domain itself
@@ -65,9 +65,9 @@ export const getWellKnownUrl = async (domain: string) => {
};
/** Get supported Matrix features */
export const getSupportedFeatures = async (baseUrl: string) => {
export const getSupportedFeatures = async (baseUrl: string, signal?: AbortSignal) => {
const versionUrl = `${baseUrl}/_matrix/client/versions`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET", signal });
return response.json;
};
@@ -76,20 +76,20 @@ export const getSupportedFeatures = async (baseUrl: string) => {
* @param baseUrl the base URL of the homeserver
* @returns array of supported login flows
*/
export const getSupportedLoginFlows = async (baseUrl: string) => {
export const getSupportedLoginFlows = async (baseUrl: string, signal?: AbortSignal) => {
const loginFlowsUrl = `${baseUrl}/_matrix/client/v3/login`;
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET", signal });
return response.json.flows;
};
export const getAuthMetadata = async (baseUrl: string): Promise<AuthMetadata | null> => {
export const getAuthMetadata = async (baseUrl: string, signal?: AbortSignal): Promise<AuthMetadata | null> => {
const endpoints = [
`${baseUrl}/_matrix/client/v1/auth_metadata`, // stable (Matrix spec v1.14+)
`${baseUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_metadata`, // legacy unstable fallback
];
for (const url of endpoints) {
try {
const response = await fetchUtils.fetchJson(url, { method: "GET" });
const response = await fetchUtils.fetchJson(url, { method: "GET", signal });
if (response.status === 200 && response.json?.issuer) {
return response.json;
}
+3
View File
@@ -117,6 +117,9 @@ export const FetchWellKnownConfig = async () => {
}
log.info("well-known config loaded", { homeserver });
// Well-known config overlays config.json values — including restrictBaseUrl —
// intentionally; well-known is admin-trusted as part of the deployment's
// homeserver config.
LoadConfig(wkConfig);
return true;
} catch (e) {