diff --git a/apps/ios/Config/Secrets.example.xcconfig b/apps/ios/Config/Secrets.example.xcconfig index cd1c6b6606..08504e93b1 100644 --- a/apps/ios/Config/Secrets.example.xcconfig +++ b/apps/ios/Config/Secrets.example.xcconfig @@ -15,7 +15,7 @@ CLERK_PUBLISHABLE_KEY = pk_test_your_key_here // Maple's own telemetry — the PUBLIC ingest key, `maple_pk_…`. Leave it unset // and the SDK stays quiet: no spans, no recordings, one log line at launch. -// Take the value from https:/$()/app.maple.dev/developer?tab=ingestion. +// Take the value from https:/$()/app.maple.dev/settings?tab=ingestion. // MAPLE_INGEST_KEY = maple_pk_your_key_here // Point telemetry at a local gateway or staging instead of production. Same diff --git a/apps/web/src/components/common/segmented-select.tsx b/apps/web/src/components/common/segmented-select.tsx index 60b7b19cc7..487ea4b02c 100644 --- a/apps/web/src/components/common/segmented-select.tsx +++ b/apps/web/src/components/common/segmented-select.tsx @@ -95,6 +95,7 @@ export function MultiSegmentedSelect({ onChange, size = "default", className, + disabled, "aria-label": ariaLabel, }: { options: ReadonlyArray> @@ -102,11 +103,13 @@ export function MultiSegmentedSelect({ onChange: (value: T[]) => void size?: Size className?: string + disabled?: boolean "aria-label"?: string }) { return ( onChange(next as T[])} 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({ /> - +

