mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move step-ca into a plugin (C4)
This commit is contained in:
1 parent
98d367d7e2
commit
b027717c46
128 files changed
+3805
-1781
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
|
||||
|
||||
const CALLBACK_PATH = "plugin-api/step-ca/callback";
|
||||
const LEGACY_CALLBACK_PATH = "host/step-ca-callback";
|
||||
|
||||
/** The callback URI to register with the provisioner's identity provider. */
|
||||
export function redirectUri(
|
||||
legacy: boolean,
|
||||
baseUri = document.baseURI,
|
||||
): string {
|
||||
return new URL(
|
||||
legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH,
|
||||
baseUri,
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function RedirectUriSetting({ values }: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const uri = redirectUri(values.legacyCallback === true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("settings.redirectUri.label")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={uri}
|
||||
readOnly
|
||||
className="rounded-none font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={async () => {
|
||||
if (await copyToClipboard(uri)) toast.success(t("common.copied"));
|
||||
else toast.error(t("common.copyFailed"));
|
||||
}}
|
||||
>
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("settings.redirectUri.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const DOCS_URL =
|
||||
"https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on";
|
||||
|
||||
/** The host editor's "stepca" auth method: nothing to fill in, just a pointer. */
|
||||
export function StepCaAuthEditor() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("editor.label")}
|
||||
</span>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("editor.docs")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("editor.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export type SignInStage = "chooser" | "waiting" | "authenticating" | "error";
|
||||
|
||||
interface StepCaDialogProps {
|
||||
stage: SignInStage;
|
||||
error?: string;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
const actionButton =
|
||||
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2";
|
||||
const footerButton =
|
||||
"rounded-none text-[10px] font-bold uppercase tracking-widest";
|
||||
|
||||
/** The browser sign-in the terminal shows while Step CA waits. */
|
||||
export function StepCaDialog({
|
||||
stage,
|
||||
error,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
backgroundColor,
|
||||
}: StepCaDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("dialog.title")}
|
||||
</h3>
|
||||
</div>
|
||||
{stage === "chooser" && (
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("dialog.description")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
{stage === "chooser" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onOpenUrl}
|
||||
className={actionButton}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("dialog.openBrowser")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(stage === "waiting" || stage === "authenticating") && (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stage === "waiting"
|
||||
? t("dialog.waiting")
|
||||
: t("dialog.authenticating")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "error" && error && (
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
|
||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
|
||||
{t("common.error")}
|
||||
</p>
|
||||
<p className="text-xs text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className={footerButton}
|
||||
>
|
||||
{stage === "error" ? t("common.close") : t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { StepCaDialog, type SignInStage } from "./StepCaDialog";
|
||||
|
||||
/** What the ssh-terminal plugin hands a "terminal.overlay" component. */
|
||||
export interface TerminalOverlayProps {
|
||||
host: { id: number | string; [key: string]: unknown } | undefined;
|
||||
backgroundColor?: string;
|
||||
subscribe: (
|
||||
listener: (message: { type: string; [key: string]: unknown }) => void,
|
||||
) => () => void;
|
||||
holdConnectTimeout: (held: boolean) => void;
|
||||
fail: (message: string) => void;
|
||||
disconnect: () => void;
|
||||
send: (type: string, data?: unknown) => void;
|
||||
connectPayload: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
authUrl: string;
|
||||
requestId: string;
|
||||
stage: SignInStage;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** How long the sign-in dialog waits before giving up on the connection. */
|
||||
const DIALOG_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Runs the Step CA browser sign-in for a terminal: asks the server to start
|
||||
* it, opens the identity provider, and reconnects once the CA has issued the
|
||||
* certificate.
|
||||
*/
|
||||
export function StepCaOverlay({
|
||||
subscribe,
|
||||
holdConnectTimeout,
|
||||
fail,
|
||||
disconnect,
|
||||
send,
|
||||
connectPayload,
|
||||
backgroundColor,
|
||||
}: TerminalOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heldRef = useRef(false);
|
||||
// A second "auth required" after a sign-in means the certificate did not work.
|
||||
const failedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
const hold = (held: boolean) => {
|
||||
if (heldRef.current === held) return;
|
||||
heldRef.current = held;
|
||||
holdConnectTimeout(held);
|
||||
};
|
||||
const showError = (requestId: unknown, error: string) => {
|
||||
clearTimer();
|
||||
setDialog((current) =>
|
||||
current
|
||||
? { ...current, stage: "error", error }
|
||||
: {
|
||||
authUrl: "",
|
||||
requestId: typeof requestId === "string" ? requestId : "",
|
||||
stage: "error",
|
||||
error,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const unsubscribe = subscribe((message) => {
|
||||
switch (message.type) {
|
||||
case "stepca_auth_required":
|
||||
hold(true);
|
||||
if (failedRef.current) {
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
hold(false);
|
||||
fail(t("errors.authFailed"));
|
||||
} else {
|
||||
failedRef.current = true;
|
||||
send("stepca_start_auth", { hostId: message.hostId });
|
||||
}
|
||||
break;
|
||||
case "stepca_status":
|
||||
if (message.stage === "chooser") {
|
||||
setDialog({
|
||||
authUrl: typeof message.url === "string" ? message.url : "",
|
||||
requestId:
|
||||
typeof message.requestId === "string" ? message.requestId : "",
|
||||
stage: "chooser",
|
||||
});
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDialog(null);
|
||||
hold(false);
|
||||
disconnect();
|
||||
}, DIALOG_TIMEOUT_MS);
|
||||
} else {
|
||||
setDialog((current) =>
|
||||
current
|
||||
? { ...current, stage: message.stage as SignInStage }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "stepca_completed":
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
send("stepca_auth_completed", connectPayload());
|
||||
break;
|
||||
case "stepca_error":
|
||||
failedRef.current = true;
|
||||
showError(message.requestId, String(message.error ?? ""));
|
||||
break;
|
||||
case "stepca_timeout":
|
||||
failedRef.current = true;
|
||||
showError(message.requestId, t("errors.timeout"));
|
||||
break;
|
||||
case "stepca_config_error":
|
||||
showError(message.requestId, String(message.error ?? ""));
|
||||
break;
|
||||
case "connected":
|
||||
case "sessionAttached":
|
||||
failedRef.current = false;
|
||||
hold(false);
|
||||
break;
|
||||
case "session_closed":
|
||||
clearTimer();
|
||||
setDialog(null);
|
||||
hold(false);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearTimer();
|
||||
if (heldRef.current) {
|
||||
heldRef.current = false;
|
||||
holdConnectTimeout(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
subscribe,
|
||||
holdConnectTimeout,
|
||||
fail,
|
||||
disconnect,
|
||||
send,
|
||||
connectPayload,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!dialog) return null;
|
||||
|
||||
const close = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (dialog.requestId) {
|
||||
send("stepca_cancel", { requestId: dialog.requestId });
|
||||
}
|
||||
setDialog(null);
|
||||
if (heldRef.current) {
|
||||
heldRef.current = false;
|
||||
holdConnectTimeout(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StepCaDialog
|
||||
stage={dialog.stage}
|
||||
error={dialog.error}
|
||||
onCancel={close}
|
||||
onOpenUrl={() => {
|
||||
if (!dialog.authUrl) return;
|
||||
window.open(dialog.authUrl, "_blank");
|
||||
setDialog((current) =>
|
||||
current ? { ...current, stage: "waiting" } : null,
|
||||
);
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { StepCaAuthEditor } from "./StepCaAuthEditor";
|
||||
import { StepCaOverlay } from "./StepCaOverlay";
|
||||
import { RedirectUriSetting } from "./RedirectUriSetting";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerSshAuthEditor({
|
||||
authType: "stepca",
|
||||
titleKey: "hosts.filterAuthStepca",
|
||||
component: StepCaAuthEditor,
|
||||
});
|
||||
|
||||
// The browser sign-in while a terminal connects to a Step CA host.
|
||||
app.registerSlotContribution("terminal.overlay", {
|
||||
actionId: "step-ca.signIn",
|
||||
titleKey: "dialog.title",
|
||||
kind: "component",
|
||||
component: StepCaOverlay as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
|
||||
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// Registrations through app are disposed by core.
|
||||
}
|
||||
Reference in new issue
Block a user