diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8fe1da14..b9fda2dd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -159,7 +159,7 @@ A turn the user didn't start (a heartbeat, a schedule, a sub-agent's result, car ## Bank: cards and wallets -A nibblet can be given money to spend, as virtual cards or as crypto wallets that work like them. Where the money comes from is a bank plugin (`packages/bank-*`) implementing `BankPlugin` from `@nibblet/bank`: it issues a card with a lifetime limit, gives its details again (`reveal`), reports what's been spent on it (`inspect`), and closes it (`close`). Each plugin's factory describes its own settings form (`fields`: labels, hints, secrets, choices, keys the form can make, and fields that can't change while cards are open), so Settings → Bank sets any of them up without the web app knowing one bank from another. Saving checks the settings with the provider (`check`), keeps them in the `settings` table (`banks`, secrets never sent back), and loads the plugin without a restart (`apps/server/src/banks.ts`, which lists the plugins). A bank with no saved settings falls back to the server's environment, whose variables are named like its fields. A plugin holds its provider's credentials, which never leave the server. Settings shows each bank, its balance, where to top a treasury up (`fundAt`), and any whose settings are wrong. A bank can't be removed, or have its locked fields changed, while it has cards that aren't closed. +A nibblet can be given money to spend, as virtual cards or as crypto wallets that work like them. Where the money comes from is a bank plugin (`packages/bank-*`) implementing `BankPlugin` from `@nibblet/bank`: it issues a card with a lifetime limit, gives its details again (`reveal`), reports what's been spent on it (`inspect`), and closes it (`close`). Each plugin's factory describes its own settings form (`fields`: labels, hints, secrets, choices, keys the form can make, and fields that can't change while cards are open), so Settings → Bank sets any of them up without the web app knowing one bank from another. Saving checks the settings with the provider (`check`), keeps them in the `settings` table (`banks`, secrets never sent back), and loads the plugin without a restart (`apps/server/src/banks.ts`, which lists the plugins). There can be several banks on one plugin (two Slash accounts, or a signet and a mainnet treasury), each with its own id, settings and name; one per plugin can go without a name. The first bank on a plugin gets the plugin's own id (`slash`) and later ones one from their name (`slash-travel`), and a card keeps its bank's id, so renaming a bank never strands its cards. A plugin with no saved bank under its own id also gets one from the server's environment, whose variables are named like its fields. A plugin holds its provider's credentials, which never leave the server. Settings shows each bank, its balance, where to top a treasury up (`fundAt`), and any whose settings are wrong. A bank can't be removed, or have its locked fields changed, while it has cards that aren't closed. - **Limits:** the person sets each nibblet's `spendingLimits`, one per asset (`USD`, `BTC`, `ETH`), per day, week or month (UTC) or in total. A nibblet has none to begin with, so it can't have a card until it's given one, and it can't change its own. Every card counts against its period for its whole limit while it's open, since it could all still be spent, and for what was spent once it's closed. One request or kill at a time per nibblet, so two at once can't both fit the same budget. - **The card file:** `card_request` asks the plugin for a card, stores its record (`cards`: never the number or key), and sends its details over the runner socket (`card_put`). The runner writes them to `$TMPDIR/nibblet-cards/.json`, `0600` in a `0700` folder, as the agent's user, and answers `card_done` with the path, which is all the tool returns. So a card's number reaches the model only if it reads the file, and never the thread, the database or a snapshot. A card that can't be written is closed again. The folder goes with the container; when a runner reconnects, the server asks the plugins for its open cards again and rewrites them. diff --git a/apps/server/src/banks.test.ts b/apps/server/src/banks.test.ts index 7c921fa1..344f2049 100644 --- a/apps/server/src/banks.test.ts +++ b/apps/server/src/banks.test.ts @@ -89,81 +89,140 @@ function setUp(env: Record = {}) { return { banks, settings, live, loaded, logs }; } +/** A change to the fake bank: a new one, unless `id` says which. */ +const change = (settings: Record, more: { id?: string; name?: string } = {}) => ({ + id: more.id ?? null, + kind: "fake", + name: more.name ?? "", + settings, +}); + describe("setting banks up", () => { it("saves settings the provider takes, and loads the bank with them", async () => { const { banks, settings, loaded } = setUp(); await banks.load(); expect(loaded).toEqual([[]]); - const status = await banks.configure("fake", { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" }); - expect(settings.get("banks")).toEqual({ fake: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } }); + const status = await banks.configure(change({ FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" })); + expect(settings.get("banks")).toEqual([ + { id: "fake", kind: "fake", name: "", settings: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } }, + ]); expect(loaded.at(-1)).toEqual(["fake"]); expect(status.providers.map((p) => p.label)).toEqual(["Fake (acc)"]); - expect(status.kinds).toEqual([ - expect.objectContaining({ + expect(status.kinds.map((k) => k.id)).toEqual(["fake"]); + expect(status.banks).toEqual([ + { id: "fake", - name: "Fake", + kind: "fake", + name: "", source: "saved", settings: { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }, liveCards: 0, - }), + }, ]); }); it("keeps a secret sent back masked", async () => { const { banks, settings } = setUp(); - await banks.configure("fake", { FAKE_KEY: "k1" }); - await banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }); - expect(settings.get("banks")).toEqual({ fake: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } }); + await banks.configure(change({ FAKE_KEY: "k1" })); + await banks.configure(change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }, { id: "fake" })); + expect(settings.get("banks")).toEqual([ + { id: "fake", kind: "fake", name: "", settings: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } }, + ]); + }); + + it("sets up several banks on one plugin, each with a name and its own id", async () => { + const { banks, loaded } = setUp(); + await banks.configure(change({ FAKE_KEY: "k1" })); + await expect(banks.configure(change({ FAKE_KEY: "k2" }))).rejects.toThrow( + "Give it a name, to tell it from your other Fake bank", + ); + await banks.configure(change({ FAKE_KEY: "k2" }, { name: "Travel" })); + await expect(banks.configure(change({ FAKE_KEY: "k3" }, { name: "travel" }))).rejects.toThrow( + "There's already a Fake bank called travel", + ); + const status = await banks.configure(change({ FAKE_KEY: "k3" }, { name: "Ops" })); + expect(status.banks.map((b) => [b.id, b.name])).toEqual([ + ["fake", ""], + ["fake-travel", "Travel"], + ["fake-ops", "Ops"], + ]); + expect(loaded.at(-1)).toEqual(["fake", "fake-travel", "fake-ops"]); + // Renaming keeps its id, which its cards point at. + const renamed = await banks.configure( + change({ FAKE_KEY: MASKED }, { id: "fake-travel", name: "Holidays" }), + ); + expect(renamed.banks.map((b) => [b.id, b.name])).toContainEqual(["fake-travel", "Holidays"]); }); it("saves nothing the provider refuses, saying why", async () => { const { banks, settings, logs } = setUp(); - await expect(banks.configure("fake", { FAKE_KEY: "" })).rejects.toThrow("API key is needed"); - await expect(banks.configure("fake", { FAKE_KEY: "short" })).rejects.toThrow( + await expect(banks.configure(change({ FAKE_KEY: "" }))).rejects.toThrow("API key is needed"); + await expect(banks.configure(change({ FAKE_KEY: "short" }))).rejects.toThrow( "API key is too short", ); - await expect(banks.configure("fake", { FAKE_KEY: "wrong" })).rejects.toThrow( + await expect(banks.configure(change({ FAKE_KEY: "wrong" }))).rejects.toThrow( "Fake: Invalid API key", ); // Anything but a BankError could carry a credential, so it's only logged. - const down = banks.configure("fake", { FAKE_KEY: "down" }); + const down = banks.configure(change({ FAKE_KEY: "down" })); await expect(down).rejects.toThrow("Fake didn't answer; the server's log has why"); expect(logs.join("\n")).toContain("key=down"); - await expect(banks.configure("nope", {})).rejects.toThrow("No bank called nope"); + await expect(banks.configure({ ...change({}), kind: "nope" })).rejects.toThrow( + "No bank plugin called nope", + ); + await expect(banks.configure(change({ FAKE_KEY: "k" }, { id: "nope" }))).rejects.toThrow( + "No bank called nope", + ); expect(settings.has("banks")).toBe(false); }); it("won't change what open cards depend on, or remove a bank they need", async () => { const { banks, live, settings } = setUp(); - await banks.configure("fake", { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" }); - live.push({ provider: "fake" }, { provider: "other" }); + await banks.configure(change({ FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" })); + await banks.configure(change({ FAKE_KEY: "k2" }, { name: "Travel" })); + live.push({ provider: "fake-travel" }, { provider: "other" }); await expect( - banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc2" }), - ).rejects.toThrow( - "Account can't change while Fake has a card that isn't closed: kill them first.", + banks.configure( + change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "x" }, { id: "fake-travel", name: "Travel" }), + ), + ).rejects.toThrow("Account can't change while Travel (Fake) has a card that isn't closed"); + // Its other fields still can, and the bank without cards can change anything. + await banks.configure(change({ FAKE_KEY: "k3" }, { id: "fake-travel", name: "Travel" })); + await banks.configure(change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "y" }, { id: "fake" })); + await expect(banks.remove("fake-travel")).rejects.toThrow( + "Travel (Fake) has a card that isn't closed, and closing them needs it", ); - // Fields that aren't locked still can. - await banks.configure("fake", { FAKE_KEY: "k2", FAKE_ACCOUNT: "acc" }); - await expect(banks.remove("fake")).rejects.toThrow( - "Fake has a card that isn't closed, and closing them needs it", - ); - live.length = 0; await banks.remove("fake"); - expect(settings.get("banks")).toEqual({}); + live.length = 0; + await banks.remove("fake-travel"); + expect(settings.get("banks")).toEqual([]); }); it("falls back to the environment, and lets Settings take over from it", async () => { const { banks, loaded } = setUp({ FAKE_KEY: "from-env" }); await banks.load(); expect(loaded).toEqual([["fake"]]); - expect((await banks.status()).kinds[0]).toMatchObject({ + expect((await banks.status()).banks[0]).toMatchObject({ + id: "fake", source: "env", settings: { FAKE_KEY: MASKED }, }); - // Saving keeps the environment's key when it's sent back masked. - await banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }); - expect((await banks.status()).kinds[0]).toMatchObject({ source: "saved" }); + await expect(banks.remove("fake")).rejects.toThrow(/server's environment/); + // A second bank needs a name, and leaves the environment's in place. + await banks.configure(change({ FAKE_KEY: "k2" }, { name: "Travel" })); + expect((await banks.status()).banks.map((b) => [b.id, b.source])).toEqual([ + ["fake-travel", "saved"], + ["fake", "env"], + ]); + // Saving the environment's keeps its key when it's sent back masked, and takes it over. + await banks.configure(change({ FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }, { id: "fake" })); + expect((await banks.status()).banks.find((b) => b.id === "fake")).toMatchObject({ + source: "saved", + settings: { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }, + }); await banks.remove("fake"); - expect((await banks.status()).kinds[0]).toMatchObject({ source: "env" }); + expect((await banks.status()).banks.find((b) => b.id === "fake")).toMatchObject({ + source: "env", + }); }); }); diff --git a/apps/server/src/banks.ts b/apps/server/src/banks.ts index 850b14a3..ec2b2e08 100644 --- a/apps/server/src/banks.ts +++ b/apps/server/src/banks.ts @@ -4,20 +4,23 @@ import { loadBankPlugins, maskSettings, mergeSettings, - resolveSettings, + newBankId, + resolveBanks, type BankDeps, type BankPlugin, type BankPluginFactory, type BankSettingValues, + type ResolvedBank, + type SavedBank, } from "@nibblet/bank"; import { bitcoin } from "@nibblet/bank-bitcoin"; import { ethereum } from "@nibblet/bank-ethereum"; import { slash } from "@nibblet/bank-slash"; import { + BankName, BankProviderId, BankSettings, RpcError, - type BankKind, type BankStatus, } from "@nibblet/protocol"; import { z } from "zod"; @@ -25,16 +28,18 @@ import type { Bank } from "./bank.ts"; import type { CardRow, Store } from "./store.ts"; /** - * Every bank plugin the server knows. Each is set up in Settings, which - * keeps its settings in the database, or else from the server's environment - * (variables named like its fields; see `docker/compose.yaml`), and stays - * off otherwise. + * Every bank plugin the server knows. Banks are set up with them in + * Settings, which keeps their settings in the database; there can be + * several on one plugin (two Slash accounts, say). A plugin with no bank + * saved under its own id can also have one from the server's environment + * (variables named like its fields; see `docker/compose.yaml`). */ export const BANK_PLUGINS: readonly BankPluginFactory[] = [slash, bitcoin, ethereum]; const SETTING = "banks"; -const Saved = z.record(BankProviderId, BankSettings); -type Saved = z.infer; +const Saved = z.array( + z.object({ id: BankProviderId, kind: BankProviderId, name: BankName, settings: BankSettings }), +); export interface BanksDeps { store: Pick & { @@ -47,6 +52,14 @@ export interface BanksDeps { log?(message: string): void; } +export interface BankChange { + /** Null to set a new bank up. */ + id: string | null; + kind: string; + name: string; + settings: BankSettings; +} + /** * Which banks are set up, and setting them up: their settings, checked with * their providers before they're saved, then loaded into the `Bank` without @@ -70,91 +83,127 @@ export class Banks { /** Loads every bank as it's set up now into the `Bank`. */ async load(): Promise { - const saved = await this.saved(); - const settings = new Map( - this.factories.map((f) => [f.id, resolveSettings(f, saved[f.id], this.deps.env).settings]), - ); - const { plugins, errors } = loadBankPlugins(this.factories, settings, this.plugin); + const { plugins, errors } = loadBankPlugins(this.factories, await this.banks(), this.plugin); for (const plugin of plugins.values()) console.log(`bank: ${plugin.label} is set up`); for (const [id, problem] of errors) this.log(`${id} isn't set up: ${problem}`); this.deps.bank.use(plugins, errors); } async status(): Promise { - const [health, kinds] = await Promise.all([this.deps.bank.status(), this.kinds()]); - return { ...health, kinds }; + const [health, banks, live] = await Promise.all([ + this.deps.bank.status(), + this.banks(), + this.deps.store.liveCards(), + ]); + return { + ...health, + kinds: this.factories.map((f) => ({ + id: f.id, + name: f.name, + about: f.about, + instrument: f.instrument, + fields: [...f.fields], + })), + banks: banks.map((b) => ({ + id: b.id, + kind: b.kind, + name: b.name, + source: b.source, + // A bank whose plugin has gone shows no settings, rather than its secrets. + settings: maskSettings( + this.factories.find((f) => f.id === b.kind)?.fields ?? [], + b.settings, + ), + liveCards: live.filter((c) => c.provider === b.id).length, + })), + }; } /** Checks the settings with the provider, saves them, and loads the bank with them. */ - configure(id: string, input: BankSettings): Promise { + configure(change: BankChange): Promise { return this.serially(async () => { - const factory = this.factory(id); - const saved = await this.saved(); - const current = resolveSettings(factory, saved[id], this.deps.env).settings; - const next = mergeSettings(factory.fields, input, current); - const missing = factory.fields.find((f) => f.required && !next[f.key]); - if (missing) throw new RpcError("bad_request", `${missing.label} is needed`); - const live = await this.liveCards(id); - const locked = changedLockedFields(factory.fields, current, next); - if (live > 0 && locked.length > 0) { + const factory = this.factory(change.kind); + const banks = await this.banks(); + const existing = change.id === null ? null : this.bank(banks, change.id); + if (existing && existing.kind !== factory.id) { + throw new RpcError("bad_request", `${change.id} isn't a ${factory.name} bank`); + } + const name = change.name.trim(); + const others = banks.filter((b) => b.kind === factory.id && b.id !== existing?.id); + // One bank on a plugin can go without a name; the rest need one to tell them apart. + if (!name && others.some((b) => !b.name)) { throw new RpcError( - "conflict", - `${locked.map((f) => f.label).join(" and ")} can't change while ${factory.name} has ${openCards(live)}: kill them first.`, + "bad_request", + `Give it a name, to tell it from your other ${factory.name} bank${others.length === 1 ? "" : "s"}`, ); } + if (name && others.some((b) => b.name.toLowerCase() === name.toLowerCase())) { + throw new RpcError("conflict", `There's already a ${factory.name} bank called ${name}`); + } + const current = existing?.settings ?? {}; + const next = mergeSettings(factory.fields, change.settings, current); + const missing = factory.fields.find((f) => f.required && !next[f.key]); + if (missing) throw new RpcError("bad_request", `${missing.label} is needed`); + if (existing) { + const live = await this.liveCards(existing.id); + const locked = changedLockedFields(factory.fields, current, next); + if (live > 0 && locked.length > 0) { + throw new RpcError( + "conflict", + `${locked.map((f) => f.label).join(" and ")} can't change while ${title(existing, factory)} has ${openCards(live)}: kill them first.`, + ); + } + } const plugin = this.build(factory, next); try { await plugin.check(); } catch (err) { if (err instanceof BankError) throw new RpcError("bad_request", err.message); - this.log(`checking ${id}: ${err instanceof Error ? err.message : String(err)}`); + this.log(`checking ${factory.id}: ${err instanceof Error ? err.message : String(err)}`); throw new RpcError( "unavailable", `${factory.name} didn't answer; the server's log has why`, ); } - await this.deps.store.setSetting(SETTING, { ...saved, [id]: next }); + const id = existing?.id ?? newBankId(factory.id, name, new Set(banks.map((b) => b.id))); + const saved: SavedBank = { id, kind: factory.id, name, settings: next }; + const list = await this.saved(); + const at = list.findIndex((b) => b.id === id); + if (at >= 0) list[at] = saved; + else list.push(saved); + await this.deps.store.setSetting(SETTING, list); await this.load(); return this.status(); }); } - /** Forgets a bank's saved settings, unless it has cards that still need it to close. */ + /** Forgets a saved bank, unless it has cards that still need it to close. */ remove(id: string): Promise { return this.serially(async () => { - const factory = this.factory(id); + const bank = this.bank(await this.banks(), id); + const factory = this.factory(bank.kind); + if (bank.source === "env") { + throw new RpcError( + "conflict", + `${title(bank, factory)} is set up from the server's environment: take its variables out of the server's .env to remove it.`, + ); + } const live = await this.liveCards(id); if (live > 0) { throw new RpcError( "conflict", - `${factory.name} has ${openCards(live)}, and closing them needs it: kill them first.`, + `${title(bank, factory)} has ${openCards(live)}, and closing them needs it: kill them first.`, ); } - const saved = await this.saved(); - delete saved[id]; - await this.deps.store.setSetting(SETTING, saved); + await this.deps.store.setSetting( + SETTING, + (await this.saved()).filter((b) => b.id !== id), + ); await this.load(); return this.status(); }); } - private async kinds(): Promise { - const [saved, live] = await Promise.all([this.saved(), this.deps.store.liveCards()]); - return this.factories.map((f) => { - const { source, settings } = resolveSettings(f, saved[f.id], this.deps.env); - return { - id: f.id, - name: f.name, - about: f.about, - instrument: f.instrument, - fields: [...f.fields], - source, - settings: maskSettings(f.fields, settings), - liveCards: live.filter((c) => c.provider === f.id).length, - }; - }); - } - private build(factory: BankPluginFactory, settings: BankSettings): BankPlugin { let plugin: BankPlugin | null; try { @@ -166,14 +215,24 @@ export class Banks { return plugin; } - private factory(id: string): BankPluginFactory { - const factory = this.factories.find((f) => f.id === id); - if (!factory) throw new RpcError("not_found", `No bank called ${id}`); + private factory(kind: string): BankPluginFactory { + const factory = this.factories.find((f) => f.id === kind); + if (!factory) throw new RpcError("not_found", `No bank plugin called ${kind}`); return factory; } - private saved(): Promise { - return this.deps.store.getSetting(SETTING, (v) => Saved.parse(v), {}); + private bank(banks: readonly ResolvedBank[], id: string): ResolvedBank { + const bank = banks.find((b) => b.id === id); + if (!bank) throw new RpcError("not_found", `No bank called ${id}`); + return bank; + } + + private async banks(): Promise { + return resolveBanks(this.factories, await this.saved(), this.deps.env); + } + + private saved(): Promise { + return this.deps.store.getSetting(SETTING, (v) => Saved.parse(v), []); } private async liveCards(id: string): Promise { @@ -187,6 +246,11 @@ export class Banks { } } +/** "Travel (Slash)", or just "Slash". */ +function title(bank: { name: string }, factory: BankPluginFactory): string { + return bank.name ? `${bank.name} (${factory.name})` : factory.name; +} + function openCards(n: number): string { return n === 1 ? "a card that isn't closed" : `${n} cards that aren't closed`; } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 354218dd..4cc0a551 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -276,7 +276,7 @@ export function createHandlers(s: Services): Handlers s.bank.cards(nibbletId), allowances: ({ nibbletId }) => s.bank.allowances(nibbletId), kill: ({ id }) => s.bank.kill(id, "user"), - configure: ({ id, settings }) => s.banks.configure(id, settings), + configure: (change) => s.banks.configure(change), remove: ({ id }) => s.banks.remove(id), }, snapshots: { diff --git a/apps/web/src/components/bank/BankDialog.tsx b/apps/web/src/components/bank/BankDialog.tsx index ab303ec4..2658b124 100644 --- a/apps/web/src/components/bank/BankDialog.tsx +++ b/apps/web/src/components/bank/BankDialog.tsx @@ -1,13 +1,15 @@ import { useEffect, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ChevronRight, Copy, CreditCard, KeyRound, Wallet } from "lucide-react"; -import type { BankField, BankInstrument, BankKind, BankStatus } from "@nibblet/protocol"; +import type { BankField, BankInstrument, BankKind, BankSetup, BankStatus } from "@nibblet/protocol"; import type { ServerConnection } from "../../lib/connection.ts"; import { bankDraftChanged, bankDraftOf, bankSettingsOf, + bankTitle, missingField, + needsName, newPrivateKey, secretSaved, type BankDraft, @@ -23,27 +25,34 @@ export const instrumentIcons: Record = { }; /** - * Setting a bank up, or changing one: a choice of the banks the server - * knows, then the form its plugin describes. Nothing here knows one bank - * from another; each plugin says what it needs (`BankKind.fields`). + * Setting a bank up, or changing one: a choice of the plugins the server + * knows, then the form the plugin describes. Nothing here knows one bank + * from another; each plugin says what it needs (`BankKind.fields`). There + * can be several banks on one plugin, each with a name. */ export function BankDialog({ connection, kinds, + banks, chosen, onClose, }: { connection: ServerConnection; kinds: readonly BankKind[]; - /** A bank's id to open its form, null to choose one to add, undefined when closed. */ + banks: readonly BankSetup[]; + /** A bank's id to open its form, null to choose a plugin to add one on, undefined when closed. */ chosen: string | null | undefined; onClose: () => void; }) { const [picked, setPicked] = useState(null); useEffect(() => setPicked(null), [chosen]); - const id = chosen ?? picked; - const kind = kinds.find((k) => k.id === id) ?? null; - const title = kind ? (kind.source ? kind.name : `Set up ${kind.name}`) : "Add a bank"; + const bank = banks.find((b) => b.id === chosen) ?? null; + const kind = kinds.find((k) => k.id === (bank?.kind ?? picked)) ?? null; + const title = !kind + ? "Add a bank" + : bank + ? bankTitle(bank.name, kind.name) + : `Set up ${banks.some((b) => b.kind === kind.id) ? "another " : ""}${kind.name}`; return ( {kind ? ( setPicked(null) : null} onDone={onClose} /> ) : ( - k.source === null)} onChoose={setPicked} /> + )} ); @@ -68,18 +79,18 @@ export function BankDialog({ function BankChoice({ kinds, + banks, onChoose, }: { kinds: readonly BankKind[]; + banks: readonly BankSetup[]; onChoose: (id: string) => void; }) { - if (kinds.length === 0) { - return

