mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: 1Password Connect secret sources for SSH credentials (#1341)
* 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
This commit is contained in:
@@ -245,6 +245,27 @@ export async function setStepCaPrivateEndpoints(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/secret-source-private-endpoints")).data
|
||||
.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSecretSourcePrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (
|
||||
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
|
||||
).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setNotificationPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface SecretSource {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
kind: "onepassword-connect";
|
||||
baseUrl: string;
|
||||
shared: boolean;
|
||||
hasToken: boolean;
|
||||
owned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SecretSourcePayload {
|
||||
name: string;
|
||||
kind?: "onepassword-connect";
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export async function listSecretSources(): Promise<SecretSource[]> {
|
||||
try {
|
||||
return (await authApi.get("/secret-sources")).data.sources;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list secret sources");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSecretSource(
|
||||
payload: SecretSourcePayload,
|
||||
): Promise<SecretSource> {
|
||||
try {
|
||||
return (await authApi.post("/secret-sources", payload)).data.source;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create secret source");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSecretSource(
|
||||
id: string,
|
||||
payload: Partial<SecretSourcePayload>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.put(`/secret-sources/${id}`, payload);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update secret source");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSecretSource(id: string): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/secret-sources/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete secret source");
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSecretSource(
|
||||
id: string,
|
||||
): Promise<{ ok: boolean; vaults?: number; error?: string }> {
|
||||
try {
|
||||
return (await authApi.post(`/secret-sources/${id}/test`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "test secret source");
|
||||
}
|
||||
}
|
||||
@@ -888,6 +888,22 @@
|
||||
"selectAVaultProfile": "Select a Vault profile...",
|
||||
"vaultProfileHint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
|
||||
"vaultNewProfile": "New profile",
|
||||
"secretRefHint": "You can paste a 1Password reference (op://vault/item/field) instead of the secret; it is fetched from your secret source when connecting.",
|
||||
"secretSourcesManage": "Manage secret sources",
|
||||
"secretSourcesTitle": "Secret sources",
|
||||
"secretSourcesDesc": "External password managers Termix reads secrets from at connect time. Currently 1Password Connect (self-hosted). The access token is stored encrypted with your data key.",
|
||||
"secretSourceNew": "New source",
|
||||
"secretSourceUrlLabel": "Connect server URL",
|
||||
"secretSourceTokenLabel": "Connect access token",
|
||||
"secretSourceTokenKeep": "Leave empty to keep the current token",
|
||||
"secretSourceSharedLabel": "Share with all users (admins only; resolves while you are signed in)",
|
||||
"secretSourceShared": "shared",
|
||||
"secretSourceTest": "Test",
|
||||
"secretSourceTestOk": "Connected — {{count}} vault(s) visible",
|
||||
"secretSourceTestFailed": "Connection test failed",
|
||||
"secretSourceRequired": "Name, server URL and token are required",
|
||||
"secretSourceSaved": "Secret source saved",
|
||||
"secretSourceDeleted": "Secret source deleted",
|
||||
"vaultManageProfiles": "Manage Vault profiles",
|
||||
"vaultAddrLabel": "Vault Address",
|
||||
"vaultNamespaceLabel": "Namespace",
|
||||
@@ -3700,6 +3716,9 @@
|
||||
"stepCaProvisioner": "OIDC provisioner name",
|
||||
"stepCaSaved": "Step CA settings saved",
|
||||
"stepCaSaveFailed": "Failed to save Step CA settings",
|
||||
"secretSourcePrivateEndpoints": "Allowed private secret source hosts",
|
||||
"secretSourcePrivateEndpointsDesc": "Private hosts that secret sources (1Password Connect) may contact. Separate them with commas.",
|
||||
"updateSecretSourceEndpointsFailed": "Failed to update the secret source endpoint allowlist",
|
||||
"stepCaPrivateEndpoints": "Allowed private Step CA hosts",
|
||||
"stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.",
|
||||
"updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
getNotificationPrivateEndpoints,
|
||||
getStepCaPrivateEndpoints,
|
||||
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
|
||||
getSecretSourcePrivateEndpoints,
|
||||
setSecretSourcePrivateEndpoints as setSecretSourcePrivateEndpointsApi,
|
||||
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
||||
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
||||
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
||||
@@ -188,6 +190,8 @@ export function AdminSettingsPanel({
|
||||
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [secretSourcePrivateEndpoints, setSecretSourcePrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
const [stepCaSettings, setStepCaSettings] = useState({
|
||||
caUrl: "",
|
||||
fingerprint: "",
|
||||
@@ -404,6 +408,7 @@ export function AdminSettingsPanel({
|
||||
aiEndpoints,
|
||||
notificationEndpoints,
|
||||
stepCaEndpoints,
|
||||
secretSourceEndpoints,
|
||||
imageStorage,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
@@ -424,6 +429,7 @@ export function AdminSettingsPanel({
|
||||
getAiPrivateEndpoints(),
|
||||
getNotificationPrivateEndpoints(),
|
||||
getStepCaPrivateEndpoints(),
|
||||
getSecretSourcePrivateEndpoints(),
|
||||
getTerminalImageStorageSettings(),
|
||||
]);
|
||||
|
||||
@@ -479,6 +485,9 @@ export function AdminSettingsPanel({
|
||||
if (stepCaEndpoints.status === "fulfilled") {
|
||||
setStepCaPrivateEndpoints(stepCaEndpoints.value);
|
||||
}
|
||||
if (secretSourceEndpoints.status === "fulfilled") {
|
||||
setSecretSourcePrivateEndpoints(secretSourceEndpoints.value);
|
||||
}
|
||||
if (notificationEndpoints.status === "fulfilled") {
|
||||
setNotificationPrivateEndpoints(notificationEndpoints.value);
|
||||
}
|
||||
@@ -652,6 +661,19 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSecretSourcePrivateEndpoints(hosts: string[]) {
|
||||
const previous = secretSourcePrivateEndpoints;
|
||||
setSecretSourcePrivateEndpoints(hosts);
|
||||
try {
|
||||
setSecretSourcePrivateEndpoints(
|
||||
await setSecretSourcePrivateEndpointsApi(hosts),
|
||||
);
|
||||
} catch {
|
||||
setSecretSourcePrivateEndpoints(previous);
|
||||
toast.error(t("admin.updateSecretSourceEndpointsFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveStepCaPrivateEndpoints(hosts: string[]) {
|
||||
const previous = stepCaPrivateEndpoints;
|
||||
setStepCaPrivateEndpoints(hosts);
|
||||
@@ -1218,6 +1240,10 @@ export function AdminSettingsPanel({
|
||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
|
||||
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
|
||||
secretSourcePrivateEndpoints={secretSourcePrivateEndpoints}
|
||||
onSaveSecretSourcePrivateEndpoints={
|
||||
handleSaveSecretSourcePrivateEndpoints
|
||||
}
|
||||
stepCaSettings={stepCaSettings}
|
||||
setStepCaSettings={setStepCaSettings}
|
||||
handleSaveStepCaSettings={handleSaveStepCaSettings}
|
||||
|
||||
@@ -35,6 +35,8 @@ type GeneralSettingsSectionProps = {
|
||||
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
|
||||
stepCaPrivateEndpoints: string[];
|
||||
onSaveStepCaPrivateEndpoints: (hosts: string[]) => void;
|
||||
secretSourcePrivateEndpoints: string[];
|
||||
onSaveSecretSourcePrivateEndpoints: (hosts: string[]) => void;
|
||||
stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string };
|
||||
setStepCaSettings: Dispatch<
|
||||
SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }>
|
||||
@@ -96,6 +98,8 @@ export function AdminGeneralSettingsSection({
|
||||
onSaveNotificationPrivateEndpoints,
|
||||
stepCaPrivateEndpoints,
|
||||
onSaveStepCaPrivateEndpoints,
|
||||
secretSourcePrivateEndpoints,
|
||||
onSaveSecretSourcePrivateEndpoints,
|
||||
stepCaSettings,
|
||||
setStepCaSettings,
|
||||
handleSaveStepCaSettings,
|
||||
@@ -249,6 +253,28 @@ export function AdminGeneralSettingsSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.secretSourcePrivateEndpoints")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("admin.secretSourcePrivateEndpointsDesc")}
|
||||
</span>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
defaultValue={secretSourcePrivateEndpoints.join(", ")}
|
||||
placeholder="connect.internal, 10.0.0.5"
|
||||
onBlur={(event) =>
|
||||
onSaveSecretSourcePrivateEndpoints(
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.stepCa")}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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";
|
||||
@@ -42,6 +46,7 @@ export function CredentialEditorView({
|
||||
// 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 ?? "",
|
||||
@@ -291,7 +296,15 @@ export function CredentialEditorView({
|
||||
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">
|
||||
|
||||
@@ -94,6 +94,10 @@ import {
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import { HostStatsTab } from "./HostEditorStatsTab";
|
||||
import { VaultProfileManager } from "./VaultProfileManager";
|
||||
import {
|
||||
SecretReferenceHint,
|
||||
SecretSourceManager,
|
||||
} from "./SecretSourceManager";
|
||||
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
|
||||
import {
|
||||
toCredentialOption,
|
||||
@@ -190,6 +194,7 @@ export function HostEditor({
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||
const [showSecretSources, setShowSecretSources] = useState(false);
|
||||
const [quickCredentialName, setQuickCredentialName] = useState("");
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
|
||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||
@@ -697,8 +702,17 @@ export function HostEditor({
|
||||
}}
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
/>
|
||||
<SecretReferenceHint
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(authMethod === "password" || authMethod === "key") &&
|
||||
showSecretSources && (
|
||||
<SecretSourceManager
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
{authMethod === "key" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, Pencil, Plus, Trash2, 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 { getErrorMessage } from "@/lib/error-message";
|
||||
import {
|
||||
createSecretSource,
|
||||
deleteSecretSource,
|
||||
listSecretSources,
|
||||
testSecretSource,
|
||||
updateSecretSource,
|
||||
type SecretSource,
|
||||
} from "@/api/secret-sources-api";
|
||||
|
||||
/** One line under a secret field: references are allowed, here is where to set them up. */
|
||||
export function SecretReferenceHint({ onManage }: { onManage: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.secretRefHint")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent-brand hover:underline"
|
||||
onClick={onManage}
|
||||
>
|
||||
{t("hosts.secretSourcesManage")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
id?: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
shared: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
token: "",
|
||||
shared: false,
|
||||
};
|
||||
|
||||
export function SecretSourceManager({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [sources, setSources] = useState<SecretSource[]>([]);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setSources(await listSecretSources());
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
if (
|
||||
!form.name.trim() ||
|
||||
!form.baseUrl.trim() ||
|
||||
(!form.id && !form.token)
|
||||
) {
|
||||
toast.error(t("hosts.secretSourceRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateSecretSource(form.id, {
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
shared: form.shared,
|
||||
...(form.token ? { token: form.token } : {}),
|
||||
});
|
||||
} else {
|
||||
await createSecretSource({
|
||||
name: form.name,
|
||||
baseUrl: form.baseUrl,
|
||||
token: form.token,
|
||||
shared: form.shared,
|
||||
});
|
||||
}
|
||||
toast.success(t("hosts.secretSourceSaved"));
|
||||
setForm(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (source: SecretSource) => {
|
||||
try {
|
||||
await deleteSecretSource(source.id);
|
||||
toast.success(t("hosts.secretSourceDeleted"));
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (source: SecretSource) => {
|
||||
setTesting(source.id);
|
||||
try {
|
||||
const result = await testSecretSource(source.id);
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
t("hosts.secretSourceTestOk", { count: result.vaults ?? 0 }),
|
||||
);
|
||||
} else {
|
||||
toast.error(result.error ?? t("hosts.secretSourceTestFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourcesTitle")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.secretSourcesDesc")}
|
||||
</p>
|
||||
|
||||
{!form && (
|
||||
<>
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs"
|
||||
>
|
||||
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate">
|
||||
{source.name}
|
||||
{source.shared && (
|
||||
<span className="ml-1 text-[9px] uppercase text-muted-foreground">
|
||||
{t("hosts.secretSourceShared")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{source.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
disabled={testing === source.id}
|
||||
onClick={() => void handleTest(source)}
|
||||
>
|
||||
{t("hosts.secretSourceTest")}
|
||||
</Button>
|
||||
{source.owned && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
baseUrl: source.baseUrl,
|
||||
token: "",
|
||||
shared: source.shared,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void handleDelete(source)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => setForm(emptyForm)}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.secretSourceNew")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="Team 1Password"
|
||||
value={form.name}
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourceUrlLabel")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="https://connect.internal:8080"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setField("baseUrl", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.secretSourceTokenLabel")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={
|
||||
form.id ? t("hosts.secretSourceTokenKeep") : "eyJhbGciOi..."
|
||||
}
|
||||
value={form.token}
|
||||
onChange={(e) => setField("token", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.shared}
|
||||
onChange={(e) => setField("shared", e.target.checked)}
|
||||
/>
|
||||
{t("hosts.secretSourceSharedLabel")}
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setForm(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
>
|
||||
{form.id ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user