Install the Maple skills, then run the prompt in Claude Code, Codex, or Cursor. The{" "} maple-onboard skill walks every service in the repo, installs @@ -246,9 +244,9 @@ export function ConnectInstructions({ {apiKeyStatus === "failed" ? ( ) : apiKeyStatus === "loading" || !apiKey ? ( - + ) : ( - + )} {tabs} diff --git a/apps/web/src/components/ingest/use-ingest-connection.test.ts b/apps/web/src/components/ingest/use-ingest-connection.test.ts index b88a66dc23..55d9909c18 100644 --- a/apps/web/src/components/ingest/use-ingest-connection.test.ts +++ b/apps/web/src/components/ingest/use-ingest-connection.test.ts @@ -2,7 +2,9 @@ import { assert, describe, it } from "@effect/vitest" import { Effect } from "effect" import { TestClock } from "effect/testing" import { FetchHttpClient } from "effect/http" -import { sendTestEventEffect } from "./use-ingest-connection" +import * as AsyncResult from "effect/reactivity/AsyncResult" +import { Cause } from "effect" +import { readIngestKeys, sendTestEventEffect } from "./use-ingest-connection" interface RecordedRequest { readonly url: string @@ -70,3 +72,33 @@ describe("sendTestEventEffect", () => { }), ) }) + +describe("readIngestKeys", () => { + const keys = { + object: "ingest_keys" as const, + public_key: "maple_pk_public", + private_key: "maple_sk_private", + public_rotated_at: "2026-10-01T00:00:00.000Z", + private_rotated_at: "2026-10-01T00:00:00.000Z", + } + + it("hands the server snippets the private key and the browser test event the public one", () => { + assert.deepStrictEqual(readIngestKeys(AsyncResult.success(keys)), { + publicKey: "maple_pk_public", + privateKey: "maple_sk_private", + status: "ready", + }) + }) + + it("exposes neither key when the admin-only read is denied", () => { + assert.deepStrictEqual(readIngestKeys(AsyncResult.failure(Cause.fail("forbidden"))), { + publicKey: "", + privateKey: "", + status: "failed", + }) + }) + + it("reports loading before the read settles", () => { + assert.strictEqual(readIngestKeys(AsyncResult.initial(true)).status, "loading") + }) +}) diff --git a/apps/web/src/components/ingest/use-ingest-connection.ts b/apps/web/src/components/ingest/use-ingest-connection.ts index b0bcc587f5..9105912fe5 100644 --- a/apps/web/src/components/ingest/use-ingest-connection.ts +++ b/apps/web/src/components/ingest/use-ingest-connection.ts @@ -1,4 +1,6 @@ import { Clock, Effect, Schema } from "effect" +import type * as AsyncResult from "effect/reactivity/AsyncResult" +import type { V2IngestKeys } from "@maple/domain/http/v2" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" @@ -18,9 +20,11 @@ export interface IngestConnection { firstRealService?: string /** Spans per minute across real services, averaged over the 1h window. */ spansPerMinute: number - /** The org's public ingest key (empty string until loaded / when denied). */ - apiKey: string - /** `failed` when the key could not be read (e.g. members without access to org keys). */ + /** Public key, for browser senders such as the test event (empty until loaded / when denied). */ + publicKey: string + /** Private key, for the server-side SDK snippets (empty until loaded / when denied). */ + privateKey: string + /** `failed` when the keys could not be read (members cannot read org ingest keys). */ apiKeyStatus: IngestKeyStatus /** Force an immediate re-poll of the service overview. */ refresh: () => void @@ -31,22 +35,29 @@ interface UseIngestConnectionOptions { poll?: boolean } +/** Both keys come from one admin-only read, so a member sees neither rather than just the public one. */ +export function readIngestKeys(result: AsyncResult.AsyncResult): { + publicKey: string + privateKey: string + status: IngestKeyStatus +} { + if (result._tag === "Success") { + return { publicKey: result.value.public_key, privateKey: result.value.private_key, status: "ready" } + } + return { publicKey: "", privateKey: "", status: result._tag === "Failure" ? "failed" : "loading" } +} + /** * Live ingest-connection state, shared across the Connect popover, ingestion * settings, and the dashboard setup checklist. Polls the service overview and * filters out the seeded `demo-` services so the signal reflects the user's own - * telemetry. Reuses the cached `ingestKeys` query for the public key. + * telemetry. Reuses the cached `ingestKeys` query for both keys. */ export function useIngestConnection({ poll = true }: UseIngestConnectionOptions = {}): IngestConnection { const { startTime, endTime } = useEffectiveTimeRange(undefined, undefined, "1h") const keysResult = useAtomValue(retainedQueryV2("ingestKeys", "retrieve", {})) - const apiKey = Result.isSuccess(keysResult) ? keysResult.value.public_key : "" - const apiKeyStatus: IngestKeyStatus = Result.isSuccess(keysResult) - ? "ready" - : Result.isFailure(keysResult) - ? "failed" - : "loading" + const keys = readIngestKeys(keysResult) const overviewAtom = getServiceOverviewResultAtom({ data: { startTime, endTime } }) const overviewResult = useAtomValue(overviewAtom) @@ -68,8 +79,9 @@ export function useIngestConnection({ poll = true }: UseIngestConnectionOptions serviceCount: realServices.length, firstRealService, spansPerMinute, - apiKey, - apiKeyStatus, + publicKey: keys.publicKey, + privateKey: keys.privateKey, + apiKeyStatus: keys.status, refresh, } } diff --git a/apps/web/src/components/quick-start/sdk-snippets.ts b/apps/web/src/components/quick-start/sdk-snippets.ts index c4a416e7bd..f9a3fb50cf 100644 --- a/apps/web/src/components/quick-start/sdk-snippets.ts +++ b/apps/web/src/components/quick-start/sdk-snippets.ts @@ -9,6 +9,7 @@ export interface SdkSnippet { instrument: string } +// Every snippet configures a server-side sender, so `{{API_KEY}}` is the private ingest key. export const sdkSnippets: SdkSnippet[] = [ { language: "nextjs", diff --git a/apps/web/src/components/settings/ai-provider-section.tsx b/apps/web/src/components/settings/ai-provider-section.tsx index e3cb7e04f3..8c4b87e26c 100644 --- a/apps/web/src/components/settings/ai-provider-section.tsx +++ b/apps/web/src/components/settings/ai-provider-section.tsx @@ -44,6 +44,7 @@ export function AiProviderSection() { const [draft, setDraft] = useState("") const [confirmRemove, setConfirmRemove] = useState(false) + const [confirmAddOn, setConfirmAddOn] = useState(false) const [saveKey, isSaving] = useAsyncAction(async (apiKey: string) => { const exit = await setMutation({ @@ -172,13 +173,25 @@ export function AiProviderSection() { label={`Add-on, ${ADD_ON_PRICE}`} description="Billed with your plan. Cancel any time from billing, and Maple's own key takes over again." control={ - } /> )} + {/* With a card on file the attach charges at once and no checkout page follows. */} + addOn()} + /> + { + 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..10f30603e9 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,22 @@ 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 + {" "} + tab; you can create one of those yourself. + + ) + } actions={ + isAdmin ? ( + + ) : null + } + footer={ + isAdmin ? null : ( + + Only org admins can add, edit, delete or turn off attribute mappings. + + ) } >

