feat(nexus): add Telegram, About, and DangerZone settings cards (phase 13)
TelegramSection wraps /telegram/token (masked password Input) and /telegram/status so the bot token can be set or replaced inline. Values are never logged. AboutSection renders app name, version, fork lineage, and MIT license with an outbound link. DangerZoneSection marks the reset-workspace and delete-all- conversations actions from spec §8.1 as disabled placeholders — their server endpoints are not yet wired, and fabricating fake ones would break the "preserve existing functionality" rule. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b772aa1bd
commit
6aeadeeb11
8 changed files with 447 additions and 5 deletions
52
ui/src/components/settings/AboutSection.test.tsx
Normal file
52
ui/src/components/settings/AboutSection.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AboutSection } from "./AboutSection";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("AboutSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container.parentNode) container.remove();
|
||||
});
|
||||
|
||||
function render() {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<AboutSection />);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders the About header and version", () => {
|
||||
render();
|
||||
expect(container.querySelector("h2")?.textContent).toBe("About");
|
||||
expect(container.textContent).toContain("Nexus");
|
||||
expect(container.textContent).toContain("1.7-dev");
|
||||
expect(container.textContent).toContain("MIT");
|
||||
});
|
||||
|
||||
it("links the MIT label to the opensource license page", () => {
|
||||
render();
|
||||
const links = Array.from(container.querySelectorAll("a"));
|
||||
const mit = links.find((a) => a.textContent === "MIT");
|
||||
expect(mit?.getAttribute("href")).toBe("https://opensource.org/license/mit");
|
||||
});
|
||||
});
|
||||
39
ui/src/components/settings/AboutSection.tsx
Normal file
39
ui/src/components/settings/AboutSection.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { VOCAB } from "@paperclipai/branding";
|
||||
import { SettingsSection, SettingsRow } from "./SettingsSection";
|
||||
|
||||
const NEXUS_VERSION = "1.7-dev";
|
||||
|
||||
export function AboutSection() {
|
||||
return (
|
||||
<SettingsSection title="About">
|
||||
<SettingsRow label="Version">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{VOCAB.appName} {NEXUS_VERSION}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Fork of">
|
||||
<span className="font-mono text-xs text-muted-foreground">Paperclip</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="License">
|
||||
<a
|
||||
href="https://opensource.org/license/mit"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
MIT
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Source">
|
||||
<a
|
||||
href="https://github.com/paperclip-ai/paperclip"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
github.com/paperclip-ai/paperclip
|
||||
</a>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -94,10 +94,10 @@ describe("CloudProvidersSection", () => {
|
|||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
// Allow the secrets query to resolve across several microtask ticks.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Allow the secrets query to resolve.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
ui/src/components/settings/DangerZoneSection.test.tsx
Normal file
52
ui/src/components/settings/DangerZoneSection.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DangerZoneSection } from "./DangerZoneSection";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("DangerZoneSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container.parentNode) container.remove();
|
||||
});
|
||||
|
||||
function render() {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<DangerZoneSection />);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders both destructive actions as disabled placeholders", () => {
|
||||
render();
|
||||
expect(container.querySelector("h2")?.textContent).toBe("Danger zone");
|
||||
const resetBtn = container.querySelector(
|
||||
"button[aria-label='Reset workspace (not yet available)']",
|
||||
) as HTMLButtonElement | null;
|
||||
const deleteBtn = container.querySelector(
|
||||
"button[aria-label='Delete all conversations (not yet available)']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(resetBtn).not.toBeNull();
|
||||
expect(deleteBtn).not.toBeNull();
|
||||
expect(resetBtn?.disabled).toBe(true);
|
||||
expect(deleteBtn?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
63
ui/src/components/settings/DangerZoneSection.tsx
Normal file
63
ui/src/components/settings/DangerZoneSection.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingsRow } from "./SettingsSection";
|
||||
|
||||
/**
|
||||
* Destructive workspace actions.
|
||||
*
|
||||
* Both endpoints (reset workspace, delete all conversations) are not yet
|
||||
* wired on the server. The buttons render disabled with a "coming soon"
|
||||
* hint so we neither fabricate API calls nor silently drop the spec row.
|
||||
*/
|
||||
export function DangerZoneSection() {
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Danger zone"
|
||||
description="Destructive actions that cannot be undone. Handle with care."
|
||||
className="border-destructive/40"
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
||||
role="note"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
These actions are not yet wired up in this build. The buttons are
|
||||
placeholders to mark the intended surface from spec §8.1.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsRow
|
||||
label="Reset workspace"
|
||||
description="Clear all local workspace state and reconfigure from scratch through the onboarding wizard."
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
aria-label="Reset workspace (not yet available)"
|
||||
className="border-destructive/40 text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Reset workspace
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Delete all conversations"
|
||||
description="Permanently delete every stored assistant conversation in this workspace."
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
aria-label="Delete all conversations (not yet available)"
|
||||
className="border-destructive/40 text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Delete conversations
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,9 +77,9 @@ describe("SkillsSection", () => {
|
|||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
97
ui/src/components/settings/TelegramSection.test.tsx
Normal file
97
ui/src/components/settings/TelegramSection.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { apiGetSpy, apiPostSpy, pushToastSpy } = vi.hoisted(() => ({
|
||||
apiGetSpy: vi.fn(),
|
||||
apiPostSpy: vi.fn(),
|
||||
pushToastSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
api: {
|
||||
get: apiGetSpy,
|
||||
post: apiPostSpy,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({
|
||||
pushToast: pushToastSpy,
|
||||
dismissToast: () => {},
|
||||
clearToasts: () => {},
|
||||
toasts: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TelegramSection } from "./TelegramSection";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("TelegramSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
apiGetSpy.mockReset();
|
||||
apiPostSpy.mockReset();
|
||||
pushToastSpy.mockReset();
|
||||
apiGetSpy.mockResolvedValue({ running: false });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container.parentNode) container.remove();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
async function renderAndFlush() {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TelegramSection />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("renders the Telegram bridge header and 'Not running' status", async () => {
|
||||
await renderAndFlush();
|
||||
expect(container.querySelector("h2")?.textContent).toBe("Telegram bridge");
|
||||
expect(container.textContent).toContain("Not running");
|
||||
});
|
||||
|
||||
it("renders a masked password input when Set token is clicked", async () => {
|
||||
await renderAndFlush();
|
||||
const setBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Set token",
|
||||
);
|
||||
expect(setBtn).toBeDefined();
|
||||
act(() => {
|
||||
setBtn!.click();
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
expect(input?.getAttribute("type")).toBe("password");
|
||||
expect(input?.getAttribute("aria-label")).toBe("Telegram bot token input");
|
||||
});
|
||||
});
|
||||
139
ui/src/components/settings/TelegramSection.tsx
Normal file
139
ui/src/components/settings/TelegramSection.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SettingsSection, SettingsRow } from "./SettingsSection";
|
||||
|
||||
interface TelegramStatus {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface TelegramTokenResponse {
|
||||
ok: true;
|
||||
botUsername: string;
|
||||
}
|
||||
|
||||
const TELEGRAM_STATUS_KEY = ["settings", "telegram", "status"] as const;
|
||||
|
||||
export function TelegramSection() {
|
||||
const [token, setToken] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: TELEGRAM_STATUS_KEY,
|
||||
queryFn: () => api.get<TelegramStatus>("/telegram/status"),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (value: string) =>
|
||||
api.post<TelegramTokenResponse>("/telegram/token", { token: value }),
|
||||
onSuccess: async (data) => {
|
||||
setEditing(false);
|
||||
setToken("");
|
||||
pushToast({
|
||||
type: "success",
|
||||
message: `Telegram bot @${data.botUsername} saved.`,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: TELEGRAM_STATUS_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Failed to save Telegram token.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const running = statusQuery.data?.running === true;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Telegram bridge"
|
||||
description="Send notifications and quick replies from your phone through a Telegram bot. Set up the bot via @BotFather."
|
||||
>
|
||||
<SettingsRow
|
||||
label="Bot status"
|
||||
description="Running status of the Telegram bot daemon."
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
running ? "text-[#166534] dark:text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{statusQuery.isLoading ? "..." : running ? "Running" : "Not running"}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
|
||||
{editing ? (
|
||||
<SettingsRow
|
||||
label="Bot token"
|
||||
description="Paste the token from @BotFather. It is stored encrypted and never logged."
|
||||
>
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(token);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
autoComplete="off"
|
||||
aria-label="Telegram bot token input"
|
||||
className="h-8 w-64 font-mono text-xs focus-visible:ring-offset-background"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={saveMutation.isPending || !token.trim()}
|
||||
>
|
||||
{saveMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setToken("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsRow>
|
||||
) : (
|
||||
<SettingsRow
|
||||
label="Bot token"
|
||||
description="Paste the token from @BotFather. It is stored encrypted and never logged."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{running ? "set" : "not set"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{running ? "Replace" : "Set token"}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue