mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
fix(auth): allow retrying unavailable second-factor interfaces (#1549)
This commit is contained in:
1 parent
c66702deb1
commit
c9a1348836
6 files changed
+79
-7
No files matched your search
+30
-4
@@ -296,6 +296,15 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [pendingToken, setPendingToken] = useState("");
|
const [pendingToken, setPendingToken] = useState("");
|
||||||
const [secondFactors, setSecondFactors] = useState<SecondFactorRef[]>([]);
|
const [secondFactors, setSecondFactors] = useState<SecondFactorRef[]>([]);
|
||||||
const [activeFactorId, setActiveFactorId] = 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 loginMethodUIs = useLoginMethods();
|
||||||
const secondFactorUIs = useSecondFactors();
|
const secondFactorUIs = useSecondFactors();
|
||||||
// A server that did not list its factors gets every factor with a UI here.
|
// 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))
|
.then((allowed) => setPasswordResetAllowed(allowed))
|
||||||
.catch(() => setPasswordResetAllowed(false));
|
.catch(() => setPasswordResetAllowed(false));
|
||||||
// Plugins that draw login or second-factor UI load before sign-in.
|
// Plugins that draw login or second-factor UI load before sign-in.
|
||||||
void startPreLoginPlugins().catch(() => {});
|
void startPreLoginPlugins()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setAuthPluginsLoading(false));
|
||||||
getLoginMethods()
|
getLoginMethods()
|
||||||
.then((methods) => setAuthMethods(methods))
|
.then((methods) => setAuthMethods(methods))
|
||||||
.catch(() => setAuthMethods([]))
|
.catch(() => setAuthMethods([]))
|
||||||
@@ -1364,9 +1375,24 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
);
|
);
|
||||||
if (!ui) {
|
if (!ui) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-destructive">
|
<div className="space-y-2" aria-live="polite">
|
||||||
{t("auth.secondFactorNoUI")}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{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;
|
const Component = ui.component;
|
||||||
|
|||||||
@@ -1060,7 +1060,8 @@
|
|||||||
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
"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.",
|
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
|
||||||
"serverAuthentication": "Server sign-in",
|
"serverAuthentication": "Server sign-in",
|
||||||
"minChars": "min. {{min}} characters"
|
"minChars": "min. {{min}} characters",
|
||||||
|
"retrySecondFactorUI": "Reload verification methods"
|
||||||
},
|
},
|
||||||
"hostKey": {
|
"hostKey": {
|
||||||
"verifyNewHost": "Verify SSH Host Key",
|
"verifyNewHost": "Verify SSH Host Key",
|
||||||
|
|||||||
@@ -1058,7 +1058,8 @@
|
|||||||
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
"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.",
|
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
|
||||||
"serverAuthentication": "Server sign-in",
|
"serverAuthentication": "Server sign-in",
|
||||||
"minChars": "min. {{min}} characters"
|
"minChars": "min. {{min}} characters",
|
||||||
|
"retrySecondFactorUI": "重新加载验证方式"
|
||||||
},
|
},
|
||||||
"hostKey": {
|
"hostKey": {
|
||||||
"verifyNewHost": "验证 SSH 主机密钥",
|
"verifyNewHost": "验证 SSH 主机密钥",
|
||||||
|
|||||||
@@ -449,7 +449,9 @@ async function fetchPreLoginPlugins(): Promise<PluginSummary[]> {
|
|||||||
* factors) before anyone has signed in. After sign-in the full runtime
|
* factors) before anyone has signed in. After sign-in the full runtime
|
||||||
* reconciles, keeping these active when their bundle has not changed.
|
* 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();
|
installPluginHostBridge();
|
||||||
let list: PluginSummary[];
|
let list: PluginSummary[];
|
||||||
try {
|
try {
|
||||||
@@ -468,6 +470,9 @@ export async function startPreLoginPlugins(): Promise<void> {
|
|||||||
// subset would unload everything else. Auth remounts during the
|
// subset would unload everything else. Auth remounts during the
|
||||||
// post-login fade, so this runs after sign-in every time.
|
// post-login fade, so this runs after sign-in every time.
|
||||||
if (started) return;
|
if (started) return;
|
||||||
|
if (options.retryFailed) {
|
||||||
|
for (const plugin of list) failedVersions.delete(plugin.id);
|
||||||
|
}
|
||||||
await reconcile(list);
|
await reconcile(list);
|
||||||
markPluginsSettled();
|
markPluginsSettled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ vi.mock("sonner", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { Auth } from "../../auth/Auth";
|
import { Auth } from "../../auth/Auth";
|
||||||
|
import { startPreLoginPlugins } from "@/plugin-host/loader";
|
||||||
import {
|
import {
|
||||||
registerLoginMethod,
|
registerLoginMethod,
|
||||||
registerSecondFactor,
|
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 () => {
|
it("offers every registered factor when the server lists none", async () => {
|
||||||
const dispose = registerSecondFactor({
|
const dispose = registerSecondFactor({
|
||||||
id: "pin",
|
id: "pin",
|
||||||
|
|||||||
@@ -232,6 +232,20 @@ describe("plugin loader", () => {
|
|||||||
expect(isPluginFrontendActive("alpha")).toBe(true);
|
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 () => {
|
it("ignores a pre-login load started after sign-in", async () => {
|
||||||
modules.login = railPlugin("login");
|
modules.login = railPlugin("login");
|
||||||
modules.alpha = railPlugin("alpha");
|
modules.alpha = railPlugin("alpha");
|
||||||
|
|||||||
Reference in new issue
Block a user