Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/web/src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CredentialStatus } from "@nibblet/protocol";
import type { ServerConnection } from "../lib/connection.ts";
import { AccountSection, RemoteAccessSection } from "./RemoteAccess.tsx";
import { TailnetKeysSection } from "./TailnetKeysSection.tsx";
import { BankSection } from "./bank/BankSection.tsx";
import { keys, useServers } from "../lib/servers.tsx";
import { clockTime, relativeTime } from "../lib/time.ts";
import { Button } from "./ui/Button.tsx";
Expand Down Expand Up @@ -53,6 +54,7 @@ export function SettingsPage() {
<CredentialsSection connection={connection} />
<HostToolsSection connection={connection} />
<OnePasswordSettings connection={connection} />
<BankSection connection={connection} />
<RemoteAccessSection connection={connection} />
<TailnetKeysSection connection={connection} />
<PairingSection connection={connection} />
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/components/bank/BankSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, XCircle } from "lucide-react";
import type { ServerConnection } from "../../lib/connection.ts";
import { money } from "../../lib/bank.ts";
import { keys } from "../../lib/servers.tsx";
import { SectionHeading } from "../ui/Field.tsx";

/**
* The banks nibblets' cards come from: card issuers and crypto treasuries,
* set up in the server's settings, and what each has left to fund cards with.
*/
export function BankSection({ connection }: { connection: ServerConnection }) {
const status = useQuery({
queryKey: keys.bank(connection.id),
queryFn: () => connection.client.bank.status({}),
refetchInterval: 60_000,
});
const providers = status.data?.providers ?? [];
const problems = status.data?.problems ?? [];
return (
<>
<SectionHeading>Bank</SectionHeading>
<p className="text-xs text-faint">
{providers.length === 0 && problems.length === 0
? "No bank is set up, so nibblets can't have cards. Add your Slash account (SLASH_API_KEY) or a crypto treasury (BITCOIN_TREASURY_KEY, ETHEREUM_TREASURY_KEY) to the server's .env and restart it."
: "Nibblets get cards from these, within the spending limit you give each one in its settings."}
</p>
{providers.map((p) => (
<div
key={p.id}
className="flex items-start gap-2.5 rounded-md border border-line bg-surface px-2.5 py-2"
>
{p.state === "ready" ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-ok" />
) : (
<XCircle className="mt-0.5 size-4 shrink-0 text-danger" />
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{p.label}
<span className="ml-1.5 font-normal text-dim">{p.asset.code}</span>
</span>
<span className="text-xs break-all text-dim">
{p.state === "error"
? `Can't be reached: ${p.detail ?? "no reason given"}`
: p.balance !== null
? `${money(p.balance, p.asset)} available`
: "Ready"}
</span>
</div>
</div>
))}
{problems.map((p) => (
<div
key={p.id}
className="flex items-start gap-2.5 rounded-md border border-line bg-surface px-2.5 py-2"
>
<XCircle className="mt-0.5 size-4 shrink-0 text-danger" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">{p.id}</span>
<span className="text-xs break-all text-dim">Not set up: {p.detail}</span>
</div>
</div>
))}
</>
);
}
217 changes: 217 additions & 0 deletions apps/web/src/components/bank/NibbletSpending.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CreditCard, Wallet } from "lucide-react";
import { SpendingPeriod, type Card, type Nibblet } from "@nibblet/protocol";
import type { ServerConnection } from "../../lib/connection.ts";
import {
bankAssets,
describeCard,
draftOf,
limitsOf,
money,
periodLabels,
type LimitsDraft,
} from "../../lib/bank.ts";
import { keys } from "../../lib/servers.tsx";
import { relativeTime } from "../../lib/time.ts";
import { Button } from "../ui/Button.tsx";
import { FieldRow, Input, SectionHeading, Select } from "../ui/Field.tsx";

const periodOptions = SpendingPeriod.options.map((p) => ({ value: p, label: periodLabels[p] }));

/**
* What a nibblet may spend, in each asset the server's banks deal in, and
* the cards it has. Only the person sets the limits; the nibblet asks for
* cards within them. Hidden when no bank is set up.
*/
export function NibbletSpending({
connection,
nibblet,
}: {
connection: ServerConnection;
nibblet: Nibblet;
}) {
const status = useQuery({
queryKey: keys.bank(connection.id),
queryFn: () => connection.client.bank.status({}),
});
const cards = useQuery({
queryKey: keys.cards(connection.id, nibblet.id),
queryFn: () => connection.client.bank.cards({ nibbletId: nibblet.id }),
});
const allowances = useQuery({
queryKey: keys.allowances(connection.id, nibblet.id),
queryFn: () => connection.client.bank.allowances({ nibbletId: nibblet.id }),
});
const assets = useMemo(() => bankAssets(status.data?.providers ?? []), [status.data]);
const saved = useMemo(
() => draftOf(nibblet.spendingLimits, assets),
[nibblet.spendingLimits, assets],
);
const [draft, setDraft] = useState<LimitsDraft>(saved);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// By content: every live update brings a new nibblet, which mustn't wipe what's half typed.
useEffect(() => setDraft(saved), [JSON.stringify(saved)]);

if (assets.length === 0 && !cards.data?.length) return null;

const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
const save = async () => {
const result = limitsOf(draft, assets, nibblet.spendingLimits);
if ("error" in result) {
setError(result.error);
return;
}
setBusy(true);
setError(null);
try {
await connection.client.nibblets.update({ id: nibblet.id, spendingLimits: result.limits });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};

return (
<>
<SectionHeading>Spending</SectionHeading>
<p className="text-xs text-faint">
The most it may put on cards. Open cards count in full until they're killed. Leave a limit
empty for no cards in that currency.
</p>
{assets.map((asset) => {
const entry = draft[asset.code] ?? { amount: "", period: "month" };
const left = allowances.data?.find((a) => a.limit.asset === asset.code);
return (
<FieldRow
key={asset.code}
label={asset.code}
hint={left ? `${money(left.remaining, asset)} left` : undefined}
>
<span className="flex gap-1.5">
<Input
className="w-32"
inputMode="decimal"
placeholder="No cards"
value={entry.amount}
onChange={(e) =>
setDraft({ ...draft, [asset.code]: { ...entry, amount: e.target.value } })
}
/>
<Select
className="w-32"
value={entry.period}
options={periodOptions}
onChange={(v) => {
const period = SpendingPeriod.safeParse(v);
if (period.success)
setDraft({ ...draft, [asset.code]: { ...entry, period: period.data } });
}}
/>
</span>
</FieldRow>
);
})}
{dirty || error ? (
<div className="flex items-center gap-2">
<Button size="sm" variant="primary" disabled={busy || !dirty} onClick={() => void save()}>
Save limits
</Button>
{dirty ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => setDraft(saved)}>
Cancel
</Button>
) : null}
{error ? <span className="text-xs text-danger">{error}</span> : null}
</div>
) : null}
<CardList connection={connection} cards={cards.data ?? []} />
</>
);
}

function CardList({ connection, cards }: { connection: ServerConnection; cards: Card[] }) {
const [showClosed, setShowClosed] = useState(false);
const open = cards.filter((c) => c.state !== "closed");
const closed = cards.filter((c) => c.state === "closed");
if (cards.length === 0) return <p className="text-xs text-faint">No cards yet.</p>;
return (
<div className="flex flex-col gap-1.5">
{open.map((card) => (
<CardRow key={card.id} connection={connection} card={card} />
))}
{open.length === 0 ? <p className="text-xs text-faint">No open cards.</p> : null}
{closed.length > 0 ? (
<button
type="button"
className="self-start text-xs text-dim hover:text-text"
onClick={() => setShowClosed(!showClosed)}
>
{showClosed ? "Hide" : "Show"} {closed.length} closed
</button>
) : null}
{showClosed
? closed.map((card) => <CardRow key={card.id} connection={connection} card={card} />)
: null}
</div>
);
}

const closedByLabel: Record<NonNullable<Card["closedBy"]>, string> = {
nibblet: "killed by the nibblet",
user: "killed by you",
provider: "closed after use",
server: "closed: it couldn't reach the container",
};

