mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
markUserAuthenticated,
|
||||
type AuthResponse,
|
||||
} from "@/main-axios";
|
||||
|
||||
export interface PublicLoginMethod {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
kind: "redirect" | "form";
|
||||
labelKey: string;
|
||||
icon?: string;
|
||||
instances: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface SecondFactorRef {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export type LoginResponse = AuthResponse & {
|
||||
requires_second_factor?: boolean;
|
||||
second_factors?: SecondFactorRef[];
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
function remember(data: LoginResponse): LoginResponse {
|
||||
if (data?.token) localStorage.setItem("jwt", data.token);
|
||||
if (data?.success && !data.requires_totp) markUserAuthenticated();
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Enabled login methods for the login screen. Public. */
|
||||
export async function getLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
try {
|
||||
const response = await authApi.get("/users/auth/methods");
|
||||
return Array.isArray(response.data?.methods) ? response.data.methods : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitLoginMethod(
|
||||
methodId: string,
|
||||
body: Record<string, unknown>,
|
||||
instanceId?: string,
|
||||
): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/users/auth/${encodeURIComponent(methodId)}/verify`,
|
||||
{ ...body, instanceId },
|
||||
);
|
||||
return remember(response.data);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "login");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startLoginRedirect(
|
||||
methodId: string,
|
||||
params: {
|
||||
instanceId?: string;
|
||||
rememberMe?: boolean;
|
||||
desktopCallbackPort?: number;
|
||||
},
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/users/auth/${encodeURIComponent(methodId)}/start`,
|
||||
{
|
||||
params: {
|
||||
instance: params.instanceId,
|
||||
rememberMe: params.rememberMe,
|
||||
desktopCallbackPort: params.desktopCallbackPort,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data?.redirectUrl as string;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start login");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a second factor. With no temp token the server reads the pending
|
||||
* login cookie a redirect method left.
|
||||
*/
|
||||
export async function verifySecondFactor(
|
||||
factorId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/users/auth/second-factor/${encodeURIComponent(factorId)}/verify`,
|
||||
body,
|
||||
);
|
||||
return remember(response.data);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify second factor");
|
||||
}
|
||||
}
|
||||
|
||||
export async function challengeSecondFactor(
|
||||
factorId: string,
|
||||
tempToken?: string,
|
||||
): Promise<unknown> {
|
||||
const response = await authApi.post(
|
||||
`/users/auth/second-factor/${encodeURIComponent(factorId)}/challenge`,
|
||||
{ temp_token: tempToken },
|
||||
);
|
||||
return response.data?.challenge ?? null;
|
||||
}
|
||||
|
||||
export interface UserSecondFactorSummary {
|
||||
pluginId: string;
|
||||
factorId: string;
|
||||
labelKey: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export async function getUserSecondFactors(
|
||||
userId: string,
|
||||
): Promise<UserSecondFactorSummary[]> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/users/admin/${encodeURIComponent(userId)}/second-factors`,
|
||||
);
|
||||
return response.data?.factors ?? [];
|
||||
} catch (error) {
|
||||
handleApiError(error, "list second factors");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetUserSecondFactors(userId: string): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(
|
||||
`/users/admin/${encodeURIComponent(userId)}/second-factors`,
|
||||
);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reset second factors");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SshAuthProviderSummary {
|
||||
type: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
pluginId: string;
|
||||
fields: Array<Record<string, unknown>>;
|
||||
credentialType: boolean;
|
||||
needsUserInteraction: boolean;
|
||||
supportsBackground: boolean;
|
||||
available: boolean;
|
||||
missingPlugin?: { id: string; name: string };
|
||||
}
|
||||
|
||||
export async function getSshAuthProviders(): Promise<SshAuthProviderSummary[]> {
|
||||
try {
|
||||
const response = await authApi.get("/ssh-auth/providers");
|
||||
return Array.isArray(response.data?.providers)
|
||||
? response.data.providers
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+335
-512
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,8 @@ interface SaveRemoteSyncJwtResult {
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
"auth_component",
|
||||
"totp_auth_component",
|
||||
"passkey_auth_component",
|
||||
"method_auth_component",
|
||||
"oidc_callback",
|
||||
]);
|
||||
|
||||
@@ -135,6 +137,9 @@ export function ElectronLoginForm({
|
||||
const sendResultToIframe = (result: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
@@ -156,6 +161,9 @@ export function ElectronLoginForm({
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -175,6 +183,16 @@ export function ElectronLoginForm({
|
||||
await handleAuthSuccess(result.token);
|
||||
return;
|
||||
}
|
||||
// The login stopped for a second factor; the iframe asks for it.
|
||||
if (result.secondFactor) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
secondFactor: true,
|
||||
tempToken: result.tempToken,
|
||||
factors: result.factors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: result.error || t("errors.failedOidcLogin"),
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Login and second-factor UI that still lives in core for 2.9.0 (OIDC, LDAP,
|
||||
* passkeys, TOTP), registered through the same registries a plugin uses.
|
||||
* Phase C moves each into its plugin by moving its block out of this file;
|
||||
* D1 deletes the file once it is empty.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronDown, ChevronUp, Fingerprint } from "lucide-react";
|
||||
import type {
|
||||
LoginMethodUIProps,
|
||||
SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
registerSshAuthEditor,
|
||||
} from "@/plugin-host/auth-registry";
|
||||
import { isPasskeySupported, loginWithPasskey } from "@/api/webauthn-api";
|
||||
|
||||
const primaryButton =
|
||||
"w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold";
|
||||
|
||||
function OidcLoginButtons({
|
||||
instances,
|
||||
disabled,
|
||||
startRedirect,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
{instances.map((instance) => (
|
||||
<Button
|
||||
key={instance.id}
|
||||
onClick={async () => {
|
||||
setLoadingId(instance.id);
|
||||
try {
|
||||
await startRedirect(instance.id);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loadingId !== null}
|
||||
className={primaryButton}
|
||||
>
|
||||
{loadingId === instance.id
|
||||
? t("common.loading")
|
||||
: t("auth.loginWithProvider", { name: instance.label })}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LdapLoginForms({ instances, disabled, submit }: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
}, [expandedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{instances.map((instance) => {
|
||||
const expanded = expandedId === instance.id;
|
||||
return (
|
||||
<div key={instance.id} className="flex flex-col border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(expanded ? null : instance.id)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t("auth.loginWithProvider", { name: instance.label })}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
toast.error(t("errors.requiredField"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await submit(
|
||||
{ username: username.trim(), password },
|
||||
instance.id,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-3 p-3 border-t border-border"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("auth.ldapUsername")}
|
||||
</span>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("auth.ldapPassword")}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className="font-mono"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
className={primaryButton}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? t("common.loading") : t("auth.ldapSignIn")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeyLoginButton({
|
||||
rememberMe,
|
||||
disabled,
|
||||
complete,
|
||||
username,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [supported] = useState(() => {
|
||||
try {
|
||||
return isPasskeySupported();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!supported) return null;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await loginWithPasskey(
|
||||
username?.trim() || undefined,
|
||||
rememberMe,
|
||||
);
|
||||
await complete(result as unknown as Record<string, unknown>);
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
name?: string;
|
||||
message?: string;
|
||||
response?: { data?: { error?: string } };
|
||||
};
|
||||
// Closing the browser prompt is not a failure worth a toast.
|
||||
if (error?.name === "NotAllowedError" || error?.name === "AbortError")
|
||||
return;
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("auth.passkeyLoginFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
className="w-full h-10 font-bold"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Fingerprint className="size-4" />
|
||||
{t("auth.signInWithPasskey")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Six-digit codes, or a backup code of up to eight letters and digits. */
|
||||
export function normalizeTotpInput(value: string): string {
|
||||
return value
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.toUpperCase()
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
export function isValidTotpInput(value: string): boolean {
|
||||
return /^[A-Z0-9]{6,8}$/.test(value);
|
||||
}
|
||||
|
||||
function TotpChallenge({ disabled, verify, cancel }: SecondFactorUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!isValidTotpInput(code)) {
|
||||
toast.error(t("auth.enterCode"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await verify({ totp_code: code });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("auth.verifyCode")}
|
||||
</span>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={8}
|
||||
value={code}
|
||||
onChange={(e) => setCode(normalizeTotpInput(e.target.value))}
|
||||
disabled={disabled || loading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("auth.backupCodeHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className={primaryButton}
|
||||
disabled={disabled || loading || !isValidTotpInput(code)}
|
||||
>
|
||||
{loading ? t("common.loading") : t("auth.verifyCode")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={cancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificateAuthInfo({
|
||||
labelKey,
|
||||
descriptionKey,
|
||||
docsUrl,
|
||||
}: {
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
docsUrl: string;
|
||||
}) {
|
||||
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(labelKey)}
|
||||
</span>
|
||||
<a
|
||||
href={docsUrl}
|
||||
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(descriptionKey)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpksshAuthEditor() {
|
||||
return (
|
||||
<CertificateAuthInfo
|
||||
labelKey="hosts.opksshLabel"
|
||||
descriptionKey="hosts.opksshDesc"
|
||||
docsUrl="https://docs.termix.site/features/authentication/opkssh"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCaAuthEditor() {
|
||||
return (
|
||||
<CertificateAuthInfo
|
||||
labelKey="hosts.stepcaLabel"
|
||||
descriptionKey="hosts.stepcaDesc"
|
||||
docsUrl="https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function ensureLegacyAuthUI(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerLoginMethod({
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
titleKey: "auth.loginWithSso",
|
||||
component: OidcLoginButtons,
|
||||
});
|
||||
registerLoginMethod({
|
||||
id: "ldap",
|
||||
pluginId: "core",
|
||||
titleKey: "auth.loginWithLdap",
|
||||
component: LdapLoginForms,
|
||||
});
|
||||
registerLoginMethod({
|
||||
id: "passkey",
|
||||
pluginId: "core",
|
||||
titleKey: "auth.signInWithPasskey",
|
||||
component: PasskeyLoginButton,
|
||||
});
|
||||
registerSshAuthEditor({
|
||||
id: "opkssh",
|
||||
pluginId: "core",
|
||||
titleKey: "hosts.filterAuthOpkssh",
|
||||
component: OpksshAuthEditor,
|
||||
});
|
||||
registerSshAuthEditor({
|
||||
id: "stepca",
|
||||
pluginId: "core",
|
||||
titleKey: "hosts.filterAuthStepca",
|
||||
component: StepCaAuthEditor,
|
||||
});
|
||||
registerSecondFactor({
|
||||
id: "totp",
|
||||
pluginId: "core",
|
||||
titleKey: "auth.twoFactorAuth",
|
||||
component: TotpChallenge,
|
||||
});
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetLegacyAuthUIForTests(): void {
|
||||
registered = false;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
@@ -1380,6 +1381,9 @@ export function DashboardTab({
|
||||
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
|
||||
>(new Map());
|
||||
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
||||
const sshAuthProviders = useSshAuthProviders();
|
||||
const sshAuthProvidersRef = useRef(sshAuthProviders);
|
||||
sshAuthProvidersRef.current = sshAuthProviders;
|
||||
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
|
||||
|
||||
const fetchMetrics = useCallback(async (hostList: Host[]) => {
|
||||
@@ -1399,12 +1403,9 @@ export function DashboardTab({
|
||||
const hostId = Number(host.id);
|
||||
const knownStatus = statuses?.[hostId]?.status;
|
||||
if (knownStatus === "offline") return null;
|
||||
if (
|
||||
host.authType === "none" ||
|
||||
host.authType === "opkssh" ||
|
||||
host.authType === "stepca"
|
||||
)
|
||||
return null;
|
||||
// Types that need a person or a browser sign-in cannot be polled.
|
||||
const authOption = sshAuthProvidersRef.current.find(host.authType);
|
||||
if (authOption && !authOption.supportsBackground) return null;
|
||||
|
||||
try {
|
||||
const existing = newSessions.get(hostId);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getSshAuthProviders,
|
||||
type SshAuthProviderSummary,
|
||||
} from "@/api/auth-methods-api";
|
||||
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
|
||||
|
||||
/** Shown until the server answers, so the editor does not jump. */
|
||||
const BUILTIN_TYPES = ["password", "key", "credential", "agent", "none"];
|
||||
|
||||
function builtinSummary(type: string): SshAuthProviderSummary {
|
||||
return {
|
||||
type,
|
||||
labelKey: `hosts.filterAuth${type.charAt(0).toUpperCase()}${type.slice(1)}`,
|
||||
pluginId: "core",
|
||||
fields: [],
|
||||
credentialType: type === "password" || type === "key",
|
||||
needsUserInteraction: false,
|
||||
supportsBackground: type !== "none",
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
let cache: Promise<SshAuthProviderSummary[]> | null = null;
|
||||
|
||||
/** Drops the cached list, e.g. after a plugin was switched on or off. */
|
||||
export function invalidateSshAuthProviders(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export interface SshAuthTypeOption extends SshAuthProviderSummary {
|
||||
/** Plugin-registered editor title, when there is one. */
|
||||
editorTitleKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every SSH auth type the server can connect with, merged with the editors
|
||||
* plugins registered in this browser. Types whose plugin is off come back
|
||||
* with available: false and missingPlugin set.
|
||||
*/
|
||||
export function useSshAuthProviders(): {
|
||||
providers: SshAuthTypeOption[];
|
||||
loaded: boolean;
|
||||
find: (type: string | null | undefined) => SshAuthTypeOption | undefined;
|
||||
} {
|
||||
const editors = useSshAuthEditors();
|
||||
const [summaries, setSummaries] = useState<SshAuthProviderSummary[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
cache ??= getSshAuthProviders();
|
||||
cache.then((list) => {
|
||||
if (!cancelled) setSummaries(list);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
const base =
|
||||
summaries && summaries.length > 0
|
||||
? summaries
|
||||
: BUILTIN_TYPES.map(builtinSummary);
|
||||
const byType = new Map<string, SshAuthTypeOption>(
|
||||
base.map((summary) => [summary.type, { ...summary }]),
|
||||
);
|
||||
for (const editor of editors) {
|
||||
const existing = byType.get(editor.id);
|
||||
if (existing) {
|
||||
existing.editorTitleKey = editor.titleKey;
|
||||
} else {
|
||||
// An editor with no server provider yet: the plugin's backend may
|
||||
// still be starting. Offer it; connecting will say what is missing.
|
||||
byType.set(editor.id, {
|
||||
...builtinSummary(editor.id),
|
||||
labelKey: editor.titleKey,
|
||||
pluginId: editor.pluginId ?? "",
|
||||
credentialType: false,
|
||||
editorTitleKey: editor.titleKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
const providers = [...byType.values()];
|
||||
return {
|
||||
providers,
|
||||
loaded: summaries !== null,
|
||||
find: (type) =>
|
||||
type ? providers.find((provider) => provider.type === type) : undefined,
|
||||
};
|
||||
}, [summaries, editors]);
|
||||
}
|
||||
+25
-4
@@ -1568,7 +1568,13 @@
|
||||
"webUiErrorLabelDuplicate": "Each endpoint needs a different name.",
|
||||
"webUiErrorPortRange": "Endpoint {{row}} needs a port between 1 and 65535.",
|
||||
"webUiErrorPathInvalid": "Endpoint {{row}} has an invalid path. Use a path like /admin.",
|
||||
"copyViewUrlAction": "Copy {{name}} link"
|
||||
"copyViewUrlAction": "Copy {{name}} link",
|
||||
"filterAuthAgent": "SSH Agent",
|
||||
"filterAuthVault": "Vault",
|
||||
"filterAuthTailscale": "Tailscale",
|
||||
"authTypeNeedsPlugin": "This host uses {{type}}, which needs the {{plugin}} plugin. Turn it on to connect.",
|
||||
"authTypeUnknown": "This host uses {{type}}, which no enabled plugin provides.",
|
||||
"authFieldsAfterSave": "Save the host first, then fill in these fields."
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "Search hosts, commands, or settings...",
|
||||
@@ -2507,7 +2513,14 @@
|
||||
"signInWithPasskey": "Sign in with passkey",
|
||||
"passkeyLoginFailed": "Passkey login failed",
|
||||
"attemptsRemaining": "{{count}} attempts remaining",
|
||||
"confirmResetDataWipe": "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets. Continue?"
|
||||
"confirmResetDataWipe": "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets. Continue?",
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"password": "Password",
|
||||
"totpFactor": "Authenticator code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes.",
|
||||
"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."
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verify SSH Host Key",
|
||||
@@ -3217,7 +3230,15 @@
|
||||
"pluginPermissionGranted": "Granted",
|
||||
"pluginPermissionNotGranted": "Not granted",
|
||||
"pluginGrantFailed": "Failed to grant the capability",
|
||||
"pluginRevokeFailed": "Failed to revoke the capability"
|
||||
"pluginRevokeFailed": "Failed to revoke the capability",
|
||||
"secondFactorsSectionTitle": "Second factors",
|
||||
"secondFactorsNone": "No second factors enrolled",
|
||||
"secondFactorUnavailable": "The {{plugin}} plugin is off",
|
||||
"resetSecondFactors": "Reset second factors",
|
||||
"resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.",
|
||||
"secondFactorsResetSuccess": "Second factors reset",
|
||||
"secondFactorsResetFailed": "Failed to reset second factors",
|
||||
"passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
@@ -4008,7 +4029,7 @@
|
||||
},
|
||||
"auth:provide": {
|
||||
"title": "Handle sign in",
|
||||
"consequence": "It can add a login method or a second factor."
|
||||
"consequence": "It can add a login method, a second factor or an SSH sign-in type."
|
||||
},
|
||||
"system:tls": {
|
||||
"title": "Manage certificates",
|
||||
|
||||
@@ -1850,7 +1850,11 @@ export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPasswordLoginAllowed(): Promise<{ allowed: boolean }> {
|
||||
export async function getPasswordLoginAllowed(): Promise<{
|
||||
allowed: boolean;
|
||||
/** Turned off by an admin but kept on because nothing else can sign in. */
|
||||
forced?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/password-login-allowed");
|
||||
return response.data;
|
||||
|
||||
@@ -320,7 +320,7 @@ export function createPluginApp(
|
||||
...method,
|
||||
titleKey: key(method.titleKey),
|
||||
pluginId,
|
||||
component: scoped(method.component),
|
||||
component: scoped(method.component) as never,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -331,7 +331,10 @@ export function createPluginApp(
|
||||
...factor,
|
||||
titleKey: key(factor.titleKey),
|
||||
pluginId,
|
||||
component: scoped(factor.component),
|
||||
component: scoped(factor.component) as never,
|
||||
enrollment: factor.enrollment
|
||||
? (scoped(factor.enrollment) as never)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type {
|
||||
LoginMethodUIProps,
|
||||
SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
/**
|
||||
* Auth UI plugins contribute.
|
||||
*
|
||||
* SSH auth editors are live: the host editor lists registered auth types next
|
||||
* to the core ones (password, key, credential, agent, none) and renders the
|
||||
* plugin's editor when one is picked. Login methods and second factors are
|
||||
* declared here so the app object is complete; A8 wires them into the login
|
||||
* screen.
|
||||
* SSH auth editors render in the host and credential editors when their auth
|
||||
* type is picked. Login methods render on the login screen for methods the
|
||||
* server reports as enabled; second factors render in the step after a first
|
||||
* login and, when they bring one, in Settings > Security for enrolment.
|
||||
* Core's own (OIDC, LDAP, passkeys, TOTP) register from
|
||||
* src/ui/auth/legacy-auth-ui.tsx until they move into plugins.
|
||||
*/
|
||||
export interface SshAuthEditorDef {
|
||||
/** The authType value stored on the host. */
|
||||
@@ -28,14 +33,15 @@ export interface LoginMethodDef {
|
||||
pluginId?: string;
|
||||
titleKey: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
component: ComponentType<Record<string, unknown>>;
|
||||
component: ComponentType<LoginMethodUIProps>;
|
||||
}
|
||||
|
||||
export interface SecondFactorDef {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
titleKey: string;
|
||||
component: ComponentType<Record<string, unknown>>;
|
||||
component: ComponentType<SecondFactorUIProps>;
|
||||
enrollment?: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const sshAuthEditors = createRegistry<SshAuthEditorDef>();
|
||||
@@ -47,9 +53,11 @@ export const getSshAuthEditor = sshAuthEditors.get;
|
||||
export const useSshAuthEditors = sshAuthEditors.useList;
|
||||
|
||||
export const registerLoginMethod = loginMethods.register;
|
||||
export const getLoginMethodUI = loginMethods.get;
|
||||
export const useLoginMethods = loginMethods.useList;
|
||||
|
||||
export const registerSecondFactor = secondFactors.register;
|
||||
export const getSecondFactorUI = secondFactors.get;
|
||||
export const useSecondFactors = secondFactors.useList;
|
||||
|
||||
export function resetAuthRegistries(): void {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { usePluginScope } from "./scope";
|
||||
import { knownPluginIds } from "./plugin-store";
|
||||
import { tabsApi, useShellHosts } from "./shell-bridge";
|
||||
import { invokeAction } from "@/shell/action-registry";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
|
||||
/**
|
||||
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
|
||||
@@ -243,6 +244,22 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
useTabs: () => tabsApi,
|
||||
|
||||
invokeAction: (id, ...args) => invokeAction(id, ...args),
|
||||
|
||||
useSshAuthTypes: () => {
|
||||
const { providers, loaded } = useSshAuthProviders();
|
||||
return {
|
||||
loaded,
|
||||
types: providers
|
||||
.filter((option) => option.available)
|
||||
.map((option) => ({
|
||||
type: option.type,
|
||||
labelKey: option.editorTitleKey ?? option.labelKey,
|
||||
pluginId: option.pluginId,
|
||||
credentialType: option.credentialType,
|
||||
supportsBackground: option.supportsBackground,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Installs the bridge. Idempotent. */
|
||||
|
||||
@@ -350,6 +350,35 @@ async function fetchGuestPlugins(): Promise<PluginSummary[]> {
|
||||
return Array.isArray(body) ? (body as PluginSummary[]) : [];
|
||||
}
|
||||
|
||||
async function fetchPreLoginPlugins(): Promise<PluginSummary[]> {
|
||||
const response = await fetch(getBackendUrl("/plugins/public-manifest"));
|
||||
if (!response.ok) return [];
|
||||
const body = (await response.json()) as unknown;
|
||||
return Array.isArray(body) ? (body as PluginSummary[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the plugins the login screen needs (login methods and second
|
||||
* 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> {
|
||||
installPluginHostBridge();
|
||||
let list: PluginSummary[];
|
||||
try {
|
||||
list = await fetchPreLoginPlugins();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (list.length === 0) return;
|
||||
setPluginLocaleResolver((namespace, _language, file) => {
|
||||
const summary = getPluginRecord(namespace)?.summary;
|
||||
if (!summary) return Promise.resolve(null);
|
||||
return currentDeps().loadLocale(summary, file);
|
||||
});
|
||||
await syncPlugins(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the runtime. Idempotent. `guest` is for anonymous pages (shared
|
||||
* session links): only plugins declaring contributes.guest load, from the
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import type {
|
||||
LoginMethodUIProps,
|
||||
SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const mainAxios = vi.hoisted(() => ({
|
||||
loginUser: vi.fn(),
|
||||
registerUser: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
getRegistrationAllowed: vi.fn(),
|
||||
getPasswordLoginAllowed: vi.fn(),
|
||||
getPasswordResetAllowed: vi.fn(),
|
||||
getSetupRequired: vi.fn(),
|
||||
initiatePasswordReset: vi.fn(),
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
isElectron: vi.fn(),
|
||||
getCurrentToken: vi.fn(),
|
||||
getOidcSilentLoginDefault: vi.fn(),
|
||||
requestDesktopAutoSession: vi.fn(),
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
const authMethodsApi = vi.hoisted(() => ({
|
||||
getLoginMethods: vi.fn(),
|
||||
submitLoginMethod: vi.fn(),
|
||||
startLoginRedirect: vi.fn(),
|
||||
verifySecondFactor: vi.fn(),
|
||||
challengeSecondFactor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => mainAxios);
|
||||
vi.mock("@/api/auth-methods-api", () => authMethodsApi);
|
||||
vi.mock("@/plugin-host/loader", () => ({
|
||||
startPreLoginPlugins: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/i18n/i18n", () => ({
|
||||
changeAppLanguage: vi.fn(async (code: string) => code),
|
||||
normalizeLanguageCode: vi.fn((code: string | null) => code || "en"),
|
||||
rememberLoginLanguage: vi.fn((code: string) => code),
|
||||
}));
|
||||
vi.mock("../../auth/silent-signin", () => ({
|
||||
removeSilentSigninFromSearch: vi.fn(),
|
||||
shouldTriggerSilentSignin: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.name === "string" ? `${key}:${opts.name}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { Auth } from "../../auth/Auth";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
} from "@/plugin-host/auth-registry";
|
||||
|
||||
const OriginalResizeObserver = globalThis.ResizeObserver;
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.ResizeObserver = OriginalResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.history.replaceState({}, "", "/");
|
||||
mainAxios.isElectron.mockReturnValue(false);
|
||||
mainAxios.getRegistrationAllowed.mockResolvedValue({ allowed: true });
|
||||
mainAxios.getPasswordLoginAllowed.mockResolvedValue({ allowed: true });
|
||||
mainAxios.getPasswordResetAllowed.mockResolvedValue(true);
|
||||
mainAxios.getSetupRequired.mockResolvedValue({ setup_required: false });
|
||||
mainAxios.getOidcSilentLoginDefault.mockResolvedValue({ enabled: false });
|
||||
mainAxios.requestTrustedProxyLogin.mockResolvedValue({ enabled: false });
|
||||
mainAxios.getUserInfo.mockResolvedValue({
|
||||
username: "alice",
|
||||
userId: "u1",
|
||||
is_admin: false,
|
||||
});
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "password",
|
||||
pluginId: "core",
|
||||
kind: "form",
|
||||
labelKey: "auth.password",
|
||||
instances: [{ id: "password", label: "Password" }],
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
kind: "redirect",
|
||||
labelKey: "auth.loginWithSso",
|
||||
instances: [{ id: "3", label: "Keycloak" }],
|
||||
},
|
||||
{
|
||||
id: "corp-sso",
|
||||
pluginId: "corp",
|
||||
kind: "redirect",
|
||||
labelKey: "corp.sso",
|
||||
instances: [{ id: "a", label: "Corp" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
async function openExternalTab() {
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0),
|
||||
);
|
||||
fireEvent.click(screen.getAllByText("auth.external")[0]);
|
||||
}
|
||||
|
||||
describe("login methods on the login screen", () => {
|
||||
it("lists every enabled method, core and plugin", async () => {
|
||||
await openExternalTab();
|
||||
expect(screen.getByText("auth.loginWithProvider:Keycloak")).toBeTruthy();
|
||||
// A plugin redirect method with no UI of its own gets a plain button.
|
||||
expect(screen.getByText("auth.loginWithProvider:Corp")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a plugin redirect login through the generic endpoint", async () => {
|
||||
const replace = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, replace, search: "" },
|
||||
});
|
||||
authMethodsApi.startLoginRedirect.mockResolvedValue("https://corp/auth");
|
||||
await openExternalTab();
|
||||
fireEvent.click(screen.getByText("auth.loginWithProvider:Corp"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.startLoginRedirect).toHaveBeenCalledWith(
|
||||
"corp-sso",
|
||||
expect.objectContaining({ instanceId: "a" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("https://corp/auth"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a plugin's form UI and runs its second factor", async () => {
|
||||
authMethodsApi.getLoginMethods.mockResolvedValue([
|
||||
{
|
||||
id: "corp-form",
|
||||
pluginId: "corp",
|
||||
kind: "form",
|
||||
labelKey: "corp.form",
|
||||
instances: [],
|
||||
},
|
||||
]);
|
||||
const disposeMethod = registerLoginMethod({
|
||||
id: "corp-form",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.form",
|
||||
component: ({ submit }: LoginMethodUIProps) => (
|
||||
<button onClick={() => void submit({ token: "abc" })}>
|
||||
corp sign in
|
||||
</button>
|
||||
),
|
||||
});
|
||||
const disposeFactor = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp.pin",
|
||||
component: ({ verify }: SecondFactorUIProps) => (
|
||||
<button onClick={() => void verify({ pin: "1234" })}>send pin</button>
|
||||
),
|
||||
});
|
||||
authMethodsApi.submitLoginMethod.mockResolvedValue({
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
temp_token: "pending-1",
|
||||
second_factors: [{ id: "pin", pluginId: "corp", labelKey: "corp.pin" }],
|
||||
});
|
||||
authMethodsApi.verifySecondFactor.mockResolvedValue({
|
||||
success: true,
|
||||
username: "alice",
|
||||
});
|
||||
const onLogin = vi.fn();
|
||||
try {
|
||||
render(<Auth onLogin={onLogin} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0),
|
||||
);
|
||||
fireEvent.click(screen.getAllByText("auth.external")[0]);
|
||||
fireEvent.click(await screen.findByText("corp sign in"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.submitLoginMethod).toHaveBeenCalledWith(
|
||||
"corp-form",
|
||||
expect.objectContaining({ token: "abc" }),
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("send pin"));
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith(
|
||||
"pin",
|
||||
expect.objectContaining({ pin: "1234", temp_token: "pending-1" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onLogin).toHaveBeenCalledWith("alice", "u1", false),
|
||||
);
|
||||
} finally {
|
||||
disposeMethod();
|
||||
disposeFactor();
|
||||
}
|
||||
});
|
||||
|
||||
it("picks up the second factor step after a redirect login", async () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/?second_factor=1&second_factors=totp",
|
||||
);
|
||||
render(<Auth onLogin={vi.fn()} />);
|
||||
const input = await screen.findByPlaceholderText("000000");
|
||||
// Backup codes are letters and digits, and are accepted now.
|
||||
fireEvent.change(input, { target: { value: "abcd-2345" } });
|
||||
expect((input as HTMLInputElement).value).toBe("ABCD2345");
|
||||
authMethodsApi.verifySecondFactor.mockResolvedValue({ success: true });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
await waitFor(() =>
|
||||
expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("totp", {
|
||||
totp_code: "ABCD2345",
|
||||
rememberMe: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ElectronLoginForm } from "../../auth/ElectronLoginForm";
|
||||
|
||||
@@ -23,4 +23,35 @@ describe("ElectronLoginForm", () => {
|
||||
expect(permissions).toContain("publickey-credentials-get");
|
||||
expect(permissions).toContain("publickey-credentials-create");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"auth_component",
|
||||
"totp_auth_component",
|
||||
"passkey_auth_component",
|
||||
"method_auth_component",
|
||||
])("accepts a login hand-off from %s", async (source) => {
|
||||
const onAuthSuccess = vi.fn();
|
||||
render(
|
||||
<ElectronLoginForm
|
||||
serverUrl="https://termix.example.com"
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
onChangeServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const frame = screen.getByTitle(
|
||||
"Server Authentication",
|
||||
) as HTMLIFrameElement;
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
data: {
|
||||
type: "AUTH_SUCCESS",
|
||||
platform: "desktop",
|
||||
source,
|
||||
token: "jwt-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({ getSshAuthProviders: vi.fn() }));
|
||||
vi.mock("@/api/auth-methods-api", () => api);
|
||||
|
||||
import {
|
||||
invalidateSshAuthProviders,
|
||||
useSshAuthProviders,
|
||||
} from "@/hooks/useSshAuthProviders";
|
||||
import { registerSshAuthEditor } from "@/plugin-host/auth-registry";
|
||||
|
||||
function summary(type: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type,
|
||||
labelKey: `hosts.filterAuth${type}`,
|
||||
pluginId: "core",
|
||||
fields: [],
|
||||
credentialType: false,
|
||||
needsUserInteraction: false,
|
||||
supportsBackground: true,
|
||||
available: true,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
invalidateSshAuthProviders();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useSshAuthProviders", () => {
|
||||
it("lists the server's types, flags ones whose plugin is off, and merges editors", async () => {
|
||||
api.getSshAuthProviders.mockResolvedValue([
|
||||
summary("password", { credentialType: true }),
|
||||
summary("corp", { pluginId: "corp" }),
|
||||
summary("gone", {
|
||||
available: false,
|
||||
pluginId: "gone-plugin",
|
||||
missingPlugin: { id: "gone-plugin", name: "Gone" },
|
||||
}),
|
||||
]);
|
||||
const dispose = registerSshAuthEditor({
|
||||
id: "corp",
|
||||
pluginId: "corp",
|
||||
titleKey: "corp:title",
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useSshAuthProviders());
|
||||
await waitFor(() => expect(result.current.loaded).toBe(true));
|
||||
expect(result.current.providers.map((p) => p.type)).toEqual([
|
||||
"password",
|
||||
"corp",
|
||||
"gone",
|
||||
]);
|
||||
expect(result.current.find("corp")?.editorTitleKey).toBe("corp:title");
|
||||
expect(result.current.find("gone")).toMatchObject({
|
||||
available: false,
|
||||
missingPlugin: { name: "Gone" },
|
||||
});
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers the built-in types while the server has not answered", () => {
|
||||
api.getSshAuthProviders.mockReturnValue(new Promise(() => {}));
|
||||
const { result } = renderHook(() => useSshAuthProviders());
|
||||
expect(result.current.loaded).toBe(false);
|
||||
expect(result.current.providers.map((p) => p.type)).toEqual([
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"agent",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUserSecondFactors: vi.fn(),
|
||||
resetUserSecondFactors: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/auth-methods-api", () => api);
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.plugin ? `${key}:${opts.plugin}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { AdminSecondFactorsSection } from "@/sidebar/AdminSecondFactorsSection";
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("AdminSecondFactorsSection", () => {
|
||||
it("shows a factor whose plugin is off and resets after confirming", async () => {
|
||||
api.getUserSecondFactors.mockResolvedValue([
|
||||
{
|
||||
pluginId: "core",
|
||||
factorId: "totp",
|
||||
labelKey: "auth.totpFactor",
|
||||
available: true,
|
||||
},
|
||||
{ pluginId: "yubi", factorId: "otp", labelKey: null, available: false },
|
||||
]);
|
||||
api.resetUserSecondFactors.mockResolvedValue(undefined);
|
||||
const onReset = vi.fn();
|
||||
const requestConfirm = vi.fn((_message: string, onConfirm: () => void) =>
|
||||
onConfirm(),
|
||||
);
|
||||
render(
|
||||
<AdminSecondFactorsSection
|
||||
userId="u1"
|
||||
username="alice"
|
||||
heading={<h2>factors</h2>}
|
||||
requestConfirm={requestConfirm}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("admin.secondFactorUnavailable:yubi"),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("admin.resetSecondFactors"));
|
||||
expect(requestConfirm).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(api.resetUserSecondFactors).toHaveBeenCalledWith("u1"),
|
||||
);
|
||||
expect(onReset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -69,11 +69,12 @@ describe("sanitizeCredentialSidebarPreferences", () => {
|
||||
it("filters out invalid entries from filter arrays instead of rejecting them", () => {
|
||||
const result = sanitizeCredentialSidebarPreferences({
|
||||
filters: {
|
||||
type: ["key", "bogus"],
|
||||
type: ["key", "corp-token", "Bad Type!", 3],
|
||||
tags: ["prod", 42, null],
|
||||
},
|
||||
});
|
||||
expect(result.filters.type).toEqual(["key"]);
|
||||
// Plugins add credential types, so any well-formed id is kept.
|
||||
expect(result.filters.type).toEqual(["key", "corp-token"]);
|
||||
expect(result.filters.tags).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -118,12 +118,13 @@ describe("sanitizeHostSidebarPreferences", () => {
|
||||
const result = sanitizeHostSidebarPreferences({
|
||||
filters: {
|
||||
status: ["online", "bogus", "pinned"],
|
||||
authType: ["key", "nope"],
|
||||
authType: ["key", "corp-sso", "Not A Type!", 7],
|
||||
tags: ["prod", 42, null],
|
||||
},
|
||||
});
|
||||
expect(result.filters.status).toEqual(["online", "pinned"]);
|
||||
expect(result.filters.authType).toEqual(["key"]);
|
||||
// Plugins add auth types, so any well-formed id is kept.
|
||||
expect(result.filters.authType).toEqual(["key", "corp-sso"]);
|
||||
expect(result.filters.tags).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user