mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: convert secret-sources into a plugin (B20)
This commit is contained in:
1 parent
2ac7779eda
commit
f4f26a9b8b
72 files changed
+18843
-933
No files matched your search
@@ -33,27 +33,6 @@ 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[]> {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -611,22 +611,6 @@
|
||||
"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",
|
||||
@@ -2103,9 +2087,6 @@
|
||||
"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",
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
getNotificationPrivateEndpoints,
|
||||
getStepCaPrivateEndpoints,
|
||||
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
|
||||
getSecretSourcePrivateEndpoints,
|
||||
setSecretSourcePrivateEndpoints as setSecretSourcePrivateEndpointsApi,
|
||||
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
||||
} from "@/api/private-endpoints-api";
|
||||
import {
|
||||
@@ -142,8 +140,6 @@ export function AdminSettingsPanel({
|
||||
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [secretSourcePrivateEndpoints, setSecretSourcePrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
const [stepCaSettings, setStepCaSettings] = useState({
|
||||
caUrl: "",
|
||||
fingerprint: "",
|
||||
@@ -351,7 +347,6 @@ export function AdminSettingsPanel({
|
||||
analytics,
|
||||
notificationEndpoints,
|
||||
stepCaEndpoints,
|
||||
secretSourceEndpoints,
|
||||
branding,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
@@ -366,7 +361,6 @@ export function AdminSettingsPanel({
|
||||
getAnalyticsEnabled(),
|
||||
getNotificationPrivateEndpoints(),
|
||||
getStepCaPrivateEndpoints(),
|
||||
getSecretSourcePrivateEndpoints(),
|
||||
getBranding(),
|
||||
]);
|
||||
|
||||
@@ -401,9 +395,6 @@ export function AdminSettingsPanel({
|
||||
if (stepCaEndpoints.status === "fulfilled") {
|
||||
setStepCaPrivateEndpoints(stepCaEndpoints.value);
|
||||
}
|
||||
if (secretSourceEndpoints.status === "fulfilled") {
|
||||
setSecretSourcePrivateEndpoints(secretSourceEndpoints.value);
|
||||
}
|
||||
if (notificationEndpoints.status === "fulfilled") {
|
||||
setNotificationPrivateEndpoints(notificationEndpoints.value);
|
||||
}
|
||||
@@ -541,19 +532,6 @@ 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);
|
||||
@@ -1033,10 +1011,6 @@ export function AdminSettingsPanel({
|
||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
|
||||
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
|
||||
secretSourcePrivateEndpoints={secretSourcePrivateEndpoints}
|
||||
onSaveSecretSourcePrivateEndpoints={
|
||||
handleSaveSecretSourcePrivateEndpoints
|
||||
}
|
||||
stepCaSettings={stepCaSettings}
|
||||
setStepCaSettings={setStepCaSettings}
|
||||
handleSaveStepCaSettings={handleSaveStepCaSettings}
|
||||
|
||||
@@ -31,8 +31,6 @@ 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 }>
|
||||
@@ -72,8 +70,6 @@ export function AdminGeneralSettingsSection({
|
||||
onSaveNotificationPrivateEndpoints,
|
||||
stepCaPrivateEndpoints,
|
||||
onSaveStepCaPrivateEndpoints,
|
||||
secretSourcePrivateEndpoints,
|
||||
onSaveSecretSourcePrivateEndpoints,
|
||||
stepCaSettings,
|
||||
setStepCaSettings,
|
||||
handleSaveStepCaSettings,
|
||||
@@ -167,28 +163,6 @@ 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,8 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
SecretReferenceHint,
|
||||
SecretSourceManager,
|
||||
} from "./SecretSourceManager";
|
||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { Copy, Info, Lock, Upload, X } from "lucide-react";
|
||||
@@ -296,12 +293,14 @@ export function CredentialEditorView({
|
||||
value={credForm.password}
|
||||
onChange={(e) => setCredField("password", e.target.value)}
|
||||
/>
|
||||
<SecretReferenceHint
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
{showSecretSources && (
|
||||
<SecretSourceManager
|
||||
<PluginComponent
|
||||
id="secret-sources.manager"
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -89,10 +89,7 @@ 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,
|
||||
} from "./SecretSourceManager";
|
||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||
import {
|
||||
toCredentialOption,
|
||||
type CredentialOption,
|
||||
@@ -698,14 +695,16 @@ export function HostEditor({
|
||||
}}
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
/>
|
||||
<SecretReferenceHint
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(authMethod === "password" || authMethod === "key") &&
|
||||
showSecretSources && (
|
||||
<SecretSourceManager
|
||||
<PluginComponent
|
||||
id="secret-sources.manager"
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
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