nexus/ui/src/components/settings/CloudProvidersSection.tsx
Nexus Dev 1a0d611cb1 refactor(nexus): consolidate Settings into single-column scroll page (phase 13)
Rewrites InstanceGeneralSettings.tsx into the consolidated /instance/
settings/general destination per spec §8.1 — a single-column scroll
that stacks eight section cards (Workspace, Local AI, Cloud Providers,
Skills, Routines, Telegram, About, Danger Zone).

App.tsx nested settings sub-routes collapse:
 - /instance/settings/heartbeats → redirect to /general
 - /instance/settings/experimental → redirect to /general
The /instance/settings/plugins tree is left intact: it is a
plugin-system surface with its own pages, not a settings sub-page.

Deletes InstanceSidebar.tsx (already unmounted by Phase 8),
InstanceSettings.tsx (scheduler heartbeats dashboard, folded out per
spec), and InstanceExperimentalSettings.tsx (experimental toggles are
now part of the Workspace section).

Also fixes ToastInput shape (title/tone instead of message/type) in
CloudProvidersSection and TelegramSection, and drops the unavailable
cronExpression access in RoutinesSection since the RoutineListItem
trigger Pick does not include it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:28:40 +00:00

166 lines
5 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { secretsApi } from "@/api/secrets";
import { puterProxyApi } from "@/api/puter-proxy";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsSection, SettingsRow } from "./SettingsSection";
const PROVIDERS: ReadonlyArray<{ id: "anthropic" | "openai"; label: string }> = [
{ id: "anthropic", label: "Anthropic API key" },
{ id: "openai", label: "OpenAI API key" },
];
interface ProviderRowProps {
id: "anthropic" | "openai";
label: string;
hasKey: boolean;
companyId: string | null;
}
function ProviderRow({ id, label, hasKey, companyId }: ProviderRowProps) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState("");
const queryClient = useQueryClient();
const { pushToast } = useToast();
const saveMutation = useMutation({
mutationFn: async (apiKey: string) => {
if (!companyId) throw new Error("No workspace selected");
if (!apiKey.trim()) throw new Error("API key cannot be empty");
await puterProxyApi.storeApiKey(companyId, id, apiKey.trim());
},
onSuccess: async () => {
setEditing(false);
setValue("");
pushToast({ tone: "success", title: `${label} saved.` });
if (companyId) {
await queryClient.invalidateQueries({
queryKey: queryKeys.secrets.list(companyId),
});
}
},
onError: (error) => {
pushToast({
tone: "error",
title: error instanceof Error ? error.message : `Failed to save ${label}.`,
});
},
});
if (editing) {
return (
<SettingsRow label={label} description={`Paste your ${label.toLowerCase()} to store it encrypted.`}>
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
saveMutation.mutate(value);
}}
>
<Input
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="sk-..."
autoComplete="off"
aria-label={`${label} input`}
className="h-8 w-56 text-xs focus-visible:ring-offset-background"
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={saveMutation.isPending || !value.trim() || !companyId}
>
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setEditing(false);
setValue("");
}}
>
Cancel
</Button>
</form>
</SettingsRow>
);
}
return (
<SettingsRow
label={label}
description={
hasKey
? "Key is stored encrypted in the workspace secret vault."
: "Not set — the workspace will fall back to Puter.js free tier when available."
}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
{hasKey ? "set" : "not set"}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditing(true)}
disabled={!companyId}
>
{hasKey ? "Replace" : "Set key"}
</Button>
</div>
</SettingsRow>
);
}
export function CloudProvidersSection() {
const { selectedCompanyId } = useCompany();
const secretsQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => (selectedCompanyId ? secretsApi.list(selectedCompanyId) : Promise.resolve([])),
enabled: Boolean(selectedCompanyId),
});
const secretNames = new Set(
(secretsQuery.data ?? []).map((s) => s.name.toLowerCase()),
);
return (
<SettingsSection
title="Cloud providers"
description="Bring your own API keys. Values are masked on display and never logged."
>
{PROVIDERS.map((provider) => (
<ProviderRow
key={provider.id}
id={provider.id}
label={provider.label}
hasKey={secretNames.has(`${provider.id}_api_key`)}
companyId={selectedCompanyId}
/>
))}
<SettingsRow
label="Puter.js"
description="Zero-config free tier that routes through the Puter cloud when no API key is set."
>
<span className="text-xs text-muted-foreground">Enabled</span>
</SettingsRow>
{!selectedCompanyId ? (
<div className="rounded-md border border-border/60 bg-transparent px-3 py-2 text-xs text-muted-foreground">
Select a workspace to configure API keys.
</div>
) : null}
</SettingsSection>
);
}