Every bank this server knows is set up.

; - } return (
{kinds.map((kind) => { const Icon = instrumentIcons[kind.instrument]; + const count = banks.filter((b) => b.kind === kind.id).length; return ( ) : null} - {kind.source === "saved" ? ( + {bank?.source === "saved" ? ( @@ -190,16 +243,18 @@ function BankForm({ {error ? error - : missing - ? `${missing.label} is needed.` - : `It's checked with ${kind.name} when you save.`} + : named && !name.trim() + ? "Give it a name." + : missing + ? `${missing.label} is needed.` + : `It's checked with ${kind.name} when you save.`}
diff --git a/apps/web/src/components/bank/BankSection.tsx b/apps/web/src/components/bank/BankSection.tsx index e4567614..42caf1f2 100644 --- a/apps/web/src/components/bank/BankSection.tsx +++ b/apps/web/src/components/bank/BankSection.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { CheckCircle2, Copy, Plus, XCircle } from "lucide-react"; -import type { BankKind, BankProvider } from "@nibblet/protocol"; +import type { BankProvider, BankSetup } from "@nibblet/protocol"; import type { ServerConnection } from "../../lib/connection.ts"; -import { money } from "../../lib/bank.ts"; +import { bankTitle, money } from "../../lib/bank.ts"; import { keys } from "../../lib/servers.tsx"; import { Button } from "../ui/Button.tsx"; import { SectionHeading } from "../ui/Field.tsx"; @@ -28,13 +28,15 @@ export function BankSection({ connection }: { connection: ServerConnection }) { void status.refetch(); }; const kinds = status.data?.kinds ?? []; - const setUp = kinds.filter((k) => k.source !== null); - const addable = kinds.some((k) => k.source === null); + // Each plugin's banks together, in the order the server lists its plugins. + const banks = [...(status.data?.banks ?? [])].sort( + (a, b) => kinds.findIndex((k) => k.id === a.kind) - kinds.findIndex((k) => k.id === b.kind), + ); return ( <> 0 ? (