mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(hub): Billing in the account menu + in-app /billing view (managed hubs) (#55)
* feat(hub): Billing entry with current plan in the account menu (managed hubs) webapp.Server gains an optional Billing hook — the display mirror of the Quota seam: managed deployments return (plan, url) per signed-in user and /api/config exposes it as the 'billing' block; OSS hubs leave it nil and nothing changes. The frontend renders a Billing item with a plan chip under the Organization section of the account menu when the block is present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * architecture: Server gains the Billing display seam Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(hub): Billing is an in-app view at /billing (managed hubs) The account-menu Billing entry now routes to a real SPA view instead of a standalone server page: /billing is a top-level route like /orgs, rendered in the app shell from BillingView, which fetches config.billing.url with Accept: application/json (plan, usage, seats, plan cards, checkout/portal form URLs). OSS hubs without a billing block get an honest 'no billing on this hub' page at that path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2c85747464
commit
46db6507c4
@@ -28,6 +28,7 @@ classDiagram
|
||||
|
||||
class router {
|
||||
+VIEW_ROUTES insights history install settings
|
||||
+top-level routes orgs billing
|
||||
+parseRoute(pathname, mode) Route
|
||||
+urlForPath / urlForView
|
||||
+encodePath / decodePath
|
||||
|
||||
@@ -24,6 +24,7 @@ classDiagram
|
||||
+Reads *ReadLedger
|
||||
+Dir Directory
|
||||
+Quota QuotaProvider
|
||||
+Billing func(email) (plan, url, ok)
|
||||
+ShareRPM int
|
||||
-vols per-project volume cache
|
||||
+Handler() http.Handler
|
||||
|
||||
@@ -464,3 +464,49 @@ func readFileString(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// TestConfigBillingSeam: the billing block appears in /api/config only when
|
||||
// the hook is set, the caller is signed in, and the hook says ok.
|
||||
func TestConfigBillingSeam(t *testing.T) {
|
||||
srv, _, _ := authHub(t, true)
|
||||
srv.Billing = func(email string) (string, string, bool) {
|
||||
if email == "a@x.io" {
|
||||
return "Team", "/billing", true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
h := srv.Handler()
|
||||
|
||||
get := func(cookie *http.Cookie) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("config: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Signed out: no billing key, even with the hook set.
|
||||
if _, ok := get(nil)["billing"]; ok {
|
||||
t.Fatal("billing leaked to a signed-out config")
|
||||
}
|
||||
// Signed in and the hook says ok: plan + url.
|
||||
cookie := signupAndSession(t, h, "a@x.io", "Alice", "password1")
|
||||
if got := string(get(cookie)["billing"]); got != `{"plan":"Team","url":"/billing"}` {
|
||||
t.Fatalf("billing block = %s", got)
|
||||
}
|
||||
// A user the hook declines (no org yet): key absent.
|
||||
other := signupAndSession(t, h, "b@x.io", "Bob", "password1")
|
||||
if _, ok := get(other)["billing"]; ok {
|
||||
t.Fatal("billing shown to a user the hook declined")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ async function fail(r: Response): Promise<never> {
|
||||
}
|
||||
|
||||
export async function getJSON<T>(url: string): Promise<T> {
|
||||
const r = await fetch(url);
|
||||
// Explicit Accept so endpoints that content-negotiate (e.g. /billing:
|
||||
// JSON data vs the app shell) know this is a data fetch.
|
||||
const r = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
if (r.status === 401) toLogin();
|
||||
if (!r.ok) await fail(r);
|
||||
return r.json();
|
||||
|
||||
@@ -17,6 +17,24 @@ export interface ServerConfig {
|
||||
};
|
||||
reads: { enabled: boolean };
|
||||
me?: { email: string; name: string };
|
||||
// Managed deployments only: where billing lives + the user's current plan.
|
||||
billing?: { plan: string; url: string };
|
||||
}
|
||||
|
||||
// GET <config.billing.url> with Accept: application/json (managed hubs).
|
||||
// The SPA's /billing view renders this; the money actions stay server-side
|
||||
// (checkout_url/portal_url are plain form-POST targets that leave the SPA).
|
||||
export interface BillingInfo {
|
||||
org: string;
|
||||
role: string;
|
||||
owner: boolean;
|
||||
plan: { id: string; name: string; status?: string };
|
||||
usage: { used: string; cap: string; pct: number };
|
||||
seats: { used: number; cap: number };
|
||||
plans: { id: string; name: string; price: string; blurb: string; current: boolean }[];
|
||||
has_customer: boolean;
|
||||
checkout_url: string;
|
||||
portal_url: string;
|
||||
}
|
||||
|
||||
// Per-project permission levels (perms.go). Ordered: each includes the ones
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OrgAdmin } from "../components/OrgAdmin";
|
||||
import { HubSettings } from "../components/HubSettings";
|
||||
import { ProjectNav } from "../components/ProjectNav";
|
||||
import { AccountBar } from "../components/AccountBar";
|
||||
import { BillingView } from "../components/BillingView";
|
||||
import { ProjectSettings } from "../components/ProjectSettings";
|
||||
import { ConnectGuide } from "../components/ConnectGuide";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -83,6 +84,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
me={config.me}
|
||||
org={org}
|
||||
orgActive={!!route.org}
|
||||
billing={config.billing}
|
||||
admin={
|
||||
isAdmin
|
||||
? {
|
||||
@@ -170,6 +172,24 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}
|
||||
: null;
|
||||
|
||||
// Billing is hub-level (the managed deployment's surface), not
|
||||
// project-scoped — like the org route it borrows whichever project the
|
||||
// sidebar is showing. An OSS hub has no billing block; a hand-typed
|
||||
// /billing there says so instead of silently showing files.
|
||||
const billingPage = route.billing
|
||||
? {
|
||||
crumb: "Billing",
|
||||
body: config.billing ? (
|
||||
<BillingView url={config.billing.url} />
|
||||
) : (
|
||||
<div className="empty">
|
||||
<h3>No billing on this hub</h3>
|
||||
<p>This BearDrive hub doesn't have a billing surface.</p>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
const routePage =
|
||||
route.view === "settings"
|
||||
? {
|
||||
@@ -203,7 +223,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
// URL; replace so back/forward never bounces through the redirect. The
|
||||
// org route is not project-scoped, so it is exempt — it borrows whichever
|
||||
// project the sidebar is showing.
|
||||
if (!route.org && route.project !== current.id) {
|
||||
if (!route.org && !route.billing && route.project !== current.id) {
|
||||
return <Redirect to={"/" + current.id} />;
|
||||
}
|
||||
|
||||
@@ -265,7 +285,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
),
|
||||
orgBar: accountBar,
|
||||
}}
|
||||
panel={activePanel || orgPage || routePage}
|
||||
panel={activePanel || orgPage || billingPage || routePage}
|
||||
onClosePanel={() => setPanel(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -24,11 +24,13 @@ export function AccountBar({
|
||||
org,
|
||||
admin,
|
||||
orgActive,
|
||||
billing,
|
||||
}: {
|
||||
me: { email: string; name: string };
|
||||
org: Org | null;
|
||||
admin?: { pending: number; onClick: () => void }; // hub admins only
|
||||
orgActive?: boolean; // the org page is the open surface
|
||||
billing?: { plan: string; url: string }; // managed deployments only
|
||||
}) {
|
||||
const display = me.name || me.email;
|
||||
// The menu's open state is ours because the org entry is a link: linkProps
|
||||
@@ -39,6 +41,7 @@ export function AccountBar({
|
||||
// (middle-click, copy link address).
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const orgLink = org ? linkProps(org.manage_url) : null;
|
||||
const billingLink = billing ? linkProps(billing.url) : null;
|
||||
return (
|
||||
<footer id="accountbar">
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
@@ -80,6 +83,24 @@ export function AccountBar({
|
||||
)}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{billing && (
|
||||
<DropdownMenuItem asChild>
|
||||
{/* An in-app view route (/billing); the chip shows the
|
||||
org's current plan. */}
|
||||
<a
|
||||
id="menu-billing"
|
||||
{...billingLink}
|
||||
onClick={(e) => {
|
||||
billingLink?.onClick?.(e);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="card" />
|
||||
<span>Billing</span>
|
||||
<span className="ps-chip plan-chip">{billing.plan}</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{admin && (
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getJSON } from "../api/http";
|
||||
import type { BillingInfo } from "../api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
// The Billing view (managed hubs) renders inside the app shell; the data and
|
||||
// the money actions live on the hub's billing endpoints — the SPA only knows
|
||||
// the URL /api/config handed it. Checkout and the portal are plain form
|
||||
// POSTs: both leave the app for Stripe anyway, so a full-page navigation is
|
||||
// the honest shape.
|
||||
export function BillingView({ url }: { url: string }) {
|
||||
const q = useQuery({ queryKey: ["billing"], queryFn: () => getJSON<BillingInfo>(url) });
|
||||
if (q.isLoading) return <div className="empty">Loading…</div>;
|
||||
if (q.error || !q.data) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<h3>Billing is unavailable</h3>
|
||||
<p>{(q.error as Error | null)?.message || "Try again shortly."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const b = q.data;
|
||||
return (
|
||||
<div className="project-settings" id="billing-view">
|
||||
<h2>
|
||||
Billing
|
||||
<span className="ps-chip plan-chip">{b.plan.name}</span>
|
||||
</h2>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{b.plan.name} plan{b.plan.status ? ` (${b.plan.status})` : ""}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Organization {b.org} · {b.usage.used} of {b.usage.cap} used · {b.seats.used} of {b.seats.cap}{" "}
|
||||
{b.seats.cap === 1 ? "seat" : "seats"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent>
|
||||
<div className="usage-bar">
|
||||
<div style={{ width: `${b.usage.pct}%` }} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{b.owner ? (
|
||||
<div className="plan-grid">
|
||||
{b.plans.map((p) => (
|
||||
<Card key={p.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{p.name}</CardTitle>
|
||||
<CardDescription>{p.blurb}</CardDescription>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent>
|
||||
<p className="plan-price">
|
||||
{p.price}
|
||||
<small> / user / month</small>
|
||||
</p>
|
||||
<form method="post" action={b.checkout_url}>
|
||||
<input type="hidden" name="plan" value={p.id} />
|
||||
<Button type="submit" disabled={p.current} variant={p.current ? "subtle" : "default"}>
|
||||
{p.current ? "Current plan" : `Upgrade to ${p.name}`}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-note">Only an organization owner can change the plan.</p>
|
||||
)}
|
||||
|
||||
{b.owner && b.has_customer && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manage subscription</CardTitle>
|
||||
<CardDescription>Change seats, update the card, download invoices, or cancel.</CardDescription>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent>
|
||||
<form method="post" action={b.portal_url}>
|
||||
<Button type="submit" variant="subtle">
|
||||
Open the billing portal
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Code,
|
||||
Compass,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Database,
|
||||
Download,
|
||||
Ellipsis,
|
||||
@@ -125,6 +126,7 @@ if (typeof window !== "undefined") {
|
||||
// `.ico` sizing/stroke rules apply unchanged.
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
alert: TriangleAlert,
|
||||
card: CreditCard,
|
||||
check: Check,
|
||||
chev: ChevronRight,
|
||||
chevd: ChevronDown,
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface Route {
|
||||
// rather than a view under a project. The server hands out this URL (see
|
||||
// manage_url on /api/orgs), which is why it is reserved here.
|
||||
org?: string;
|
||||
// Billing (managed hubs) is hub-level like the org route; the URL comes
|
||||
// from /api/config's billing block. Reserved only in hub mode — project
|
||||
// ids are p-… so the segment can't collide with a project.
|
||||
billing?: boolean;
|
||||
project?: string;
|
||||
path: string;
|
||||
view?: ViewName;
|
||||
@@ -45,6 +49,9 @@ export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
|
||||
if (raw === "orgs" || raw.startsWith("orgs/")) {
|
||||
return { org: raw.slice(5).replace(/\/+$/, ""), path: "" };
|
||||
}
|
||||
if (raw === "billing" || raw.startsWith("billing/")) {
|
||||
return { billing: true, path: "" };
|
||||
}
|
||||
const slash = raw.indexOf("/");
|
||||
if (slash === -1) return { project: raw, path: "" };
|
||||
const r: Route = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
|
||||
|
||||
@@ -281,9 +281,20 @@ button, input, a.btn { font-family: inherit; }
|
||||
#account-menu [role="menuitem"]:hover { background: var(--hover); color: var(--text); }
|
||||
#account-menu [role="menuitem"] b { font-weight: 600; }
|
||||
#account-menu [role="menuitem"] .ico { width: 15px; height: 15px; }
|
||||
#account-menu .plan-chip { margin-left: auto; color: var(--accent); border-color: var(--border-2); }
|
||||
#account-menu #signout { color: var(--del); }
|
||||
#account-menu #signout:hover { color: var(--del); background: var(--hover); }
|
||||
|
||||
/* ---- billing view (managed hubs) ---- */
|
||||
#billing-view .plan-chip { color: var(--accent); }
|
||||
.plan-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 700px) { .plan-grid { grid-template-columns: 1fr; } }
|
||||
.usage-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; height: 6px; overflow: hidden; }
|
||||
.usage-bar > div { background: var(--accent); height: 100%; }
|
||||
.plan-price { font-size: 20px; font-weight: 700; margin: 0 0 10px; }
|
||||
.plan-price small { font-size: 12px; color: var(--text-dim); font-weight: 500; }
|
||||
.muted-note { color: var(--text-dim); font-size: 13px; }
|
||||
|
||||
/* ---- main pane ---- */
|
||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
#topbar {
|
||||
|
||||
@@ -84,6 +84,13 @@ type Server struct {
|
||||
// Quota, when set, enforces plan limits (managed deployments). Nil
|
||||
// means UnlimitedQuota: the open-source server never says no.
|
||||
Quota QuotaProvider
|
||||
// Billing, when set, surfaces a billing entry in the frontend's account
|
||||
// menu: the billing page URL plus the signed-in user's current plan name
|
||||
// (/api/config `billing`). The OSS hub has no billing; managed
|
||||
// deployments plug this in. Nil — or ok=false for a user with no org —
|
||||
// hides the entry. The mirror of the Quota seam: Quota enforces the
|
||||
// plan, Billing displays it.
|
||||
Billing func(email string) (plan, url string, ok bool)
|
||||
// ShareRPM is the per-IP request rate on public share links (/s/*);
|
||||
// 0 means DefaultShareRPM.
|
||||
ShareRPM int
|
||||
@@ -477,6 +484,11 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if me.Email != "" {
|
||||
out["me"] = map[string]string{"email": me.Email, "name": me.Name}
|
||||
if s.Billing != nil {
|
||||
if plan, url, ok := s.Billing(me.Email); ok {
|
||||
out["billing"] = map[string]string{"plan": plan, "url": url}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DNEdP71j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C9MMaLMG.css">
|
||||
<script type="module" crossorigin src="/assets/index-89AlIPy9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_qEQ9NO.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user