From bf960d1f692cfafb237d3b17563171cee29926d9 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Sat, 17 Oct 2026 01:40:41 +0200
Subject: [PATCH 01/14] fix(settings): point setup audit fix hints and links at
screens that exist
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Alerts → Destinations is now Alerts → Routing. Error notification policy and
anomaly detection have no settings screen, so their hints name the MCP tool and
API instead, and their Fix links are hidden. Alert checks deep-link to the
right alerts tab, the dashboard check to /dashboards, and the error state can
retry.
---
.../settings/setup-audit-section.tsx | 109 ++++++++++++------
packages/domain/src/setup-audit.ts | 9 +-
2 files changed, 76 insertions(+), 42 deletions(-)
diff --git a/apps/web/src/components/settings/setup-audit-section.tsx b/apps/web/src/components/settings/setup-audit-section.tsx
index 289f38be29..b0766ad283 100644
--- a/apps/web/src/components/settings/setup-audit-section.tsx
+++ b/apps/web/src/components/settings/setup-audit-section.tsx
@@ -40,38 +40,68 @@ const SEVERITY: Record = ["critical", "warn", "info"]
/**
- * Each category's owning surface, so a finding is one click from where it gets fixed. `tab` targets a
- * sibling settings tab; `to` targets a standalone route. Kept as separate fields because TanStack
+ * Where a finding gets fixed, so it is one click away. Kept as separate shapes because TanStack
* Router types `search` per route; a widened `object` does not typecheck.
*/
-type CategoryTarget =
- | { label: string; fixLabel: string; tab: SettingsTab }
- | { label: string; fixLabel: string; to: "/alerts" | "/traces" | "/integrations" }
+type FixTarget =
+ | { tab: SettingsTab }
+ | { alertsTab: "rules" | "routing" }
+ | { to: "/alerts" | "/traces" | "/integrations" | "/dashboards" }
-const CATEGORY: Record = {
- alerting: { label: "Alerting", fixLabel: "Open alerts", to: "/alerts" },
- notifications: { label: "Notifications", fixLabel: "Open notifications", tab: "notifications" },
- ingestion: { label: "Ingestion", fixLabel: "Open ingestion", tab: "ingestion" },
- attributes: { label: "Attributes", fixLabel: "Open ingestion", tab: "ingestion" },
- traces: { label: "Traces", fixLabel: "Open traces", to: "/traces" },
- integrations: { label: "Integrations", fixLabel: "Open integrations", to: "/integrations" },
- data_platform: { label: "Data platform", fixLabel: "Open data platform", tab: "data-platform" },
-} satisfies Record
+type CategoryOpen = { label: string; target: FixTarget }
-function CategoryLink({
- target,
- label,
- className,
-}: {
- target: CategoryTarget
- label: string
- className: string
-}) {
- return "tab" in target ? (
-
- {label}
-
- ) : (
+/** `open: null` means the category has no single screen that fixes its findings. */
+const CATEGORY = {
+ alerting: { label: "Alerting", open: { label: "Open alerts", target: { to: "/alerts" } } },
+ notifications: { label: "Notifications", open: null },
+ ingestion: { label: "Ingestion", open: { label: "Open ingestion", target: { tab: "ingestion" } } },
+ attributes: { label: "Attributes", open: { label: "Open ingestion", target: { tab: "ingestion" } } },
+ traces: { label: "Traces", open: { label: "Open traces", target: { to: "/traces" } } },
+ integrations: {
+ label: "Integrations",
+ open: { label: "Open integrations", target: { to: "/integrations" } },
+ },
+ data_platform: {
+ label: "Data platform",
+ open: { label: "Open data platform", target: { tab: "data-platform" } },
+ },
+} satisfies Record
+
+/**
+ * Per-check overrides of the category target. `null` hides the Fix link: the fix has no UI yet and
+ * the hint names where it lives (MCP tool or API).
+ */
+const CHECK_TARGET = new Map([
+ ["CFG-ALERT-01", { alertsTab: "routing" }],
+ ["CFG-ALERT-02", { alertsTab: "rules" }],
+ ["CFG-ALERT-03", { alertsTab: "rules" }],
+ ["CFG-ALERT-04", { alertsTab: "routing" }],
+ ["CFG-NOTIF-01", null],
+ ["CFG-ANOM-01", null],
+ ["CFG-DASH-01", { to: "/dashboards" }],
+])
+
+const fixTargetFor = (check: V2SetupAuditCheck): FixTarget | null =>
+ CHECK_TARGET.has(check.id)
+ ? (CHECK_TARGET.get(check.id) ?? null)
+ : (CATEGORY[check.category].open?.target ?? null)
+
+function FixLink({ target, label, className }: { target: FixTarget; label: string; className: string }) {
+ if ("tab" in target) {
+ return (
+
+ {label}
+
+ )
+ }
+ if ("alertsTab" in target) {
+ return (
+
+ {label}
+
+ )
+ }
+ return (
{label}
@@ -98,7 +128,7 @@ function SummaryPill({ count, label, className }: { count: number; label: string
function CheckRow({ check }: { check: V2SetupAuditCheck }) {
const severity = SEVERITY[check.severity]
- const category = CATEGORY[check.category]
+ const fixTarget = fixTargetFor(check)
const isFinding = check.status === "fail"
const Icon = isFinding ? severity.icon : check.status === "pass" ? CircleCheckIcon : CircleInfoIcon
@@ -148,9 +178,9 @@ function CheckRow({ check }: { check: V2SetupAuditCheck }) {
)}
- {isFinding && (
-
@@ -177,11 +207,13 @@ function CategoryCard({
{findings > 0 ? countLabel(findings, "finding") : "clear"}
-
+ {meta.open !== null && (
+
+ )}
{checks.map((check) => (
@@ -300,11 +332,12 @@ function Report({ audit, refreshing }: { audit: V2SetupAudit; refreshing: boolea
export function SetupAuditSection() {
const result = useAtomValue(setupAuditAtom)
+ const refresh = useAtomRefresh(setupAuditAtom)
return (
{Result.isFailure(result) ? (
-
+
) : !Result.isSuccess(result) ? (
diff --git a/packages/domain/src/setup-audit.ts b/packages/domain/src/setup-audit.ts
index fb287fd68b..678d3426ba 100644
--- a/packages/domain/src/setup-audit.ts
+++ b/packages/domain/src/setup-audit.ts
@@ -395,7 +395,7 @@ const alertingChecks: ReadonlyArray = [
category: "alerting",
title: "At least one notification destination is enabled",
severity: "critical",
- fixHint: "Add a destination in Alerts → Destinations (Slack, PagerDuty, webhook, email, …).",
+ fixHint: "Add a destination in Alerts → Routing (Slack, PagerDuty, webhook, email, …).",
evaluate: ({ config }) =>
config.alertDestinations.some((destination) => destination.enabled)
? PASS
@@ -467,7 +467,7 @@ const alertingChecks: ReadonlyArray = [
title: "Notification destinations delivered successfully on their last test",
severity: "warn",
fixHint:
- "Re-test the destination in Alerts → Destinations; a stale webhook URL or revoked token is the usual cause.",
+ "Re-test the destination in Alerts → Routing; a stale webhook URL or revoked token is the usual cause.",
evaluate: ({ config }) => {
const failing = config.alertDestinations
.filter((destination) => destination.enabled && destination.lastTestError !== null)
@@ -555,7 +555,8 @@ const notificationChecks: ReadonlyArray = [
category: "notifications",
title: "New errors notify someone",
severity: "warn",
- fixHint: "Settings → Notifications: enable error notifications and select at least one destination.",
+ fixHint:
+ "Error notifications have no settings screen yet. Ask the Maple MCP to run `update_error_notification_policy` with `enabled: true` and at least one enabled destination from Alerts → Routing.",
evaluate: ({ config }) => {
// A missing row means Maple's defaults apply: enabled, but with an empty destination list —
// which still routes nowhere, so the second arm below catches it.
@@ -580,7 +581,7 @@ const notificationChecks: ReadonlyArray = [
title: "Anomaly detection is enabled",
severity: "info",
fixHint:
- "Settings → Automation: re-enable anomaly detection to get baseline-relative alerts without authoring rules.",
+ 'Anomaly detection has no settings screen yet. Re-enable it with `PATCH /v2/anomalies/settings` and `{"enabled": true}` (org admin, `anomalies:write` scope) to get baseline-relative alerts without authoring rules.',
evaluate: ({ config }) =>
// Only an explicit opt-out counts; a missing row means the zero-config default (enabled).
config.anomalyDetector !== null && !config.anomalyDetector.enabled
From a3f1a268dc9726d94e7f7562938249bf56b5fb30 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Sat, 17 Oct 2026 01:42:44 +0200
Subject: [PATCH 02/14] fix(ingest): give server-side SDK snippets the private
ingest key
Every guided-setup snippet configures a server-side sender, but they were
interpolated with the public key. They now carry the private key; the browser
test event keeps the public one. Both come from the same admin-only read, so
members still see neither.
---
.../components/dashboard/setup-checklist.tsx | 4 +--
.../src/components/ingest/guided-setup.tsx | 12 +++----
.../ingest/use-ingest-connection.test.ts | 34 +++++++++++++++++-
.../ingest/use-ingest-connection.ts | 36 ++++++++++++-------
.../components/quick-start/sdk-snippets.ts | 1 +
.../components/settings/ingestion-section.tsx | 12 +++----
6 files changed, 70 insertions(+), 29 deletions(-)
diff --git a/apps/web/src/components/dashboard/setup-checklist.tsx b/apps/web/src/components/dashboard/setup-checklist.tsx
index 4d09cbbe20..59f4967bc5 100644
--- a/apps/web/src/components/dashboard/setup-checklist.tsx
+++ b/apps/web/src/components/dashboard/setup-checklist.tsx
@@ -87,11 +87,11 @@ function SetupChecklistCard() {
-
+
diff --git a/apps/web/src/components/ingest/guided-setup.tsx b/apps/web/src/components/ingest/guided-setup.tsx
index d66957e2c4..49cba0a355 100644
--- a/apps/web/src/components/ingest/guided-setup.tsx
+++ b/apps/web/src/components/ingest/guided-setup.tsx
@@ -45,7 +45,7 @@ const ROLE_DEFAULT_FRAMEWORK = {
} satisfies Record
interface GuidedSetupProps {
- /** Public ingest key, interpolated into the instrument snippet. */
+ /** Private ingest key: every snippet configures a server-side sender. */
apiKey: string
apiKeyStatus?: IngestKeyStatus
/** Render the endpoint + key credentials column beside the snippet tabs. */
@@ -170,6 +170,7 @@ export function ConnectInstructions({
variant = "boxed",
}: {
framework: FrameworkId
+ /** Private ingest key: every snippet configures a server-side sender. */
apiKey: string
apiKeyStatus?: IngestKeyStatus
showCredentials?: boolean
@@ -216,10 +217,7 @@ export function ConnectInstructions({
/>
-
+
From 575cc9036cf4d4197b5617dd3522ad958859d4db Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Sat, 17 Oct 2026 01:49:33 +0200
Subject: [PATCH 09/14] fix(settings): use the shared admin hook in
Organization and Members
Both tabs compared Clerk's orgRole to org:admin themselves while the nav,
Billing and API keys read useIsOrgAdmin. They now use the same hook, so every
tab agrees on who is an admin.
---
apps/web/src/components/settings/members-section.tsx | 5 +++--
apps/web/src/components/settings/organization-section.tsx | 6 +++---
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/settings/members-section.tsx b/apps/web/src/components/settings/members-section.tsx
index fcc1d1c30d..d92857633e 100644
--- a/apps/web/src/components/settings/members-section.tsx
+++ b/apps/web/src/components/settings/members-section.tsx
@@ -33,6 +33,7 @@ import { PlusIcon, TrashIcon, ShieldIcon, UserIcon, EnvelopeIcon } from "@/compo
import { useTimezonePreference } from "@/hooks/use-timezone-preference"
import { formatDateInTimezone } from "@/lib/timezone-format"
import { useAsyncAction } from "@/hooks/use-mutation-action"
+import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
function roleBadge(role: string) {
if (role === "org:admin") {
@@ -42,7 +43,7 @@ function roleBadge(role: string) {
}
export function MembersSection() {
- const { orgRole, userId } = useAuth()
+ const { userId } = useAuth()
const { organization, memberships, invitations, isLoaded } = useOrganization({
memberships: { infinite: true },
invitations: { infinite: true, status: ["pending"] },
@@ -68,7 +69,7 @@ export function MembersSection() {
const [withRevokeLoading, revokeLoading] = useAsyncAction((task: () => Promise) => task())
const { effectiveTimezone } = useTimezonePreference()
- const isAdmin = orgRole === "org:admin"
+ const isAdmin = useIsOrgAdmin()
function handleInvite() {
if (!organization || !inviteEmail.trim()) return
diff --git a/apps/web/src/components/settings/organization-section.tsx b/apps/web/src/components/settings/organization-section.tsx
index 8638dfdaaa..c25429bb59 100644
--- a/apps/web/src/components/settings/organization-section.tsx
+++ b/apps/web/src/components/settings/organization-section.tsx
@@ -1,7 +1,7 @@
import { useAtomSet } from "@/lib/effect-atom"
import { useEffect, useState } from "react"
import { useNavigate } from "@tanstack/react-router"
-import { useAuth, useOrganization, useOrganizationList } from "@clerk/clerk-react"
+import { useOrganization, useOrganizationList } from "@clerk/clerk-react"
import { toastManager } from "@maple/ui/components/ui/toast"
import { Panel } from "@maple/ui/components/ui/panel"
import { Field, FieldLabel, FieldDescription } from "@maple/ui/components/ui/field"
@@ -22,16 +22,16 @@ import { organizationHomeRegion } from "@maple/domain/organization-regions"
import { MAPLE_REGION_LABELS } from "@/lib/region"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
import { useAsyncAction } from "@/hooks/use-mutation-action"
+import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
export function OrganizationSection() {
- const { orgRole } = useAuth()
const { organization, isLoaded } = useOrganization()
const { setActive, userMemberships } = useOrganizationList({
userMemberships: { infinite: true },
})
const navigate = useNavigate()
- const isAdmin = orgRole === "org:admin"
+ const isAdmin = useIsOrgAdmin()
const [name, setName] = useState("")
const [withSavingName, isSavingName] = useAsyncAction((task: () => Promise) => task())
From edd9acc47d3efbb287f152cee774466a4866f580 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Sat, 17 Oct 2026 01:52:37 +0200
Subject: [PATCH 10/14] fix(settings): gate member-rejected actions and say why
Members saw actions the API answers with 403: billing portal, subscribe and
trial buttons, API key roll and revoke, attribute mapping writes, and the
recommendations Apply fix. Each is now disabled or hidden for members with a
one-line AdminOnlyNote under its section. API key rows follow the server rule
exactly: admins may roll and revoke, members may revoke their own MCP keys.
---
.../settings/api-key-permissions.test.ts | 47 +++++++++++++
.../settings/api-key-permissions.ts | 14 ++++
.../components/settings/api-keys-section.tsx | 57 +++++++++-------
.../settings/attribute-mappings-section.tsx | 67 ++++++++++++-------
.../components/settings/billing-section.tsx | 16 ++++-
.../settings/escalation-policy-section.tsx | 5 +-
.../components/settings/invoices-section.tsx | 21 ++++--
.../src/components/settings/plan-offer.tsx | 39 +++++++++--
.../settings/recommended-mappings-section.tsx | 12 +++-
.../components/settings/settings-section.tsx | 9 +++
apps/web/src/hooks/use-is-org-admin.ts | 8 +++
11 files changed, 226 insertions(+), 69 deletions(-)
create mode 100644 apps/web/src/components/settings/api-key-permissions.test.ts
create mode 100644 apps/web/src/components/settings/api-key-permissions.ts
diff --git a/apps/web/src/components/settings/api-key-permissions.test.ts b/apps/web/src/components/settings/api-key-permissions.test.ts
new file mode 100644
index 0000000000..6746ea147c
--- /dev/null
+++ b/apps/web/src/components/settings/api-key-permissions.test.ts
@@ -0,0 +1,47 @@
+import { UserId } from "@maple/domain/primitives"
+import { Schema } from "effect"
+import { describe, expect, it } from "vitest"
+import { apiKeyRowPermissions } from "./api-key-permissions"
+
+const ME = Schema.decodeSync(UserId)("user_me")
+const OTHER = Schema.decodeSync(UserId)("user_other")
+const live = { revoked: false, created_by: ME }
+
+describe("apiKeyRowPermissions", () => {
+ it("lets an admin roll and revoke any live key", () => {
+ expect(
+ apiKeyRowPermissions(
+ { ...live, kind: "standard", created_by: OTHER },
+ { isAdmin: true, userId: ME },
+ ),
+ ).toEqual({ canRoll: true, canRevoke: true })
+ })
+
+ it("lets a member revoke only their own MCP key, and never roll", () => {
+ const member = { isAdmin: false, userId: ME }
+ expect(apiKeyRowPermissions({ ...live, kind: "mcp" }, member)).toEqual({
+ canRoll: false,
+ canRevoke: true,
+ })
+ expect(apiKeyRowPermissions({ ...live, kind: "mcp", created_by: OTHER }, member)).toEqual({
+ canRoll: false,
+ canRevoke: false,
+ })
+ expect(apiKeyRowPermissions({ ...live, kind: "standard" }, member)).toEqual({
+ canRoll: false,
+ canRevoke: false,
+ })
+ })
+
+ it("offers nothing on a revoked key or before the session names the viewer", () => {
+ expect(
+ apiKeyRowPermissions({ ...live, kind: "mcp", revoked: true }, { isAdmin: true, userId: ME }),
+ ).toEqual({
+ canRoll: false,
+ canRevoke: false,
+ })
+ expect(
+ apiKeyRowPermissions({ ...live, kind: "mcp" }, { isAdmin: false, userId: null }).canRevoke,
+ ).toBe(false)
+ })
+})
diff --git a/apps/web/src/components/settings/api-key-permissions.ts b/apps/web/src/components/settings/api-key-permissions.ts
new file mode 100644
index 0000000000..8ed781aeb5
--- /dev/null
+++ b/apps/web/src/components/settings/api-key-permissions.ts
@@ -0,0 +1,14 @@
+import type { V2ApiKey } from "@maple/domain/http/v2"
+
+/**
+ * Mirrors the v2 api-keys routes: rolling is admin-only, and revoking is admin-only except
+ * that a member may revoke an MCP key they created, since members can mint those.
+ */
+export function apiKeyRowPermissions(
+ key: Pick,
+ viewer: { readonly isAdmin: boolean; readonly userId: string | null },
+): { readonly canRoll: boolean; readonly canRevoke: boolean } {
+ if (key.revoked) return { canRoll: false, canRevoke: false }
+ const ownMcpKey = key.kind === "mcp" && viewer.userId !== null && key.created_by === viewer.userId
+ return { canRoll: viewer.isAdmin, canRevoke: viewer.isAdmin || ownMcpKey }
+}
diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx
index 0fba9b0ce0..d225a2b852 100644
--- a/apps/web/src/components/settings/api-keys-section.tsx
+++ b/apps/web/src/components/settings/api-keys-section.tsx
@@ -36,14 +36,15 @@ import { useApiKeyMutationSync, useApiKeysList } from "@/hooks/use-api-keys"
import { SyncUnavailable } from "@/components/common/sync-unavailable"
import { retryOrgCollections } from "@/lib/collections/org-collections"
import { useLiveClock } from "@/hooks/use-live-clock"
-import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
+import { useIsOrgAdmin, useSessionUserId } from "@/hooks/use-is-org-admin"
import { toastExit } from "@/lib/error-toast"
import { useTimezonePreference } from "@/hooks/use-timezone-preference"
import { formatDateInTimezone } from "@/lib/timezone-format"
-import { SettingsSection, SettingsSections } from "@/components/settings/settings-section"
+import { AdminOnlyNote, SettingsSection, SettingsSections } from "@/components/settings/settings-section"
import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client"
import { CreateApiKeyDialog } from "./create-api-key-dialog"
import { RollApiKeyDialog } from "./roll-api-key-dialog"
+import { apiKeyRowPermissions } from "./api-key-permissions"
type ApiKey = V2ApiKey
@@ -92,6 +93,7 @@ const VIEW_LABELS = {
export function ApiKeysSection() {
const isAdmin = useIsOrgAdmin()
+ const userId = useSessionUserId()
const [view, setView] = useState("active")
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false)
@@ -166,6 +168,21 @@ export function ApiKeysSection() {
+ Only org admins can create, roll or revoke API keys; you can revoke MCP keys you
+ created. For a key that connects your editor to Maple, use the{" "}
+
+ MCP
+ {" "}
+ page; you can create one of those yourself.
+
+ )
+ }
actions={
setCreateOpen(true)} size="sm" disabled={!isAdmin}>
@@ -293,34 +310,24 @@ export function ApiKeysSection() {
- {visibleKeys.map((key) => (
- openRollDialog(key)}
- onRevoke={key.revoked ? undefined : () => openRevokeDialog(key)}
- />
- ))}
+ {visibleKeys.map((key) => {
+ const can = apiKeyRowPermissions(key, { isAdmin, userId })
+ return (
+ openRollDialog(key) : undefined}
+ onRevoke={can.canRevoke ? () => openRevokeDialog(key) : undefined}
+ />
+ )
+ })}
)}
- {!isAdmin ? (
-
- Only org admins can create API keys. For a key that connects your editor to Maple, use the{" "}
-
- MCP
- {" "}
- page; you can create one of those yourself.
-
- ) : null}
-
diff --git a/apps/web/src/components/settings/attribute-mappings-section.tsx b/apps/web/src/components/settings/attribute-mappings-section.tsx
index 982a06fb4f..990b3579de 100644
--- a/apps/web/src/components/settings/attribute-mappings-section.tsx
+++ b/apps/web/src/components/settings/attribute-mappings-section.tsx
@@ -48,7 +48,8 @@ import { DocsLink, EmptyActions } from "@/components/common/docs-link"
import { ErrorState } from "@/components/common/error-state"
import { useAsyncAction, useKeyedAsyncAction } from "@/hooks/use-mutation-action"
import { toastExit } from "@/lib/error-toast"
-import { SettingsSection } from "./settings-section"
+import { AdminOnlyNote, SettingsSection } from "./settings-section"
+import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
const SOURCE_CONTEXT_LABELS: Record = {
span: "Span attribute",
@@ -79,6 +80,7 @@ const toSourceContext = (value: string | null): IngestMappingSourceContext =>
const toOperation = (value: string | null): IngestMappingOperation => (value === "move" ? "move" : "copy")
export function AttributeMappingsSection() {
+ const isAdmin = useIsOrgAdmin()
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(null)
@@ -192,10 +194,19 @@ export function AttributeMappingsSection() {
description="Rename or promote span attribute keys at ingest. Applied only to spans received after a rule is saved."
padded={false}
actions={
-
-
- Add mapping
-
+ isAdmin ? (
+
+
+ Add mapping
+
+ ) : null
+ }
+ footer={
+ isAdmin ? null : (
+
+ Only org admins can add, edit, delete or turn off attribute mappings.
+
+ )
}
>
+ {!isAdmin && (
+
+ Only org admins can manage billing, change the plan or edit billing controls.
+
+ )}
{isLegacy && (
You're on a legacy plan that's no longer offered. Switch to a current plan below for the
@@ -125,7 +132,7 @@ function SubscriptionStrip({
)
}
-export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) {
+export function BillingSection({ isAdmin }: { isAdmin: boolean }) {
const customerResult = useAtomValue(billingCustomerAtom)
const refreshCustomer = useAtomRefresh(billingCustomerAtom)
const plansResult = useAtomValue(billingPlansAtom)
@@ -186,6 +193,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) {
const invoices = (
openCustomerPortal({ returnUrl: window.location.href })}
/>
@@ -215,6 +223,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) {
isLegacy={isLegacy}
billingPeriodLabel={billingPeriodLabel}
isLoading={Result.isInitial(customerResult)}
+ isAdmin={isAdmin}
onManageBilling={() => openCustomerPortal({ returnUrl: window.location.href })}
/>
@@ -271,6 +280,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) {
>
openCustomerPortal({ returnUrl: window.location.href })}
/>
diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx
index f172338faf..b278876b14 100644
--- a/apps/web/src/components/settings/escalation-policy-section.tsx
+++ b/apps/web/src/components/settings/escalation-policy-section.tsx
@@ -28,6 +28,7 @@ import { destinationProvider, ProviderLogo } from "@/components/alerts/destinati
import { SeverityBadge, SEVERITY_ORDER } from "@/components/errors/severity-badge"
import { DocsLink } from "@/components/common/docs-link"
import { ErrorState } from "@/components/common/error-state"
+import { AdminOnlyNote } from "@/components/settings/settings-section"
import { useAsyncAction } from "@/hooks/use-mutation-action"
import { toastExit } from "@/lib/error-toast"
@@ -255,9 +256,7 @@ export function EscalationPolicySection({
{!isAdmin ? (
-
- Only org admins can change the escalation policy.
-
+ Only org admins can change the escalation policy.
) : null}
)
diff --git a/apps/web/src/components/settings/invoices-section.tsx b/apps/web/src/components/settings/invoices-section.tsx
index c35934f905..2e853109ad 100644
--- a/apps/web/src/components/settings/invoices-section.tsx
+++ b/apps/web/src/components/settings/invoices-section.tsx
@@ -95,7 +95,14 @@ function InvoicesSkeleton() {
* the Stripe-hosted invoice (view/PDF). Newest first. On upstream failure the
* Stripe billing portal (via the provided handler) remains the escape hatch.
*/
-export function InvoicesSection({ onManageBilling }: { onManageBilling: () => void }) {
+export function InvoicesSection({
+ canManageBilling,
+ onManageBilling,
+}: {
+ /** The billing portal is admin-only server side. */
+ canManageBilling: boolean
+ onManageBilling: () => void
+}) {
const invoicesResult = useAtomValue(billingInvoicesAtom)
const { effectiveTimezone } = useTimezonePreference()
@@ -110,11 +117,15 @@ export function InvoicesSection({ onManageBilling }: { onManageBilling: () => vo
return (
- Couldn't load invoices. You can still view them in the billing portal.
+ {canManageBilling
+ ? "Couldn't load invoices. You can still view them in the billing portal."
+ : "Couldn't load invoices. An org admin can view them in the billing portal."}
-
- Open billing portal
-
+ {canManageBilling && (
+
+ Open billing portal
+
+ )}
)
}
diff --git a/apps/web/src/components/settings/plan-offer.tsx b/apps/web/src/components/settings/plan-offer.tsx
index 8a0fac11ca..020feea8d7 100644
--- a/apps/web/src/components/settings/plan-offer.tsx
+++ b/apps/web/src/components/settings/plan-offer.tsx
@@ -63,7 +63,17 @@ export function PlanOfferSkeleton() {
* plan. Their plan is not for sale, so it can never come from the catalog; the
* numbers here are their own, from the expanded subscription plus balances.
*/
-function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onManageBilling: () => void }) {
+const ADMIN_ONLY_CAPTION = "Only org admins can change the plan"
+
+function CustomPlanPlate({
+ model,
+ canManage,
+ onManageBilling,
+}: {
+ model: SpendModel
+ canManage: boolean
+ onManageBilling: () => void
+}) {
const price = model.basePlan?.price?.amount
const interval = model.basePlan?.price?.interval ?? "month"
// A hard-capped plan bills nothing past its allotments, so "+ usage" would be
@@ -122,10 +132,12 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana
-
+
Manage plan
-
Invoices and payment method
+
+ {canManage ? "Invoices and payment method" : ADMIN_ONLY_CAPTION}
+
)
@@ -133,10 +145,13 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana
export function PlanOffer({
model,
+ canManage,
onManageBilling,
}: {
/** Needed to render a custom plan, whose terms exist only on the customer. */
model: SpendModel | null
+ /** Attach and the billing portal are admin-only server side. */
+ canManage: boolean
onManageBilling: () => void
}) {
const plansResult = useAtomValue(billingPlansAtom)
@@ -191,7 +206,9 @@ export function PlanOffer({
return (
{framed ? {children} : children}
+ {footer}
)
}
@@ -46,3 +50,8 @@ export function SettingsSection({
export function SettingsSections({ children }: { children: React.ReactNode }) {
return
{children}
}
+
+/** The one-line reason beside a control a member sees but cannot use. */
+export function AdminOnlyNote({ children, className }: { children: React.ReactNode; className?: string }) {
+ return
{children}
+}
diff --git a/apps/web/src/hooks/use-is-org-admin.ts b/apps/web/src/hooks/use-is-org-admin.ts
index 349de77908..9fb675c647 100644
--- a/apps/web/src/hooks/use-is-org-admin.ts
+++ b/apps/web/src/hooks/use-is-org-admin.ts
@@ -20,3 +20,11 @@ export function useIsOrgAdmin(): boolean {
.onSuccess((session) => session.roles.some((role) => role === "root" || role === "org:admin"))
.orElse(() => false)
}
+
+/** The signed-in user's id from the same session atom, or `null` until it loads. */
+export function useSessionUserId(): string | null {
+ const sessionResult = useAtomValue(retainedQuery("auth", "session", {}))
+ return Result.builder(sessionResult)
+ .onSuccess((session): string | null => session.userId)
+ .orElse(() => null)
+}
From 3f30463ae4ba2dacbbf410e191aacdc5118c2793 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Sat, 17 Oct 2026 01:53:10 +0200
Subject: [PATCH 11/14] fix(billing): format billing control amounts in the
spend model's currency
The maximum invoice and the cap summary passed "usd" to formatCurrency while
the rest of the tab uses the spend model's currency.
---
.../settings/billing-controls-card.tsx | 5 ++++-
.../settings/billing-controls-dialog.tsx | 16 ++++++++++++++--
2 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/settings/billing-controls-card.tsx b/apps/web/src/components/settings/billing-controls-card.tsx
index 2d4116f9c0..958377a5a1 100644
--- a/apps/web/src/components/settings/billing-controls-card.tsx
+++ b/apps/web/src/components/settings/billing-controls-card.tsx
@@ -56,7 +56,9 @@ export function BillingControlsCard({