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>
166 lines
5 KiB
TypeScript
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>
|
|
);
|
|
}
|