fix(auth): allow retrying unavailable second-factor interfaces (#1549)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-03 21:03:13 +08:00
1 parent c66702deb1
commit c9a1348836
6 files changed
+79 -7

No files matched your search

+30 -4
View File
@@ -296,6 +296,15 @@ export function Auth({ onLogin }: AuthProps) {
const [pendingToken, setPendingToken] = useState("");
const [secondFactors, setSecondFactors] = useState<SecondFactorRef[]>([]);
const [activeFactorId, setActiveFactorId] = useState("");
const [authPluginsLoading, setAuthPluginsLoading] = useState(true);
const retryAuthPlugins = async () => {
setAuthPluginsLoading(true);
try {
await startPreLoginPlugins({ retryFailed: true });
} finally {
setAuthPluginsLoading(false);
}
};
const loginMethodUIs = useLoginMethods();
const secondFactorUIs = useSecondFactors();
// A server that did not list its factors gets every factor with a UI here.
@@ -434,7 +443,9 @@ export function Auth({ onLogin }: AuthProps) {
.then((allowed) => setPasswordResetAllowed(allowed))
.catch(() => setPasswordResetAllowed(false));
// Plugins that draw login or second-factor UI load before sign-in.
void startPreLoginPlugins().catch(() => {});
void startPreLoginPlugins()
.catch(() => {})
.finally(() => setAuthPluginsLoading(false));
getLoginMethods()
.then((methods) => setAuthMethods(methods))
.catch(() => setAuthMethods([]))
@@ -1364,9 +1375,24 @@ export function Auth({ onLogin }: AuthProps) {
);
if (!ui) {
return (
<p className="text-xs text-destructive">
{t("auth.secondFactorNoUI")}
</p>
<div className="space-y-2" aria-live="polite">
<p className="text-xs text-muted-foreground">
{t(
authPluginsLoading
? "common.loading"
: "auth.secondFactorNoUI",
)}
</p>
<Button
type="button"
disabled={authPluginsLoading}
onClick={() =>
void retryAuthPlugins().catch(() => {})
}
>
{t("auth.retrySecondFactorUI")}
</Button>
</div>
);
}
const Component = ui.component;
+2 -1
View File
@@ -1060,7 +1060,8 @@
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
"serverAuthentication": "Server sign-in",
"minChars": "min. {{min}} characters"
"minChars": "min. {{min}} characters",
"retrySecondFactorUI": "Reload verification methods"
},
"hostKey": {
"verifyNewHost": "Verify SSH Host Key",
+2 -1
View File
@@ -1058,7 +1058,8 @@
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
"serverAuthentication": "Server sign-in",
"minChars": "min. {{min}} characters"
"minChars": "min. {{min}} characters",
"retrySecondFactorUI": "重新加载验证方式"
},
"hostKey": {
"verifyNewHost": "验证 SSH 主机密钥",
+6 -1
View File
@@ -449,7 +449,9 @@ async function fetchPreLoginPlugins(): Promise<PluginSummary[]> {
* factors) before anyone has signed in. After sign-in the full runtime
* reconciles, keeping these active when their bundle has not changed.
*/
export async function startPreLoginPlugins(): Promise<void> {
export async function startPreLoginPlugins(
options: { retryFailed?: boolean } = {},
): Promise<void> {
installPluginHostBridge();
let list: PluginSummary[];
try {
@@ -468,6 +470,9 @@ export async function startPreLoginPlugins(): Promise<void> {
// subset would unload everything else. Auth remounts during the
// post-login fade, so this runs after sign-in every time.
if (started) return;
if (options.retryFailed) {
for (const plugin of list) failedVersions.delete(plugin.id);
}
await reconcile(list);
markPluginsSettled();
});
@@ -71,6 +71,7 @@ vi.mock("sonner", () => ({
}));
import { Auth } from "../../auth/Auth";
import { startPreLoginPlugins } from "@/plugin-host/loader";
import {
registerLoginMethod,
registerSecondFactor,
@@ -270,6 +271,30 @@ describe("login methods on the login screen", () => {
}
});
it("recovers an unavailable second-factor UI without restarting the login", async () => {
window.history.replaceState({}, "", "/?second_factor=1&second_factors=pin");
render(<Auth onLogin={vi.fn()} />);
expect(await screen.findByText("auth.secondFactorNoUI")).toBeTruthy();
let dispose = () => {};
vi.mocked(startPreLoginPlugins).mockImplementationOnce(async () => {
dispose = registerSecondFactor({
id: "pin",
pluginId: "corp",
titleKey: "corp.pin",
component: () => <span>recovered challenge</span>,
});
});
try {
fireEvent.click(screen.getByText("auth.retrySecondFactorUI"));
expect(await screen.findByText("recovered challenge")).toBeTruthy();
expect(startPreLoginPlugins).toHaveBeenLastCalledWith({
retryFailed: true,
});
} finally {
dispose();
}
});
it("offers every registered factor when the server lists none", async () => {
const dispose = registerSecondFactor({
id: "pin",
+14
View File
@@ -232,6 +232,20 @@ describe("plugin loader", () => {
expect(isPluginFrontendActive("alpha")).toBe(true);
});
it("retries failed login bundles only when explicitly requested", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(JSON.stringify([summary("login")])),
);
await startPreLoginPlugins();
expect(isPluginFrontendActive("login")).toBe(false);
modules.login = railPlugin("login");
await startPreLoginPlugins();
expect(isPluginFrontendActive("login")).toBe(false);
await startPreLoginPlugins({ retryFailed: true });
expect(isPluginFrontendActive("login")).toBe(true);
});
it("ignores a pre-login load started after sign-in", async () => {
modules.login = railPlugin("login");
modules.alpha = railPlugin("alpha");