8000 fix(settings): restore chat credential fields by adeelahmad · Pull Request #721 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Draft
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
3 changes: 3 additions & 0 deletions changelog.d/fix-chat-settings-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed a crash when opening Telegram, Slack, or Assistant email settings. Their credential fields are now registered
with the shared form, so tokens and the mailbox password can be saved, replaced, or cleared without exposing stored
values. Cancel and Undo preserve the saved credentials.
8 changes: 6 additions & 2 deletions docs/chat-rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Lens's assistant can answer in Slack, Discord, Telegram, Matrix, Mattermost, IRC
Telegram needs no Matterbridge and no public address: Lens reads its own bot's messages (long polling) and answers.

1. In Telegram, message **@BotFather**, send `/newbot` and copy the token it gives you.
2. Open **Settings → Telegram**, paste the token and turn on **Answer in Telegram**.
2. Open **Settings → Telegram**, paste the token, turn on **Answer in Telegram**, and click **Save**.
3. Message your bot. You show on the settings page under **Wrote to the bot, not let in**: click **Let in**.

Anyone can find a bot, so it answers only the people in **People it talks to** (Telegram user ids or usernames);
Expand All @@ -23,6 +23,9 @@ namespace's assistant with **Chats for a namespace's assistant** (`-100123456789

Give Lens a bot of its own: a bot that Matterbridge also reads answers neither reliably (**Check the bot** says so).

The bot token is write-only: reopening Settings shows whether it is set, never its value. Use **Replace** to change
it or **Clear** to remove it, then **Save**. **Cancel** and **Undo** leave the saved token unchanged.

Telegram is one adapter of the chat-channel layer (`app/domain/channels.py`) that Matterbridge is another of; a new
chat app adds an adapter (reading, sending, files) and gets everything in a room for free. The assistant's own email
address is one too ([The assistant by email](assistant-email.md)).
Expand All @@ -37,7 +40,8 @@ and answers there.
2. In the app's page, **Install to Workspace**, then copy the **Bot User OAuth Token** (`xoxb-…`, under OAuth &
Permissions). Under **Basic Information → App-Level Tokens**, generate one with `connections:write` and copy it
(`xapp-…`).
3. Paste both tokens in Settings → Slack and turn on **Answer in Slack**. **Check the app** says whether both work.
3. Paste both tokens in Settings → Slack, turn on **Answer in Slack**, and click **Save**. **Check the app** says
whether both saved tokens work.

Message the app directly (always for Lens), invite it to a channel with `/invite @Lens` and say "Lens, …" or
@mention it, or type `/lens what did we decide about the launch?` anywhere (the answer shows in that channel).
Expand Down
131 changes: 131 additions & 0 deletions nextjs-frontend/__tests__/settings-chat-credentials.test.tsx
8000
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";

import { Admin } from "@/app/openapi-client";
import { fieldId, fieldsOf, toUi, type SectionId, type SettingsView } from "@/components/settings/model";
import { SettingsSection } from "@/components/settings/section";
import { TooltipProvider } from "@/components/ui/tooltip";

jest.mock("@/app/openapi-client", () => ({
Admin: {
updateSettings: jest.fn(),
getSecrets: jest.fn(),
telegramStatus: jest.fn(),
slackStatus: jest.fn(),
mailboxStatus: jest.fn(),
},
Metadata: {},
}));
jest.mock("next-auth/react", () => ({ useSession: () => ({ data: { accessToken: "t" } }) }));
jest.mock("next/navigation", () => ({ useRouter: () => ({ push: jest.fn() }) }));
jest.mock("@/lib/hooks/session", () => ({ useArchive: () => ({ namespaces: [] }) }));
jest.mock("@/components/ui/toast", () => ({ useToast: () => jest.fn() }));

const ok = (data: unknown) => Promise.resolve({ data, response: { ok: true, status: 200 } });
const CREDENTIALS: [SectionId, string, string][] = [
["telegram", "token", "Bot token"],
["slack", "bot_token", "Bot token (xoxb-…, from OAuth & Permissions)"],
["slack", "app_token", "App-level token (xapp-…, from Basic Information, with connections:write)"],
["mailbox", "password", "Password"],
];

function view(section: SectionId, key: string, saved: boolean): SettingsView {
return {
[section]: {
values: {
enabled: false,
account: null,
name: "Lens",
answer: "mention",
users: [],
rooms: [],
approve: "low_risk",
api_url: null,
token: { secret: true, set: false },
bot_token: { secret: true, set: false },
app_token: { secret: true, set: false },
address: null,
password: { secret: true, set: false },
username: null,
imap_host: null,
imap_port: null,
imap_security: "ssl",
smtp_host: null,
smtp_port: null,
smtp_security: "starttls",
folder: "INBOX",
routes: [],
imports: "auto",
check: "authenticated",
auth_server: null,
poll_seconds: 30,
[key]: { secret: true, set: saved },
},
},
} as SettingsView;
}

function show(section: SectionId, key: string, saved = false) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<TooltipProvider>
<SettingsSection id={section} view={view(section, key, saved)} => undefined} />
</TooltipProvider>
</QueryClientProvider>,
);
}

beforeEach(() => {
(Admin.updateSettings as jest.Mock).mockReset().mockImplementation(() => ok({ ok: true }));
(Admin.getSecrets as jest.Mock)
.mockReset()
.mockImplementation(() => ok({ providers: [], references: [], fields: [] }));
for (const status of [Admin.telegramStatus, Admin.slackStatus, Admin.mailboxStatus])
(status as jest.Mock).mockReset().mockImplementation(() => ok({ state: "off", people: [], chats: [] }));
});

describe.each(CREDENTIALS)("%s.%s", (section, key, label) => {
it("registers the custom credential control as a write-only field", () => {
const field = fieldsOf(section).find((f) => fieldId(f) === `${section}.${key}`);
expect(field).toMatchObject({ section, key, kind: "secret" });
expect(toUi(field!, { secret: true, set: true })).toBeUndefined();
});

it("opens without crashing and saves a new credential", async () => {
show(section, key);
const input = screen.getByLabelText(label);
expect(input).toHaveAttribute("type", "password");
expect(input).toHaveValue("");
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: "test-credential" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(Admin.updateSettings).toHaveBeenCalledTimes(1));
expect((Admin.updateSettings as jest.Mock).mock.calls[0][0]).toMatchObject({
path: { section },
body: { [key]: "test-credential" },
});
expect((Admin.updateSettings as jest.Mock).mock.calls[0][0].body).toEqual({ [key]: "test-credential" });
});

it("keeps a saved credential unchanged after Cancel and Undo", () => {
show(section, key, true);
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
fireEvent.change(screen.getByLabelText(label), { target: { value: "replacement" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
fireEvent.click(screen.getByRole("button", { name: "Undo" }));
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
expect(Admin.updateSettings).not.toHaveBeenCalled();
});

it("submits an explicit clear without resubmitting masked secrets", async () => {
show(section, key, true);
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(Admin.updateSettings).toHaveBeenCalledTimes(1));
expect((Admin.updateSettings as jest.Mock).mock.calls[0][0].body).toEqual({ [key]: "" });
});
});
4 changes: 4 additions & 0 deletions nextjs-frontend/components/settings/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,7 @@ export const FIELDS: FieldSpec[] = [
},
// Telegram (telegram.py)
{ section: "telegram", key: "enabled", label: "Answer in Telegram", kind: "switch" },
{ section: "telegram", key: "token", label: "Bot token", kind: "secret" },
{
section: "telegram",
key: "account",
Expand Down Expand Up @@ -1849,6 +1850,8 @@ export const FIELDS: FieldSpec[] = [
},
// Slack (slack.py)
{ section: "slack", key: "enabled", label: "Answer in Slack", kind: "switch" },
{ section: "slack", key: "bot_token", label: "Bot token", kind: "secret" },
{ section: "slack", key: "app_token", label: "App-level token", kind: "secret" },
{
section: "slack",
key: "account",
Expand Down Expand Up @@ -1898,6 +1901,7 @@ export const FIELDS: FieldSpec[] = [
},
// The assistant's mailbox (mailbox.py)
{ section: "mailbox", key: "enabled", label: "Answer email", kind: "switch" },
{ section: "mailbox", key: "password", label: "Password", kind: "secret" },
{
section: "mailbox",
key: "address",
Expand Down
Loading
0