/** What a card that isn't closed is doing, and since when. */
function liveLabel(card: Card): string {
const made = relativeTime(card.createdAt);
if (card.state === "pending") return `being made (asked ${made})`;
if (card.state === "closing") return `closing, waiting on ${card.provider} · made ${made}`;
return `made ${made}`;
}

function CardRow({ connection, card }: { connection: ServerConnection; card: Card }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const kill = async () => {
setBusy(true);
setError(null);
try {
await connection.client.bank.kill({ id: card.id });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const Icon = card.display.startsWith("•") ? CreditCard : Wallet;
return (
<div className="flex items-start gap-2.5 rounded-md border border-line bg-surface px-2.5 py-2">
<Icon
className={`mt-0.5 size-4 shrink-0 ${card.state === "closed" ? "text-faint" : "text-accent"}`}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium" title={card.purpose}>
{card.purpose}
</span>
<span className="font-mono text-2xs break-all text-dim">{card.display}</span>
<span className="text-xs text-dim">
{describeCard(card)} · {card.provider} ·{" "}
{card.state === "closed"
? `${card.closedBy ? closedByLabel[card.closedBy] : "closed"} ${card.closedAt ? relativeTime(card.closedAt) : ""}`
: liveLabel(card)}
</span>
{error ? <span className="text-xs text-danger">{error}</span> : null}
</div>
{card.state === "open" || card.state === "closing" ? (
<Button size="sm" variant="danger" disabled={busy} onClick={() => void kill()}>
Kill
</Button>
) : null}
</div>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/components/panels/NibbletSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { PromptNameField } from "../PromptNameField.tsx";
import { NibbletVaults } from "../onepassword/OnePasswordSection.tsx";
import { BudgetSection } from "./BudgetSection.tsx";
import { ResourcesSection } from "./ResourcesSection.tsx";
import { NibbletSpending } from "../bank/NibbletSpending.tsx";
import { TailscaleSection } from "./TailscaleSection.tsx";

export function NibbletSettings({
Expand Down Expand Up @@ -216,6 +217,8 @@ export function NibbletSettings({

<NibbletVaults connection={connection} nibblet={nibblet} />

<NibbletSpending connection={connection} nibblet={nibblet} />

<TailscaleSection connection={connection} nibblet={nibblet} />

<ResourcesSection connection={connection} nibblet={nibblet} />
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/lib/bank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { bankAssets, draftOf, limitsOf, money } from "./bank.ts";

const usd = { code: "USD", decimals: 2 };
const btc = { code: "BTC", decimals: 8 };

describe("bank limits", () => {
it("lists each asset once", () => {
expect(bankAssets([{ asset: usd }, { asset: btc }, { asset: usd }])).toEqual([usd, btc]);
});

it("shows limits in each asset's own units", () => {
expect(draftOf([{ asset: "BTC", amount: "100000", period: "total" }], [usd, btc])).toEqual({
USD: { amount: "", period: "month" },
BTC: { amount: "0.001", period: "total" },
});
});

it("turns a draft back into limits, dropping empty ones", () => {
const draft = {
USD: { amount: "50", period: "week" as const },
BTC: { amount: "", period: "month" as const },
};
expect(limitsOf(draft, [usd, btc], [])).toEqual({
limits: [{ asset: "USD", amount: "5000", period: "week" }],
});
expect(limitsOf({ USD: { amount: "0", period: "day" } }, [usd], [])).toEqual({ limits: [] });
});

it("keeps limits in assets no bank here deals in", () => {
const eth = { asset: "ETH", amount: "1", period: "day" as const };
expect(limitsOf({ USD: { amount: "1", period: "day" } }, [usd], [eth])).toEqual({
limits: [eth, { asset: "USD", amount: "100", period: "day" }],
});
});

it("says what's wrong with an amount", () => {
expect(limitsOf({ USD: { amount: "1.234", period: "day" } }, [usd], [])).toEqual({
error: 'USD: "1.234" has more than 2 decimal places',
});
});

it("formats money", () => {
expect(money("1250", usd)).toBe("12.50 USD");
expect(money(100000n, btc)).toBe("0.001 BTC");
});
});
Loading
Loading