mirror of
https://github.com/etkecc/synapse-admin.git
synced 2026-06-01 18:57:37 +02:00
refactor login lorm
This commit is contained in:
+35
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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" };
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 هستید.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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={{
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user