mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* feat: 1Password Connect secret sources for SSH credentials Hosts and credentials can hold op://vault/item/field references instead of secrets; they are resolved at connect time from the user's secret source (1Password Connect) at the single point where every subsystem receives plaintext credentials, so terminal, SFTP, Docker, metrics and tunnels all work without per-subsystem changes. Sources are per user, optionally shared, with the access token encrypted under the owner's data key; resolved values are cached briefly in memory. * style: format secret source changes
587 lines
23 KiB
TypeScript
587 lines
23 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import {
|
|
SecretReferenceHint,
|
|
SecretSourceManager,
|
|
} from "./SecretSourceManager";
|
|
import { useTranslation } from "react-i18next";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import { Copy, Info, Lock, Upload, X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/button";
|
|
import { Input } from "@/components/input";
|
|
import { PasswordInput } from "@/components/password-input";
|
|
import { SectionCard } from "@/components/section-card";
|
|
import {
|
|
createCredential,
|
|
generateKeyPair,
|
|
generatePublicKeyFromPrivate,
|
|
updateCredential,
|
|
duplicateCredential,
|
|
adminCreateUserCredential,
|
|
adminUpdateUserCredential,
|
|
} from "@/main-axios";
|
|
import type { Credential, Host } from "@/types/ui-types";
|
|
import { FolderPathPicker } from "./FolderPathPicker";
|
|
|
|
export function CredentialEditorView({
|
|
credential,
|
|
activeTab,
|
|
onBack,
|
|
onSave,
|
|
adminTargetUserId,
|
|
existingFolders = [],
|
|
saveAsNewHost,
|
|
}: {
|
|
credential: Credential | null;
|
|
activeTab: string;
|
|
onBack: () => void;
|
|
onSave: (
|
|
saved: Record<string, unknown>,
|
|
options?: { assignToHost?: boolean },
|
|
) => void;
|
|
adminTargetUserId?: string;
|
|
existingFolders?: string[];
|
|
// When set, this credential is being edited from within a host's editor;
|
|
// shows a "Save as New" action that clones it and reassigns the host.
|
|
saveAsNewHost?: Host | "new";
|
|
}) {
|
|
const [showSecretSources, setShowSecretSources] = useState(false);
|
|
const [credForm, setCredForm] = useState(() => ({
|
|
name: credential?.name ?? "",
|
|
username: credential?.username ?? "",
|
|
folder: credential?.folder ?? "",
|
|
description: credential?.description ?? "",
|
|
tags: credential?.tags ?? ([] as string[]),
|
|
tagInput: "",
|
|
type: credential?.type ?? "password",
|
|
value: credential?.type === "key" ? (credential?.value ?? "") : "",
|
|
password:
|
|
credential?.type === "password"
|
|
? (credential?.value ?? "")
|
|
: (credential?.password ?? ""),
|
|
publicKey: credential?.publicKey ?? "",
|
|
passphrase: credential?.passphrase ?? "",
|
|
certPublicKey: credential?.certPublicKey ?? "",
|
|
}));
|
|
const { t } = useTranslation();
|
|
const [generatingKey, setGeneratingKey] = useState(false);
|
|
const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
|
|
const credFileInputRef = useRef<HTMLInputElement>(null);
|
|
const certFileInputRef = useRef<HTMLInputElement>(null);
|
|
const setCredField = <K extends keyof typeof credForm>(
|
|
k: K,
|
|
v: (typeof credForm)[K],
|
|
) => setCredForm((p) => ({ ...p, [k]: v }));
|
|
const [saving, setSaving] = useState(false);
|
|
const [savingAsNew, setSavingAsNew] = useState(false);
|
|
|
|
const buildCredentialData = () => {
|
|
const hasKey =
|
|
credForm.value === "existing_key" || credForm.value.trim() !== "";
|
|
return {
|
|
hasKey,
|
|
data: {
|
|
name: credForm.name,
|
|
username: credForm.username,
|
|
folder: credForm.folder || null,
|
|
description: credForm.description || null,
|
|
tags: credForm.tags,
|
|
authType: hasKey ? "key" : "password",
|
|
password: credForm.password || null,
|
|
key: hasKey
|
|
? credForm.value === "existing_key"
|
|
? undefined
|
|
: credForm.value || null
|
|
: null,
|
|
publicKey: hasKey ? credForm.publicKey : null,
|
|
certPublicKey: hasKey ? credForm.certPublicKey || null : null,
|
|
keyPassword: hasKey
|
|
? credForm.passphrase === "existing_key_password"
|
|
? undefined
|
|
: credForm.passphrase || null
|
|
: null,
|
|
},
|
|
};
|
|
};
|
|
|
|
const validateForm = () => {
|
|
if (!credForm.name.trim()) {
|
|
toast.error(t("hosts.credentialNameRequired"));
|
|
return false;
|
|
}
|
|
const hasKey =
|
|
credForm.value === "existing_key" || credForm.value.trim() !== "";
|
|
if (!hasKey && !credForm.password) {
|
|
toast.error(t("hosts.credentialAuthRequired"));
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!validateForm()) return;
|
|
setSaving(true);
|
|
try {
|
|
const { data } = buildCredentialData();
|
|
let saved: Record<string, unknown>;
|
|
if (adminTargetUserId) {
|
|
saved = credential
|
|
? await adminUpdateUserCredential(
|
|
adminTargetUserId,
|
|
Number(credential.id),
|
|
data,
|
|
)
|
|
: await adminCreateUserCredential(adminTargetUserId, data);
|
|
} else {
|
|
saved = credential
|
|
? await updateCredential(Number(credential.id), data)
|
|
: await createCredential(data);
|
|
}
|
|
toast.success(
|
|
credential
|
|
? t("hosts.credentialUpdated")
|
|
: t("hosts.credentialCreated"),
|
|
);
|
|
if (!adminTargetUserId) {
|
|
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
|
}
|
|
onSave(saved);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : null;
|
|
toast.error(msg || t("hosts.failedToSaveCredential"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveAsNew = async () => {
|
|
if (!credential || adminTargetUserId) return;
|
|
if (!validateForm()) return;
|
|
setSavingAsNew(true);
|
|
try {
|
|
const { data } = buildCredentialData();
|
|
const saved = await duplicateCredential(Number(credential.id), data);
|
|
toast.success(t("hosts.credentialSavedAsNew"));
|
|
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
|
onSave(saved, { assignToHost: true });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : null;
|
|
toast.error(msg || t("hosts.failedToSaveCredentialAsNew"));
|
|
} finally {
|
|
setSavingAsNew(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{activeTab === "general" && (
|
|
<SectionCard
|
|
title={t("hosts.basicInformation")}
|
|
icon={<Info className="size-3.5" />}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.friendlyNameLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder="e.g. Production SSH Key"
|
|
value={credForm.name}
|
|
onChange={(e) => setCredField("name", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.folder")}
|
|
</label>
|
|
<FolderPathPicker
|
|
value={credForm.folder}
|
|
onChange={(path) => setCredField("folder", path)}
|
|
folderPaths={existingFolders}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.descriptionLabel")}
|
|
</label>
|
|
<Input
|
|
placeholder="Optional details..."
|
|
value={credForm.description}
|
|
onChange={(e) => setCredField("description", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 col-span-2">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.tags")}
|
|
</label>
|
|
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
|
|
{credForm.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
|
|
>
|
|
{tag}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setCredField(
|
|
"tags",
|
|
credForm.tags.filter((tg) => tg !== tag),
|
|
)
|
|
}
|
|
className="text-muted-foreground hover:text-destructive ml-0.5"
|
|
>
|
|
<X className="size-2.5" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<input
|
|
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
|
|
placeholder={
|
|
credForm.tags.length === 0
|
|
? t("hosts.addTagsPlaceholder")
|
|
: ""
|
|
}
|
|
value={credForm.tagInput}
|
|
onChange={(e) => setCredField("tagInput", e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
(e.key === " " || e.key === "Enter") &&
|
|
credForm.tagInput.trim()
|
|
) {
|
|
e.preventDefault();
|
|
const tag = credForm.tagInput.trim();
|
|
if (!credForm.tags.includes(tag))
|
|
setCredField("tags", [...credForm.tags, tag]);
|
|
setCredField("tagInput", "");
|
|
} else if (
|
|
e.key === "Backspace" &&
|
|
!credForm.tagInput &&
|
|
credForm.tags.length > 0
|
|
) {
|
|
setCredField("tags", credForm.tags.slice(0, -1));
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
)}
|
|
|
|
{activeTab === "auth" && (
|
|
<SectionCard
|
|
title={t("hosts.authDetailsSection")}
|
|
icon={<Lock className="size-3.5" />}
|
|
>
|
|
<div className="flex flex-col gap-4 py-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.username")}
|
|
</label>
|
|
<Input
|
|
placeholder="e.g. root or deploy"
|
|
value={credForm.username}
|
|
onChange={(e) => setCredField("username", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.password")} ({t("common.optional")})
|
|
</label>
|
|
<PasswordInput
|
|
className="h-8 text-xs pr-8"
|
|
placeholder="••••••••"
|
|
value={credForm.password}
|
|
onChange={(e) => setCredField("password", e.target.value)}
|
|
/>
|
|
<SecretReferenceHint
|
|
onManage={() => setShowSecretSources((v) => !v)}
|
|
/>
|
|
</div>
|
|
{showSecretSources && (
|
|
<SecretSourceManager
|
|
onClose={() => setShowSecretSources(false)}
|
|
/>
|
|
)}
|
|
<div className="flex flex-col gap-4">
|
|
<div className="p-3 border border-border bg-muted/20">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
|
|
{t("hosts.generateKeyPairTitle")}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground mb-2">
|
|
{t("hosts.generateKeyPairDescription")}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
{ label: "Ed25519", type: "ssh-ed25519" },
|
|
{
|
|
label: "ECDSA (nistp256)",
|
|
type: "ecdsa-sha2-nistp256",
|
|
},
|
|
{ label: "RSA (2048)", type: "ssh-rsa", bits: 2048 },
|
|
].map(({ label, type: keyType, bits }) => (
|
|
<Button
|
|
key={label}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-[10px] px-2"
|
|
disabled={generatingKey}
|
|
onClick={async () => {
|
|
setGeneratingKey(true);
|
|
try {
|
|
const result = await generateKeyPair(
|
|
keyType as
|
|
"ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
|
|
bits,
|
|
credForm.passphrase === "existing_key_password"
|
|
? undefined
|
|
: credForm.passphrase || undefined,
|
|
);
|
|
if (result.success) {
|
|
setCredField("value", result.privateKey);
|
|
setCredField("publicKey", result.publicKey);
|
|
toast.success(
|
|
t("hosts.keyPairGenerated", { label }),
|
|
);
|
|
} else {
|
|
toast.error(
|
|
result.error ??
|
|
t("hosts.failedToGenerateKeyPair"),
|
|
);
|
|
}
|
|
} catch {
|
|
toast.error(t("hosts.failedToGenerateKeyPair"));
|
|
} finally {
|
|
setGeneratingKey(false);
|
|
}
|
|
}}
|
|
>
|
|
{generatingKey
|
|
? t("hosts.generatingKey")
|
|
: t("hosts.generateLabel", { label })}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.sshPrivateKey")}
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1"
|
|
onClick={() => credFileInputRef.current?.click()}
|
|
>
|
|
<Upload className="size-3" /> {t("hosts.uploadFileBtn")}
|
|
</button>
|
|
</div>
|
|
<input
|
|
ref={credFileInputRef}
|
|
type="file"
|
|
accept=".pem,.key,.ppk,.txt"
|
|
className="hidden"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const text = await file.text();
|
|
setCredField("value", text.trim());
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
{credForm.value === "existing_key" && (
|
|
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
|
|
{t("hosts.keySaved")} — {t("hosts.keyReplaceNotice")}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
|
rows={8}
|
|
value={
|
|
credForm.value === "existing_key" ? "" : credForm.value
|
|
}
|
|
onChange={(e) => setCredField("value", e.target.value)}
|
|
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.keyPassphraseOptional")}
|
|
</label>
|
|
<PasswordInput
|
|
className="h-8 text-xs pr-8"
|
|
placeholder={
|
|
credForm.passphrase === "existing_key_password"
|
|
? t("hosts.keyPassphraseSaved")
|
|
: "••••••••"
|
|
}
|
|
value={
|
|
credForm.passphrase === "existing_key_password"
|
|
? ""
|
|
: credForm.passphrase
|
|
}
|
|
onFocus={() => {
|
|
if (credForm.passphrase === "existing_key_password")
|
|
setCredField("passphrase", "");
|
|
}}
|
|
onChange={(e) => setCredField("passphrase", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("hosts.sshPublicKeyOptional")}
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
|
disabled={
|
|
!credForm.value ||
|
|
credForm.value === "existing_key" ||
|
|
generatingPublicKey
|
|
}
|
|
onClick={async () => {
|
|
setGeneratingPublicKey(true);
|
|
try {
|
|
const result = await generatePublicKeyFromPrivate(
|
|
credForm.value,
|
|
credForm.passphrase === "existing_key_password"
|
|
? undefined
|
|
: credForm.passphrase || undefined,
|
|
);
|
|
if (result?.publicKey) {
|
|
setCredField("publicKey", result.publicKey);
|
|
toast.success(t("hosts.publicKeyGenerated"));
|
|
} else {
|
|
toast.error(t("hosts.failedToGeneratePublicKey"));
|
|
}
|
|
} catch {
|
|
toast.error(t("hosts.failedToGeneratePublicKey"));
|
|
} finally {
|
|
setGeneratingPublicKey(false);
|
|
}
|
|
}}
|
|
>
|
|
{generatingPublicKey
|
|
? t("hosts.generatingKey")
|
|
: t("hosts.generateFromPrivateKey")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] px-2"
|
|
disabled={!credForm.publicKey}
|
|
onClick={() => {
|
|
copyToClipboard(credForm.publicKey ?? "");
|
|
toast.success(t("hosts.publicKeyCopied"));
|
|
}}
|
|
>
|
|
<Copy className="size-3 mr-1" /> {t("common.copy")}
|
|
</Button>
|
|
</div>
|
|
<textarea
|
|
placeholder="ssh-rsa AAAAB3Nza..."
|
|
rows={3}
|
|
value={credForm.publicKey}
|
|
onChange={(e) => setCredField("publicKey", e.target.value)}
|
|
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 p-3 border border-border bg-muted/20">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("credentials.caCertificate")}
|
|
</label>
|
|
{credForm.certPublicKey && (
|
|
<button
|
|
type="button"
|
|
className="text-[10px] text-destructive hover:text-destructive/80"
|
|
onClick={() => setCredField("certPublicKey", "")}
|
|
>
|
|
{t("credentials.clearCert")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("credentials.caCertificateDescription")}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1 self-start"
|
|
onClick={() => certFileInputRef.current?.click()}
|
|
>
|
|
<Upload className="size-3" />{" "}
|
|
{t("credentials.uploadCertFile")}
|
|
</button>
|
|
<input
|
|
ref={certFileInputRef}
|
|
type="file"
|
|
accept=".pub,.txt"
|
|
className="hidden"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const text = await file.text();
|
|
setCredField("certPublicKey", text.trim());
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<textarea
|
|
placeholder={t("credentials.pasteOrUploadCert")}
|
|
rows={2}
|
|
value={credForm.certPublicKey}
|
|
onChange={(e) =>
|
|
setCredField("certPublicKey", e.target.value)
|
|
}
|
|
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3 mt-3">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onBack}
|
|
disabled={saving || savingAsNew}
|
|
>
|
|
{t("hosts.cancelBtn")}
|
|
</Button>
|
|
{saveAsNewHost && credential && !adminTargetUserId && (
|
|
<Button
|
|
variant="outline"
|
|
className="px-6"
|
|
onClick={handleSaveAsNew}
|
|
disabled={saving || savingAsNew}
|
|
title={t("hosts.saveCredentialAsNewDesc")}
|
|
>
|
|
{savingAsNew
|
|
? t("hosts.savingBtn")
|
|
: t("hosts.saveCredentialAsNewBtn")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
|
|
onClick={handleSave}
|
|
disabled={saving || savingAsNew}
|
|
>
|
|
{saving
|
|
? t("hosts.savingBtn")
|
|
: credential
|
|
? t("hosts.updateCredentialBtn")
|
|
: t("hosts.addCredentialBtn")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|