Skip to main content
Glama

Convex MCP server

Official
by get-convex
OauthApps.tsx26.3 kB
import React, { useState } from "react"; import { Sheet } from "@ui/Sheet"; import { Button } from "@ui/Button"; import { TextInput } from "@ui/TextInput"; import { useIsCurrentMemberTeamAdmin } from "api/roles"; import { OauthAppResponse } from "generatedApi"; import { PlusIcon, EyeNoneIcon, EyeOpenIcon, InfoCircledIcon, DotsVerticalIcon, QuestionMarkCircledIcon, ExternalLinkIcon, } from "@radix-ui/react-icons"; import { ConfirmationDialog } from "@ui/ConfirmationDialog"; import { useTeamOauthApps, useUpdateOauthApp, useRegisterOauthApp, useDeleteOauthApp, useRegenerateOauthClientSecret, } from "api/oauth"; import { Modal } from "@ui/Modal"; import { Formik } from "formik"; import * as Yup from "yup"; import { Loading, LoadingTransition } from "@ui/Loading"; import { CopyTextButton } from "dashboard-common/src/elements/CopyTextButton"; import { Tooltip } from "@ui/Tooltip"; import { Menu, MenuItem } from "@ui/Menu"; import { cn } from "@ui/cn"; import { toast } from "@common/lib/utils"; import { captureException, captureMessage } from "@sentry/nextjs"; import { useProfile } from "api/profile"; import Link from "next/link"; import { TimestampDistance } from "@common/elements/TimestampDistance"; // Utility function to validate URLs without side effects function isValidOauthRedirectUri(uri: string): boolean { try { const url = new URL(uri); // Only allow http and https if (!["http:", "https:"].includes(url.protocol)) return false; // Disallow fragments if (url.hash && url.hash !== "") return false; // Must have a hostname if (!url.hostname) return false; // Allow localhost and private IPs (no extra check needed) return true; } catch { return false; } } // Shared validation schema for both create and edit const OAUTH_APP_SCHEMA = Yup.object({ appName: Yup.string() .min(3, "App name must be at least 3 characters") .max(128, "App name must be at most 128 characters") .required("App name is required"), redirectUris: Yup.string() .required("At least one redirect URI is required") .test( "uris", "Must provide between 1 and 20 comma-delimited URIs", (value) => { if (!value) return false; const uris = value .split(",") .map((s) => s.trim()) .filter(Boolean); return uris.length >= 1 && uris.length <= 20; }, ) .test("valid-urls", function validateUrls(value: string | undefined) { if (!value) return this.createError({ message: "At least one redirect URI is required", }); const uris = value .split(",") .map((s) => s.trim()) .filter(Boolean); for (const uri of uris) { if (!isValidOauthRedirectUri(uri)) { return this.createError({ message: `Redirect URI is not valid: ${uri}`, }); } } return true; }), }); // Validation schema for verification request form const VERIFICATION_REQUEST_SCHEMA = Yup.object({ description: Yup.string() .min(10, "Description must be at least 10 characters") .max(2000, "Description must be at most 2000 characters") .required("Description is required"), }); // Handler for verification request submission async function handleVerificationRequest( description: string, app: OauthAppResponse, teamId: number, setVerificationError: (error: string) => void, setVerificationLoading: (loading: boolean) => void, setVerificationModalOpen: (open: boolean) => void, ): Promise<void> { setVerificationError(""); setVerificationLoading(true); try { const body = JSON.stringify({ subject: `OAuth App Verification Request: ${app.appName}`, message: `OAuth App Verification Request Team ID: ${teamId} Client ID: ${app.clientId} App Name: ${app.appName} Description: ${description} Please review this OAuth application for verification.`, teamId, }); const resp = await fetch("/api/contact-form", { method: "POST", body, headers: { "Content-Type": "application/json", }, }); if (!resp.ok) { try { if (resp.status < 500 || resp.status >= 400) { const { error } = await resp.json(); captureMessage(error, "error"); } } catch (e) { captureException(e); } toast( "error", "Failed to send verification request. Please try again or email us at support@convex.dev", ); return; } setVerificationModalOpen(false); toast("success", "Verification request sent!"); } catch (err: any) { setVerificationError(err?.message || "Failed to send verification request"); } finally { setVerificationLoading(false); } } // Move OauthAppForm to the top of the file so it is in scope for all usages function OauthAppForm({ initialValues, validationSchema, onSubmit, submitLabel, loading, error, isVerified, }: { initialValues: { appName: string; redirectUris: string }; validationSchema: any; onSubmit: ( values: { appName: string; redirectUris: string }, helpers: { setSubmitting: (isSubmitting: boolean) => void; resetForm?: () => void; }, ) => Promise<void>; submitLabel: string; loading: boolean; error?: string; isVerified: boolean; }) { return ( <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={onSubmit} enableReinitialize > {({ values, errors, touched, handleChange, handleSubmit, isSubmitting, }) => ( <form className="flex flex-col gap-4" onSubmit={handleSubmit}> <TextInput id="app-name" name="appName" label="Application Name" disabled={isVerified} placeholder="My OAuth App" value={values.appName} onChange={handleChange} error={ touched.appName && typeof errors.appName === "string" ? errors.appName : undefined } required /> <label htmlFor="redirect-uris" className="flex flex-col gap-1 text-sm text-content-primary" > Redirect URIs (seperated by commas) <textarea id="redirect-uris" name="redirectUris" className="h-24 resize-y rounded-sm border bg-background-secondary px-4 py-2 text-content-primary placeholder:text-content-tertiary focus:border-border-selected focus:outline-hidden" value={values.redirectUris} onChange={handleChange} placeholder="https://example.com/callback, http://localhost:1337/callback" required /> {touched.redirectUris && typeof errors.redirectUris === "string" && ( <p className="text-xs text-content-errorSecondary" role="alert"> {errors.redirectUris} </p> )} </label> <div className="mt-2 flex items-center justify-between"> {error && ( <span className="mr-2 text-xs text-content-errorSecondary"> {error} </span> )} <Button type="submit" className="ml-auto" loading={isSubmitting || loading} > {submitLabel} </Button> </div> </form> )} </Formik> ); } // Verification request form component function VerificationRequestForm({ app, onSubmit, loading, error, }: { app: OauthAppResponse; onSubmit: (description: string) => Promise<void>; loading: boolean; error?: string; }) { const profile = useProfile(); const userEmail = profile?.email; if (!userEmail) { return <Loading />; } return ( <Formik initialValues={{ description: "" }} validationSchema={VERIFICATION_REQUEST_SCHEMA} onSubmit={async (values, { setSubmitting }) => { await onSubmit(values.description); setSubmitting(false); }} > {({ values, errors, touched, handleChange, handleSubmit, isSubmitting, }) => ( <form className="flex flex-col gap-2" onSubmit={handleSubmit}> <div className="space-y-2 text-sm"> <div> <span>Application Name:</span> <span className="ml-1 font-semibold text-content-primary"> {app.appName} </span> <span className="ml-2 text-content-tertiary"> (cannot be changed after verification) </span> </div> </div> <label htmlFor="description" className="flex flex-col gap-1 text-sm text-content-primary" > Application Description <textarea id="description" name="description" className="h-48 resize-y rounded-sm border bg-background-secondary px-4 py-2 text-content-primary placeholder:text-content-tertiary focus:border-border-selected focus:outline-hidden" value={values.description} onChange={handleChange} placeholder="Tell us a bit about your application." required /> {touched.description && typeof errors.description === "string" && ( <p className="text-xs text-content-errorSecondary" role="alert"> {errors.description} </p> )} </label> <div className="rounded border bg-blue-50 p-3 dark:bg-blue-900/20"> <p className="text-sm text-content-secondary"> <strong>Note:</strong> The Convex team will review your request and respond to you at{" "} <span className="font-mono text-content-primary"> {userEmail || "your email"} </span> . </p> </div> <div className="mt-2 flex items-center justify-between"> {error && ( <span className="mr-2 text-xs text-content-errorSecondary"> {error} </span> )} <Button type="submit" className="ml-auto" loading={isSubmitting || loading} > Send Request </Button> </div> </form> )} </Formik> ); } export function OauthApps({ teamId }: { teamId: number }) { const { data: oauthApps, isLoading } = useTeamOauthApps(teamId); const isAdmin = useIsCurrentMemberTeamAdmin(); const registerOauthApp = useRegisterOauthApp(teamId); const [createModalOpen, setCreateModalOpen] = useState(false); const [registerError, setRegisterError] = useState(""); return ( <Sheet className="max-w-fit"> {createModalOpen && ( <Modal onClose={() => setCreateModalOpen(false)} title="Register a new OAuth App" > <OauthAppForm initialValues={{ appName: "", redirectUris: "" }} validationSchema={OAUTH_APP_SCHEMA} isVerified={false} onSubmit={async ( values: { appName: string; redirectUris: string }, { setSubmitting, resetForm, }: { setSubmitting: (isSubmitting: boolean) => void; resetForm?: () => void; }, ) => { setRegisterError(""); setSubmitting(true); try { const redirectUris = values.redirectUris .split(",") .map((s) => s.trim()) .filter(Boolean); await registerOauthApp({ appName: values.appName, redirectUris, }); if (resetForm) resetForm(); setCreateModalOpen(false); } catch (err: any) { setRegisterError(err?.message || "Failed to register app"); } finally { setSubmitting(false); } }} submitLabel="Save" loading={false} error={registerError} /> </Modal> )} <LoadingTransition loadingProps={{ className: "w-[28.125rem] h-80" }}> {isLoading ? null : oauthApps && oauthApps.length ? ( <div className="flex flex-col gap-4"> <div className="mb-4 flex flex-wrap items-center justify-between gap-4"> <p className="flex flex-col gap-1 text-sm"> These are the OAuth applications registered by your team. <Link href="https://docs.convex.dev/platform-apis/oauth-applications" target="_blank" rel="noopener noreferrer" className="flex w-fit items-center gap-1 text-content-link hover:underline" > <ExternalLinkIcon /> Learn more about OAuth applications </Link> </p> <Button size="xs" icon={<PlusIcon />} disabled={!isAdmin} tip={ !isAdmin ? "Only team admins can create OAuth apps." : undefined } onClick={() => setCreateModalOpen(true)} > Create Application </Button> </div> <div className="flex w-full flex-col gap-4"> {oauthApps.map((app: OauthAppResponse) => ( <OauthAppListItem key={app.clientId} app={app} isAdmin={isAdmin} oauthAppSchema={OAUTH_APP_SCHEMA} teamId={teamId} /> ))} </div> </div> ) : ( <div className="flex w-full flex-col items-center gap-4"> <div className="text-center"> <h3 className="mb-2 font-semibold text-content-primary"> No OAuth Applications </h3> <p className="mb-4 max-w-md text-sm text-content-secondary"> OAuth applications allow you to create and connect to Convex deployments owned by other teams. </p> <p className="mb-4 max-w-md text-sm text-content-secondary"> This page is for developers who want to create Convex integrations. </p> <div className="mb-2 flex w-full flex-col items-center space-y-2 text-sm text-content-tertiary"> <p> <Link href="https://docs.convex.dev/platform-apis/oauth-applications" target="_blank" rel="noopener noreferrer" className="flex w-fit items-center gap-1 text-content-link hover:underline" > <ExternalLinkIcon /> Learn more about OAuth applications </Link> </p> <p> <Link href="https://docs.convex.dev/platform-apis" target="_blank" rel="noopener noreferrer" className="flex w-fit items-center gap-1 text-content-link hover:underline" > <ExternalLinkIcon /> Learn more about Convex Platform APIs </Link> </p> </div> </div> <Button size="sm" icon={<PlusIcon />} onClick={() => setCreateModalOpen(true)} > Create Your First Application </Button> </div> )} </LoadingTransition> </Sheet> ); } function OauthAppListItem({ app, isAdmin, oauthAppSchema, teamId, }: { app: OauthAppResponse; isAdmin: boolean; oauthAppSchema: any; teamId: number; }) { // Local state for edit modal const [editModalOpen, setEditModalOpen] = useState(false); const [editName, setEditName] = useState(app.appName); const [editUris, setEditUris] = useState(app.redirectUris.join(", ")); const [editError, setEditError] = useState(""); const [editLoading, setEditLoading] = useState(false); const updateOauthApp = useUpdateOauthApp(teamId, app.clientId); const deleteOauthApp = useDeleteOauthApp(teamId, app.clientId); const regenerateOauthClientSecret = useRegenerateOauthClientSecret( teamId, app.clientId, ); // Local state for delete confirmation const [showDelete, setShowDelete] = useState(false); const [deleteError, setDeleteError] = useState(""); const [secretVisible, setSecretVisible] = useState(false); // Local state for verification request modal const [verificationModalOpen, setVerificationModalOpen] = useState(false); const [verificationError, setVerificationError] = useState(""); const [verificationLoading, setVerificationLoading] = useState(false); // Local state for regenerate secret confirmation const [showRegenerateSecret, setShowRegenerateSecret] = useState(false); const [regenerateSecretError, setRegenerateSecretError] = useState(""); return ( <div className="scrollbar flex w-full flex-col gap-2 overflow-x-auto rounded border bg-background-secondary p-3"> <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex items-center gap-3"> <h4>{app.appName}</h4> <Tooltip tip={ !app.verified ? "This app is not verified yet. You may test this app by authorizing it to the team that registered it. To allow this app to work for other teams, you must request verification." : undefined } side="right" > <div className={cn( "flex items-center gap-1 rounded-sm border p-1 text-xs", !app.verified && "border-yellow-700 bg-yellow-100/50 text-yellow-700 dark:border-yellow-200 dark:bg-yellow-900/50 dark:text-yellow-200", )} > {app.verified ? "Verified" : "Unverified"} {!app.verified && <QuestionMarkCircledIcon />} </div> </Tooltip> </div> <div className="flex items-center gap-2"> <TimestampDistance date={new Date(app.createTime)} prefix="Created" /> <Menu placement="bottom-start" buttonProps={{ variant: "neutral", icon: <DotsVerticalIcon />, "aria-label": "App options", size: "xs", }} > {!app.verified ? ( <MenuItem action={() => setVerificationModalOpen(true)} disabled={!isAdmin} tip={ !isAdmin ? "Only team admins can request verification." : undefined } tipSide="right" > Request Verification </MenuItem> ) : null} <MenuItem action={() => setEditModalOpen(true)} disabled={!isAdmin} tip={ !isAdmin ? "Only team admins can edit OAuth apps." : undefined } tipSide="right" > Edit Application </MenuItem> <MenuItem action={() => setShowRegenerateSecret(true)} disabled={!isAdmin} tip={ !isAdmin ? "Only team admins can regenerate the client secret." : undefined } tipSide="right" > Regenerate Client Secret </MenuItem> <MenuItem action={() => setShowDelete(true)} variant="danger" disabled={!isAdmin} tip={ !isAdmin ? "Only team admins can delete OAuth apps." : undefined } tipSide="right" > Delete Application </MenuItem> </Menu> </div> </div> <div className="flex flex-wrap gap-2"> <div className="text-xs break-all"> <div className="leading-6 font-semibold">Client ID</div> <CopyTextButton text={app.clientId} className="font-mono text-xs" /> </div> {app.clientSecret ? ( <div className="text-xs break-all"> <div className="flex items-center gap-1 leading-6 font-semibold"> Client Secret <Button type="button" aria-label={ secretVisible ? "Hide client secret" : "Show client secret" } inline size="xs" variant="neutral" onClick={() => setSecretVisible((v) => !v)} > {secretVisible ? <EyeNoneIcon /> : <EyeOpenIcon />} </Button> </div> <CopyTextButton text={app.clientSecret} className="font-mono text-xs" textHidden={!secretVisible} /> </div> ) : ( <div className="text-xs break-all"> <div className="flex items-center gap-1 leading-6 font-semibold"> Client Secret <Tooltip tip="Only team admins can see the client secret."> <InfoCircledIcon /> </Tooltip> </div> <span className="text-xs leading-6.5"> ••••••••••••••••••••••••••••••••• </span> </div> )} </div> <div className="text-xs"> <span className="leading-6 font-semibold">Redirect URIs</span> <ul className="list-inside list-disc"> {app.redirectUris.map((uri: string, i: number) => ( <li key={i} className="max-w-prose break-all"> {uri} </li> ))} </ul> </div> {editModalOpen && ( <Modal onClose={() => setEditModalOpen(false)} title="Edit OAuth App"> <OauthAppForm initialValues={{ appName: editName, redirectUris: editUris }} isVerified={app.verified} validationSchema={oauthAppSchema} onSubmit={async ( values: { appName: string; redirectUris: string }, { setSubmitting, }: { setSubmitting: (isSubmitting: boolean) => void; resetForm?: () => void; }, ) => { setEditError(""); setEditLoading(true); setSubmitting(true); try { const redirectUris = values.redirectUris .split(",") .map((s: string) => s.trim()) .filter(Boolean); await updateOauthApp({ appName: values.appName === app.appName ? undefined : values.appName, redirectUris, }); setEditModalOpen(false); setEditName(values.appName); setEditUris(values.redirectUris); } catch (err: any) { setEditError(err?.message || "Failed to update app"); } finally { setEditLoading(false); setSubmitting(false); } }} submitLabel="Save" loading={editLoading} error={editError} /> </Modal> )} {showDelete && ( <ConfirmationDialog dialogTitle="Delete OAuth App" dialogBody="Are you sure you want to delete this OAuth app? This cannot be undone." validationText={app.appName} confirmText="Delete" error={deleteError} onClose={() => setShowDelete(false)} onConfirm={async () => { try { await deleteOauthApp(); setShowDelete(false); } catch (err: any) { setDeleteError(err?.message || "Failed to delete app"); } }} /> )} {showRegenerateSecret && ( <ConfirmationDialog dialogTitle="Regenerate Client Secret" dialogBody="Regenerating the client secret will immediately invalidate the old client secret." validationText={app.appName} confirmText="Regenerate" error={regenerateSecretError} onClose={() => setShowRegenerateSecret(false)} onConfirm={async () => { try { await regenerateOauthClientSecret(); setShowRegenerateSecret(false); } catch (err: any) { setRegenerateSecretError( err?.message || "Failed to regenerate client secret", ); } }} /> )} {verificationModalOpen && ( <Modal onClose={() => setVerificationModalOpen(false)} title="Request OAuth App Verification" > <VerificationRequestForm app={app} onSubmit={(description) => handleVerificationRequest( description, app, teamId, setVerificationError, setVerificationLoading, setVerificationModalOpen, ) } loading={verificationLoading} error={verificationError} /> </Modal> )} </div> ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/get-convex/convex-backend'

If you have feedback or need assistance with the MCP directory API, please join our Discord server