diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index df1fed65a..7d6557daa 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -296,6 +296,15 @@ export function Auth({ onLogin }: AuthProps) { const [pendingToken, setPendingToken] = useState(""); const [secondFactors, setSecondFactors] = useState([]); 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 ( -

- {t("auth.secondFactorNoUI")} -

+
+

+ {t( + authPluginsLoading + ? "common.loading" + : "auth.secondFactorNoUI", + )} +

+ +
); } const Component = ui.component; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index a19249f17..70f962e7c 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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", diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index 0815591a4..f684ef5a2 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -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 主机密钥", diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index d20811444..d40d93143 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -449,7 +449,9 @@ async function fetchPreLoginPlugins(): Promise { * 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 { +export async function startPreLoginPlugins( + options: { retryFailed?: boolean } = {}, +): Promise { installPluginHostBridge(); let list: PluginSummary[]; try { @@ -468,6 +470,9 @@ export async function startPreLoginPlugins(): Promise { // 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(); }); diff --git a/src/ui/tests/auth/AuthLoginMethods.test.tsx b/src/ui/tests/auth/AuthLoginMethods.test.tsx index c281c6908..57bb87b8f 100644 --- a/src/ui/tests/auth/AuthLoginMethods.test.tsx +++ b/src/ui/tests/auth/AuthLoginMethods.test.tsx @@ -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(); + expect(await screen.findByText("auth.secondFactorNoUI")).toBeTruthy(); + let dispose = () => {}; + vi.mocked(startPreLoginPlugins).mockImplementationOnce(async () => { + dispose = registerSecondFactor({ + id: "pin", + pluginId: "corp", + titleKey: "corp.pin", + component: () => recovered challenge, + }); + }); + 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", diff --git a/src/ui/tests/plugin-host/loader.test.tsx b/src/ui/tests/plugin-host/loader.test.tsx index afbfa0797..acd56dbfe 100644 --- a/src/ui/tests/plugin-host/loader.test.tsx +++ b/src/ui/tests/plugin-host/loader.test.tsx @@ -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");