diff --git a/apps/ai/src/platform/genai-spans.ts b/apps/ai/src/platform/genai-spans.ts
index 93835fbb76..3ede058ff7 100644
--- a/apps/ai/src/platform/genai-spans.ts
+++ b/apps/ai/src/platform/genai-spans.ts
@@ -381,7 +381,7 @@ export interface ModelCallAttempts {
}
/** Which attempt a model-call span is; only a retry past the cap carries it. */
-export const MODEL_CALL_ATTEMPT_ATTR = "maple_ai.model_call.attempt"
+const MODEL_CALL_ATTEMPT_ATTR = "maple_ai.model_call.attempt"
/**
* Build a provider's language model so that every `streamText` call — the only call Maple makes, and
diff --git a/apps/landing/src/content/docs/alerting/alert-rules.md b/apps/landing/src/content/docs/alerting/alert-rules.md
index 3c9932a391..804dd30d1b 100644
--- a/apps/landing/src/content/docs/alerting/alert-rules.md
+++ b/apps/landing/src/content/docs/alerting/alert-rules.md
@@ -121,7 +121,7 @@ The preview sends nothing. Use it to check that a threshold would not have fired
## Create a rule over the API
-Rules are available at `/v2/alerts/rules` on `https://api.maple.dev` (`https://api.eu.maple.dev` for EU organizations). Use an API key (`maple_ak_…`) from **Settings → API Keys**. Creating, updating, deleting and testing a rule requires the `alerts:write` scope and the org admin role.
+Rules are available at `/v2/alerts/rules` on `https://api.maple.dev` (`https://api.eu.maple.dev` for EU organizations). Use an API key (`maple_ak_…`) from **Settings → Keys**. Creating, updating, deleting and testing a rule requires the `alerts:write` scope and the org admin role.
```bash
curl -X POST https://api.maple.dev/v2/alerts/rules \
diff --git a/apps/landing/src/content/docs/iac/alchemy.md b/apps/landing/src/content/docs/iac/alchemy.md
index fa037e183b..1e89ce53ce 100644
--- a/apps/landing/src/content/docs/iac/alchemy.md
+++ b/apps/landing/src/content/docs/iac/alchemy.md
@@ -81,7 +81,7 @@ That is the whole integration. Each request becomes a server span, Effect logs a
### 4. Deploy
-Create an API key in **Settings → API Keys**. An org admin's key with full access is simplest; see [Authentication](#authentication) for the minimum scopes. Then deploy:
+Create an API key in **Settings → Keys**. An org admin's key with full access is simplest; see [Authentication](#authentication) for the minimum scopes. Then deploy:
```bash
MAPLE_API_KEY=maple_ak_… alchemy deploy
diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md
index 194a0d8211..837044ba52 100644
--- a/apps/landing/src/content/docs/integrations/gcp.md
+++ b/apps/landing/src/content/docs/integrations/gcp.md
@@ -522,7 +522,7 @@ When you create a connection in Maple, "The Google Cloud project acme-prod is al
| **Waiting for the first entry** for more than 20 minutes | Either nothing was logged that passes the filter, or the sink can't publish. Run the `gcloud logging write` command the band shows. If the entry does not arrive within a minute, open **Configure** and run the setup script again. |
| **Rejected**: "The Pub/Sub subscription wraps each entry in an envelope Maple can't read." | The subscription was changed to deliver wrapped messages. Run the setup script again: it resets the subscription. Entries sent meanwhile are lost. |
| **Rejected**: "Maple could not store an entry just now." | Nothing to do. Pub/Sub retries the entry for up to a day, and the status returns to **Receiving** with the next accepted entry. |
-| **Rejected**: "This Maple organization is over its plan limit, so Maple refuses new logs." | Raise the plan limit under **Settings → Billing**. Pub/Sub retries refused entries for up to a day. |
+| **Rejected**: "This Maple organization is over its plan limit, so Maple refuses new logs." | Raise the plan limit under **Settings → Billing & usage**. Pub/Sub retries refused entries for up to a day. |
| A workload's logs appear twice | The sink forwards its application output and the workload also sends its logs over OpenTelemetry. In **Configure**, choose **Replace filter**, leave its runtime unticked under [Log filter](#log-filter) and run the script again. |
| A workload's stdout and stderr are missing | Application output is not forwarded by default. Send it over OpenTelemetry, or tick its runtime under [Log filter](#log-filter) in **Configure** and run the script again. |
@@ -542,7 +542,7 @@ When you create a connection in Maple, "The Google Cloud project acme-prod is al
| "3 of 46 metric queries failed, first ..." | The rest were stored. The failed metrics miss those minutes, and Maple retries in 5 minutes. |
| "3 of 46 metric queries were not read in full" | The project, folder or organization holds more series than one read takes. Connect the folders or projects of a folder or organization as separate connections. For a single project, write to support. |
| "Reading the metrics took longer than two minutes." | Maple reads the same minutes again, which can store part of them twice. If it repeats, connect the folders or projects of a folder or organization separately. For a single project, write to support. |
-| "Metrics are paused: this Maple organization is over its plan limit." | Raise the plan limit under **Settings → Billing**. Maple tries again in an hour. |
+| "Metrics are paused: this Maple organization is over its plan limit." | Raise the plan limit under **Settings → Billing & usage**. Maple tries again in an hour. |
| "Maple could not store the metrics it read just now." | Nothing to do. Maple reads the same minutes again. |
| "Google denied the resource listing for project acme-prod." | Run the setup script again: it grants Cloud Asset Viewer. Metrics are unaffected. |
| "The project holds more than 10,000 resources, so the resource list is incomplete." | Metrics are unaffected. For a full list, connect the folders or projects of a folder or organization separately. For a single project, write to support. |
diff --git a/apps/landing/src/content/docs/reference/api.md b/apps/landing/src/content/docs/reference/api.md
index ca0e1f591b..d7f2120eb6 100644
--- a/apps/landing/src/content/docs/reference/api.md
+++ b/apps/landing/src/content/docs/reference/api.md
@@ -21,7 +21,7 @@ Use the base URL of your organization's [region](/docs/reference/regions). A key
## Authentication
-Create an API key in the dashboard under **Settings → API Keys**, or with `POST /v2/api_keys` using an existing key. Send it as a Bearer token on every request:
+Create an API key in the dashboard under **Settings → Keys**, or with `POST /v2/api_keys` using an existing key. Send it as a Bearer token on every request:
```bash
curl https://api.maple.dev/v2/services \
diff --git a/apps/landing/src/content/docs/reference/authentication.md b/apps/landing/src/content/docs/reference/authentication.md
index f8bed90c2a..e980e09cac 100644
--- a/apps/landing/src/content/docs/reference/authentication.md
+++ b/apps/landing/src/content/docs/reference/authentication.md
@@ -11,7 +11,7 @@ Maple uses two kinds of credential. **Ingest keys** send telemetry. **API keys**
| ------------------ | ----------- | ---------------------------------------- | ---------------------------------------------- | ---------------------------- |
| Public ingest key | `maple_pk_` | **Settings → Ingestion** | Send telemetry | Yes |
| Private ingest key | `maple_sk_` | **Settings → Ingestion** | Send telemetry | No |
-| API key | `maple_ak_` | **Settings → API Keys** | REST API and MCP server, limited by its scopes | No |
+| API key | `maple_ak_` | **Settings → Keys** | REST API and MCP server, limited by its scopes | No |
| MCP key | `maple_ak_` | **Settings → MCP** | MCP server only | No |
| OAuth token | | Issued to an MCP client when you sign in | MCP server only | No |
@@ -44,7 +44,7 @@ The literal key `MAPLE_TEST` is accepted and returns `200`, but the data is disc
## API keys
-Create API keys under **Settings → API Keys**, or with `POST /v2/api_keys`. When you create one you choose:
+Create API keys under **Settings → Keys**, or with `POST /v2/api_keys`. When you create one you choose:
| Field | Options |
| ---------------------------- | ----------------------------------------------------- |
@@ -64,7 +64,7 @@ API requests are limited to 600 per 60 seconds per key. See [Limits](/docs/refer
An MCP client can authenticate in three ways:
-- **An API key** from **Settings → API Keys**, sent as `Authorization: Bearer maple_ak_…`.
+- **An API key** from **Settings → Keys**, sent as `Authorization: Bearer maple_ak_…`.
- **An MCP key** from **Settings → MCP**. It has full access to the MCP tools and is rejected by the REST API.
- **OAuth**, for clients that support the MCP authorization flow. The client discovers Maple's authorization server from the endpoint URL, registers itself, and opens a browser sign-in. You pick the organization on the consent screen. Access tokens last 1 hour and are refreshed automatically. The client has to sign in again after 30 days without a refresh, and 90 days after the first sign-in at the latest. OAuth tokens only work on the MCP server.
diff --git a/apps/landing/src/content/docs/reference/cli.md b/apps/landing/src/content/docs/reference/cli.md
index 2a72b101c7..31728c07c5 100644
--- a/apps/landing/src/content/docs/reference/cli.md
+++ b/apps/landing/src/content/docs/reference/cli.md
@@ -350,7 +350,7 @@ maple use remote # optional: stop auto-detecti
maple services --since 1h
```
-`maple auth login` opens your browser, and you approve the CLI for one organization. The CLI then holds an [API key](/docs/reference/authentication#api-keys) with full access that expires after 90 days. It appears under **Settings → API Keys** with the description "Created by maple auth login". Run `maple auth login` again when it expires, and `maple auth logout` to revoke it.
+`maple auth login` opens your browser, and you approve the CLI for one organization. The CLI then holds an [API key](/docs/reference/authentication#api-keys) with full access that expires after 90 days. It appears under **Settings → Keys** with the description "Created by maple auth login". Run `maple auth login` again when it expires, and `maple auth logout` to revoke it.
Every command except `maple query` works in remote mode. The server commands (`start`, `stop`, `reset`, `checkpoint`, `restore`, `archive`, `schema`) always act on the local store. Which region your organization is in is on [Regions](/docs/reference/regions).
diff --git a/apps/landing/src/content/docs/reference/ingest.md b/apps/landing/src/content/docs/reference/ingest.md
index 94618868bd..b872a909c8 100644
--- a/apps/landing/src/content/docs/reference/ingest.md
+++ b/apps/landing/src/content/docs/reference/ingest.md
@@ -104,7 +104,7 @@ Checks run roughly in the order below, so a request fails on the first one it tr
| `415` | `ingest_unsupported_media_type` | `@maple/ingest/UnsupportedMediaType` | Unknown `Content-Type` or `Content-Encoding` | No. Fix the exporter config |
| `400` | `ingest_bad_request` | `@maple/ingest/BadRequest` | Invalid gzip, a body that is not valid OTLP, or a malformed product event, session event or replay header | No |
| `400` | `ingest_replay_body_not_gzip` | `@maple/ingest/ReplayBodyNotGzip` | A session replay chunk that is not a gzip stream | No |
-| `402` | `ingest_plan_limit_reached` | `@maple/ingest/PlanLimitReached` | No active subscription, or the plan's limit is reached | No. Check **Settings → Billing** |
+| `402` | `ingest_plan_limit_reached` | `@maple/ingest/PlanLimitReached` | No active subscription, or the plan's limit is reached | No. Check **Settings → Billing & usage** |
| `429` | `ingest_queue_throttled` | `@maple/ingest/OrgQueueThrottled` | Your organization's ingest queue is full. Nothing was written | Yes, after 1 s |
| `429` | `ingest_export_lane_full` | `@maple/ingest/ExportLaneBackpressure` | The write path for your organization is backed up. Nothing was written | Yes, after 2 s |
| `503` | `ingest_unavailable` | `@maple/ingest/ServiceUnavailable` | Key lookup or another dependency is temporarily unavailable | Yes, after 5 s |
diff --git a/apps/landing/src/content/docs/reference/mcp.md b/apps/landing/src/content/docs/reference/mcp.md
index a33acdabc7..12cea11912 100644
--- a/apps/landing/src/content/docs/reference/mcp.md
+++ b/apps/landing/src/content/docs/reference/mcp.md
@@ -24,7 +24,7 @@ Use the endpoint of your organization's [region](/docs/reference/regions). The e
Any MCP client that speaks Streamable HTTP can connect. **Settings → MCP** in the dashboard shows the endpoint for your region and ready-made configuration for common clients. There are two ways to authenticate. Both are described in more detail on [Authentication](/docs/reference/authentication).
-**API key.** Create a key under **Settings → MCP** or **Settings → API Keys** and pass it as a Bearer token:
+**API key.** Create a key under **Settings → MCP** or **Settings → Keys** and pass it as a Bearer token:
```json
{
diff --git a/apps/landing/src/content/docs/reference/regions.md b/apps/landing/src/content/docs/reference/regions.md
index bd4d60d04e..a89bfd8f13 100644
--- a/apps/landing/src/content/docs/reference/regions.md
+++ b/apps/landing/src/content/docs/reference/regions.md
@@ -23,7 +23,7 @@ Keys do not cross regions. An ingest key or API key created in one region is rej
## Find your organization's region
-- **Settings → Organization** shows a **Data region** row: "United States" or "European Union (Frankfurt)".
+- **Settings → General** shows a **Data region** row: "United States" or "European Union (Frankfurt)".
- The organization switcher in the sidebar shows `· US` or `· EU` after the organization name.
- The dashboard host you use is the region: `app.maple.dev` is US, `app.eu.maple.dev` is EU.
diff --git a/apps/landing/src/content/docs/reference/retention.md b/apps/landing/src/content/docs/reference/retention.md
index e88f68fc0a..32d30f416e 100644
--- a/apps/landing/src/content/docs/reference/retention.md
+++ b/apps/landing/src/content/docs/reference/retention.md
@@ -37,7 +37,7 @@ Individual spans, log lines and data points cannot be read once they pass these
| Data | Kept for |
| ------------------------------------------------------------ | -------------------- |
| Alert rule checks (each evaluation, with its observed value) | 365 days |
-| Organization audit log (**Settings → Audit Log**) | 6 years (2,190 days) |
+| Organization audit log (**Settings → Audit log**) | 6 years (2,190 days) |
## Maple Local
diff --git a/apps/web/src/components/account/account-nav.tsx b/apps/web/src/components/account/account-nav.tsx
index a6b6e06e53..00a2f3cfe2 100644
--- a/apps/web/src/components/account/account-nav.tsx
+++ b/apps/web/src/components/account/account-nav.tsx
@@ -1,4 +1,5 @@
import {
+ BellIcon,
ComputerIcon,
EnvelopeIcon,
FingerprintIcon,
@@ -17,22 +18,24 @@ export const accountTabValues = [
"passkeys",
"connections",
"sessions",
+ "email-preferences",
] as const
export type AccountTab = (typeof accountTabValues)[number]
export const accountTabLabels: Record = {
profile: "Profile",
- emails: "Email Addresses",
+ emails: "Email addresses",
password: "Password",
- "two-factor": "Two-Factor Auth",
+ "two-factor": "Two-factor auth",
passkeys: "Passkeys",
- connections: "Connected Accounts",
- sessions: "Active Sessions",
+ connections: "Connected accounts",
+ sessions: "Active sessions",
+ "email-preferences": "Email preferences",
} satisfies Record
/**
* Landing tab when `/account` is opened with no `?tab=`. Unlike `/settings`, every account tab is
- * visible to any signed-in user, so this is a single value rather than a preference order — there
+ * visible to any signed-in user, so this is a single value rather than a preference order: there
* is no permission filtering that could make it unavailable.
*/
export const DEFAULT_ACCOUNT_TAB: AccountTab = "profile"
@@ -63,6 +66,11 @@ export const accountNavSections: ReadonlyArray> = [
{ id: "sessions", label: accountTabLabels.sessions, icon: ComputerIcon },
],
},
+ {
+ id: "notifications",
+ title: "Notifications",
+ items: [{ id: "email-preferences", label: accountTabLabels["email-preferences"], icon: BellIcon }],
+ },
]
/** Which tab `/account` should render: the requested one when it is a real tab, else the default. */
diff --git a/apps/web/src/components/settings/admin-only-notice.test.ts b/apps/web/src/components/settings/admin-only-notice.test.ts
new file mode 100644
index 0000000000..f67af08f3f
--- /dev/null
+++ b/apps/web/src/components/settings/admin-only-notice.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest"
+import { adminOnlyNoticeText, memberDisplayName } from "./admin-only-notice"
+
+describe("memberDisplayName", () => {
+ it("prefers the full name, then the identifier", () => {
+ expect(memberDisplayName({ firstName: "Ada", lastName: "Lovelace", identifier: "ada@x.dev" })).toBe(
+ "Ada Lovelace",
+ )
+ expect(memberDisplayName({ firstName: "Ada", lastName: null, identifier: "ada@x.dev" })).toBe("Ada")
+ expect(memberDisplayName({ firstName: "", lastName: null, identifier: "ada@x.dev" })).toBe(
+ "ada@x.dev",
+ )
+ })
+
+ it("returns nothing when no admin is known", () => {
+ expect(memberDisplayName(undefined)).toBeUndefined()
+ expect(memberDisplayName({ firstName: null, lastName: null, identifier: "" })).toBeUndefined()
+ })
+})
+
+describe("adminOnlyNoticeText", () => {
+ it("names the admin to ask when one is known", () => {
+ expect(adminOnlyNoticeText("billing", "Ada")).toBe("Only admins can change billing. Ask Ada.")
+ })
+
+ it("omits the ask when no admin name resolved", () => {
+ expect(adminOnlyNoticeText("billing", undefined)).toBe("Only admins can change billing.")
+ })
+})
diff --git a/apps/web/src/components/settings/admin-only-notice.tsx b/apps/web/src/components/settings/admin-only-notice.tsx
new file mode 100644
index 0000000000..1eac0760c8
--- /dev/null
+++ b/apps/web/src/components/settings/admin-only-notice.tsx
@@ -0,0 +1,58 @@
+import { useOrganization } from "@clerk/clerk-react"
+
+import { Alert, AlertDescription } from "@maple/ui/components/ui/alert"
+import { cn } from "@maple/ui/lib/utils"
+
+import { ShieldIcon } from "@/components/icons"
+import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
+import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode"
+
+interface MemberName {
+ readonly firstName?: string | null
+ readonly lastName?: string | null
+ readonly identifier?: string | null
+}
+
+/** A member's display name: full name, else their sign-in identifier, else nothing. */
+export function memberDisplayName(user: MemberName | null | undefined): string | undefined {
+ const fullName = [user?.firstName, user?.lastName].filter((part) => part).join(" ")
+ return fullName || user?.identifier || undefined
+}
+
+/** "Only admins can change X." plus "Ask ." when an admin's name is known. */
+export function adminOnlyNoticeText(subject: string, adminName: string | undefined): string {
+ const lead = `Only admins can change ${subject}.`
+ return adminName === undefined ? lead : `${lead} Ask ${adminName}.`
+}
+
+/** First admin of the active organization, from one filtered page of memberships. */
+function useFirstAdminName(): string | undefined {
+ const { memberships } = useOrganization({ memberships: { role: ["org:admin"], pageSize: 1 } })
+ return memberDisplayName(memberships?.data?.[0]?.publicUserData)
+}
+
+function NoticeWithAdminName({ subject, className }: { subject: string; className?: string }) {
+ return
+}
+
+function Notice({ text, className }: { text: string; className?: string }) {
+ return (
+
+
+ {text}
+
+ )
+}
+
+/**
+ * The one slim notice at the top of a tab a member can see but not change. Renders nothing for
+ * admins. `subject` completes "Only admins can change ...", e.g. "billing" or "AI settings".
+ */
+export function AdminOnlyNotice({ subject, className }: { subject: string; className?: string }) {
+ const isAdmin = useIsOrgAdmin()
+ if (isAdmin) return null
+ // Self-hosted runs as root (always admin), but never call Clerk hooks without Clerk.
+ if (!isClerkAuthEnabled)
+ return
+ return
+}
diff --git a/apps/web/src/components/settings/mcp-section.tsx b/apps/web/src/components/settings/mcp-section.tsx
index c16ce9e49a..a0cebc0596 100644
--- a/apps/web/src/components/settings/mcp-section.tsx
+++ b/apps/web/src/components/settings/mcp-section.tsx
@@ -140,10 +140,10 @@ export function McpSection() {
or manage existing keys in{" "}
- API Keys
+ Keys
.
diff --git a/apps/web/src/components/settings/settings-draft.browser.test.tsx b/apps/web/src/components/settings/settings-draft.browser.test.tsx
new file mode 100644
index 0000000000..190708e496
--- /dev/null
+++ b/apps/web/src/components/settings/settings-draft.browser.test.tsx
@@ -0,0 +1,155 @@
+import { act, cleanup, renderHook } from "@testing-library/react"
+import { afterEach, describe, expect, it } from "vitest"
+import { useSettingsDraft } from "./settings-draft"
+
+interface Form {
+ readonly rate: number
+}
+
+const renderDraft = (initial: Form) =>
+ renderHook(({ saved }: { saved: Form }) => useSettingsDraft(saved), {
+ initialProps: { saved: initial },
+ })
+
+describe("useSettingsDraft commit", () => {
+ afterEach(cleanup)
+
+ it("keeps showing the saved edits until the refetch returns them", () => {
+ const { result, rerender } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 50 }))
+ expect(result.current.isDirty).toBe(true)
+
+ act(() => result.current.commit({ submitted: { rate: 50 } }))
+ expect(result.current.draft).toEqual({ rate: 50 })
+ expect(result.current.isDirty).toBe(false)
+
+ rerender({ saved: { rate: 10 } })
+ expect(result.current.draft).toEqual({ rate: 50 })
+
+ rerender({ saved: { rate: 50 } })
+ expect(result.current.draft).toEqual({ rate: 50 })
+ expect(result.current.isDirty).toBe(false)
+
+ rerender({ saved: { rate: 70 } })
+ expect(result.current.draft).toEqual({ rate: 70 })
+ })
+
+ it("follows a server value that differs from the edits once it changes", () => {
+ const { result, rerender } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 55 }))
+ act(() => result.current.commit({ submitted: { rate: 55 } }))
+
+ rerender({ saved: { rate: 50 } })
+ expect(result.current.draft).toEqual({ rate: 50 })
+ expect(result.current.isDirty).toBe(false)
+ })
+
+ it("treats an edit after commit as a fresh dirty draft", () => {
+ const { result } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 50 }))
+ act(() => result.current.commit({ submitted: { rate: 50 } }))
+ act(() => result.current.updateDraft((current) => ({ rate: current.rate + 1 })))
+ expect(result.current.draft).toEqual({ rate: 51 })
+ expect(result.current.isDirty).toBe(true)
+ })
+
+ it("releases the edits when the save normalized them to the stored value", () => {
+ const { result, rerender } = renderDraft({ rate: 50 })
+ act(() => result.current.setDraft({ rate: 50.0001 }))
+ expect(result.current.isDirty).toBe(true)
+
+ act(() => result.current.commit({ submitted: { rate: 50.0001 }, sent: { rate: 50 } }))
+ rerender({ saved: { rate: 50 } })
+ expect(result.current.draft).toEqual({ rate: 50 })
+ expect(result.current.isDirty).toBe(false)
+
+ rerender({ saved: { rate: 60 } })
+ expect(result.current.draft).toEqual({ rate: 60 })
+ })
+
+ it("keeps the edits while the stored value matches neither the edits nor the sent value", () => {
+ const { result, rerender } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 25.5 }))
+ act(() => result.current.commit({ submitted: { rate: 25.5 }, sent: { rate: 25 } }))
+ rerender({ saved: { rate: 10 } })
+ expect(result.current.draft).toEqual({ rate: 25.5 })
+ expect(result.current.isDirty).toBe(false)
+ })
+
+ it("keeps an edit made while the save was in flight dirty after the refetch", () => {
+ const { result, rerender } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 50 }))
+ const submitted = result.current.draft
+ act(() => result.current.setDraft({ rate: 60 }))
+
+ act(() => result.current.commit({ submitted }))
+ expect(result.current.isDirty).toBe(true)
+
+ rerender({ saved: { rate: 50 } })
+ expect(result.current.draft).toEqual({ rate: 60 })
+ expect(result.current.isDirty).toBe(true)
+ })
+
+ it("keeps an in-flight edit dirty when the save normalized the submitted value", () => {
+ const { result, rerender } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 25.5 }))
+ act(() => result.current.setDraft({ rate: 30 }))
+ act(() => result.current.commit({ submitted: { rate: 25.5 }, sent: { rate: 25 } }))
+
+ rerender({ saved: { rate: 25 } })
+ expect(result.current.draft).toEqual({ rate: 30 })
+ expect(result.current.isDirty).toBe(true)
+ })
+
+ it("keeps a revert to the saved value dirty while the save that stores the edit is in flight", () => {
+ const { result, rerender } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 50 }))
+ let submitted = result.current.draft
+ act(() => {
+ submitted = result.current.submit()
+ })
+ expect(submitted).toEqual({ rate: 50 })
+ act(() => result.current.setDraft({ rate: 10 }))
+ expect(result.current.isDirty).toBe(true)
+
+ act(() => result.current.commit({ submitted }))
+ expect(result.current.isDirty).toBe(true)
+
+ rerender({ saved: { rate: 50 } })
+ expect(result.current.draft).toEqual({ rate: 10 })
+ expect(result.current.isDirty).toBe(true)
+ })
+
+ it("abort keeps a reverted edit clean when the save failed", () => {
+ const { result } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 50 }))
+ act(() => {
+ result.current.submit()
+ })
+ act(() => result.current.setDraft({ rate: 10 }))
+ expect(result.current.isDirty).toBe(true)
+
+ act(() => result.current.abort())
+ expect(result.current.draft).toEqual({ rate: 10 })
+ expect(result.current.isDirty).toBe(false)
+ })
+
+ it("abort keeps an unsaved edit dirty", () => {
+ const { result } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 50 }))
+ act(() => {
+ result.current.submit()
+ })
+ act(() => result.current.abort())
+ expect(result.current.draft).toEqual({ rate: 50 })
+ expect(result.current.isDirty).toBe(true)
+ })
+
+ it("discard still drops the edits immediately", () => {
+ const { result } = renderDraft({ rate: 10 })
+ act(() => result.current.setDraft({ rate: 50 }))
+ act(() => result.current.discard())
+ expect(result.current.draft).toEqual({ rate: 10 })
+ expect(result.current.isDirty).toBe(false)
+ })
+})
diff --git a/apps/web/src/components/settings/settings-draft.test.ts b/apps/web/src/components/settings/settings-draft.test.ts
new file mode 100644
index 0000000000..6a6cc3a183
--- /dev/null
+++ b/apps/web/src/components/settings/settings-draft.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest"
+import { commitEdits, divergesFromSave, isSameDraft, isSaveSettled } from "./settings-draft"
+
+describe("isSameDraft", () => {
+ it("compares nested plain data by value, ignoring key order", () => {
+ expect(isSameDraft({ a: 1, b: { c: ["x", "y"] } }, { b: { c: ["x", "y"] }, a: 1 })).toBe(true)
+ })
+
+ it("sees a changed nested value or array order as a change", () => {
+ expect(isSameDraft({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false)
+ expect(isSameDraft(["x", "y"], ["y", "x"])).toBe(false)
+ expect(isSameDraft([1], [1, 2])).toBe(false)
+ })
+
+ it("treats a key holding undefined like a missing key", () => {
+ expect(isSameDraft({ a: 1, note: undefined }, { a: 1 })).toBe(true)
+ })
+
+ it("does not equate values of different shapes", () => {
+ expect(isSameDraft({ 0: "a" }, ["a"])).toBe(false)
+ expect(isSameDraft(null, {})).toBe(false)
+ expect(isSameDraft("1", 1)).toBe(false)
+ })
+
+ it("treats NaN as equal to itself so an untouched numeric field is clean", () => {
+ expect(isSameDraft({ n: Number.NaN }, { n: Number.NaN })).toBe(true)
+ })
+})
+
+describe("commitEdits", () => {
+ it("settles the edits the save submitted, recording what it sent", () => {
+ expect(
+ commitEdits(
+ { value: { rate: 25.5 } },
+ { submitted: { rate: 25.5 }, sent: { rate: 25 } },
+ { rate: 10 },
+ isSameDraft,
+ ),
+ ).toEqual({ value: { rate: 25.5 }, committedOver: { saved: { rate: 10 }, sent: { rate: 25 } } })
+ })
+
+ it("leaves an edit made after the save started uncommitted", () => {
+ expect(
+ commitEdits({ value: { rate: 60 } }, { submitted: { rate: 50 } }, { rate: 10 }, isSameDraft),
+ ).toEqual({
+ value: { rate: 60 },
+ })
+ })
+
+ it("has nothing to settle when the edits were already dropped", () => {
+ expect(commitEdits(null, { submitted: { rate: 50 } }, { rate: 10 }, isSameDraft)).toBeNull()
+ })
+})
+
+describe("in-flight save", () => {
+ const save = { submitted: { rate: 50 }, base: { rate: 10 } }
+
+ it("counts a revert to the saved value as unsaved while the save will store something else", () => {
+ expect(divergesFromSave({ rate: 10 }, save, isSameDraft)).toBe(true)
+ expect(divergesFromSave({ rate: 50 }, save, isSameDraft)).toBe(false)
+ })
+
+ it("accepts either the submitted or the normalized sent value as matching", () => {
+ const normalized = { submitted: { rate: 25.5 }, sent: { rate: 25 } }
+ expect(divergesFromSave({ rate: 25.5 }, normalized, isSameDraft)).toBe(false)
+ expect(divergesFromSave({ rate: 25 }, normalized, isSameDraft)).toBe(false)
+ expect(divergesFromSave({ rate: 10 }, normalized, isSameDraft)).toBe(true)
+ })
+
+ it("settles once the saved value moves off the base or reaches the stored value", () => {
+ expect(isSaveSettled(save, { rate: 10 }, isSameDraft)).toBe(false)
+ expect(isSaveSettled(save, { rate: 50 }, isSameDraft)).toBe(true)
+ expect(isSaveSettled(save, { rate: 70 }, isSameDraft)).toBe(true)
+ expect(isSaveSettled({ ...save, sent: { rate: 25 } }, { rate: 25 }, isSameDraft)).toBe(true)
+ })
+})
diff --git a/apps/web/src/components/settings/settings-draft.ts b/apps/web/src/components/settings/settings-draft.ts
new file mode 100644
index 0000000000..3830b2863b
--- /dev/null
+++ b/apps/web/src/components/settings/settings-draft.ts
@@ -0,0 +1,145 @@
+import * as React from "react"
+
+/**
+ * Structural equality over the plain data a settings form edits (primitives, arrays, plain
+ * objects). Key order does not matter; a key holding `undefined` equals a missing key, so a
+ * cleared optional field does not count as a change.
+ */
+export function isSameDraft(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
+ return a.every((value, index) => isSameDraft(value, b[index]))
+ }
+ if (!isPlainRecord(a) || !isPlainRecord(b)) return false
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
+ return [...keys].every((key) => isSameDraft(a[key], b[key]))
+}
+
+const isPlainRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype
+
+export interface SettingsDraft {
+ /** What the form shows: the user's edits, else the saved value. */
+ readonly draft: T
+ /** True when the edits differ from the saved value and have not been committed. */
+ readonly isDirty: boolean
+ readonly setDraft: (next: T) => void
+ /** Derive the next draft from the current one (the edits, else the saved value). */
+ readonly updateDraft: (update: (current: T) => T) => void
+ /** Drop the edits and follow the saved value again (the Discard action). */
+ readonly discard: () => void
+ /**
+ * Call when a save starts: records the draft as in flight and returns it as `submitted`.
+ * Until the saved value moves, edits that differ from it stay dirty even if they equal `saved`.
+ */
+ readonly submit: () => T
+ /** Call when a save fails: forgets the in-flight value and keeps the edits as they are. */
+ readonly abort: () => void
+ /**
+ * Call after a successful save with the draft it `submitted` (captured when the save started)
+ * and, when the save normalized it (trimmed, parsed), the value it `sent`. Edits made after
+ * submission stay dirty; otherwise the edits show until the refetch lands.
+ */
+ readonly commit: (save: SettingsDraftSave) => void
+}
+
+export interface SettingsDraftSave {
+ /** The draft as it was when the save started. */
+ readonly submitted: T
+ /** What the save actually stored, when it differs from `submitted`. */
+ readonly sent?: T
+}
+
+export interface Edits {
+ readonly value: T
+ /** Set by `commit()`: the saved value the save started from and the value it sent. */
+ readonly committedOver?: { readonly saved: T; readonly sent: T }
+}
+
+/** Settles the edits a save stored; edits made after the save started stay plain (dirty). */
+export function commitEdits(
+ current: Edits | null,
+ { submitted, sent }: SettingsDraftSave,
+ saved: T,
+ isEqual: (a: T, b: T) => boolean,
+): Edits | null {
+ if (current === null) return null
+ if (!isEqual(current.value, submitted)) return { value: current.value }
+ return { value: current.value, committedOver: { saved, sent: sent === undefined ? submitted : sent } }
+}
+
+/** A save in flight (or committed but not yet refetched), started over the saved value `base`. */
+export interface InFlightSave extends SettingsDraftSave {
+ readonly base: T
+}
+
+/** The in-flight save is over once the saved value moved off its base or reached what it stores. */
+export const isSaveSettled = (save: InFlightSave, saved: T, isEqual: (a: T, b: T) => boolean) =>
+ !isEqual(save.base, saved) ||
+ isEqual(save.submitted, saved) ||
+ (save.sent !== undefined && isEqual(save.sent, saved))
+
+/** Edits that differ from what an unsettled save will store are unsaved, whatever `saved` says. */
+export const divergesFromSave = (draft: T, save: SettingsDraftSave, isEqual: (a: T, b: T) => boolean) =>
+ !isEqual(draft, save.submitted) && (save.sent === undefined || !isEqual(draft, save.sent))
+
+/**
+ * Draft state for a settings form over a server value. Holds only the user's edits, so a fresh
+ * `saved` value flows straight into an untouched form without any effect syncing it in.
+ */
+export function useSettingsDraft(
+ saved: T,
+ isEqual: (a: T, b: T) => boolean = isSameDraft,
+): SettingsDraft {
+ const [edits, setEdits] = React.useState | null>(null)
+ const settled =
+ edits?.committedOver !== undefined &&
+ (isEqual(edits.value, saved) ||
+ isEqual(edits.committedOver.sent, saved) ||
+ !isEqual(edits.committedOver.saved, saved))
+ // Render-phase reset: once the saved value caught up, committed edits are dropped for good.
+ if (settled) setEdits(null)
+ const active = settled ? null : edits
+ const draft = active === null ? saved : active.value
+ const [inFlight, setInFlight] = React.useState | null>(null)
+ const saveSettled = inFlight !== null && isSaveSettled(inFlight, saved, isEqual)
+ if (saveSettled) setInFlight(null)
+ const pending = saveSettled ? null : inFlight
+
+ const setDraft = React.useCallback((next: T) => setEdits({ value: next }), [])
+ const updateDraft = React.useCallback(
+ (update: (current: T) => T) =>
+ setEdits((current) => ({ value: update(current === null ? saved : current.value) })),
+ [saved],
+ )
+ const discard = React.useCallback(() => {
+ setEdits(null)
+ setInFlight(null)
+ }, [])
+ const submit = React.useCallback(() => {
+ setInFlight({ submitted: draft, base: saved })
+ return draft
+ }, [draft, saved])
+ const abort = React.useCallback(() => setInFlight(null), [])
+ // Stays in flight until the refetch moves `saved`, so a revert before it lands is still dirty.
+ const commit = React.useCallback(
+ (save: SettingsDraftSave) => {
+ setEdits((current) => commitEdits(current, save, saved, isEqual))
+ setInFlight((current) => ({ ...save, base: current === null ? saved : current.base }))
+ },
+ [saved, isEqual],
+ )
+
+ const editsDirty = active !== null && active.committedOver === undefined && !isEqual(active.value, saved)
+ return {
+ draft,
+ isDirty: editsDirty || (pending !== null && divergesFromSave(draft, pending, isEqual)),
+ setDraft,
+ updateDraft,
+ discard,
+ submit,
+ abort,
+ commit,
+ }
+}
diff --git a/apps/web/src/components/settings/settings-nav.test.ts b/apps/web/src/components/settings/settings-nav.test.ts
index 29e3591517..13082878ee 100644
--- a/apps/web/src/components/settings/settings-nav.test.ts
+++ b/apps/web/src/components/settings/settings-nav.test.ts
@@ -2,36 +2,38 @@ import { describe, expect, it } from "vitest"
import { GearIcon, ServerIcon, type IconComponent } from "@/components/icons"
import {
DEFAULT_SETTINGS_TAB_ORDER,
+ legacySettingsTabValues,
resolveActiveSettingsTab,
+ resolveLegacySettingsTab,
settingsTabValues,
type SettingsTab,
} from "./settings-nav"
const item = (id: SettingsTab, icon: IconComponent = GearIcon) => ({ id, label: id, icon })
-/** Organization, Members, Billing and Notifications are Clerk-gated, so self-hosted drops them. */
+/** General, Members and Billing are Clerk-gated, so self-hosted drops them. */
const SELF_HOSTED = [
item("ingestion", ServerIcon),
item("data-platform"),
- item("setup-audit"),
+ item("setup-health"),
item("automation"),
- item("api-keys"),
+ item("keys"),
item("mcp"),
]
const CLERK = [
- item("organization"),
+ item("general"),
item("members"),
item("billing"),
item("ingestion", ServerIcon),
- item("setup-audit"),
- item("api-keys"),
+ item("setup-health"),
+ item("keys"),
]
describe("resolveActiveSettingsTab", () => {
it("honours an explicitly requested tab that is visible", () => {
- expect(resolveActiveSettingsTab("api-keys", CLERK)).toBe("api-keys")
- expect(resolveActiveSettingsTab("setup-audit", SELF_HOSTED)).toBe("setup-audit")
+ expect(resolveActiveSettingsTab("keys", CLERK)).toBe("keys")
+ expect(resolveActiveSettingsTab("setup-health", SELF_HOSTED)).toBe("setup-health")
})
it("ignores a requested tab the current account cannot see", () => {
@@ -39,22 +41,22 @@ describe("resolveActiveSettingsTab", () => {
})
it("ignores an unknown tab value", () => {
- expect(resolveActiveSettingsTab("nonsense", CLERK)).toBe("organization")
+ expect(resolveActiveSettingsTab("nonsense", CLERK)).toBe("general")
})
- it("lands on Organization for a Clerk workspace", () => {
- expect(resolveActiveSettingsTab(undefined, CLERK)).toBe("organization")
+ it("lands on General for a Clerk workspace", () => {
+ expect(resolveActiveSettingsTab(undefined, CLERK)).toBe("general")
})
- it("lands on Ingestion when self-hosted, never on Setup Audit", () => {
- // `setup-audit` is not Clerk-gated, so it survives into self-hosted mode and a positional
- // default could land on it — which would run the audit's warehouse reads on every visit
- // to Settings. Ordering the nav must never be able to make that happen.
+ it("lands on Ingestion when self-hosted, never on Setup health", () => {
+ // `setup-health` is not Clerk-gated, so it survives into self-hosted mode and a positional
+ // default could land on it (which would run the audit's warehouse reads on every visit
+ // to Settings). Ordering the nav must never be able to make that happen.
expect(resolveActiveSettingsTab(undefined, SELF_HOSTED)).toBe("ingestion")
})
it("falls back to the first visible item when no preferred default is available", () => {
- expect(resolveActiveSettingsTab(undefined, [item("mcp"), item("api-keys")])).toBe("mcp")
+ expect(resolveActiveSettingsTab(undefined, [item("mcp"), item("keys")])).toBe("mcp")
})
it("falls back to ingestion when nothing is visible at all", () => {
@@ -70,8 +72,52 @@ describe("DEFAULT_SETTINGS_TAB_ORDER", () => {
})
it("never lands users on a tab that does expensive work on mount", () => {
- // `setup-audit` issues a dozen warehouse reads (two of them cross-span joins) when its
+ // `setup-health` issues a dozen warehouse reads (two of them cross-span joins) when its
// section mounts. It must stay an explicit navigation, never a default.
- expect(DEFAULT_SETTINGS_TAB_ORDER).not.toContain("setup-audit")
+ expect(DEFAULT_SETTINGS_TAB_ORDER).not.toContain("setup-health")
+ })
+})
+
+describe("resolveLegacySettingsTab", () => {
+ it("sends renamed tabs to their new ids", () => {
+ expect(resolveLegacySettingsTab("organization")).toEqual({ kind: "tab", tab: "general" })
+ expect(resolveLegacySettingsTab("api-keys")).toEqual({ kind: "tab", tab: "keys" })
+ expect(resolveLegacySettingsTab("ai-provider")).toEqual({ kind: "tab", tab: "ai" })
+ expect(resolveLegacySettingsTab("setup-audit")).toEqual({ kind: "tab", tab: "setup-health" })
+ expect(resolveLegacySettingsTab("developer")).toEqual({ kind: "tab", tab: "keys" })
+ expect(resolveLegacySettingsTab("escalations")).toEqual({ kind: "tab", tab: "automation" })
+ })
+
+ it("sends tabs that left Settings to their new page", () => {
+ expect(resolveLegacySettingsTab("notifications")).toEqual({
+ kind: "account",
+ tab: "email-preferences",
+ })
+ expect(resolveLegacySettingsTab("connectors")).toEqual({ kind: "integrations" })
+ expect(resolveLegacySettingsTab("integrations")).toEqual({ kind: "integrations" })
+ })
+
+ it("leaves current and unknown tab values alone", () => {
+ for (const tab of settingsTabValues) {
+ expect(resolveLegacySettingsTab(tab)).toBeUndefined()
+ }
+ expect(resolveLegacySettingsTab("nonsense")).toBeUndefined()
+ expect(resolveLegacySettingsTab(undefined)).toBeUndefined()
+ })
+
+ it("never shadows a current tab id with a legacy one", () => {
+ // A value in both unions would redirect away from a live tab forever.
+ for (const legacy of legacySettingsTabValues) {
+ expect(settingsTabValues).not.toContain(legacy)
+ }
+ })
+
+ it("only redirects to tabs that exist", () => {
+ const targets = legacySettingsTabValues.flatMap((legacy) => {
+ const target = resolveLegacySettingsTab(legacy)
+ return target?.kind === "tab" ? [target.tab] : []
+ })
+ expect(targets.length).toBeGreaterThan(0)
+ expect(targets.filter((tab) => !settingsTabValues.includes(tab))).toEqual([])
})
})
diff --git a/apps/web/src/components/settings/settings-nav.tsx b/apps/web/src/components/settings/settings-nav.tsx
index 615c59024f..d9f5ca6dc6 100644
--- a/apps/web/src/components/settings/settings-nav.tsx
+++ b/apps/web/src/components/settings/settings-nav.tsx
@@ -7,7 +7,6 @@ import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-fl
import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
import { retainedQuery } from "@/lib/services/common/atom-client"
import {
- BellIcon,
ChatBubbleSparkleIcon,
CircleCheckIcon,
CreditCardIcon,
@@ -25,36 +24,75 @@ import {
import { SettingsNavShell } from "@/components/settings/settings-nav-shell"
export const settingsTabValues = [
- "organization",
+ "general",
"members",
"billing",
"audit-log",
+ "keys",
+ "mcp",
+ "ai",
"ingestion",
"data-platform",
- "setup-audit",
- "notifications",
+ "setup-health",
"automation",
- "api-keys",
- "mcp",
- "ai-provider",
] as const
export type SettingsTab = (typeof settingsTabValues)[number]
export const settingsTabLabels: Record = {
- organization: "Organization",
+ general: "General",
members: "Members",
- billing: "Billing",
- "audit-log": "Audit Log",
+ billing: "Billing & usage",
+ "audit-log": "Audit log",
+ keys: "Keys",
+ mcp: "MCP",
+ ai: "AI",
ingestion: "Ingestion",
- "data-platform": "Data Platform",
- "setup-audit": "Setup Audit",
- notifications: "Notifications",
+ "data-platform": "Data platform",
+ "setup-health": "Setup health",
automation: "Automation",
- "api-keys": "API Keys",
- mcp: "MCP",
- "ai-provider": "AI Provider",
} satisfies Record
+/**
+ * Retired `?tab=` values and where each one lives now, so old deep links (emails, docs, bookmarks)
+ * keep working. A redirect is either another settings tab or a different page entirely.
+ */
+export type LegacySettingsRedirect =
+ | { readonly kind: "tab"; readonly tab: SettingsTab }
+ | { readonly kind: "integrations" }
+ | { readonly kind: "account"; readonly tab: "email-preferences" }
+
+export const legacySettingsTabValues = [
+ "organization",
+ "api-keys",
+ "ai-provider",
+ "setup-audit",
+ "notifications",
+ "connectors",
+ "integrations",
+ "escalations",
+ "developer",
+] as const
+export type LegacySettingsTab = (typeof legacySettingsTabValues)[number]
+
+export const legacySettingsTabRedirects = {
+ organization: { kind: "tab", tab: "general" },
+ "api-keys": { kind: "tab", tab: "keys" },
+ "ai-provider": { kind: "tab", tab: "ai" },
+ "setup-audit": { kind: "tab", tab: "setup-health" },
+ notifications: { kind: "account", tab: "email-preferences" },
+ connectors: { kind: "integrations" },
+ integrations: { kind: "integrations" },
+ escalations: { kind: "tab", tab: "automation" },
+ // "API reference" became the reference block at the foot of the Keys tab.
+ developer: { kind: "tab", tab: "keys" },
+} satisfies Record
+
+/** Where a retired `?tab=` value now lives, or undefined for a current (or unknown) value. */
+export function resolveLegacySettingsTab(tab: string | undefined): LegacySettingsRedirect | undefined {
+ const legacy = legacySettingsTabValues.find((value) => value === tab)
+ return legacy === undefined ? undefined : legacySettingsTabRedirects[legacy]
+}
+
interface NavItem {
id: SettingsTab
label: string
@@ -63,17 +101,17 @@ interface NavItem {
/**
* Preference order for the landing tab when `/settings` is opened with no `?tab=`, most-preferred
- * first. `organization` is the Clerk-workspace landing tab; `ingestion` is the self-hosted one, where
- * `organization` is filtered out.
+ * first. `general` is the Clerk-workspace landing tab; `ingestion` is the self-hosted one, where
+ * `general` is filtered out.
*
* This is declared rather than inferred from nav position on purpose: a positional default silently
- * moves whenever an item is added. `setup-audit` is deliberately not Clerk-gated, so in self-hosted
- * mode it is the only surviving Workspace item — as the first visible item it would have become the
- * landing tab and run the audit's dozen warehouse reads on every visit to Settings.
+ * moves whenever an item is added. `setup-health` is deliberately not Clerk-gated, so in self-hosted
+ * mode a positional default could land on it and run the audit's dozen warehouse reads on every
+ * visit to Settings.
*
* Never list a tab here whose section does expensive work on mount.
*/
-export const DEFAULT_SETTINGS_TAB_ORDER: ReadonlyArray = ["organization", "ingestion"]
+export const DEFAULT_SETTINGS_TAB_ORDER: ReadonlyArray = ["general", "ingestion"]
/**
* Which tab `/settings` should render: the requested one when it is actually visible, else the first
@@ -98,15 +136,15 @@ interface NavLinkItem {
}
export interface SettingsNavSection {
- id: "workspace" | "connections" | "data" | "alerting"
+ id: "workspace" | "access" | "data" | "alerting"
title: string
- /** Tabs and sibling-page links in one ordered list — nav position is declared, not derived. */
+ /** Tabs and sibling-page links in one ordered list: nav position is declared, not derived. */
items: Array
}
/**
- * Four groups, each earning its header: who can use the workspace and what it costs; what talks to
- * Maple from outside; where telemetry comes from and where it lands; and what Maple does when
+ * Four groups, each earning its header: who can use the workspace and what it costs; what can reach
+ * Maple from outside (integrations, keys, agents); where telemetry comes from and where it lands; and what Maple does when
* something breaks.
*
* Group order does not decide the landing tab; `DEFAULT_SETTINGS_TAB_ORDER` does, on purpose.
@@ -118,47 +156,47 @@ const navSections: SettingsNavSection[] = [
id: "workspace",
title: "Workspace",
items: [
- { id: "organization", label: "Organization", icon: GearIcon },
- { id: "members", label: "Members", icon: UserIcon },
- { id: "billing", label: "Billing", icon: CreditCardIcon },
- { id: "audit-log", label: "Audit Log", icon: HistoryIcon },
+ { id: "general", label: settingsTabLabels.general, icon: GearIcon },
+ { id: "members", label: settingsTabLabels.members, icon: UserIcon },
+ { id: "billing", label: settingsTabLabels.billing, icon: CreditCardIcon },
+ { id: "audit-log", label: settingsTabLabels["audit-log"], icon: HistoryIcon },
],
},
{
- id: "connections",
- title: "Connections",
+ id: "access",
+ title: "Access",
items: [
// A sibling page rather than a tab, and the most-visited row in the nav, so it leads its
// group. Nothing about it being a route should push it down the list.
{ id: "integrations", label: "Integrations", icon: GridIcon, to: "/integrations" },
- { id: "api-keys", label: "API Keys", icon: KeyIcon },
- { id: "mcp", label: "MCP", icon: SquareTerminalIcon },
- { id: "ai-provider", label: "AI Provider", icon: ChatBubbleSparkleIcon },
+ { id: "keys", label: settingsTabLabels.keys, icon: KeyIcon },
+ { id: "mcp", label: settingsTabLabels.mcp, icon: SquareTerminalIcon },
+ { id: "ai", label: settingsTabLabels.ai, icon: ChatBubbleSparkleIcon },
],
},
{
id: "data",
title: "Data",
items: [
- { id: "ingestion", label: "Ingestion", icon: ServerIcon },
- { id: "data-platform", label: "Data Platform", icon: DatabaseIcon },
+ { id: "ingestion", label: settingsTabLabels.ingestion, icon: ServerIcon },
+ { id: "data-platform", label: settingsTabLabels["data-platform"], icon: DatabaseIcon },
// A diagnostic over everything that feeds Maple, so it closes the group it reports on.
- { id: "setup-audit", label: "Setup Audit", icon: CircleCheckIcon },
+ { id: "setup-health", label: settingsTabLabels["setup-health"], icon: CircleCheckIcon },
],
},
{
id: "alerting",
title: "Alerting",
items: [
- { id: "notifications", label: "Notifications", icon: BellIcon },
- { id: "automation", label: "Automation", icon: ShieldIcon },
+ // Temporary home for escalation and AI triage until the alerts routing redesign ships.
+ { id: "automation", label: settingsTabLabels.automation, icon: ShieldIcon },
],
},
]
/**
* The tab rows of a section list, dropping sibling-page links. `visibleItems` feeds tab resolution,
- * and `/integrations` is a route rather than a tab — it must never surface as a fallback tab id.
+ * and `/integrations` is a route rather than a tab: it must never surface as a fallback tab id.
*/
const tabItems = (sections: ReadonlyArray): ReadonlyArray =>
sections.flatMap((section) => section.items.filter((row): row is NavItem => !("to" in row)))
@@ -184,12 +222,7 @@ export function useVisibleSettingsSections() {
.map((section) => ({
...section,
items: section.items.filter((item) => {
- if (
- item.id === "organization" ||
- item.id === "members" ||
- item.id === "billing" ||
- item.id === "notifications"
- ) {
+ if (item.id === "general" || item.id === "members" || item.id === "billing") {
return isClerkAuthEnabled
}
return true
@@ -221,7 +254,7 @@ export function useVisibleSettingsSections() {
// member open it into a 403.
if (item.id === "audit-log") return isAdmin
// Only admins can attach the add-on or manage the key.
- if (item.id === "ai-provider") return isAdmin
+ if (item.id === "ai") return isAdmin
return true
}),
}))
@@ -234,10 +267,10 @@ export function useVisibleSettingsSections() {
canAccessDataPlatform,
canAccessAi,
isCustomerLoading,
- // The shell only waits on the (fast) session query. The billing customer —
- // dominated by an upstream Autumn round-trip — keeps loading in the
+ // The shell only waits on the (fast) session query. The billing customer -
+ // dominated by an upstream Autumn round-trip: keeps loading in the
// background; `canAccessDataPlatform` stays false until it resolves, so the
- // "Data Platform" nav item just appears when ready (same as /integrations),
+ // "Data platform" nav item just appears when ready (same as /integrations),
// instead of blocking the whole page behind it.
isLoading: Result.isInitial(sessionResult),
}
diff --git a/apps/web/src/components/settings/settings-save-bar.browser.test.tsx b/apps/web/src/components/settings/settings-save-bar.browser.test.tsx
new file mode 100644
index 0000000000..6b4efc3713
--- /dev/null
+++ b/apps/web/src/components/settings/settings-save-bar.browser.test.tsx
@@ -0,0 +1,90 @@
+import { act, cleanup, render, screen } from "@testing-library/react"
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router"
+import type { ReactNode } from "react"
+import { afterEach, expect, it } from "vitest"
+
+import { type SettingsDraft, useSettingsDraft } from "./settings-draft"
+import { SettingsSaveBar } from "./settings-save-bar"
+
+afterEach(cleanup)
+
+/** The latest render of the form under test, so the test can drive it. */
+interface RenderedForm {
+ form?: SettingsDraft
+}
+
+async function renderBar({ dirty, pending }: { dirty: boolean; pending: boolean }) {
+ return renderSettingsPage(() => (
+ {}} onSave={() => {}} />
+ ))
+}
+
+async function renderSettingsPage(page: () => ReactNode) {
+ const rootRoute = createRootRoute({ component: () => })
+ const settings = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/",
+ component: page,
+ })
+ const account = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/account",
+ component: () => "Other page",
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([settings, account]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ })
+ await router.load()
+ render()
+ return router
+}
+
+it("asks before leaving while a save is still in flight", async () => {
+ const router = await renderBar({ dirty: true, pending: true })
+
+ await act(async () => {
+ void router.navigate({ to: "/account" })
+ })
+
+ expect(await screen.findByText("Discard unsaved changes?")).toBeTruthy()
+ expect(router.state.location.pathname).toBe("/")
+})
+
+it("lets the user leave without a prompt once the save committed", async () => {
+ const router = await renderBar({ dirty: false, pending: false })
+
+ await act(async () => {
+ await router.navigate({ to: "/account" })
+ })
+
+ expect(screen.queryByText("Discard unsaved changes?")).toBeNull()
+ expect(router.state.location.pathname).toBe("/account")
+})
+
+it("asks before leaving after reverting an edit whose save is still in flight", async () => {
+ const page: RenderedForm = {}
+ const router = await renderSettingsPage(function Page() {
+ const form = useSettingsDraft(10)
+ page.form = form
+ return {}} onSave={() => {}} />
+ })
+ act(() => page.form?.setDraft(50))
+ act(() => {
+ page.form?.submit()
+ })
+ act(() => page.form?.setDraft(10))
+ await act(async () => {
+ void router.navigate({ to: "/account" })
+ })
+
+ expect(await screen.findByText("Discard unsaved changes?")).toBeTruthy()
+ expect(router.state.location.pathname).toBe("/")
+})
diff --git a/apps/web/src/components/settings/settings-save-bar.tsx b/apps/web/src/components/settings/settings-save-bar.tsx
new file mode 100644
index 0000000000..4e2b3258c7
--- /dev/null
+++ b/apps/web/src/components/settings/settings-save-bar.tsx
@@ -0,0 +1,95 @@
+import * as React from "react"
+import { useBlocker } from "@tanstack/react-router"
+
+import { Button } from "@maple/ui/components/ui/button"
+import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog"
+import { cn } from "@maple/ui/lib/utils"
+
+import type { SettingsWidth } from "@/components/settings/settings-section"
+
+/**
+ * Asks before leaving a page with unsaved edits: in-app navigation (including a settings tab
+ * switch) gets a confirm dialog, a reload or tab close gets the browser's own prompt.
+ */
+export function UnsavedChangesGuard({ when }: { when: boolean }) {
+ const { proceed, reset, status } = useBlocker({
+ shouldBlockFn: () => when,
+ enableBeforeUnload: () => when,
+ withResolver: true,
+ })
+ return (
+ {
+ if (!open) reset?.()
+ }}
+ title="Discard unsaved changes?"
+ description="You have changes on this page that are not saved yet."
+ cancelLabel="Keep editing"
+ confirmLabel="Discard changes"
+ onConfirm={() => proceed?.()}
+ />
+ )
+}
+
+/**
+ * The bar pinned to the bottom of a settings tab while it has unsaved edits. Renders nothing
+ * when clean, and guards navigation while dirty, so a tab only needs this one element.
+ */
+export function SettingsSaveBar({
+ dirty,
+ pending = false,
+ context,
+ onDiscard,
+ onSave,
+ saveLabel = "Save",
+ saveDisabled = false,
+ width = "standard",
+ className,
+}: {
+ dirty: boolean
+ /** Save in flight: the save button spins and discard is disabled. */
+ pending?: boolean
+ /** Optional detail after "Unsaved changes", e.g. "2 rules changed". */
+ context?: React.ReactNode
+ onDiscard: () => void
+ onSave: () => void
+ saveLabel?: React.ReactNode
+ /** Keeps save disabled while the draft is invalid. */
+ saveDisabled?: boolean
+ /** Match the tab's `SettingsSections` width. */
+ width?: SettingsWidth
+ className?: string
+}) {
+ const visible = dirty || pending
+ return (
+ <>
+ {/* Stays armed while a save is in flight: a failed save must not lose the edits. */}
+
+ {visible ? (
+
+
+
+ ) : null}
+ >
+ )
+}
diff --git a/apps/web/src/components/settings/settings-section.tsx b/apps/web/src/components/settings/settings-section.tsx
index 779a94123d..b7243c5103 100644
--- a/apps/web/src/components/settings/settings-section.tsx
+++ b/apps/web/src/components/settings/settings-section.tsx
@@ -5,11 +5,18 @@ import { cn } from "@maple/ui/lib/utils"
import { SectionHeading } from "@/components/common/section-heading"
+/**
+ * Column width of a settings tab. `standard` is the shared `max-w-3xl` reading column; `wide`
+ * takes the full content width, for table-led tabs (members, keys, audit log).
+ */
+export type SettingsWidth = "standard" | "wide"
+
+const widthClass = (width: SettingsWidth) => (width === "wide" ? "w-full" : "w-full max-w-3xl")
+
/**
* One block of a settings page: heading, optional description and actions, then the
- * content in a `Panel`. Every section shares the fixed `max-w-3xl` column so tabs don't
- * jump width as you move between them. `framed={false}` for content that brings its
- * own frame (a table, a list of cards).
+ * content in a `Panel`. Sections share one column width per tab so tabs don't jump width
+ * as you move between them. `framed={false}` for content that brings its own frame.
*/
export function SettingsSection({
title,
@@ -19,6 +26,7 @@ export function SettingsSection({
padded = true,
className,
footer,
+ width = "standard",
children,
}: {
title: string
@@ -31,11 +39,12 @@ export function SettingsSection({
className?: string
/** Below the content, e.g. an `AdminOnlyNote` explaining controls a member cannot use. */
footer?: React.ReactNode
+ width?: SettingsWidth
children: React.ReactNode
}) {
const id = React.useId()
return (
-
+
{description ?
{description}
: null}
@@ -47,8 +56,15 @@ export function SettingsSection({
}
/** Stacks a settings tab's sections at the shared rhythm. */
-export function SettingsSections({ children }: { children: React.ReactNode }) {
- return
{children}
+export function SettingsSections({
+ width = "standard",
+ children,
+}: {
+ /** `wide` for table-led tabs; give their sections `width="wide"` too. */
+ width?: SettingsWidth
+ children: React.ReactNode
+}) {
+ return
{children}
}
/** The one-line reason beside a control a member sees but cannot use. */
diff --git a/apps/web/src/lib/alerts/tag-grouping.ts b/apps/web/src/lib/alerts/tag-grouping.ts
index e2e8e9e058..d86a33b047 100644
--- a/apps/web/src/lib/alerts/tag-grouping.ts
+++ b/apps/web/src/lib/alerts/tag-grouping.ts
@@ -9,11 +9,6 @@
export const UNTAGGED_KEY = "__untagged__"
const UNTAGGED_LABEL = "Untagged"
-export interface TagFacet {
- name: string
- count: number
-}
-
export interface TagGroup {
/** Tag value, or {@link UNTAGGED_KEY}. */
key: string
@@ -22,36 +17,6 @@ export interface TagGroup {
items: T[]
}
-/**
- * One facet per distinct tag with the number of items carrying it. Sorted by
- * count desc, then name, so the heaviest tags surface first in the filter.
- */
-export function tagFacets(items: readonly T[], getTags: (item: T) => readonly string[]): TagFacet[] {
- const counts = new Map()
- for (const item of items) {
- for (const tag of getTags(item)) {
- counts.set(tag, (counts.get(tag) ?? 0) + 1)
- }
- }
- return [...counts.entries()]
- .map(([name, count]) => ({ name, count }))
- .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
-}
-
-/**
- * Keep an item when it carries any of the selected tags (OR semantics). An
- * empty selection is a no-op (returns the input array reference).
- */
-export function filterByTags(
- items: readonly T[],
- getTags: (item: T) => readonly string[],
- selected: readonly string[],
-): readonly T[] {
- if (selected.length === 0) return items
- const wanted = new Set(selected)
- return items.filter((item) => getTags(item).some((tag) => wanted.has(tag)))
-}
-
/**
* Bucket items by tag for "Group by tag". An item appears under *every* tag it
* carries (label-style), and items with no tags collapse into a single
diff --git a/apps/web/src/routes/account.tsx b/apps/web/src/routes/account.tsx
index 1814351baa..b359fcdc64 100644
--- a/apps/web/src/routes/account.tsx
+++ b/apps/web/src/routes/account.tsx
@@ -17,6 +17,7 @@ import { TwoFactorSection } from "@/components/account/two-factor-section"
import { PasskeysSection } from "@/components/account/passkeys-section"
import { ConnectedAccountsSection } from "@/components/account/connected-accounts-section"
import { ActiveSessionsSection } from "@/components/account/active-sessions-section"
+import { NotificationsSection } from "@/components/settings/notifications-section"
const AccountSearch = Schema.Struct({
tab: Schema.optional(Schema.Literals(accountTabValues)),
@@ -55,6 +56,7 @@ function AccountPage() {
{activeTab === "passkeys" && }
{activeTab === "connections" && }
{activeTab === "sessions" && }
+ {activeTab === "email-preferences" && }
)
}
diff --git a/apps/web/src/routes/developer.tsx b/apps/web/src/routes/developer.tsx
index d4ef384bfc..0bcc17eb18 100644
--- a/apps/web/src/routes/developer.tsx
+++ b/apps/web/src/routes/developer.tsx
@@ -1,8 +1,8 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
/**
- * Legacy path: the Developer page duplicated the Ingestion and API Keys settings tabs.
- * `?tab=ingestion` keeps its meaning; everything else lands on API Keys.
+ * Legacy path: the Developer page duplicated the Ingestion and Keys settings tabs.
+ * `?tab=ingestion` keeps its meaning; everything else lands on Keys.
*/
export const Route = createFileRoute("/developer")({
validateSearch: (search: Record): { tab?: string } => ({
@@ -11,7 +11,7 @@ export const Route = createFileRoute("/developer")({
beforeLoad: ({ search }) => {
throw redirect({
to: "/settings",
- search: { tab: search.tab === "ingestion" ? "ingestion" : "api-keys" },
+ search: { tab: search.tab === "ingestion" ? "ingestion" : "keys" },
replace: true,
})
},
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx
index 86a5716052..a51f3b08af 100644
--- a/apps/web/src/routes/settings.tsx
+++ b/apps/web/src/routes/settings.tsx
@@ -12,13 +12,14 @@ import { ApiKeysSection } from "@/components/settings/api-keys-section"
import { AuditLogSection } from "@/components/settings/audit-log-section"
import { McpSection } from "@/components/settings/mcp-section"
import { AiProviderSection } from "@/components/settings/ai-provider-section"
-import { NotificationsSection } from "@/components/settings/notifications-section"
import { AutomationSection } from "@/components/settings/automation-section"
import { OrgClickHouseSettingsSection } from "@/components/settings/org-clickhouse-settings-section"
import { OrganizationSection } from "@/components/settings/organization-section"
import { SetupAuditSection } from "@/components/settings/setup-audit-section"
import {
+ legacySettingsTabValues,
resolveActiveSettingsTab,
+ resolveLegacySettingsTab,
SettingsNav,
settingsTabLabels,
settingsTabValues,
@@ -26,11 +27,8 @@ import {
type SettingsTab,
} from "@/components/settings/settings-nav"
-/** Retired tabs — kept decodable so old deep links redirect instead of landing on a blank page. */
-const legacyTabValues = ["connectors", "integrations", "escalations", "ai", "developer"] as const
-
const SettingsSearch = Schema.Struct({
- tab: Schema.optional(Schema.Literals([...settingsTabValues, ...legacyTabValues])),
+ tab: Schema.optional(Schema.Literals([...settingsTabValues, ...legacySettingsTabValues])),
// Stripe Checkout return marker — see `lib/billing/checkout-return.ts`.
checkout: Schema.optional(Schema.Literal("complete")),
})
@@ -53,16 +51,12 @@ function SettingsPage() {
isLoading,
} = useVisibleSettingsSections()
- // Pre-hub deep links: these tabs moved to the Integrations hub.
- if (search.tab === "connectors" || search.tab === "integrations") {
- return
- }
- if (search.tab === "escalations" || search.tab === "ai") {
- return
- }
- // "API Reference" is now the reference block at the foot of the API Keys page.
- if (search.tab === "developer") {
- return
+ // Retired tab ids keep old deep links (emails, docs, bookmarks) working.
+ const legacy = resolveLegacySettingsTab(search.tab)
+ if (legacy?.kind === "integrations") return
+ if (legacy?.kind === "account") return
+ if (legacy?.kind === "tab") {
+ return
}
const activeTab = resolveActiveSettingsTab(search.tab, visibleItems)
@@ -107,15 +101,14 @@ function SettingsPage() {
No settings are available for the current account.
) : (
<>
- {activeTab === "organization" && }
+ {activeTab === "general" && }
{activeTab === "members" && }
{activeTab === "audit-log" && }
- {activeTab === "setup-audit" && }
+ {activeTab === "setup-health" && }
{activeTab === "ingestion" && }
- {activeTab === "api-keys" && }
+ {activeTab === "keys" && }
{activeTab === "mcp" && }
- {activeTab === "ai-provider" && }
- {activeTab === "notifications" && }
+ {activeTab === "ai" && }
{activeTab === "automation" && (
)}
diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx
index 098ef39421..c6647bbe06 100644
--- a/apps/web/src/routes/unsubscribe.tsx
+++ b/apps/web/src/routes/unsubscribe.tsx
@@ -79,7 +79,7 @@ function UnsubscribeConfirm({ token }: { token: string }) {
}
+ render={}
>
Notification settings
diff --git a/knip.json b/knip.json
index 25bc4c41d6..07f913de47 100644
--- a/knip.json
+++ b/knip.json
@@ -1,7 +1,13 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"ignoreWorkspaces": ["apps/ingest", "apps/ios", "examples/*"],
- "ignore": [".deepsec/**", "**/*.type-test.ts"],
+ "ignore": [
+ ".deepsec/**",
+ "**/*.type-test.ts",
+ // Shared settings pieces first used by the General tab in the next layer of the stack.
+ "apps/web/src/components/settings/settings-save-bar.tsx",
+ "apps/web/src/components/settings/admin-only-notice.tsx"
+ ],
"ignoreExportsUsedInFile": true,
"workspaces": {
".": {
diff --git a/packages/backend/src/services/digest/DigestService.ts b/packages/backend/src/services/digest/DigestService.ts
index ff9268d3ce..a4261b6a40 100644
--- a/packages/backend/src/services/digest/DigestService.ts
+++ b/packages/backend/src/services/digest/DigestService.ts
@@ -800,7 +800,7 @@ export class DigestService extends Context.Service()("@maple/api/
ingestion: { ...curUsage, approximate: isScoped },
baseUrl: env.MAPLE_APP_BASE_URL,
dashboardUrl: env.MAPLE_APP_BASE_URL,
- unsubscribeUrl: `${env.MAPLE_APP_BASE_URL}/settings?tab=notifications`,
+ unsubscribeUrl: `${env.MAPLE_APP_BASE_URL}/account?tab=email-preferences`,
}
yield* Effect.annotateCurrentSpan("maple.digest.total_requests", totalRequests)
diff --git a/packages/backend/src/services/digest/WebAnalyticsDigestService.ts b/packages/backend/src/services/digest/WebAnalyticsDigestService.ts
index 1303231b2c..44283ffdd2 100644
--- a/packages/backend/src/services/digest/WebAnalyticsDigestService.ts
+++ b/packages/backend/src/services/digest/WebAnalyticsDigestService.ts
@@ -412,7 +412,7 @@ export class WebAnalyticsDigestService extends Context.Service` (`maple_ak_…`, created under Settings → API keys). Omit it to use OAuth instead: the server advertises its authorization server at `/.well-known/oauth-protected-resource/mcp`.",
+ "`Bearer ` (`maple_ak_…`, created under Settings → Keys). Omit it to use OAuth instead: the server advertises its authorization server at `/.well-known/oauth-protected-resource/mcp`.",
isRequired: false,
isSecret: true,
},
diff --git a/packages/domain/src/setup-audit.ts b/packages/domain/src/setup-audit.ts
index 678d3426ba..f909c94408 100644
--- a/packages/domain/src/setup-audit.ts
+++ b/packages/domain/src/setup-audit.ts
@@ -710,7 +710,7 @@ const platformChecks: ReadonlyArray = [
title: "Bring-your-own ClickHouse is reachable",
severity: "critical",
fixHint:
- "Settings → Data Platform: re-test the connection. Credentials, network policy and cluster health are the usual causes.",
+ "Settings → Data platform: re-test the connection. Credentials, network policy and cluster health are the usual causes.",
evaluate: ({ config }) => {
const clickhouse = config.clickhouse
if (clickhouse === null || clickhouse.syncStatus !== "error") return PASS
@@ -725,7 +725,7 @@ const platformChecks: ReadonlyArray = [
title: "Bring-your-own ClickHouse schema is up to date",
severity: "warn",
fixHint:
- "Settings → Data Platform → Apply schema. Newer Maple features query tables the older schema doesn't have.",
+ "Settings → Data platform → Apply schema. Newer Maple features query tables the older schema doesn't have.",
evaluate: ({ config }) => {
const clickhouse = config.clickhouse
if (clickhouse === null) return PASS
diff --git a/packages/email/src/samples.ts b/packages/email/src/samples.ts
index f79822ca64..115d4c3d95 100644
--- a/packages/email/src/samples.ts
+++ b/packages/email/src/samples.ts
@@ -154,7 +154,7 @@ export const healthyDigestProps: WeeklyDigestProps = {
},
baseUrl: "https://app.maple.dev",
dashboardUrl: "https://app.maple.dev",
- unsubscribeUrl: "https://app.maple.dev/settings?tab=notifications",
+ unsubscribeUrl: "https://app.maple.dev/account?tab=email-preferences",
}
/** Elevated error rate, errors and latency climbing week over week. */
@@ -475,7 +475,7 @@ export const webAnalyticsDigestProps: WebAnalyticsDigestProps = {
analyticsUrl:
"https://app.maple.dev/analytics?startTime=2026-09-21+00%3A00%3A00&endTime=2026-09-27+23%3A59%3A59",
aiUrl: "https://app.maple.dev/analytics?startTime=2026-09-21+00%3A00%3A00&endTime=2026-09-27+23%3A59%3A59&tab=ai",
- unsubscribeUrl: "https://app.maple.dev/settings?tab=notifications",
+ unsubscribeUrl: "https://app.maple.dev/account?tab=email-preferences",
}
/** A small site with no AI traffic and a warehouse without the crawler table. */