feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldOff } from "lucide-react";
import { Button } from "@/components/button";
import {
getUserSecondFactors,
resetUserSecondFactors,
type UserSecondFactorSummary,
} from "@/api/auth-methods-api";
/**
* Every second factor a user enrolled in, including ones whose plugin is off
* (which is what locks a user out), with a reset for all of them.
*/
export function AdminSecondFactorsSection({
userId,
username,
heading,
requestConfirm,
onReset,
}: {
userId: string;
username: string;
heading: ReactNode;
requestConfirm: (message: string, onConfirm: () => void) => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const [factors, setFactors] = useState<UserSecondFactorSummary[]>([]);
const [working, setWorking] = useState(false);
const load = useCallback(() => {
getUserSecondFactors(userId)
.then(setFactors)
.catch(() => setFactors([]));
}, [userId]);
useEffect(() => {
load();
}, [load]);
async function reset() {
setWorking(true);
try {
await resetUserSecondFactors(userId);
toast.success(t("admin.secondFactorsResetSuccess"));
onReset();
load();
} catch {
toast.error(t("admin.secondFactorsResetFailed"));
} finally {
setWorking(false);
}
}
return (
<div className="flex flex-col gap-2">
{heading}
{factors.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("admin.secondFactorsNone")}
</span>
) : (
<div className="flex flex-col gap-1">
{factors.map((factor) => (
<div
key={`${factor.pluginId}:${factor.factorId}`}
className="flex items-center justify-between text-xs"
>
<span>
{factor.labelKey
? t(factor.labelKey, { defaultValue: factor.factorId })
: factor.factorId}
</span>
{!factor.available && (
<span className="text-[10px] text-destructive">
{t("admin.secondFactorUnavailable", {
plugin: factor.pluginId,
})}
</span>
)}
</div>
))}
</div>
)}
{factors.length > 0 && (
<Button
variant="outline"
size="sm"
className="h-7 w-fit text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={working}
onClick={() =>
requestConfirm(
t("admin.resetSecondFactorsConfirm", { username }),
() => {
void reset();
},
)
}
>
<ShieldOff className="size-3" />
{t("admin.resetSecondFactors")}
</Button>
)}
</div>
);
}
+9 -2
View File
@@ -157,6 +157,7 @@ export function AdminSettingsPanel({
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
const [passwordLoginForced, setPasswordLoginForced] = useState(false);
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
const [sessionTimeout, setSessionTimeout] = useState("24");
const [terminalTimeout, setTerminalTimeout] = useState("30");
@@ -425,8 +426,13 @@ export function AdminSettingsPanel({
]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
if (pwLogin.status === "fulfilled")
setAllowPasswordLogin(pwLogin.value.allowed);
if (pwLogin.status === "fulfilled") {
// When forced on, the setting itself is off; show that and warn.
setPasswordLoginForced(!!pwLogin.value.forced);
setAllowPasswordLogin(
pwLogin.value.forced ? false : pwLogin.value.allowed,
);
}
if (oidcProv.status === "fulfilled")
setOidcAutoProvision(oidcProv.value.enabled);
if (oidcSilent.status === "fulfilled") {
@@ -1223,6 +1229,7 @@ export function AdminSettingsPanel({
allowRegistration={allowRegistration}
handleToggleRegistration={handleToggleRegistration}
allowPasswordLogin={allowPasswordLogin}
passwordLoginForced={passwordLoginForced && !allowPasswordLogin}
handleTogglePasswordLogin={handleTogglePasswordLogin}
oidcAutoProvision={oidcAutoProvision}
handleToggleOidcAutoProvision={handleToggleOidcAutoProvision}
+7
View File
@@ -43,6 +43,7 @@ type GeneralSettingsSectionProps = {
allowRegistration: boolean;
handleToggleRegistration: () => void;
allowPasswordLogin: boolean;
passwordLoginForced?: boolean;
handleTogglePasswordLogin: () => void;
oidcAutoProvision: boolean;
handleToggleOidcAutoProvision: () => void;
@@ -93,6 +94,7 @@ export function AdminGeneralSettingsSection({
allowRegistration,
handleToggleRegistration,
allowPasswordLogin,
passwordLoginForced,
handleTogglePasswordLogin,
oidcAutoProvision,
handleToggleOidcAutoProvision,
@@ -285,6 +287,11 @@ export function AdminGeneralSettingsSection({
onToggle={handleTogglePasswordLogin}
/>
</SettingRow>
{passwordLoginForced && (
<p className="text-[10px] text-destructive">
{t("admin.passwordLoginForced")}
</p>
)}
<SettingRow
label={t("admin.oidcAutoProvision")}
description={t("admin.oidcAutoProvisionDesc")}
+14
View File
@@ -51,6 +51,7 @@ import {
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import { CredentialEditorView } from "./CredentialEditorView";
import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import type { AdminSession, AdminUser } from "./AdminManagementSections";
@@ -585,6 +586,19 @@ export function AdminUserManagePanel({
</div>
</div>
<AdminSecondFactorsSection
userId={user.id}
username={user.username}
heading={sectionHeading(t("admin.secondFactorsSectionTitle"))}
requestConfirm={(message, onConfirm) =>
setConfirmDialog({ message, onConfirm })
}
onReset={() => {
setTotpEnabled(false);
onTotpDisabled();
}}
/>
{/* Roles */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.userRoles"))}
+19 -12
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
@@ -36,6 +37,7 @@ export function CredentialsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const sshAuthProviders = useSshAuthProviders();
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
@@ -233,18 +235,23 @@ export function CredentialsPanel({
<DropdownMenuLabel>
{t("credentials.filterTypeGroup")}
</DropdownMenuLabel>
{(["password", "key"] as const).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.type.includes(val)}
onCheckedChange={() => handleFilterToggle("type", val)}
onSelect={(e) => e.preventDefault()}
>
{t(
`credentials.filterType${val.charAt(0).toUpperCase() + val.slice(1)}`,
)}
</DropdownMenuCheckboxItem>
))}
{sshAuthProviders.providers
.filter((option) => option.credentialType)
.map((option) => (
<DropdownMenuCheckboxItem
key={option.type}
checked={filterState.type.includes(option.type)}
onCheckedChange={() =>
handleFilterToggle("type", option.type)
}
onSelect={(e) => e.preventDefault()}
>
{t(
`credentials.filterType${option.type.charAt(0).toUpperCase() + option.type.slice(1)}`,
{ defaultValue: t(option.labelKey) },
)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
+42 -57
View File
@@ -90,6 +90,10 @@ import {
makeHostTabs,
} from "./HostManagerTabs";
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { SshAuthProviderFields } from "./SshAuthProviderFields";
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
import { ensureLegacyAuthUI } from "@/auth/legacy-auth-ui";
import {
SecretReferenceHint,
SecretSourceManager,
@@ -102,17 +106,7 @@ import {
const CUSTOM_FONT_OPTION = "__custom__";
/** The SSH auth types core itself handles. Plugins add the rest. */
const CORE_AUTH_METHODS = [
"password",
"key",
"credential",
"vault",
"none",
"opkssh",
"stepca",
"agent",
];
ensureLegacyAuthUI();
export function HostEditor({
host,
@@ -373,10 +367,27 @@ export function HostEditor({
(editor) => editor.id === authMethod,
);
const ActiveAuthEditor = activeAuthEditor?.component;
const sshAuthProviders = useSshAuthProviders();
const authEditorLabel = (method: string) => {
const editor = sshAuthEditors.find((item) => item.id === method);
return editor ? t(editor.titleKey) : method;
const option = sshAuthProviders.find(method);
if (option?.editorTitleKey) return t(option.editorTitleKey);
if (option?.labelKey) return t(option.labelKey, { defaultValue: method });
return method;
};
const selectableAuthMethods = sshAuthProviders.providers
.filter((option) => option.available)
.map((option) => option.type);
const currentAuthOption = sshAuthProviders.find(authMethod);
// The host uses a type nothing here provides (its plugin is off).
const missingAuthNotice =
sshAuthProviders.loaded && authMethod && !currentAuthOption?.available
? currentAuthOption?.missingPlugin
? t("hosts.authTypeNeedsPlugin", {
type: authMethod,
plugin: currentAuthOption.missingPlugin.name,
})
: t("hosts.authTypeUnknown", { type: authMethod })
: null;
const availableCredentials =
quickCreatedCredential &&
!credentials.some(
@@ -611,10 +622,7 @@ export function HostEditor({
role="radiogroup"
aria-label={t("hosts.authenticationMethod")}
>
{[
...CORE_AUTH_METHODS,
...sshAuthEditors.map((editor) => editor.id),
].map((m) => (
{selectableAuthMethods.map((m) => (
<button
key={m}
type="button"
@@ -640,6 +648,11 @@ export function HostEditor({
{t("hosts.sharing.ownerOnlyControl")}
</p>
)}
{missingAuthNotice && (
<p className="text-[10px] text-destructive">
{missingAuthNotice}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4 mt-1">
<div className="flex flex-col gap-1.5">
@@ -669,26 +682,6 @@ export function HostEditor({
{t("hosts.oidcUsernameHint")}
</p>
)}
{authMethod === "stepca" && (
<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("hosts.stepcaLabel")}
</span>
<a
href="https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hosts.stepcaDesc")}
</p>
</div>
)}
{activeAuthEditor?.hintKey && (
<p className="text-[10px] text-muted-foreground/60">
{t(activeAuthEditor.hintKey)}
@@ -1056,26 +1049,18 @@ export function HostEditor({
</>
)}
</div>
{authMethod === "opkssh" && (
<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("hosts.opksshLabel")}
</span>
<a
href="https://docs.termix.site/features/authentication/opkssh"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hosts.opksshDesc")}
</p>
</div>
)}
{!ActiveAuthEditor &&
currentAuthOption?.available &&
currentAuthOption.pluginId !== "core" &&
currentAuthOption.fields.length > 0 && (
<SshAuthProviderFields
pluginId={currentAuthOption.pluginId}
hostId={host?.id ? Number(host.id) : undefined}
fields={
currentAuthOption.fields as unknown as PluginSettingsField[]
}
/>
)}
{ActiveAuthEditor && (
<ActiveAuthEditor
form={form}
+9 -16
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useEffect, useRef, useState } from "react";
import type { HostData } from "@/types/index";
import { useTranslation } from "react-i18next";
@@ -204,6 +205,7 @@ export function HostsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const sshAuthProviders = useSshAuthProviders();
const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
@@ -750,27 +752,18 @@ export function HostsPanel({
<DropdownMenuLabel>
{t("hosts.filterAuthGroup")}
</DropdownMenuLabel>
{(
[
"password",
"key",
"credential",
"none",
"opkssh",
"stepca",
] as const
).map((val) => (
{sshAuthProviders.providers.map((option) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.authType.includes(val)}
key={option.type}
checked={filterState.authType.includes(option.type)}
onCheckedChange={() =>
handleFilterToggle("authType", val)
handleFilterToggle("authType", option.type)
}
onSelect={(e) => e.preventDefault()}
>
{t(
`hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`,
)}
{option.editorTitleKey
? t(option.editorTitleKey)
: t(option.labelKey, { defaultValue: option.type })}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import { useSecondFactors } from "@/plugin-host/auth-registry";
/**
* Enrolment sections second-factor plugins register. Core's own TOTP and
* passkey sections sit above this until they move into plugins.
*/
export function PluginSecondFactorEnrollment() {
const { t } = useTranslation();
const factors = useSecondFactors().filter(
(factor) => factor.enrollment && factor.pluginId !== "core",
);
if (factors.length === 0) return null;
return (
<div className="flex flex-col gap-4">
{factors.map((factor) => {
const Enrollment = factor.enrollment!;
return (
<div key={factor.id} className="flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t(factor.titleKey)}
</span>
<Enrollment />
</div>
);
})}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import {
getPluginHostSettings,
updatePluginHostSettings,
PluginSettingsValidationError,
} from "@/api/plugins-api";
/**
* Fields an SSH auth type declares, for a plugin that did not register its
* own editor. They are the plugin's host settings (so the plugin reads them
* with ctx.settings.getHost), which means a host must be saved first.
*/
export function SshAuthProviderFields({
pluginId,
hostId,
fields,
}: {
pluginId: string;
hostId: number | null | undefined;
fields: PluginSettingsField[];
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!hostId) return;
let cancelled = false;
getPluginHostSettings(pluginId, hostId)
.then((loaded) => {
if (!cancelled) setValues(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [pluginId, hostId]);
if (!hostId) {
return (
<p className="text-[10px] text-muted-foreground">
{t("hosts.authFieldsAfterSave")}
</p>
);
}
const setValue = (key: string, value: unknown) => {
const next = { ...values, [key]: value };
setValues(next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
updatePluginHostSettings(pluginId, hostId, { [key]: value })
.then(() => setErrors((prev) => ({ ...prev, [key]: "" })))
.catch((error) => {
if (error instanceof PluginSettingsValidationError) {
setErrors((prev) => ({ ...prev, ...error.errors }));
}
});
}, 400);
};
return (
<div className="flex flex-col gap-3 border-t border-border pt-3">
{fields.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={pluginId}
field={field}
values={values}
setValue={setValue}
running
error={errors[field.key] || undefined}
/>
))}
</div>
);
}
+3
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { PluginSecondFactorEnrollment } from "./PluginSecondFactorEnrollment";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import {
@@ -2568,6 +2569,8 @@ export function UserProfilePanel({
</div>
</div>
<PluginSecondFactorEnrollment />
{canChangePasword && (
<PasswordChangeSection
showPassword={showPassword}
+1 -1
View File
@@ -55,7 +55,7 @@ export function sortCredentials(
export function credentialPassesFilters(
cred: Credential,
filters: { type: ("password" | "key")[]; tags: string[] },
filters: { type: string[]; tags: string[] },
): boolean {
if (filters.type.length > 0 && !filters.type.includes(cred.type))
return false;