Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/react/src/components/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ function TooltipContent({
className,
sideOffset = 0,
children,
hideArrow = false,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
hideArrow?: boolean;
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
Expand All @@ -42,7 +45,9 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
{!hideArrow && (
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
)}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
Expand Down
53 changes: 46 additions & 7 deletions packages/react/src/pages/secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, Suspense } from "react";
import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
import { secretsAtom, setSecret, removeSecret } from "../api/atoms";
import { secretWriteKeys } from "../api/reactivity-keys";
import { useUniqueSecretIdInput } from "../plugins/secret-id";
import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin";
import { SecretId } from "@executor/sdk";
import { useScope } from "../hooks/use-scope";
Expand Down Expand Up @@ -41,6 +42,7 @@ import {
CardStackHeader,
} from "../components/card-stack";
import { Badge } from "../components/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../components/tooltip";

type SecretStorageOption = {
readonly label: string;
Expand All @@ -62,9 +64,9 @@ function AddSecretDialog(props: {
onOpenChange: (v: boolean) => void;
description: string;
storageOptions: readonly SecretStorageOption[];
existingSecretIds: readonly string[];
}) {
const initialProvider = props.storageOptions[0]?.value ?? "auto";
const [id, setId] = useState("");
const [name, setName] = useState("");
const [value, setValue] = useState("");
const [provider, setProvider] = useState(initialProvider);
Expand All @@ -73,9 +75,19 @@ function AddSecretDialog(props: {

const scopeId = useScope();
const doSet = useAtomSet(setSecret, { mode: "promise" });
const {
secretId: id,
duplicateError,
setSecretIdOverride,
resetSecretIdOverride,
} = useUniqueSecretIdInput({
baseName: name,
existingSecretIds: props.existingSecretIds,
fallbackId: "",
});

const reset = () => {
setId("");
resetSecretIdOverride();
setName("");
setValue("");
setProvider(initialProvider);
Expand All @@ -84,7 +96,7 @@ function AddSecretDialog(props: {
};

const handleSave = async () => {
if (!id.trim() || !name.trim() || !value.trim()) return;
if (!id.trim() || !name.trim() || !value.trim() || duplicateError) return;
setSaving(true);
setError(null);
try {
Expand Down Expand Up @@ -135,9 +147,12 @@ function AddSecretDialog(props: {
id="secret-id"
placeholder="github-token"
value={id}
onChange={(e) => setId((e.target as HTMLInputElement).value)}
onChange={(e) => setSecretIdOverride((e.target as HTMLInputElement).value)}
className="font-mono text-xs h-9"
/>
{duplicateError && (
<p className="text-xs text-destructive">{duplicateError}</p>
)}
</div>
<div className="grid gap-1.5">
<Label
Expand Down Expand Up @@ -214,7 +229,7 @@ function AddSecretDialog(props: {
<Button
size="sm"
onClick={handleSave}
disabled={!id.trim() || !name.trim() || !value.trim() || saving}
disabled={!id.trim() || !name.trim() || !value.trim() || !!duplicateError || saving}
>
{saving ? "Saving…" : "Save secret"}
</Button>
Expand All @@ -238,8 +253,26 @@ function SecretRow(props: {
return (
<CardStackEntry>
<CardStackEntryContent>
<CardStackEntryTitle className="flex items-center gap-2">
<span className="truncate">{secret.name}</span>
<CardStackEntryTitle className="flex min-w-0 items-center gap-2">
<span className="min-w-0 shrink truncate" title={secret.name}>
{secret.name}
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-40 shrink truncate font-mono text-xs text-muted-foreground">
{secret.id}
</span>
</TooltipTrigger>
<TooltipContent
sideOffset={6}
hideArrow
className="border border-emerald-500/60 bg-black font-mono text-foreground shadow-none"
>
{secret.id}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardStackEntryTitle>
</CardStackEntryContent>
<CardStackEntryActions>
Expand Down Expand Up @@ -291,6 +324,11 @@ export function SecretsPage(props: {
const [addOpen, setAddOpen] = useState(false);
const scopeId = useScope();
const secrets = useAtomValue(secretsAtom(scopeId));
const existingSecretIds = Result.match(secrets, {
onInitial: () => [] as string[],
onFailure: () => [] as string[],
onSuccess: ({ value }) => value.map((secret) => secret.id),
});
const doRemove = useAtomSet(removeSecret, { mode: "promise" });

const handleRemove = async (secretId: string) => {
Expand Down Expand Up @@ -407,6 +445,7 @@ export function SecretsPage(props: {
onOpenChange={setAddOpen}
description={addSecretDescription}
storageOptions={storageOptions}
existingSecretIds={existingSecretIds}
/>
</div>
</div>
Expand Down
30 changes: 17 additions & 13 deletions packages/react/src/plugins/secret-header-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Button } from "../components/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field";
import { Input } from "../components/input";
import { Spinner } from "../components/spinner";
import { slugifyForSecretId, useUniqueSecretIdInput } from "./secret-id";
import { SecretPicker, type SecretPickerSecret } from "./secret-picker";
import { SecretId } from "@executor/sdk";

Expand Down Expand Up @@ -60,22 +61,14 @@ function SecretVisibilityIcon(props: { revealed: boolean }) {
);
}

function slugifyForSecretId(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}

export function InlineCreateSecret(props: {
suggestedId: string;
suggestedName: string;
existingSecretIds: readonly string[];
onCreated: (secretId: string) => void;
onCancel: () => void;
}) {
const [nameOverride, setNameOverride] = useState<string | null>(null);
const [idOverride, setIdOverride] = useState<string | null>(null);
const [secretValue, setSecretValue] = useState("");
const [secretRevealed, setSecretRevealed] = useState(false);
const [saving, setSaving] = useState(false);
Expand All @@ -87,10 +80,18 @@ export function InlineCreateSecret(props: {
const secretValueInputId = useId();

const secretName = nameOverride ?? props.suggestedName;
const secretId = idOverride ?? (slugifyForSecretId(secretName) || "custom-header");
const {
secretId,
duplicateError,
setSecretIdOverride,
} = useUniqueSecretIdInput({
baseName: secretName,
existingSecretIds: props.existingSecretIds,
fallbackId: props.suggestedId || "custom-header",
});

const handleSave = async () => {
if (!secretId.trim() || !secretValue.trim()) return;
if (!secretId.trim() || !secretValue.trim() || duplicateError) return;
setSaving(true);
setError(null);
try {
Expand Down Expand Up @@ -129,10 +130,11 @@ export function InlineCreateSecret(props: {
<Input
id={secretIdInputId}
value={secretId}
onChange={(e) => setIdOverride((e.target as HTMLInputElement).value)}
onChange={(e) => setSecretIdOverride((e.target as HTMLInputElement).value)}
placeholder="my-api-token"
className="font-mono"
/>
{duplicateError && <FieldError>{duplicateError}</FieldError>}
</Field>
</div>
<Field>
Expand Down Expand Up @@ -170,7 +172,7 @@ export function InlineCreateSecret(props: {
<Button
size="xs"
onClick={handleSave}
disabled={!secretId.trim() || !secretValue.trim() || saving}
disabled={!secretId.trim() || !secretValue.trim() || !!duplicateError || saving}
>
{saving ? "Saving…" : "Create and use"}
</Button>
Expand Down Expand Up @@ -341,6 +343,7 @@ export function SecretHeaderAuthRow(props: {
<InlineCreateSecret
suggestedId={suggestedId}
suggestedName={suggestedName}
existingSecretIds={existingSecrets.map((secret) => secret.id)}
onCreated={(id) => {
onSelectSecret(id);
setCreating(false);
Expand Down Expand Up @@ -445,6 +448,7 @@ export function CreatableSecretPicker(props: {
<InlineCreateSecret
suggestedId={suggestedId}
suggestedName={suggestedName}
existingSecretIds={secrets.map((secret) => secret.id)}
onCreated={(id) => {
onSelect(id);
setCreating(false);
Expand Down
34 changes: 34 additions & 0 deletions packages/react/src/plugins/secret-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";

import {
getUniqueSecretId,
isSecretIdTaken,
slugifyForSecretId,
} from "./secret-id";

describe("secret id helpers", () => {
it("slugifies display names into secret ids", () => {
expect(slugifyForSecretId("GitHub PAT")).toBe("github-pat");
expect(slugifyForSecretId(" Client Secret ")).toBe("client-secret");
});

it("returns the base id when it is unused", () => {
expect(getUniqueSecretId("GitHub PAT", ["openai-api-key"])).toBe("github-pat");
});

it("appends a numeric suffix when the base id already exists", () => {
expect(getUniqueSecretId("GitHub PAT", ["github-pat"])).toBe("github-pat-2");
expect(getUniqueSecretId("GitHub PAT", ["github-pat", "github-pat-2"])).toBe(
"github-pat-3",
);
});

it("matches existing ids exactly after trimming", () => {
expect(isSecretIdTaken("github-pat", [" github-pat "])).toBe(true);
expect(isSecretIdTaken("github-pat", ["github-pat-2"])).toBe(false);
});

it("allows an empty fallback for flows that should start blank", () => {
expect(getUniqueSecretId("", [], "")).toBe("");
});
});
71 changes: 71 additions & 0 deletions packages/react/src/plugins/secret-id.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useMemo, useState } from "react";

export function slugifyForSecretId(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}

const normalizeSecretId = (secretId: string): string => secretId.trim();

export function isSecretIdTaken(
secretId: string,
existingSecretIds: Iterable<string>,
): boolean {
const normalizedId = normalizeSecretId(secretId);
if (!normalizedId) return false;

for (const existingSecretId of existingSecretIds) {
if (normalizeSecretId(existingSecretId) === normalizedId) {
return true;
}
}

return false;
}

export function getUniqueSecretId(
baseName: string,
existingSecretIds: Iterable<string>,
fallbackId = "secret",
): string {
const baseId = slugifyForSecretId(baseName) || fallbackId;
if (!baseId) return "";
if (!isSecretIdTaken(baseId, existingSecretIds)) return baseId;

let suffix = 2;
while (isSecretIdTaken(`${baseId}-${suffix}`, existingSecretIds)) {
suffix += 1;
}
return `${baseId}-${suffix}`;
}

export function useUniqueSecretIdInput(options: {
baseName: string;
existingSecretIds: readonly string[];
fallbackId?: string;
}) {
const { baseName, existingSecretIds, fallbackId = "secret" } = options;
const [idOverride, setIdOverride] = useState<string | null>(null);

const suggestedId = useMemo(
() => getUniqueSecretId(baseName, existingSecretIds, fallbackId),
[baseName, existingSecretIds, fallbackId],
);
const secretId = idOverride ?? suggestedId;
const duplicateError = useMemo(
() => (isSecretIdTaken(secretId, existingSecretIds) ? "Secret ID already exists" : null),
[secretId, existingSecretIds],
);

return {
secretId,
suggestedId,
duplicateError,
hasManualOverride: idOverride !== null,
setSecretIdOverride: (value: string) => setIdOverride(value),
resetSecretIdOverride: () => setIdOverride(null),
};
}
7 changes: 7 additions & 0 deletions packages/react/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
});
Loading