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 ? ( +
+
+ + Unsaved changes + {context ? {context} : null} +
+
+ + +
+
+ ) : 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 }) {
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. */