From 542c1c38013f634ad07ecee44cac9f17dbf13ed3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 21 Jul 2026 14:42:11 +0200 Subject: [PATCH] fix(api-keys): let members create MCP keys, and surface why creation failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user reported they couldn't create an API key for MCP from the MCP view: they got a bare "Failed to create API key" toast with no reason. They were not an org admin. Two causes. POST /v2/api_keys required an org-admin role for *every* key kind, and the create dialog collapsed the resulting 403 into a generic toast, so the actual message ("Only org admins can create API keys") never reached the user. The admin gate existed for a real reason: a key with no role metadata resolves as `root`, so letting a member mint one would hand them a root credential. Rather than widen that hole, MCP keys now pin the creator's own roles into metadataJson, reusing the mechanism CLI device-auth keys already use — the key can never carry more authority than the user who created it. - v2 create: skip requireAdmin for kind="mcp" and persist { source: "maple_mcp", roles: }. Standard keys stay admin-only. - v2 revoke: a member may revoke an MCP key they created themselves — minting a credential you can't kill is a hole. - readKeyRoleMetadata decodes either metadata source and fails closed: unreadable role metadata rejects the key instead of falling back to root. - Fix roll() dropping metadataJson, which silently escalated a rolled CLI/MCP key from its creator's roles to root. Frontend: key create/roll/revoke toasts now go through formatBackendError, so a 403 reads "Not authorized — Only org admins can create API keys". The standard-key button is disabled for members with copy pointing at the MCP page, and the MCP view keeps a "Create another" button instead of hiding it after the first key. The orphan /mcp route (a drifted copy of the settings MCP section) now renders the shared . Co-Authored-By: Claude Opus 4.8 --- apps/api/src/routes/v2/api-keys.http.test.ts | 98 ++++++++- apps/api/src/routes/v2/api-keys.http.ts | 23 +- .../services/ApiKeysService.scopes.test.ts | 59 ++++++ apps/api/src/services/ApiKeysService.ts | 62 ++++++ .../components/settings/api-keys-section.tsx | 31 ++- .../settings/create-api-key-dialog.tsx | 4 +- .../src/components/settings/mcp-section.tsx | 13 +- .../settings/roll-api-key-dialog.tsx | 4 +- .../src/components/settings/settings-nav.tsx | 5 +- apps/web/src/hooks/use-is-org-admin.ts | 22 ++ apps/web/src/routes/mcp.tsx | 197 +----------------- 11 files changed, 304 insertions(+), 214 deletions(-) create mode 100644 apps/web/src/hooks/use-is-org-admin.ts diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts index 57c28f5d3..de23cc375 100644 --- a/apps/api/src/routes/v2/api-keys.http.test.ts +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "@effect/vitest" -import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Option, Schema } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { OrgId, UserId } from "@maple/domain/http" @@ -102,18 +102,42 @@ const makeHarness = ( const ORG = Schema.decodeUnknownSync(OrgId)("org_e2e") const USER = Schema.decodeUnknownSync(UserId)("user_e2e") - const bootstrapKey = (scopes?: ReadonlyArray, kind: "standard" | "mcp" = "standard") => + const bootstrapKey = ( + scopes?: ReadonlyArray, + kind: "standard" | "mcp" = "standard", + options: { metadataJson?: unknown; userId?: UserId } = {}, + ) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService - return yield* service.create(ORG, USER, { + return yield* service.create(ORG, options.userId ?? USER, { name: scopes === undefined ? "root-key" : `scoped:${scopes.join(",")}`, scopes, kind, + ...(options.metadataJson !== undefined ? { metadataJson: options.metadataJson } : {}), }) }), ) + /** + * A caller with a real, non-admin role. Keys resolve with `roles: null` + * (= `root`) unless their metadata pins roles, which is how the CLI login + * flow issues member-scoped credentials. + */ + const bootstrapMemberKey = (userId: UserId = USER) => + bootstrapKey(undefined, "standard", { + userId, + metadataJson: { source: "maple_cli", roles: ["org:member"], deviceName: "laptop" }, + }) + + const resolveKey = (secret: string) => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.resolveByKey(secret) + }), + ) + const bootstrapSession = () => runtime.runPromise( Effect.gen(function* () { @@ -125,7 +149,11 @@ const makeHarness = ( return { request, bootstrapKey, + bootstrapMemberKey, bootstrapSession, + resolveKey, + ORG, + USER, closeDatabase: () => testDb.close(), dispose: async () => { await disposeHandler() @@ -313,6 +341,70 @@ describe("v2 api_keys over HTTP", () => { await harness.dispose() }) + it("lets a non-admin member create an MCP key that carries their own roles", async () => { + const harness = makeHarness() + const member = await harness.bootstrapMemberKey() + + const created = await harness.request("POST", "/v2/api_keys", { + token: member.secret, + body: { name: "my editor", kind: "mcp" }, + }) + expect(created.status).toBe(200) + expect(created.body.kind).toBe("mcp") + + // The minted key must not resolve as `root` — it inherits the member's roles. + const resolved = await harness.resolveKey(created.body.secret) + expect(Option.isSome(resolved)).toBe(true) + if (Option.isSome(resolved)) { + expect(resolved.value.roles).toEqual(["org:member"]) + } + await harness.dispose() + }) + + it("still refuses standard API keys for a non-admin member", async () => { + const harness = makeHarness() + const member = await harness.bootstrapMemberKey() + + const { status, body } = await harness.request("POST", "/v2/api_keys", { + token: member.secret, + body: { name: "ci" }, + }) + expect(status).toBe(403) + expect(body.error).toEqual({ + type: "permission_error", + code: "insufficient_permissions", + message: "Only org admins can create API keys", + }) + await harness.dispose() + }) + + it("lets a member revoke their own MCP key but not anyone else's key", async () => { + const harness = makeHarness() + const member = await harness.bootstrapMemberKey() + + const own = await harness.request("POST", "/v2/api_keys", { + token: member.secret, + body: { name: "my editor", kind: "mcp" }, + }) + expect(own.status).toBe(200) + + const revoked = await harness.request("DELETE", `/v2/api_keys/${own.body.id}`, { + token: member.secret, + }) + expect(revoked.status).toBe(200) + expect(revoked.body.revoked).toBe(true) + + const other = Schema.decodeUnknownSync(UserId)("user_other") + const foreign = await harness.bootstrapKey(undefined, "mcp", { userId: other }) + const { encodePublicId } = await import("@maple/domain/http/v2") + const denied = await harness.request("DELETE", `/v2/api_keys/${encodePublicId("key", foreign.id)}`, { + token: member.secret, + }) + expect(denied.status).toBe(403) + expect(denied.body.error.code).toBe("insufficient_permissions") + await harness.dispose() + }) + it("returns envelope errors for malformed and unknown IDs", async () => { const harness = makeHarness() const root = await harness.bootstrapKey() diff --git a/apps/api/src/routes/v2/api-keys.http.ts b/apps/api/src/routes/v2/api-keys.http.ts index f4d9248a0..5fec2f9d6 100644 --- a/apps/api/src/routes/v2/api-keys.http.ts +++ b/apps/api/src/routes/v2/api-keys.http.ts @@ -109,7 +109,14 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - yield* requireAdmin(tenant.roles, adminOnly("create")) + // MCP keys are per-developer credentials any member may mint for + // themselves; the creator's roles are pinned onto the key so it + // carries no more authority than they have (an unpinned key + // resolves as `root`). Standard API keys stay admin-only. + const isMcpKey = payload.kind === "mcp" + if (!isMcpKey) { + yield* requireAdmin(tenant.roles, adminOnly("create")) + } const createdByEmail = yield* auth.getUserEmail(tenant.userId) const created = yield* apiKeysService .create(tenant.orgId, tenant.userId, { @@ -119,6 +126,9 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha kind: payload.kind, scopes: payload.scopes, createdByEmail, + ...(isMcpKey + ? { metadataJson: { source: "maple_mcp", roles: [...tenant.roles] } } + : {}), }) .pipe(Effect.mapError(mapPersistenceError("create"))) return toV2ApiKeyWithSecret(created) @@ -138,7 +148,16 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha .handle("revoke", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - yield* requireAdmin(tenant.roles, adminOnly("revoke")) + // Whoever can mint a key must be able to kill it: a member may + // revoke an MCP key they created themselves. Everything else + // stays admin-only. + const existing = yield* apiKeysService + .get(tenant.orgId, params.id) + .pipe(mapServiceError("revoke")) + const isOwnMcpKey = existing.kind === "mcp" && existing.createdBy === tenant.userId + if (!isOwnMcpKey) { + yield* requireAdmin(tenant.roles, adminOnly("revoke")) + } const revoked = yield* apiKeysService .revoke(tenant.orgId, params.id) .pipe(mapServiceError("revoke")) diff --git a/apps/api/src/services/ApiKeysService.scopes.test.ts b/apps/api/src/services/ApiKeysService.scopes.test.ts index b9ad7ecd3..ad0a09aaa 100644 --- a/apps/api/src/services/ApiKeysService.scopes.test.ts +++ b/apps/api/src/services/ApiKeysService.scopes.test.ts @@ -71,6 +71,65 @@ describe("ApiKeysService scopes", () => { }).pipe(Effect.provide(makeLayer())), ) + it.effect("fails closed for malformed Maple CLI role metadata", () => + Effect.gen(function* () { + const service = yield* ApiKeysService + const created = yield* service.create(ORG, USER, { + name: "broken cli", + metadataJson: { source: "maple_cli", roles: [42], deviceName: "laptop" }, + }) + expect(Option.isNone(yield* service.resolveByKey(created.secret))).toBe(true) + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("resolves the roles pinned by MCP key metadata", () => + Effect.gen(function* () { + const service = yield* ApiKeysService + const created = yield* service.create(ORG, USER, { + name: "member mcp", + kind: "mcp", + metadataJson: { source: "maple_mcp", roles: ["org:member"] }, + }) + + const resolved = yield* service.resolveByKey(created.secret) + expect(Option.isSome(resolved)).toBe(true) + if (Option.isSome(resolved)) { + expect(resolved.value.roles).toEqual(["org:member"]) + expect(resolved.value.cliManaged).toBe(false) + } + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("fails closed for malformed MCP role metadata", () => + Effect.gen(function* () { + const service = yield* ApiKeysService + const created = yield* service.create(ORG, USER, { + name: "broken mcp", + kind: "mcp", + metadataJson: { source: "maple_mcp", roles: "org:member" }, + }) + expect(Option.isNone(yield* service.resolveByKey(created.secret))).toBe(true) + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("roll preserves pinned roles instead of escalating to the root default", () => + Effect.gen(function* () { + const service = yield* ApiKeysService + const created = yield* service.create(ORG, USER, { + name: "laptop", + metadataJson: { source: "maple_cli", roles: ["org:member"], deviceName: "laptop" }, + }) + + const rolled = yield* service.roll(ORG, USER, created.id, {}) + const resolved = yield* service.resolveByKey(rolled.secret) + expect(Option.isSome(resolved)).toBe(true) + if (Option.isSome(resolved)) { + expect(resolved.value.roles).toEqual(["org:member"]) + expect(resolved.value.cliManaged).toBe(true) + } + }).pipe(Effect.provide(makeLayer())), + ) + it.effect("roll preserves the original key's scopes", () => Effect.gen(function* () { const service = yield* ApiKeysService diff --git a/apps/api/src/services/ApiKeysService.ts b/apps/api/src/services/ApiKeysService.ts index 8857d785b..4073b1e99 100644 --- a/apps/api/src/services/ApiKeysService.ts +++ b/apps/api/src/services/ApiKeysService.ts @@ -11,6 +11,7 @@ import { OrgId, type PostgresTransactionId, UserId, + RoleName, } from "@maple/domain/http" import { API_KEY_PREFIX, apiKeys, generateApiKey, hashApiKey, parseIngestKeyLookupHmacKey } from "@maple/db" import { and, desc, eq } from "drizzle-orm" @@ -28,6 +29,56 @@ export interface ResolvedApiKey { readonly metadataJson: string | null /** v2 scope strings; null = legacy full access. */ readonly scopes: ReadonlyArray | null + readonly roles: ReadonlyArray | null + readonly cliManaged: boolean +} + +const CliApiKeyMetadata = Schema.Struct({ + source: Schema.Literal("maple_cli"), + roles: Schema.Array(RoleName), + deviceName: Schema.String, +}) +const decodeCliApiKeyMetadata = Schema.decodeUnknownOption(CliApiKeyMetadata) + +/** + * Metadata written when a member mints an MCP key for themselves: the creator's + * own roles are pinned to the key so it can never carry more authority than the + * user who created it (an absent `roles` resolves to `root` downstream). + */ +const McpApiKeyMetadata = Schema.Struct({ + source: Schema.Literal("maple_mcp"), + roles: Schema.Array(RoleName), +}) +const decodeMcpApiKeyMetadata = Schema.decodeUnknownOption(McpApiKeyMetadata) + +/** Metadata `source` values that pin roles onto a key. */ +const ROLE_BEARING_SOURCES = ["maple_cli", "maple_mcp"] as const + +interface KeyRoleMetadata { + readonly roles: ReadonlyArray | null + readonly cliManaged: boolean +} + +/** + * Read the roles pinned onto a key by its metadata. Fails closed: metadata that + * declares a known role-bearing `source` but doesn't decode must not fall back + * to the null (= `root`) default, so the key is rejected outright. + */ +const readKeyRoleMetadata = (metadata: unknown): Option.Option => { + if (typeof metadata !== "object" || metadata === null || !("source" in metadata)) { + return Option.some({ roles: null, cliManaged: false }) + } + + const source = (metadata as { source?: unknown }).source + const cli = decodeCliApiKeyMetadata(metadata) + if (Option.isSome(cli)) return Option.some({ roles: cli.value.roles, cliManaged: true }) + const mcp = decodeMcpApiKeyMetadata(metadata) + if (Option.isSome(mcp)) return Option.some({ roles: mcp.value.roles, cliManaged: false }) + + if (typeof source === "string" && (ROLE_BEARING_SOURCES as ReadonlyArray).includes(source)) { + return Option.none() + } + return Option.some({ roles: null, cliManaged: false }) } const decodeApiKeyIdSync = Schema.decodeUnknownSync(ApiKeyId) @@ -118,6 +169,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap kind?: ApiKeyKind scopes?: ReadonlyArray | null createdByEmail?: string | null + metadataJson?: unknown }, ) { const id = decodeApiKeyIdSync(randomUUID()) @@ -147,6 +199,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap createdAt: new Date(now), createdBy: userId, createdByEmail, + metadataJson: params.metadataJson, }) .returning(txidColumn), ) @@ -206,6 +259,10 @@ export class ApiKeysService extends Context.Service()("@maple/ap keyPrefix, kind: existing.kind, scopes: existing.scopes ?? null, + // Carry the role metadata across: a rolled key that lost it + // would resolve with the null (= `root`) default, silently + // escalating a CLI/MCP key beyond its creator's roles. + metadataJson: existing.metadataJson, expiresAt: null, createdAt: new Date(now), createdBy: userId, @@ -274,6 +331,9 @@ export class ApiKeysService extends Context.Service()("@maple/ap if (row.value.expiresAt.getTime() < now) return Option.none() } + const roleMetadata = readKeyRoleMetadata(row.value.metadataJson) + if (Option.isNone(roleMetadata)) return Option.none() + return Option.some({ orgId: decodeOrgIdSync(row.value.orgId), userId: decodeUserIdSync(row.value.createdBy), @@ -281,6 +341,8 @@ export class ApiKeysService extends Context.Service()("@maple/ap kind: row.value.kind, metadataJson: row.value.metadataJson == null ? null : JSON.stringify(row.value.metadataJson), scopes: row.value.scopes ?? null, + roles: roleMetadata.value.roles, + cliManaged: roleMetadata.value.cliManaged, } satisfies ResolvedApiKey) }) diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx index 605a5628d..c6be92907 100644 --- a/apps/web/src/components/settings/api-keys-section.tsx +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -1,5 +1,6 @@ import { useAtomSet } from "@/lib/effect-atom" import { useState, type ReactNode } from "react" +import { Link } from "@tanstack/react-router" import { Exit } from "effect" import type { V2ApiKey } from "@maple/domain/http/v2" import { toast } from "sonner" @@ -44,6 +45,8 @@ import { TrashIcon, } from "@/components/icons" import { useApiKeyMutationSync, useApiKeysList } from "@/hooks/use-api-keys" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" +import { formatBackendError } from "@/lib/error-messages" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { CreateApiKeyDialog } from "./create-api-key-dialog" import { RollApiKeyDialog } from "./roll-api-key-dialog" @@ -83,6 +86,7 @@ function formatRelative(timestamp: string | null): string | null { } export function ApiKeysSection() { + const isAdmin = useIsOrgAdmin() const [createOpen, setCreateOpen] = useState(false) const [revokeOpen, setRevokeOpen] = useState(false) const [revokingKey, setRevokingKey] = useState(null) @@ -115,7 +119,8 @@ export function ApiKeysSection() { toast.success("API key revoked") void reconcileTxid(result.value.txid) } else { - toast.error("Failed to revoke API key") + const { title, description } = formatBackendError(result) + toast.error(title, { description }) } setIsRevoking(false) setRevokeOpen(false) @@ -162,7 +167,7 @@ export function ApiKeysSection() { )} - @@ -182,7 +187,8 @@ export function ApiKeysSection() { Couldn't load API keys - Something went wrong while loading your keys. Reload the page to try again. + Something went wrong while loading your keys. Reload the page to try + again. @@ -198,7 +204,7 @@ export function ApiKeysSection() { - @@ -239,6 +245,19 @@ export function ApiKeysSection() { + {!isAdmin ? ( +

+ Only org admins can create API keys. For a key that connects your editor to Maple, use the{" "} + + MCP + {" "} + page — you can create one of those yourself. +

+ ) : null} + @@ -254,8 +273,8 @@ export function ApiKeysSection() { {revokingKey ? ( <> {revokingKey.name} ( - {revokingKey.key_prefix}) will stop - working immediately. This action cannot be undone. + {revokingKey.key_prefix}) will + stop working immediately. This action cannot be undone. ) : ( <> diff --git a/apps/web/src/components/settings/create-api-key-dialog.tsx b/apps/web/src/components/settings/create-api-key-dialog.tsx index d4d730db3..707eef4f3 100644 --- a/apps/web/src/components/settings/create-api-key-dialog.tsx +++ b/apps/web/src/components/settings/create-api-key-dialog.tsx @@ -21,6 +21,7 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { useApiKeyMutationSync } from "@/hooks/use-api-keys" +import { formatBackendError } from "@/lib/error-messages" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { buildApiKeyCreatePayload } from "./api-key-create-payload" import { ApiKeySecretReveal } from "./api-key-secret-reveal" @@ -115,7 +116,8 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea onCreated?.(result.value.secret) void reconcileTxid(result.value.txid) } else { - toast.error("Failed to create API key") + const { title, description } = formatBackendError(result) + toast.error(title, { description }) } setIsCreating(false) } diff --git a/apps/web/src/components/settings/mcp-section.tsx b/apps/web/src/components/settings/mcp-section.tsx index b517ed529..ed922a234 100644 --- a/apps/web/src/components/settings/mcp-section.tsx +++ b/apps/web/src/components/settings/mcp-section.tsx @@ -86,18 +86,17 @@ export function McpSection() { -
+

Authenticate with an API key.

- {createdSecret ? ( + {createdSecret && (

Key created. Config below is ready to copy.

- ) : ( - )} +
diff --git a/apps/web/src/components/settings/roll-api-key-dialog.tsx b/apps/web/src/components/settings/roll-api-key-dialog.tsx index e8fd07d6f..17baf6abe 100644 --- a/apps/web/src/components/settings/roll-api-key-dialog.tsx +++ b/apps/web/src/components/settings/roll-api-key-dialog.tsx @@ -15,6 +15,7 @@ import { DialogTitle, } from "@maple/ui/components/ui/dialog" import { useApiKeyMutationSync } from "@/hooks/use-api-keys" +import { formatBackendError } from "@/lib/error-messages" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { ApiKeySecretReveal } from "./api-key-secret-reveal" @@ -44,7 +45,8 @@ export function RollApiKeyDialog({ open, onOpenChange, apiKey, onRolled }: RollA onRolled?.() void reconcileTxid(result.value.txid) } else { - toast.error("Failed to roll API key") + const { title, description } = formatBackendError(result) + toast.error(title, { description }) } setIsRolling(false) } diff --git a/apps/web/src/components/settings/settings-nav.tsx b/apps/web/src/components/settings/settings-nav.tsx index ecabd1e6e..be515b966 100644 --- a/apps/web/src/components/settings/settings-nav.tsx +++ b/apps/web/src/components/settings/settings-nav.tsx @@ -5,6 +5,7 @@ import { useMapleCustomer } from "@/hooks/use-maple-customer" import { Result, useAtomValue } from "@/lib/effect-atom" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { hasBringYourOwnCloudAddOn } from "@/lib/billing/plan-gating" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" import { BellIcon, @@ -120,6 +121,7 @@ export function useVisibleSettingsSections() { // today, but keeping the hooks above the early return avoids a conditional-hook // hazard if it ever becomes dynamic. const sessionResult = useAtomValue(MapleApiAtomClient.query("auth", "session", {})) + const isAdmin = useIsOrgAdmin() const { data: customer, isLoading: isCustomerLoading } = useMapleCustomer() const { organization } = useOrganization() @@ -152,9 +154,6 @@ export function useVisibleSettingsSections() { } } - const isAdmin = Result.builder(sessionResult) - .onSuccess((session) => session.roles.some((role) => role === "root" || role === "org:admin")) - .orElse(() => false) const canAccessDataPlatform = isAdmin && hasBringYourOwnCloudAddOn(customer) const hasAiMetadataFlag = organization?.publicMetadata?.bringyourownai === true const canAccessAi = isAdmin && hasAiMetadataFlag diff --git a/apps/web/src/hooks/use-is-org-admin.ts b/apps/web/src/hooks/use-is-org-admin.ts new file mode 100644 index 000000000..90a1a690e --- /dev/null +++ b/apps/web/src/hooks/use-is-org-admin.ts @@ -0,0 +1,22 @@ +import { Result, useAtomValue } from "@/lib/effect-atom" +import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" +import { MapleApiAtomClient } from "@/lib/services/common/atom-client" + +/** + * Whether the current user holds an org-admin role — the gate the API applies to + * admin-only operations (`requireAdmin` in `apps/api/src/lib/auth.ts`). Reads the + * same session atom the settings nav uses, so the two never disagree. + * + * Self-hosted (non-Clerk) deployments run as a single root user: always admin. + * Defaults to `false` while the session is loading, so a gated control stays + * disabled until we know the answer rather than flickering enabled. + */ +export function useIsOrgAdmin(): boolean { + const sessionResult = useAtomValue(MapleApiAtomClient.query("auth", "session", {})) + + if (!isClerkAuthEnabled) return true + + return Result.builder(sessionResult) + .onSuccess((session) => session.roles.some((role) => role === "root" || role === "org:admin")) + .orElse(() => false) +} diff --git a/apps/web/src/routes/mcp.tsx b/apps/web/src/routes/mcp.tsx index 6edd37290..d1857de22 100644 --- a/apps/web/src/routes/mcp.tsx +++ b/apps/web/src/routes/mcp.tsx @@ -1,208 +1,23 @@ -import { useState } from "react" -import { createFileRoute, Link } from "@tanstack/react-router" +import { createFileRoute } from "@tanstack/react-router" import { DashboardLayout } from "@/components/layout/dashboard-layout" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from "@maple/ui/components/ui/input-group" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" -import { Button } from "@maple/ui/components/ui/button" -import { CheckIcon, CopyIcon, PlusIcon } from "@/components/icons" -import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" -import { mcpUrl } from "@/lib/services/common/mcp-url" -import { McpToolsList } from "@/components/mcp/mcp-tools-list" -import { CreateApiKeyDialog } from "@/components/settings/create-api-key-dialog" -import { CodeBlock } from "@/components/quick-start/code-block" +import { McpSection } from "@/components/settings/mcp-section" export const Route = createFileRoute("/mcp")({ component: McpPage, }) -const mcpEndpoint = `${mcpUrl}/mcp` - -function generateConfig(client: "claude-code" | "cursor" | "windsurf" | "other", apiKey: string) { - const urlKey = client === "windsurf" ? "serverUrl" : "url" - const config = { - mcpServers: { - maple: { - [urlKey]: mcpEndpoint, - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - }, - } - return JSON.stringify(config, null, 2) -} - -function generateCliCommand(apiKey: string) { - return `claude mcp add --transport http maple ${mcpEndpoint} \\ - --header "Authorization: Bearer ${apiKey}"` -} - -const CONFIG_FILE_HINTS: Record = { - "claude-code": "~/.claude/claude_desktop_config.json", - cursor: ".cursor/mcp.json", - windsurf: "~/.codeium/windsurf/mcp_config.json", - other: "", -} - +// Standalone page for the same MCP setup UI rendered by /settings?tab=mcp. +// Both render so the endpoint, generated client configs, and the +// key-creation flow can't drift apart. function McpPage() { - const { copied: endpointCopied, copy: copyEndpoint } = useCopyToClipboard("MCP endpoint") - const [createDialogOpen, setCreateDialogOpen] = useState(false) - const [createdSecret, setCreatedSecret] = useState(null) - const [configTab, setConfigTab] = useState("claude-code") - - const apiKeyPlaceholder = createdSecret ?? "" - return ( -
- {/* Card 1 — Server Endpoint */} - - - Server Endpoint - - Use this URL to connect MCP-compatible clients to Maple. - - - - - - - copyEndpoint(mcpEndpoint)} - aria-label="Copy endpoint to clipboard" - title={endpointCopied ? "Copied!" : "Copy"} - > - {endpointCopied ? ( - - ) : ( - - )} - - - -
-

Authenticate with an API key.

- {createdSecret ? ( -

- Key created. Config below is ready to copy. -

- ) : ( - - )} -
-
-
- - {/* Card 2 — Quick Setup */} - - - Quick Setup - - Copy the configuration for your editor and paste it into the config file. - - - - - - Claude Code - Cursor - Windsurf - Other - - -
-
-

Run in your terminal

- -
-
-

- Or add to{" "} - - {CONFIG_FILE_HINTS["claude-code"]} - -

- -
-
-
- {(["cursor", "windsurf", "other"] as const).map((client) => ( - -
- {CONFIG_FILE_HINTS[client] && ( -

- Add to{" "} - - {CONFIG_FILE_HINTS[client]} - -

- )} - -
-
- ))} -
- {!createdSecret && ( -

- Need an API key?{" "} - {" "} - or manage existing keys on the{" "} - - Developer - {" "} - page. -

- )} -
-
- - {/* Card 3 — Available Tools */} - - - setCreatedSecret(secret)} - kind="mcp" - /> -
+
) }