mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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,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;
|
||||
|
||||
Reference in new issue
Block a user