@@ -233,10 +244,12 @@ export function AttributeMappingsSection() { - + {isAdmin && ( + + )} @@ -301,22 +314,24 @@ export function AttributeMappingsSection() {
-
- openEditDialog(mapping)} - label="Edit mapping" - > - - - setDeleteConfirm(mapping)} - label="Delete mapping" - > - - -
+ {isAdmin && ( +
+ openEditDialog(mapping)} + label="Edit mapping" + > + + + setDeleteConfirm(mapping)} + label="Delete mapping" + > + + +
+ )} }> void toggle.run(mapping.id, mapping) } - disabled={toggle.isPending(mapping.id)} + disabled={!isAdmin || toggle.isPending(mapping.id)} /> diff --git a/apps/web/src/components/settings/audit-log-section.tsx b/apps/web/src/components/settings/audit-log-section.tsx index 66fd102863..ffec4a84b2 100644 --- a/apps/web/src/components/settings/audit-log-section.tsx +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -71,17 +71,17 @@ const ACTOR_BADGES: Record< // Shared column lanes so the header row and entry rows stay aligned. Resource and // source collapse when the card is narrow; time + actor + action always stay // visible, and action is the lane that absorbs the remaining width. The card, -// not the viewport, is what the breakpoints measure: the sidebar and settings -// nav leave it far narrower than the window. +// not the viewport, is what the breakpoints measure: the settings column caps it +// at 48rem (766px inside its border), so every threshold must sit below that. // Below `@md` a row wraps: time + actor on the first line, the action on its own // line beneath, indented past the chevron. const COL = { time: "flex w-[112px] shrink-0 items-center gap-2", - actor: "min-w-0 flex-1 @md:w-[176px] @md:flex-none", + actor: "min-w-0 flex-1 @md:w-[160px] @md:flex-none", action: "min-w-0 basis-full pl-5 @md:basis-0 @md:flex-1 @md:pl-0", - resource: "hidden w-[200px] min-w-0 shrink-0 @3xl:block", - // 52rem: the card's width at a 1440px window, the narrowest desktop that should still show it. - source: "hidden w-[104px] shrink-0 @min-[52rem]:block", + resource: "hidden w-[176px] min-w-0 shrink-0 @2xl:block", + // 46rem: only a full-width settings column (736px+) leaves the action lane room for source too. + source: "hidden w-[88px] shrink-0 @min-[46rem]:block", } /** @@ -234,7 +234,10 @@ export function AuditLogSection() { refreshFirstPage() } - const waiting = !Result.isSuccess(pageResult) || pageResult.waiting + // A failed page is settled, not loading: dimming the list and spinning the footer + // on it would never end. Only a retry (which sets `waiting`) puts it back in flight. + const failed = Result.isFailure(pageResult) + const waiting = failed ? pageResult.waiting : !Result.isSuccess(pageResult) || pageResult.waiting const filtered = actorFilter !== "all" || outcomeFilter !== "all" return ( @@ -336,7 +339,18 @@ export function AuditLogSection() { )} - {view !== null && view.hasMore && view.nextCursor !== null && ( + {view !== null && failed && ( + refreshPage()} + /> + )} + + {view !== null && !failed && view.hasMore && view.nextCursor !== null && ( {formatDateTimeFull(entry.occurred_at)} diff --git a/apps/web/src/components/settings/automation-section.tsx b/apps/web/src/components/settings/automation-section.tsx index ec4d42209c..a116999ed5 100644 --- a/apps/web/src/components/settings/automation-section.tsx +++ b/apps/web/src/components/settings/automation-section.tsx @@ -35,6 +35,11 @@ export function AutomationSection({ isAdmin: boolean hasEntitlement: boolean }) { + // The evaluate endpoint only reads the stored policy, so unsaved edits are invisible to it. + // `revision` bumps on every edit, so a decision computed before it is never shown after a save. + const [policy, setPolicy] = useState({ dirty: false, revision: 0 }) + const onDirtyChange = (dirty: boolean) => + setPolicy((current) => ({ dirty, revision: dirty ? current.revision + 1 : current.revision })) return ( @@ -45,23 +50,25 @@ export function AutomationSection({ description="Confidence gates apply only to AI decisions. A manual severity change is explicit human intent and bypasses the confidence threshold." framed={false} > - + - + ) } -function PolicySimulator() { +function PolicySimulator({ policyDirty, policyRevision }: { policyDirty: boolean; policyRevision: number }) { const [severity, setSeverity] = useState("high") const [source, setSource] = useState<"ai" | "manual">("ai") const [confidence, setConfidence] = useState("high") - const [decision, setDecision] = useState<{ + const [evaluated, setEvaluated] = useState<{ + revision: number outcome: "route" | "skip" destinationIds: ReadonlyArray skipReason: string | null } | null>(null) + const decision = evaluated?.revision === policyRevision ? evaluated : null const evaluate = useAtomSet(MapleInternalAtomClient.mutation("errors", "evaluateEscalationPolicy"), { mode: "promiseExit", }) @@ -71,7 +78,7 @@ function PolicySimulator() { .orElse(() => []) const [run, busy] = useAsyncAction(async () => { - setDecision(null) + setEvaluated(null) const result = await evaluate({ payload: new EscalationPolicyEvaluationRequest({ severity, @@ -80,7 +87,7 @@ function PolicySimulator() { }), }) if (toastExit(result, { error: "Policy evaluation failed" }) && Exit.isSuccess(result)) { - setDecision(result.value) + setEvaluated({ ...result.value, revision: policyRevision }) } }) @@ -125,10 +132,14 @@ function PolicySimulator() {
- - {decision ? ( + {policyDirty ? ( + + Save the policy above to simulate it. The simulator evaluates the saved policy. + + ) : decision ? ( <> {decision.outcome} @@ -147,7 +158,7 @@ function PolicySimulator() { ) : ( - Preview the exact worker decision without sending a notification. + Preview the worker's decision under the saved policy without sending a notification. )}
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({

Maximum invoice:{" "} - {maximum === null ? "Uncapped" : formatCurrency(maximum / 100, "usd")} + {maximum === null || model === null + ? "Uncapped" + : formatCurrency(maximum / 100, model.currency)}

@@ -123,6 +125,7 @@ export function BillingControlsCard({ entry.featureId === editing) ?? null} + currency={model?.currency ?? null} featureId={editing} existingLimit={spendLimitFor(customer, editing)?.overageLimit} open diff --git a/apps/web/src/components/settings/billing-controls-dialog.tsx b/apps/web/src/components/settings/billing-controls-dialog.tsx index bbbd617f66..8768e2d1e0 100644 --- a/apps/web/src/components/settings/billing-controls-dialog.tsx +++ b/apps/web/src/components/settings/billing-controls-dialog.tsx @@ -39,10 +39,12 @@ const parseCap = (raw: string): number | null => { function CapSummary({ feature, featureId, + currency, cap, }: { readonly feature: FeatureSpend readonly featureId: SpendFeatureId + readonly currency: string readonly cap: number | null }) { if (feature.unlimited || feature.overageAllowed === false || feature.ratePerUnit === null) return null @@ -63,7 +65,7 @@ function CapSummary({ {cap === null ? "Unlimited" - : formatCurrency(Math.round(cap * feature.ratePerUnit * 100) / 100, "usd")} + : formatCurrency(Math.round(cap * feature.ratePerUnit * 100) / 100, currency)} {cap !== null && feature.included !== null && ( @@ -76,12 +78,15 @@ function CapSummary({ export function BillingControlsDialog({ feature, + currency, featureId, existingLimit, open, onOpenChange, }: { readonly feature: FeatureSpend | null + /** The spend model's currency, so the cap reads like the rest of the tab. */ + readonly currency: string | null readonly featureId: SpendFeatureId readonly existingLimit: number | undefined readonly open: boolean @@ -150,7 +155,14 @@ export function BillingControlsDialog({ )} - {feature !== null && } + {feature !== null && currency !== null && ( + + )} ) } diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx index 8a63308c4d..cbf807c861 100644 --- a/apps/web/src/components/settings/billing-section.tsx +++ b/apps/web/src/components/settings/billing-section.tsx @@ -6,7 +6,7 @@ import { Badge } from "@maple/ui/components/ui/badge" import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { cn } from "@maple/ui/lib/utils" -import { Result, useAtomValue } from "@/lib/effect-atom" +import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { billingCustomerAtom, billingDailySpendAtom, @@ -16,7 +16,7 @@ import { import { useBillingActions } from "@/hooks/use-billing-actions" 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 { getLegacyPlanInfo, getTrialStatus, type TrialStatus } from "@/lib/billing/plan-gating" import { buildSpendModel } from "@/lib/billing/spend" import { estimateCycleCost } from "@/lib/billing/cost-estimate" @@ -29,6 +29,7 @@ import { PlanOffer } from "./plan-offer" import { CostBreakdown, CostBreakdownSkeleton } from "./cost-breakdown" import { InvoicesSection } from "./invoices-section" import { BillingDetailsSection } from "./billing-details-section" +import { ErrorState } from "@/components/common/error-state" /** * Billing, spend-first. @@ -45,12 +46,14 @@ function SubscriptionStrip({ isLegacy, billingPeriodLabel, isLoading, + isAdmin, onManageBilling, }: { trial: TrialStatus isLegacy: boolean billingPeriodLabel: string isLoading: boolean + isAdmin: boolean onManageBilling: () => void }) { const { effectiveTimezone } = useTimezonePreference() @@ -103,10 +106,15 @@ function SubscriptionStrip({ {billingPeriodLabel} - + {!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 @@ -124,8 +132,9 @@ 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) const usageResult = useAtomValue(billingUsageAtom) const dailySpendResult = useAtomValue(billingDailySpendAtom) @@ -176,6 +185,37 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { ]), ) + const billingDetails = ( + + + + ) + const invoices = ( + + openCustomerPortal({ returnUrl: window.location.href })} + /> + + ) + + // Every spend view is derived from the customer, so a failed read would otherwise + // leave them all on skeletons forever. Details and invoices load on their own. + if (Result.isFailure(customerResult)) { + return ( + + + {billingDetails} + {invoices} + + ) + } + return ( openCustomerPortal({ returnUrl: window.location.href })} /> @@ -218,9 +259,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { )} - - - + {billingDetails} {(isCostLoading || costEstimate !== null) && ( @@ -232,11 +271,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { )} - - openCustomerPortal({ returnUrl: window.location.href })} - /> - + {invoices} openCustomerPortal({ returnUrl: window.location.href })} /> diff --git a/apps/web/src/components/settings/digest-subscription-load.test.ts b/apps/web/src/components/settings/digest-subscription-load.test.ts new file mode 100644 index 0000000000..2c80a6f75d --- /dev/null +++ b/apps/web/src/components/settings/digest-subscription-load.test.ts @@ -0,0 +1,26 @@ +import { DigestNotFoundError, DigestPersistenceError } from "@maple/domain/http" +import { Cause } from "effect" +import * as AsyncResult from "effect/reactivity/AsyncResult" +import { describe, expect, it } from "vitest" +import { readDigestSubscription } from "./digest-subscription-load" + +describe("readDigestSubscription", () => { + it("treats a missing subscription as ready with Maple's defaults", () => { + const result = AsyncResult.failure(Cause.fail(new DigestNotFoundError({ message: "none" }))) + expect(readDigestSubscription(result)).toEqual({ status: "ready", saved: null }) + }) + + it("reports any other error as failed, so defaults never overwrite stored preferences", () => { + const cause = Cause.fail(new DigestPersistenceError({ message: "db down" })) + expect(readDigestSubscription(AsyncResult.failure(cause))).toEqual({ status: "failed", cause }) + }) + + it("passes a loaded subscription through", () => { + const saved = { enabled: false } + expect(readDigestSubscription(AsyncResult.success(saved))).toEqual({ status: "ready", saved }) + }) + + it("is loading before the read settles", () => { + expect(readDigestSubscription(AsyncResult.initial()).status).toBe("loading") + }) +}) diff --git a/apps/web/src/components/settings/digest-subscription-load.ts b/apps/web/src/components/settings/digest-subscription-load.ts new file mode 100644 index 0000000000..13e1ac0de1 --- /dev/null +++ b/apps/web/src/components/settings/digest-subscription-load.ts @@ -0,0 +1,26 @@ +import { Cause, Option, Predicate } from "effect" +import type * as AsyncResult from "effect/reactivity/AsyncResult" + +export type DigestSubscriptionLoad = + | { readonly status: "loading" } + /** `saved: null` is a user who never subscribed, so Maple's defaults apply. */ + | { readonly status: "ready"; readonly saved: A | null } + | { readonly status: "failed"; readonly cause: Cause.Cause } + +const DIGEST_NOT_FOUND = "@maple/http/errors/DigestNotFoundError" + +/** + * A 404 is the expected "no subscription yet" answer, not a failure. Any other error + * means the stored preferences are unknown, and editable switches built on defaults + * would upsert over them. + */ +export function readDigestSubscription( + result: AsyncResult.AsyncResult, +): DigestSubscriptionLoad { + if (result._tag === "Initial") return { status: "loading" } + if (result._tag === "Success") return { status: "ready", saved: result.value } + const error = Cause.findErrorOption(result.cause) + return Option.isSome(error) && Predicate.isTagged(error.value, DIGEST_NOT_FOUND) + ? { status: "ready", saved: null } + : { status: "failed", cause: result.cause } +} diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx index 4b70e2977c..1963e8a71c 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" @@ -53,7 +54,14 @@ const emptyDraft = (): DraftRules => ({ * human) sets an issue's severity, matching destinations get notified — * detection-time alerts keep using the alert rule's own destinations. */ -export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { +export function EscalationPolicySection({ + isAdmin, + onDirtyChange, +}: { + isAdmin: boolean + /** Fires when the draft diverges from, or is saved back to, the stored policy. */ + onDirtyChange?: (dirty: boolean) => void +}) { const policyQueryAtom = retainedInternalQuery("errors", "getEscalationPolicy", { reactivityKeys: ["issueEscalationPolicy"], }) @@ -102,9 +110,26 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { payload: new IssueEscalationPolicyUpsertRequest({ enabled, rules: ruleList }), reactivityKeys: ["issueEscalationPolicy"], }) - toastExit(result, { success: "Escalation policy saved", error: "Failed to save escalation policy" }) + if ( + toastExit(result, { + success: "Escalation policy saved", + error: "Failed to save escalation policy", + }) + ) { + onDirtyChange?.(false) + } }) + // Controls lock while a save is in flight, so the saved draft is always the visible one. + const editEnabled = (next: boolean) => { + setEnabled(next) + onDirtyChange?.(true) + } + const editRule = (severity: IssueSeverity, patch: Partial) => { + setRules((current) => ({ ...current, [severity]: { ...current[severity], ...patch } })) + onDirtyChange?.(true) + } + // Never render the editable form off a failed (or pending) policy load — // saving a default draft would silently overwrite the real policy. if (!initialized) { @@ -132,7 +157,13 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { } + control={ + + } /> {Result.builder(destinationsResult) @@ -187,19 +218,13 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) {