From 5dc87b3b30ade4ca06ccf07941752d1e74cadc31 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:03:46 +0000 Subject: [PATCH 01/72] feat: add OAuth client developer settings page with approval workflow - Add new developer OAuth page at /settings/developer/oAuth for users to submit OAuth client requests - Transform admin OAuth page into management dashboard for reviewing/approving submissions - Add OAuthClientApprovalStatus enum (PENDING, APPROVED, REJECTED) to track submission status - Add userId and createdAt fields to OAuthClient model for tracking submissions - Create email notifications for admin (new submission) and user (approval) - Add sidebar navigation link in developer section below API keys - Add comprehensive translations for new UI strings - Create OAuthClientRepository for data access following repository pattern Co-Authored-By: peer@cal.com --- .../(admin-layout)/admin/oAuth/page.tsx | 10 +- .../SettingsLayoutAppDirClient.tsx | 27 +- .../developer/oauth/page.tsx | 36 ++ .../admin/oauth-clients-admin-view.tsx | 350 ++++++++++++++++++ .../settings/developer/oauth-clients-view.tsx | 258 +++++++++++++ apps/web/public/static/locales/en/common.json | 55 ++- .../AdminOAuthClientNotificationEmail.tsx | 111 ++++++ .../OAuthClientApprovedNotificationEmail.tsx | 81 ++++ packages/emails/src/templates/index.ts | 2 + .../admin-oauth-client-notification.ts | 47 +++ .../oauth-client-approved-notification.ts | 44 +++ packages/lib/server/repository/oAuthClient.ts | 172 +++++++++ .../migration.sql | 10 + packages/prisma/schema.prisma | 25 +- .../server/routers/viewer/oAuth/_router.tsx | 42 ++- .../routers/viewer/oAuth/addClient.handler.ts | 33 +- .../viewer/oAuth/listClients.handler.ts | 19 + .../viewer/oAuth/listClients.schema.ts | 9 + .../viewer/oAuth/listUserClients.handler.ts | 17 + .../viewer/oAuth/submitClient.handler.ts | 54 +++ .../viewer/oAuth/submitClient.schema.ts | 10 + .../oAuth/updateClientStatus.handler.ts | 39 ++ .../viewer/oAuth/updateClientStatus.schema.ts | 10 + 23 files changed, 1404 insertions(+), 57 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx create mode 100644 apps/web/modules/settings/admin/oauth-clients-admin-view.tsx create mode 100644 apps/web/modules/settings/developer/oauth-clients-view.tsx create mode 100644 packages/emails/src/templates/AdminOAuthClientNotificationEmail.tsx create mode 100644 packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx create mode 100644 packages/emails/templates/admin-oauth-client-notification.ts create mode 100644 packages/emails/templates/oauth-client-approved-notification.ts create mode 100644 packages/lib/server/repository/oAuthClient.ts create mode 100644 packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql create mode 100644 packages/trpc/server/routers/viewer/oAuth/listClients.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/listClients.schema.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/listUserClients.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/submitClient.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/updateClientStatus.schema.ts diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx index 8be1a816367fc0..3ae73f578cc568 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx @@ -2,12 +2,12 @@ import { _generateMetadata, getTranslate } from "app/_utils"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; -import LegacyPage from "~/settings/admin/oauth-view"; +import OAuthClientsAdminView from "~/settings/admin/oauth-clients-admin-view"; export const generateMetadata = async () => await _generateMetadata( - (t) => t("oAuth"), - (t) => t("admin_oAuth_description"), + (t) => t("oauth_clients_admin"), + (t) => t("oauth_clients_admin_description"), undefined, undefined, "/settings/admin/oAuth" @@ -16,8 +16,8 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); return ( - - + + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index e154eac9562868..88d71bf397215e 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -64,19 +64,20 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { icon: "credit-card", children: [{ name: "manage_billing", href: "/settings/billing", trackingMetadata: { section: "billing", page: "manage_billing" } }], }, - { - name: "developer", - href: "/settings/developer", - icon: "terminal", - children: [ - // - { name: "webhooks", href: "/settings/developer/webhooks", trackingMetadata: { section: "developer", page: "webhooks" } }, - { name: "api_keys", href: "/settings/developer/api-keys", trackingMetadata: { section: "developer", page: "api_keys" } }, - { name: "admin_api", href: "/settings/organizations/admin-api", trackingMetadata: { section: "developer", page: "admin_api" } }, - // TODO: Add profile level for embeds - // { name: "embeds", href: "/v2/settings/developer/embeds" }, - ], - }, + { + name: "developer", + href: "/settings/developer", + icon: "terminal", + children: [ + // + { name: "webhooks", href: "/settings/developer/webhooks", trackingMetadata: { section: "developer", page: "webhooks" } }, + { name: "api_keys", href: "/settings/developer/api-keys", trackingMetadata: { section: "developer", page: "api_keys" } }, + { name: "oauth_clients", href: "/settings/developer/oauth", trackingMetadata: { section: "developer", page: "oauth_clients" } }, + { name: "admin_api", href: "/settings/organizations/admin-api", trackingMetadata: { section: "developer", page: "admin_api" } }, + // TODO: Add profile level for embeds + // { name: "embeds", href: "/v2/settings/developer/embeds" }, + ], + }, { name: "organization", href: "/settings/organizations", diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx new file mode 100644 index 00000000000000..b0b43387ea2f3b --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx @@ -0,0 +1,36 @@ +import { _generateMetadata, getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import OAuthClientsView from "~/settings/developer/oauth-clients-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("oauth_clients"), + (t) => t("oauth_clients_description"), + undefined, + undefined, + "/settings/developer/oauth" + ); + +const Page = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const t = await getTranslate(); + + if (!session) { + redirect("/auth/login?callbackUrl=/settings/developer/oauth"); + } + + return ( + + + + ); +}; + +export default Page; diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx new file mode 100644 index 00000000000000..a8863759459fe2 --- /dev/null +++ b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, +} from "@calcom/ui/components/dialog"; +import { + Dropdown, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@calcom/ui/components/dropdown"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { Form, Label, Switch, TextField } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; +import { ImageUploader } from "@calcom/ui/components/image-uploader"; +import { showToast } from "@calcom/ui/components/toast"; +import { Tooltip } from "@calcom/ui/components/tooltip"; + +type FormValues = { + name: string; + redirectUri: string; + logo: string; + enablePkce: boolean; +}; + +export default function OAuthClientsAdminView() { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [showAddDialog, setShowAddDialog] = useState(false); + const [logo, setLogo] = useState(""); + const [createdClient, setCreatedClient] = useState<{ + clientId: string; + clientSecret?: string; + name: string; + } | null>(null); + + const oAuthForm = useForm({ + defaultValues: { + name: "", + redirectUri: "", + logo: "", + enablePkce: false, + }, + }); + + const { data: oAuthClients, isLoading } = trpc.viewer.oAuth.listClients.useQuery({}); + + const addMutation = trpc.viewer.oAuth.addClient.useMutation({ + onSuccess: async (data) => { + setCreatedClient({ + clientId: data.clientId, + clientSecret: data.clientSecret, + name: data.name, + }); + showToast(t("oauth_client_created"), "success"); + utils.viewer.oAuth.listClients.invalidate(); + }, + onError: (error) => { + showToast(`${t("oauth_client_create_error")}: ${error.message}`, "error"); + }, + }); + + const updateStatusMutation = trpc.viewer.oAuth.updateClientStatus.useMutation({ + onSuccess: async (data) => { + showToast( + t("oauth_client_status_updated", { name: data.name, status: data.approvalStatus }), + "success" + ); + utils.viewer.oAuth.listClients.invalidate(); + }, + onError: (error) => { + showToast(`${t("oauth_client_status_update_error")}: ${error.message}`, "error"); + }, + }); + + const handleAddClient = (values: FormValues) => { + addMutation.mutate({ + name: values.name, + redirectUri: values.redirectUri, + logo: values.logo, + enablePkce: values.enablePkce, + }); + }; + + const handleCloseDialog = () => { + setShowAddDialog(false); + setCreatedClient(null); + setLogo(""); + oAuthForm.reset(); + }; + + const handleApprove = (clientId: string) => { + updateStatusMutation.mutate({ clientId, status: "APPROVED" }); + }; + + const handleReject = (clientId: string) => { + updateStatusMutation.mutate({ clientId, status: "REJECTED" }); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case "APPROVED": + return {t("approved")}; + case "REJECTED": + return {t("rejected")}; + case "PENDING": + default: + return {t("pending")}; + } + }; + + if (isLoading) { + return
{t("loading")}
; + } + + return ( +
+
+ +
+ + {oAuthClients && oAuthClients.length > 0 ? ( +
+ + + + + + + + + + + + {oAuthClients.map((client) => ( + + + + + + + + ))} + +
{t("client_name")}{t("redirect_uri")}{t("submitted_by")}{t("status")}{t("actions")}
+
+ } + size="sm" + /> + {client.name} +
+
{client.redirectUri} + {client.user ? ( + + {client.user.name || client.user.email} + + ) : ( + {t("admin")} + )} + {getStatusBadge(client.approvalStatus)} + + +
+
+ ) : ( + setShowAddDialog(true)}> + {t("add_oauth_client")} + + } + /> + )} + + + + {!createdClient ? ( +
+ + + +
+ +
+ oAuthForm.setValue("enablePkce", checked)} + /> + {t("use_pkce")} +
+
+ +
+ } + className="mr-5 items-center" + imageSrc={logo} + size="lg" + /> + { + setLogo(newLogo); + oAuthForm.setValue("logo", newLogo); + }} + imageSrc={logo} + /> +
+ + + {t("cancel")} + + + + ) : ( +
+
{createdClient.name}
+
{t("client_id")}
+
+ + {createdClient.clientId} + + + + +
+ {createdClient.clientSecret && ( + <> +
{t("client_secret")}
+
+ + {createdClient.clientSecret} + + + + +
+
{t("copy_client_secret_info")}
+ + )} + + + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx new file mode 100644 index 00000000000000..67d82fedc29517 --- /dev/null +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { Form, Label, Switch, TextField } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; +import { ImageUploader } from "@calcom/ui/components/image-uploader"; +import { showToast } from "@calcom/ui/components/toast"; +import { Tooltip } from "@calcom/ui/components/tooltip"; + +type FormValues = { + name: string; + redirectUri: string; + logo: string; + enablePkce: boolean; +}; + +const OAuthClientsView = () => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [showDialog, setShowDialog] = useState(false); + const [logo, setLogo] = useState(""); + const [submittedClient, setSubmittedClient] = useState<{ + clientId: string; + name: string; + isPkceEnabled?: boolean; + } | null>(null); + + const oAuthForm = useForm({ + defaultValues: { + name: "", + redirectUri: "", + logo: "", + enablePkce: false, + }, + }); + + const { data: oAuthClients, isLoading } = trpc.viewer.oAuth.listUserClients.useQuery(); + + const submitMutation = trpc.viewer.oAuth.submitClient.useMutation({ + onSuccess: async (data) => { + setSubmittedClient({ + clientId: data.clientId, + name: data.name, + isPkceEnabled: data.isPkceEnabled, + }); + showToast(t("oauth_client_submitted"), "success"); + utils.viewer.oAuth.listUserClients.invalidate(); + }, + onError: (error) => { + showToast(`${t("oauth_client_submit_error")}: ${error.message}`, "error"); + }, + }); + + const handleSubmit = (values: FormValues) => { + submitMutation.mutate({ + name: values.name, + redirectUri: values.redirectUri, + logo: values.logo, + enablePkce: values.enablePkce, + }); + }; + + const handleCloseDialog = () => { + setShowDialog(false); + setSubmittedClient(null); + setLogo(""); + oAuthForm.reset(); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case "APPROVED": + return {t("approved")}; + case "REJECTED": + return {t("rejected")}; + case "PENDING": + default: + return {t("pending")}; + } + }; + + if (isLoading) { + return
{t("loading")}
; + } + + return ( +
+
+ +
+ + {oAuthClients && oAuthClients.length > 0 ? ( +
+ {oAuthClients.map((client, index) => ( +
+
+ } + size="md" + /> +
+
{client.name}
+
{client.redirectUri}
+
+
+
+ {getStatusBadge(client.approvalStatus)} + {client.approvalStatus === "APPROVED" && ( + +
+
+ ))} +
+ ) : ( + setShowDialog(true)}> + {t("new_oauth_client")} + + } + /> + )} + + + + {!submittedClient ? ( +
+ + + +
+ +
+ oAuthForm.setValue("enablePkce", checked)} + /> + {t("use_pkce")} +
+
+ +
+ } + className="mr-5 items-center" + imageSrc={logo} + size="lg" + /> + { + setLogo(newLogo); + oAuthForm.setValue("logo", newLogo); + }} + imageSrc={logo} + /> +
+ + + {t("cancel")} + + + + ) : ( +
+
{submittedClient.name}
+
{t("client_id")}
+
+ + {submittedClient.clientId} + + + + +
+
{t("oauth_client_pending_approval")}
+ + + +
+ )} +
+
+
+ ); +}; + +export default OAuthClientsView; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 9d0d0f85ac6a7a..9db9a36e109203 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -998,11 +998,56 @@ "create_team": "Create team", "name": "Name", "nameless_team": "Nameless Team", - "oauth_clients": "OAuth Clients", - "oauth_clients_description": "Manage OAuth clients for your organization", - "create_oauth_client": "Create OAuth Client", - "create_oauth_client_description": "Create a new OAuth client for third-party integrations", - "oauth_client_deletion_message": "OAuth client deleted successfully", + "oauth_clients": "OAuth Clients", + "oauth_clients_description": "Create and manage OAuth clients for third-party integrations", + "create_oauth_client": "Create OAuth Client", + "create_oauth_client_description": "Create a new OAuth client for third-party integrations", + "oauth_client_deletion_message": "OAuth client deleted successfully", + "oauth_clients_admin": "OAuth Clients", + "oauth_clients_admin_description": "Manage and approve OAuth client submissions", + "new_oauth_client": "New OAuth Client", + "new_oauth_client_description": "Submit a new OAuth client for approval", + "oauth_client_submitted": "OAuth Client Submitted", + "oauth_client_submitted_description": "Your OAuth client has been submitted for approval", + "oauth_client_submit_error": "Failed to submit OAuth client", + "oauth_client_created": "OAuth Client Created", + "oauth_client_created_description": "Your OAuth client has been created successfully", + "oauth_client_create_error": "Failed to create OAuth client", + "oauth_client_status_updated": "OAuth client {{name}} status updated to {{status}}", + "oauth_client_status_update_error": "Failed to update OAuth client status", + "oauth_client_pending_approval": "Your OAuth client is pending approval. You will receive an email notification once it has been reviewed.", + "add_oauth_client": "Add OAuth Client", + "add_oauth_client_description": "Add a new OAuth client", + "no_oauth_clients": "No OAuth Clients", + "no_oauth_clients_description": "You haven't created any OAuth clients yet. Create one to get started.", + "no_oauth_clients_admin_description": "No OAuth clients have been submitted yet.", + "submit_for_approval": "Submit for Approval", + "client_name": "Client Name", + "client_name_placeholder": "My OAuth App", + "redirect_uri": "Redirect URI", + "authentication_mode": "Authentication Mode", + "use_pkce": "Use PKCE (recommended for mobile/SPA applications)", + "upload_logo": "Upload Logo", + "copy_client_id": "Copy Client ID", + "client_id_copied": "Client ID copied to clipboard", + "submitted_by": "Submitted By", + "approve": "Approve", + "reject": "Reject", + "approved": "Approved", + "rejected": "Rejected", + "pending": "Pending", + "hi_user": "Hi {{name}}", + "there": "there", + "admin_oauth_notification_email_subject": "New OAuth Client Submission: {{clientName}}", + "admin_oauth_notification_email_title": "New OAuth Client: {{clientName}}", + "admin_oauth_notification_email_body": "A new OAuth client has been submitted by {{submitterEmail}} and is awaiting your review.", + "admin_oauth_notification_email_cta": "Review OAuth Clients", + "admin_oauth_notification_email_footer": "Please review this submission and approve or reject it in the admin dashboard.", + "oauth_client_approved_email_subject": "Your OAuth Client {{clientName}} Has Been Approved", + "oauth_client_approved_email_title": "OAuth Client Approved: {{clientName}}", + "oauth_client_approved_email_body": "Great news! Your OAuth client has been approved and is now ready to use.", + "oauth_client_approved_email_cta": "View Your OAuth Clients", + "oauth_client_approved_email_footer": "You can now use your client ID and secret to integrate with Cal.com.", "create_new_team_description": "Create a new team to collaborate with users.", "create_new_team": "Create a new team", "booking_redirect_uri": "URL of your booking page", diff --git a/packages/emails/src/templates/AdminOAuthClientNotificationEmail.tsx b/packages/emails/src/templates/AdminOAuthClientNotificationEmail.tsx new file mode 100644 index 00000000000000..11c92822216445 --- /dev/null +++ b/packages/emails/src/templates/AdminOAuthClientNotificationEmail.tsx @@ -0,0 +1,111 @@ +import type { TFunction } from "i18next"; + +import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; + +import { BaseEmailHtml, CallToAction } from "../components"; + +type AdminOAuthClientNotification = { + language: TFunction; + clientName: string; + clientId: string; + redirectUri: string; + submitterEmail: string; + submitterName: string | null; +}; + +export const AdminOAuthClientNotificationEmail = ({ + clientName, + clientId, + redirectUri, + submitterEmail, + submitterName, + language, +}: AdminOAuthClientNotification) => { + return ( + + }> +

+ <>{language("admin_oauth_notification_email_title", { clientName })} +

+

+ <>{language("hi_admin")}! +

+

+ {language("admin_oauth_notification_email_body", { submitterEmail })} +

+ + + + + + + + + + + + + + + + + + + +
+ {language("client_name")} + {clientName}
+ {language("client_id")} + + {clientId} +
+ {language("redirect_uri")} + {redirectUri}
{language("submitted_by")} + {submitterName ? `${submitterName} (${submitterEmail})` : submitterEmail} +
+

+ {language("admin_oauth_notification_email_footer")} +

+
+ ); +}; diff --git a/packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx b/packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx new file mode 100644 index 00000000000000..bad702bfd2ccff --- /dev/null +++ b/packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx @@ -0,0 +1,81 @@ +import type { TFunction } from "i18next"; + +import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; + +import { BaseEmailHtml, CallToAction } from "../components"; + +type OAuthClientApprovedNotification = { + language: TFunction; + userName: string | null; + clientName: string; + clientId: string; +}; + +export const OAuthClientApprovedNotificationEmail = ({ + userName, + clientName, + clientId, + language, +}: OAuthClientApprovedNotification) => { + return ( + + }> +

+ <>{language("oauth_client_approved_email_title", { clientName })} +

+

+ <>{language("hi_user", { name: userName || language("there") })}! +

+

{language("oauth_client_approved_email_body")}

+ + + + + + + + + + + +
+ {language("client_name")} + {clientName}
{language("client_id")} + {clientId} +
+

+ {language("oauth_client_approved_email_footer")} +

+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 3fb98750ebfe56..2548834c25f442 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -37,6 +37,8 @@ export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail"; export { MonthlyDigestEmail } from "./MonthlyDigestEmail"; export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail"; +export { AdminOAuthClientNotificationEmail } from "./AdminOAuthClientNotificationEmail"; +export { OAuthClientApprovedNotificationEmail } from "./OAuthClientApprovedNotificationEmail"; export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification"; export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail"; export { OrganizationCreationEmail } from "./OrganizationCreationEmail"; diff --git a/packages/emails/templates/admin-oauth-client-notification.ts b/packages/emails/templates/admin-oauth-client-notification.ts new file mode 100644 index 00000000000000..d92a54d6faf6a3 --- /dev/null +++ b/packages/emails/templates/admin-oauth-client-notification.ts @@ -0,0 +1,47 @@ +import type { TFunction } from "i18next"; + +import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; + +import renderEmail from "../src/renderEmail"; +import BaseEmail from "./_base-email"; + +export type OAuthClientNotification = { + t: TFunction; + clientName: string; + clientId: string; + redirectUri: string; + submitterEmail: string; + submitterName: string | null; +}; + +export default class AdminOAuthClientNotification extends BaseEmail { + input: OAuthClientNotification; + + constructor(input: OAuthClientNotification) { + super(); + this.name = "SEND_ADMIN_OAUTH_CLIENT_NOTIFICATION"; + this.input = input; + } + + protected async getNodeMailerPayload(): Promise> { + return { + from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, + to: "team@cal.com", + subject: `${this.input.t("admin_oauth_notification_email_subject", { clientName: this.input.clientName })}`, + html: await renderEmail("AdminOAuthClientNotificationEmail", { + clientName: this.input.clientName, + clientId: this.input.clientId, + redirectUri: this.input.redirectUri, + submitterEmail: this.input.submitterEmail, + submitterName: this.input.submitterName, + language: this.input.t, + }), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return `${this.input.t("hi_admin")}, ${this.input.t("admin_oauth_notification_email_title", { clientName: this.input.clientName })} + ${this.input.t("admin_oauth_notification_email_body", { submitterEmail: this.input.submitterEmail })}`.trim(); + } +} diff --git a/packages/emails/templates/oauth-client-approved-notification.ts b/packages/emails/templates/oauth-client-approved-notification.ts new file mode 100644 index 00000000000000..b476efd57ac0eb --- /dev/null +++ b/packages/emails/templates/oauth-client-approved-notification.ts @@ -0,0 +1,44 @@ +import type { TFunction } from "i18next"; + +import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; + +import renderEmail from "../src/renderEmail"; +import BaseEmail from "./_base-email"; + +export type OAuthClientApprovedNotification = { + t: TFunction; + userEmail: string; + userName: string | null; + clientName: string; + clientId: string; +}; + +export default class OAuthClientApprovedEmail extends BaseEmail { + input: OAuthClientApprovedNotification; + + constructor(input: OAuthClientApprovedNotification) { + super(); + this.name = "SEND_OAUTH_CLIENT_APPROVED_NOTIFICATION"; + this.input = input; + } + + protected async getNodeMailerPayload(): Promise> { + return { + from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, + to: this.input.userEmail, + subject: `${this.input.t("oauth_client_approved_email_subject", { clientName: this.input.clientName })}`, + html: await renderEmail("OAuthClientApprovedNotificationEmail", { + userName: this.input.userName, + clientName: this.input.clientName, + clientId: this.input.clientId, + language: this.input.t, + }), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return `${this.input.t("oauth_client_approved_email_title", { clientName: this.input.clientName })} + ${this.input.t("oauth_client_approved_email_body")}`.trim(); + } +} diff --git a/packages/lib/server/repository/oAuthClient.ts b/packages/lib/server/repository/oAuthClient.ts new file mode 100644 index 00000000000000..85f364b9153b07 --- /dev/null +++ b/packages/lib/server/repository/oAuthClient.ts @@ -0,0 +1,172 @@ +import { randomBytes, createHash } from "crypto"; + +import type { PrismaClient } from "@calcom/prisma"; +import type { OAuthClientApprovalStatus } from "@calcom/prisma/enums"; + +export class OAuthClientRepository { + constructor(private prismaClient: PrismaClient) {} + + static async withGlobalPrisma() { + return new OAuthClientRepository((await import("@calcom/prisma")).prisma); + } + + async findByClientId(clientId: string) { + return this.prismaClient.oAuthClient.findUnique({ + where: { clientId }, + select: { + clientId: true, + redirectUri: true, + name: true, + logo: true, + clientType: true, + approvalStatus: true, + userId: true, + createdAt: true, + }, + }); + } + + async findByClientIdIncludeUser(clientId: string) { + return this.prismaClient.oAuthClient.findUnique({ + where: { clientId }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + } + + async findByUserId(userId: number) { + return this.prismaClient.oAuthClient.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + } + + async findByUserIdAndStatus(userId: number, approvalStatus: OAuthClientApprovalStatus) { + return this.prismaClient.oAuthClient.findMany({ + where: { userId, approvalStatus }, + orderBy: { createdAt: "desc" }, + }); + } + + async findAll() { + return this.prismaClient.oAuthClient.findMany({ + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + async findByStatus(approvalStatus: OAuthClientApprovalStatus) { + return this.prismaClient.oAuthClient.findMany({ + where: { approvalStatus }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + async create(data: { + name: string; + redirectUri: string; + logo?: string; + enablePkce?: boolean; + userId?: number; + approvalStatus?: OAuthClientApprovalStatus; + }) { + const { name, redirectUri, logo, enablePkce, userId, approvalStatus } = data; + + const clientId = randomBytes(32).toString("hex"); + + let clientSecret: string | undefined; + let hashedSecret: string | undefined; + if (!enablePkce) { + const [hashed, plain] = this.generateSecret(); + hashedSecret = hashed; + clientSecret = plain; + } + + const client = await this.prismaClient.oAuthClient.create({ + data: { + name, + redirectUri, + clientId, + clientType: enablePkce ? "PUBLIC" : "CONFIDENTIAL", + logo, + approvalStatus: approvalStatus || "PENDING", + clientSecret: hashedSecret, + ...(userId && { + user: { + connect: { id: userId }, + }, + }), + }, + }); + + return { + clientId: client.clientId, + name: client.name, + redirectUri: client.redirectUri, + logo: client.logo, + clientType: client.clientType, + clientSecret, + isPkceEnabled: enablePkce, + approvalStatus: client.approvalStatus, + }; + } + + async updateStatus(clientId: string, approvalStatus: OAuthClientApprovalStatus) { + return this.prismaClient.oAuthClient.update({ + where: { clientId }, + data: { approvalStatus }, + }); + } + + async update( + clientId: string, + data: { + name?: string; + redirectUri?: string; + logo?: string; + } + ) { + return this.prismaClient.oAuthClient.update({ + where: { clientId }, + data, + }); + } + + async delete(clientId: string) { + return this.prismaClient.oAuthClient.delete({ + where: { clientId }, + }); + } + + private hashSecretKey(apiKey: string): string { + return createHash("sha256").update(apiKey).digest("hex"); + } + + private generateSecret(secret = randomBytes(32).toString("hex")): [string, string] { + return [this.hashSecretKey(secret), secret]; + } +} diff --git a/packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql b/packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql new file mode 100644 index 00000000000000..a15361895fb033 --- /dev/null +++ b/packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "public"."OAuthClientApprovalStatus" AS ENUM ('pending', 'approved', 'rejected'); + +-- AlterTable +ALTER TABLE "public"."OAuthClient" ADD COLUMN "approvalStatus" "public"."OAuthClientApprovalStatus" NOT NULL DEFAULT 'pending', +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "userId" INTEGER; + +-- AddForeignKey +ALTER TABLE "public"."OAuthClient" ADD CONSTRAINT "OAuthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0241e5820960c4..517cd7b4fd300f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -478,6 +478,7 @@ model User { whitelistWorkflows Boolean @default(false) calAiPhoneNumbers CalAiPhoneNumber[] agents Agent[] + oAuthClients OAuthClient[] @@unique([email]) @@unique([email, username]) @@ -1742,14 +1743,24 @@ enum OAuthClientType { PUBLIC @map("public") } +enum OAuthClientApprovalStatus { + PENDING @map("pending") + APPROVED @map("approved") + REJECTED @map("rejected") +} + model OAuthClient { - clientId String @id @unique - redirectUri String - clientSecret String? - clientType OAuthClientType @default(CONFIDENTIAL) - name String - logo String? - accessCodes AccessCode[] + clientId String @id @unique + redirectUri String + clientSecret String? + clientType OAuthClientType @default(CONFIDENTIAL) + name String + logo String? + accessCodes AccessCode[] + approvalStatus OAuthClientApprovalStatus @default(PENDING) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) } model AccessCode { diff --git a/packages/trpc/server/routers/viewer/oAuth/_router.tsx b/packages/trpc/server/routers/viewer/oAuth/_router.tsx index 8f9ab297e31da4..ddb912769a28c1 100644 --- a/packages/trpc/server/routers/viewer/oAuth/_router.tsx +++ b/packages/trpc/server/routers/viewer/oAuth/_router.tsx @@ -4,11 +4,18 @@ import { router } from "../../../trpc"; import { ZAddClientInputSchema } from "./addClient.schema"; import { ZGenerateAuthCodeInputSchema } from "./generateAuthCode.schema"; import { ZGetClientInputSchema } from "./getClient.schema"; +import { ZListClientsInputSchema } from "./listClients.schema"; +import { ZSubmitClientInputSchema } from "./submitClient.schema"; +import { ZUpdateClientStatusInputSchema } from "./updateClientStatus.schema"; -type OAuthRouterHandlerCache = { +type _OAuthRouterHandlerCache = { getClient?: typeof import("./getClient.handler").getClientHandler; addClient?: typeof import("./addClient.handler").addClientHandler; generateAuthCode?: typeof import("./generateAuthCode.handler").generateAuthCodeHandler; + submitClient?: typeof import("./submitClient.handler").submitClientHandler; + listClients?: typeof import("./listClients.handler").listClientsHandler; + listUserClients?: typeof import("./listUserClients.handler").listUserClientsHandler; + updateClientStatus?: typeof import("./updateClientStatus.handler").updateClientStatusHandler; }; export const oAuthRouter = router({ @@ -36,4 +43,37 @@ export const oAuthRouter = router({ input, }); }), + + submitClient: authedProcedure.input(ZSubmitClientInputSchema).mutation(async ({ ctx, input }) => { + const { submitClientHandler } = await import("./submitClient.handler"); + + return submitClientHandler({ + ctx, + input, + }); + }), + + listClients: authedAdminProcedure.input(ZListClientsInputSchema).query(async ({ input }) => { + const { listClientsHandler } = await import("./listClients.handler"); + + return listClientsHandler({ + input, + }); + }), + + listUserClients: authedProcedure.query(async ({ ctx }) => { + const { listUserClientsHandler } = await import("./listUserClients.handler"); + + return listUserClientsHandler({ + ctx, + }); + }), + + updateClientStatus: authedAdminProcedure.input(ZUpdateClientStatusInputSchema).mutation(async ({ input }) => { + const { updateClientStatusHandler } = await import("./updateClientStatus.handler"); + + return updateClientStatusHandler({ + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts b/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts index 6e26d2ae926390..6dc87b7f27d563 100644 --- a/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts @@ -1,7 +1,4 @@ -import { randomBytes, createHash } from "crypto"; - -import { prisma } from "@calcom/prisma"; -import { Prisma } from "@calcom/prisma/client"; +import { OAuthClientRepository } from "@calcom/lib/server/repository/oAuthClient"; import type { TAddClientInputSchema } from "./addClient.schema"; @@ -12,26 +9,15 @@ type AddClientOptions = { export const addClientHandler = async ({ input }: AddClientOptions) => { const { name, redirectUri, logo, enablePkce } = input; - const clientId = randomBytes(32).toString("hex"); - const clientType = enablePkce ? "PUBLIC" : "CONFIDENTIAL"; + const oAuthClientRepository = await OAuthClientRepository.withGlobalPrisma(); - // Only generate client secret for confidential clients - const clientData: Prisma.OAuthClientCreateInput = { + // Admin-created clients are auto-approved + const client = await oAuthClientRepository.create({ name, redirectUri, - clientId, - clientType, logo, - }; - - let secret: string | undefined; - if (!enablePkce) { - const [hashedSecret, plainSecret] = generateSecret(); - clientData.clientSecret = hashedSecret; - secret = plainSecret; - } - const client = await prisma.oAuthClient.create({ - data: clientData, + enablePkce, + approvalStatus: "APPROVED", }); return { @@ -40,12 +26,7 @@ export const addClientHandler = async ({ input }: AddClientOptions) => { redirectUri: client.redirectUri, logo: client.logo, clientType: client.clientType, - clientSecret: secret, // Only return plain secret for confidential clients + clientSecret: client.clientSecret, isPkceEnabled: enablePkce, }; }; - -const hashSecretKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); - -// Generate a random secret -export const generateSecret = (secret = randomBytes(32).toString("hex")) => [hashSecretKey(secret), secret]; diff --git a/packages/trpc/server/routers/viewer/oAuth/listClients.handler.ts b/packages/trpc/server/routers/viewer/oAuth/listClients.handler.ts new file mode 100644 index 00000000000000..168ef7aee32bfa --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/listClients.handler.ts @@ -0,0 +1,19 @@ +import { OAuthClientRepository } from "@calcom/lib/server/repository/oAuthClient"; + +import type { TListClientsInputSchema } from "./listClients.schema"; + +type ListClientsOptions = { + input: TListClientsInputSchema; +}; + +export const listClientsHandler = async ({ input }: ListClientsOptions) => { + const { status } = input; + + const oAuthClientRepository = await OAuthClientRepository.withGlobalPrisma(); + + if (status) { + return oAuthClientRepository.findByStatus(status); + } + + return oAuthClientRepository.findAll(); +}; diff --git a/packages/trpc/server/routers/viewer/oAuth/listClients.schema.ts b/packages/trpc/server/routers/viewer/oAuth/listClients.schema.ts new file mode 100644 index 00000000000000..2499674889cda3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/listClients.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +import { OAuthClientApprovalStatus } from "@calcom/prisma/enums"; + +export const ZListClientsInputSchema = z.object({ + status: z.nativeEnum(OAuthClientApprovalStatus).optional(), +}); + +export type TListClientsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/oAuth/listUserClients.handler.ts b/packages/trpc/server/routers/viewer/oAuth/listUserClients.handler.ts new file mode 100644 index 00000000000000..02e1d7f608bb5c --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/listUserClients.handler.ts @@ -0,0 +1,17 @@ +import { OAuthClientRepository } from "@calcom/lib/server/repository/oAuthClient"; + +type ListUserClientsOptions = { + ctx: { + user: { + id: number; + }; + }; +}; + +export const listUserClientsHandler = async ({ ctx }: ListUserClientsOptions) => { + const userId = ctx.user.id; + + const oAuthClientRepository = await OAuthClientRepository.withGlobalPrisma(); + + return oAuthClientRepository.findByUserId(userId); +}; diff --git a/packages/trpc/server/routers/viewer/oAuth/submitClient.handler.ts b/packages/trpc/server/routers/viewer/oAuth/submitClient.handler.ts new file mode 100644 index 00000000000000..1edeecf0132e0e --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/submitClient.handler.ts @@ -0,0 +1,54 @@ +import AdminOAuthClientNotification from "@calcom/emails/templates/admin-oauth-client-notification"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { OAuthClientRepository } from "@calcom/lib/server/repository/oAuthClient"; + +import type { TSubmitClientInputSchema } from "./submitClient.schema"; + +type SubmitClientOptions = { + ctx: { + user: { + id: number; + email: string; + name: string | null; + }; + }; + input: TSubmitClientInputSchema; +}; + +export const submitClientHandler = async ({ ctx, input }: SubmitClientOptions) => { + const { name, redirectUri, logo, enablePkce } = input; + const userId = ctx.user.id; + + const oAuthClientRepository = await OAuthClientRepository.withGlobalPrisma(); + + const client = await oAuthClientRepository.create({ + name, + redirectUri, + logo, + enablePkce, + userId, + approvalStatus: "PENDING", + }); + + // Send email notification to team@cal.com + const t = await getTranslation("en", "common"); + const adminNotification = new AdminOAuthClientNotification({ + t, + clientName: client.name, + clientId: client.clientId, + redirectUri: client.redirectUri, + submitterEmail: ctx.user.email, + submitterName: ctx.user.name, + }); + await adminNotification.sendEmail(); + + return { + clientId: client.clientId, + name: client.name, + redirectUri: client.redirectUri, + logo: client.logo, + clientType: client.clientType, + approvalStatus: client.approvalStatus, + isPkceEnabled: enablePkce, + }; +}; diff --git a/packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts b/packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts new file mode 100644 index 00000000000000..8c4d7208c5984d --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZSubmitClientInputSchema = z.object({ + name: z.string().min(1, "Client name is required"), + redirectUri: z.string().url("Must be a valid URL"), + logo: z.string().optional(), + enablePkce: z.boolean().optional().default(false), +}); + +export type TSubmitClientInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts new file mode 100644 index 00000000000000..bbf5d2e4018213 --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts @@ -0,0 +1,39 @@ +import OAuthClientApprovedEmail from "@calcom/emails/templates/oauth-client-approved-notification"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { OAuthClientRepository } from "@calcom/lib/server/repository/oAuthClient"; + +import type { TUpdateClientStatusInputSchema } from "./updateClientStatus.schema"; + +type UpdateClientStatusOptions = { + input: TUpdateClientStatusInputSchema; +}; + +export const updateClientStatusHandler = async ({ input }: UpdateClientStatusOptions) => { + const { clientId, status } = input; + + const oAuthClientRepository = await OAuthClientRepository.withGlobalPrisma(); + + // Get client with user info before updating + const clientWithUser = await oAuthClientRepository.findByClientIdIncludeUser(clientId); + + const updatedClient = await oAuthClientRepository.updateStatus(clientId, status); + + // Send approval notification email to user if approved + if (status === "APPROVED" && clientWithUser?.user) { + const t = await getTranslation("en", "common"); + const approvalNotification = new OAuthClientApprovedEmail({ + t, + userEmail: clientWithUser.user.email, + userName: clientWithUser.user.name, + clientName: updatedClient.name, + clientId: updatedClient.clientId, + }); + await approvalNotification.sendEmail(); + } + + return { + clientId: updatedClient.clientId, + name: updatedClient.name, + approvalStatus: updatedClient.approvalStatus, + }; +}; diff --git a/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.schema.ts b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.schema.ts new file mode 100644 index 00000000000000..7b134a54ff95df --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +import { OAuthClientApprovalStatus } from "@calcom/prisma/enums"; + +export const ZUpdateClientStatusInputSchema = z.object({ + clientId: z.string(), + status: z.nativeEnum(OAuthClientApprovalStatus), +}); + +export type TUpdateClientStatusInputSchema = z.infer; From c6c68d43ae137d6b14046e17ea29e8d9d16ee8f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:17:00 +0000 Subject: [PATCH 02/72] fix: re-export generateSecret for backward compatibility Co-Authored-By: peer@cal.com --- packages/lib/server/repository/oAuthClient.ts | 18 +++++++++--------- .../routers/viewer/oAuth/addClient.handler.ts | 5 ++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/lib/server/repository/oAuthClient.ts b/packages/lib/server/repository/oAuthClient.ts index 85f364b9153b07..820d0d089fc3b7 100644 --- a/packages/lib/server/repository/oAuthClient.ts +++ b/packages/lib/server/repository/oAuthClient.ts @@ -3,6 +3,14 @@ import { randomBytes, createHash } from "crypto"; import type { PrismaClient } from "@calcom/prisma"; import type { OAuthClientApprovalStatus } from "@calcom/prisma/enums"; +const hashSecretKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); + +// Generate a random secret - exported for backward compatibility +export const generateSecret = (secret = randomBytes(32).toString("hex")): [string, string] => [ + hashSecretKey(secret), + secret, +]; + export class OAuthClientRepository { constructor(private prismaClient: PrismaClient) {} @@ -101,7 +109,7 @@ export class OAuthClientRepository { let clientSecret: string | undefined; let hashedSecret: string | undefined; if (!enablePkce) { - const [hashed, plain] = this.generateSecret(); + const [hashed, plain] = generateSecret(); hashedSecret = hashed; clientSecret = plain; } @@ -161,12 +169,4 @@ export class OAuthClientRepository { where: { clientId }, }); } - - private hashSecretKey(apiKey: string): string { - return createHash("sha256").update(apiKey).digest("hex"); - } - - private generateSecret(secret = randomBytes(32).toString("hex")): [string, string] { - return [this.hashSecretKey(secret), secret]; - } } diff --git a/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts b/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts index 6dc87b7f27d563..fc4c59608b34bc 100644 --- a/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts @@ -1,7 +1,10 @@ -import { OAuthClientRepository } from "@calcom/lib/server/repository/oAuthClient"; +import { OAuthClientRepository, generateSecret } from "@calcom/lib/server/repository/oAuthClient"; import type { TAddClientInputSchema } from "./addClient.schema"; +// Re-export generateSecret for backward compatibility +export { generateSecret }; + type AddClientOptions = { input: TAddClientInputSchema; }; From a3f1bc915d4c300ba5ecaacd4773224d533cbfbe Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:43:22 +0000 Subject: [PATCH 03/72] feat: make logo mandatory and list items clickable for OAuth clients Co-Authored-By: peer@cal.com --- .../settings/developer/oauth-clients-view.tsx | 169 ++++++++++++++---- apps/web/public/static/locales/en/common.json | 3 + .../viewer/oAuth/submitClient.schema.ts | 2 +- 3 files changed, 140 insertions(+), 34 deletions(-) diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx index 67d82fedc29517..dc98e359f06e81 100644 --- a/apps/web/modules/settings/developer/oauth-clients-view.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -29,11 +29,20 @@ const OAuthClientsView = () => { const utils = trpc.useUtils(); const [showDialog, setShowDialog] = useState(false); const [logo, setLogo] = useState(""); + const [logoError, setLogoError] = useState(false); const [submittedClient, setSubmittedClient] = useState<{ clientId: string; name: string; isPkceEnabled?: boolean; } | null>(null); + const [selectedClient, setSelectedClient] = useState<{ + clientId: string; + clientSecret?: string | null; + name: string; + logo?: string | null; + redirectUri: string; + approvalStatus: string; + } | null>(null); const oAuthForm = useForm({ defaultValues: { @@ -62,6 +71,11 @@ const OAuthClientsView = () => { }); const handleSubmit = (values: FormValues) => { + if (!values.logo) { + setLogoError(true); + return; + } + setLogoError(false); submitMutation.mutate({ name: values.name, redirectUri: values.redirectUri, @@ -74,9 +88,14 @@ const OAuthClientsView = () => { setShowDialog(false); setSubmittedClient(null); setLogo(""); + setLogoError(false); oAuthForm.reset(); }; + const handleCloseClientDialog = () => { + setSelectedClient(null); + }; + const getStatusBadge = (status: string) => { switch (status) { case "APPROVED": @@ -106,9 +125,17 @@ const OAuthClientsView = () => { {oAuthClients.map((client, index) => (
+ }`} + onClick={() => setSelectedClient(client)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + setSelectedClient(client); + } + }}>
{
{getStatusBadge(client.approvalStatus)} - {client.approvalStatus === "APPROVED" && ( - -
))} @@ -195,24 +210,33 @@ const OAuthClientsView = () => { -
- } - className="mr-5 items-center" - imageSrc={logo} - size="lg" - /> - { - setLogo(newLogo); - oAuthForm.setValue("logo", newLogo); - }} - imageSrc={logo} - /> +
+ +
+ } + className="mr-5 items-center" + imageSrc={logo} + size="lg" + /> + { + setLogo(newLogo); + setLogoError(false); + oAuthForm.setValue("logo", newLogo); + }} + imageSrc={logo} + /> +
+ {logoError && ( +

{t("logo_required")}

+ )}
@@ -251,6 +275,85 @@ const OAuthClientsView = () => { )} + + !open && handleCloseClientDialog()}> + + {selectedClient && ( +
+
+ } + size="lg" + /> +
+
{selectedClient.name}
+
{selectedClient.redirectUri}
+
+ {getStatusBadge(selectedClient.approvalStatus)} +
+ +
+
{t("client_id")}
+
+ + {selectedClient.clientId} + + + + +
+
+ + {selectedClient.approvalStatus === "APPROVED" && selectedClient.clientSecret && ( +
+
{t("client_secret")}
+
+ + {selectedClient.clientSecret} + + + + +
+

{t("client_secret_warning")}

+
+ )} + + {selectedClient.approvalStatus === "PENDING" && ( +

{t("oauth_client_pending_approval")}

+ )} + + {selectedClient.approvalStatus === "REJECTED" && ( +

{t("oauth_client_rejected")}

+ )} + + + + +
+ )} +
+
); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 9db9a36e109203..0b961914ab5f1b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1016,6 +1016,9 @@ "oauth_client_status_updated": "OAuth client {{name}} status updated to {{status}}", "oauth_client_status_update_error": "Failed to update OAuth client status", "oauth_client_pending_approval": "Your OAuth client is pending approval. You will receive an email notification once it has been reviewed.", + "oauth_client_rejected": "Your OAuth client submission was rejected. Please contact support for more information.", + "logo_required": "Logo is required", + "client_secret_warning": "Store this secret securely. You won't be able to see it again.", "add_oauth_client": "Add OAuth Client", "add_oauth_client_description": "Add a new OAuth client", "no_oauth_clients": "No OAuth Clients", diff --git a/packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts b/packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts index 8c4d7208c5984d..a79c7af25f5aa1 100644 --- a/packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts +++ b/packages/trpc/server/routers/viewer/oAuth/submitClient.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const ZSubmitClientInputSchema = z.object({ name: z.string().min(1, "Client name is required"), redirectUri: z.string().url("Must be a valid URL"), - logo: z.string().optional(), + logo: z.string().min(1, "Logo is required"), enablePkce: z.boolean().optional().default(false), }); From b1b39a2ab3c69739c72588b72a99c97089489eb6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:46:05 +0000 Subject: [PATCH 04/72] fix: add missing translation keys and remove client secret from details dialog Co-Authored-By: peer@cal.com --- .../settings/developer/oauth-clients-view.tsx | 25 ++----------------- apps/web/public/static/locales/en/common.json | 2 ++ 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx index dc98e359f06e81..10999853cecc96 100644 --- a/apps/web/modules/settings/developer/oauth-clients-view.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -37,7 +37,6 @@ const OAuthClientsView = () => { } | null>(null); const [selectedClient, setSelectedClient] = useState<{ clientId: string; - clientSecret?: string | null; name: string; logo?: string | null; redirectUri: string; @@ -315,28 +314,8 @@ const OAuthClientsView = () => { - {selectedClient.approvalStatus === "APPROVED" && selectedClient.clientSecret && ( -
-
{t("client_secret")}
-
- - {selectedClient.clientSecret} - - - - -
-

{t("client_secret_warning")}

-
+ {selectedClient.approvalStatus === "APPROVED" && ( +

{t("oauth_client_approved_note")}

)} {selectedClient.approvalStatus === "PENDING" && ( diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 0b961914ab5f1b..a251c5e7a73d8f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1017,6 +1017,8 @@ "oauth_client_status_update_error": "Failed to update OAuth client status", "oauth_client_pending_approval": "Your OAuth client is pending approval. You will receive an email notification once it has been reviewed.", "oauth_client_rejected": "Your OAuth client submission was rejected. Please contact support for more information.", + "oauth_client_approved_note": "Your OAuth client is approved and ready to use. The client secret was shown only once when the client was approved.", + "logo": "Logo", "logo_required": "Logo is required", "client_secret_warning": "Store this secret securely. You won't be able to see it again.", "add_oauth_client": "Add OAuth Client", From 03c7d1336247c30ccab41386a1216c686a863a6f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 09:23:55 +0000 Subject: [PATCH 05/72] fix: address cubic AI reviewer comments - Remove duplicate 'there' JSON key in common.json - Add select clause to findByUserId to avoid exposing clientSecret - Add @@index([userId]) to OAuthClient model for query performance - Update migration to include the index Co-Authored-By: peer@cal.com --- apps/web/public/static/locales/en/common.json | 3 +-- packages/lib/server/repository/oAuthClient.ts | 10 ++++++++++ .../migration.sql | 3 +++ packages/prisma/schema.prisma | 2 ++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a251c5e7a73d8f..fe647abf6e1ca1 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1042,8 +1042,7 @@ "rejected": "Rejected", "pending": "Pending", "hi_user": "Hi {{name}}", - "there": "there", - "admin_oauth_notification_email_subject": "New OAuth Client Submission: {{clientName}}", + "admin_oauth_notification_email_subject":"New OAuth Client Submission: {{clientName}}", "admin_oauth_notification_email_title": "New OAuth Client: {{clientName}}", "admin_oauth_notification_email_body": "A new OAuth client has been submitted by {{submitterEmail}} and is awaiting your review.", "admin_oauth_notification_email_cta": "Review OAuth Clients", diff --git a/packages/lib/server/repository/oAuthClient.ts b/packages/lib/server/repository/oAuthClient.ts index 820d0d089fc3b7..58738c36d3f6ee 100644 --- a/packages/lib/server/repository/oAuthClient.ts +++ b/packages/lib/server/repository/oAuthClient.ts @@ -52,6 +52,16 @@ export class OAuthClientRepository { async findByUserId(userId: number) { return this.prismaClient.oAuthClient.findMany({ where: { userId }, + select: { + clientId: true, + redirectUri: true, + name: true, + logo: true, + clientType: true, + approvalStatus: true, + userId: true, + createdAt: true, + }, orderBy: { createdAt: "desc" }, }); } diff --git a/packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql b/packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql index a15361895fb033..b651d75e888638 100644 --- a/packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql +++ b/packages/prisma/migrations/20251202165107_add_oauth_client_approval_workflow/migration.sql @@ -8,3 +8,6 @@ ADD COLUMN "userId" INTEGER; -- AddForeignKey ALTER TABLE "public"."OAuthClient" ADD CONSTRAINT "OAuthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "OAuthClient_userId_idx" ON "public"."OAuthClient"("userId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 517cd7b4fd300f..5812a5bb28d51e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1761,6 +1761,8 @@ model OAuthClient { userId Int? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) + + @@index([userId]) } model AccessCode { From 582cef5298afa5dec625e0193f252bc809bdcaa4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:40:17 +0000 Subject: [PATCH 06/72] fix: address PR review comments - fix indentation and use useCopy hook Co-Authored-By: eunjae@cal.com --- .../admin/oauth-clients-admin-view.tsx | 17 ++- .../settings/developer/oauth-clients-view.tsx | 12 +- apps/web/public/static/locales/en/common.json | 110 +++++++++--------- 3 files changed, 74 insertions(+), 65 deletions(-) diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx index a8863759459fe2..ae48d4e1d562d5 100644 --- a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx +++ b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; +import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Avatar } from "@calcom/ui/components/avatar"; @@ -36,6 +37,7 @@ type FormValues = { export default function OAuthClientsAdminView() { const { t } = useLocale(); + const { copyToClipboard } = useCopy(); const utils = trpc.useUtils(); const [showAddDialog, setShowAddDialog] = useState(false); const [logo, setLogo] = useState(""); @@ -201,8 +203,9 @@ export default function OAuthClientsAdminView() { )} { - navigator.clipboard.writeText(client.clientId); - showToast(t("client_id_copied"), "success"); + copyToClipboard(client.clientId, { + onSuccess: () => showToast(t("client_id_copied"), "success"), + }); }}> {t("copy_client_id")} @@ -305,8 +308,9 @@ export default function OAuthClientsAdminView() { @@ -321,9 +324,9 @@ export default function OAuthClientsAdminView() { {createdClient.clientSecret && ( <> -
{t("client_secret")}
+
{t("client_secret")}
- + {createdClient.clientSecret} @@ -334,7 +337,8 @@ export default function OAuthClientsAdminView() { }); }} type="button" - className="rounded-l-none text-base" + size="sm" + className="rounded-l-none" StartIcon="clipboard"> {t("copy")} diff --git a/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx b/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx new file mode 100644 index 00000000000000..e4be71be230ac5 --- /dev/null +++ b/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; + +export const OAuthClientsSkeleton = () => { + return ( + +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ + +
+
+
+ +
+
+
+ ))} +
+ + ); +}; diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx index c0848bb7f8c490..51a0ddaff182b7 100644 --- a/apps/web/modules/settings/developer/oauth-clients-view.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -7,6 +7,8 @@ import { Dialog } from "@calcom/features/components/controlled-dialog"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; + +import { OAuthClientsSkeleton } from "./oauth-clients-skeleton"; import { Avatar } from "@calcom/ui/components/avatar"; import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; @@ -110,7 +112,7 @@ const OAuthClientsView = () => { }; if (isLoading) { - return
{t("loading")}
; + return ; } return ( @@ -250,9 +252,9 @@ const OAuthClientsView = () => { ) : (
{submittedClient.name}
-
{t("client_id")}
+
{t("client_id")}
- + {submittedClient.clientId} @@ -263,7 +265,8 @@ const OAuthClientsView = () => { }); }} type="button" - className="rounded-l-none text-base" + size="sm" + className="rounded-l-none" StartIcon="clipboard"> {t("copy")} @@ -297,9 +300,9 @@ const OAuthClientsView = () => {
-
{t("client_id")}
+
{t("client_id")}
- + {selectedClient.clientId} @@ -310,7 +313,8 @@ const OAuthClientsView = () => { }); }} type="button" - className="rounded-l-none text-base" + size="sm" + className="rounded-l-none" StartIcon="clipboard"> {t("copy")} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 913cc29f942397..af4cfdbdc181c6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1033,6 +1033,7 @@ "authentication_mode": "Authentication Mode", "use_pkce": "Use PKCE (recommended for mobile/SPA applications)", "upload_logo": "Upload Logo", + "client_id": "Client ID", "copy_client_id": "Copy Client ID", "client_id_copied": "Client ID copied to clipboard", "submitted_by": "Submitted By", From dfd5bc22bb86c0d210019160438baf86772c5ebf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:04:00 +0000 Subject: [PATCH 13/72] fix: improve skeleton loader to match actual OAuth client list structure - Remove divide-y from container and use conditional border-b on rows - Match the exact structure from oauth-clients-view.tsx L126-160 - Use proper spacing for text elements (mt-1 instead of space-y-2) Co-Authored-By: eunjae@cal.com --- .../developer/oauth-clients-skeleton.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx b/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx index e4be71be230ac5..13a66837f3ea76 100644 --- a/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx @@ -2,25 +2,31 @@ import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; +const skeletonItems = [1, 2, 3]; + export const OAuthClientsSkeleton = () => { return (
-
- {[1, 2, 3].map((i) => ( -
+
+ {skeletonItems.map((i, index) => ( +
-
+
- +
-
+
))} From 7500de877dec0e25c3e81bd2ef95efe8d0382efe Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 9 Dec 2025 15:12:05 +0100 Subject: [PATCH 14/72] fix skeleton --- .../settings/developer/oauth-clients-skeleton.tsx | 12 ++++++------ .../settings/developer/oauth-clients-view.tsx | 15 +++++---------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx b/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx index 13a66837f3ea76..8a058191508998 100644 --- a/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx @@ -2,7 +2,7 @@ import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; -const skeletonItems = [1, 2, 3]; +const skeletonItems = Array(3).fill(undefined); export const OAuthClientsSkeleton = () => { return ( @@ -17,16 +17,16 @@ export const OAuthClientsSkeleton = () => { className={`flex items-center justify-between p-4 ${ index !== skeletonItems.length - 1 ? "border-subtle border-b" : "" }`}> -
-
-
+
+
+
- +
-
+
))} diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx index 51a0ddaff182b7..3626143fa633fb 100644 --- a/apps/web/modules/settings/developer/oauth-clients-view.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -7,8 +7,6 @@ import { Dialog } from "@calcom/features/components/controlled-dialog"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; - -import { OAuthClientsSkeleton } from "./oauth-clients-skeleton"; import { Avatar } from "@calcom/ui/components/avatar"; import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; @@ -20,6 +18,8 @@ import { ImageUploader } from "@calcom/ui/components/image-uploader"; import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; +import { OAuthClientsSkeleton } from "./oauth-clients-skeleton"; + type FormValues = { name: string; redirectUri: string; @@ -128,7 +128,7 @@ const OAuthClientsView = () => { {oAuthClients.map((client, index) => (
setSelectedClient(client)} @@ -179,10 +179,7 @@ const OAuthClientsView = () => { submittedClient ? t("oauth_client_submitted_description") : t("new_oauth_client_description") }> {!submittedClient ? ( -
+ { imageSrc={logo} />
- {logoError && ( -

{t("logo_required")}

- )} + {logoError &&

{t("logo_required")}

}
From a1d86ac7eb60bc6298f257d2109a8f56ed0e3090 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 9 Dec 2025 15:18:35 +0100 Subject: [PATCH 15/72] rename the selected oauth client dialog --- apps/web/modules/settings/developer/oauth-clients-view.tsx | 4 ++-- apps/web/public/static/locales/en/common.json | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx index 3626143fa633fb..f9c83258f0c905 100644 --- a/apps/web/modules/settings/developer/oauth-clients-view.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -277,7 +277,7 @@ const OAuthClientsView = () => { !open && handleCloseClientDialog()}> - + {selectedClient && (
@@ -290,8 +290,8 @@ const OAuthClientsView = () => {
{selectedClient.name}
{selectedClient.redirectUri}
+ {getStatusBadge(selectedClient.approvalStatus)}
- {getStatusBadge(selectedClient.approvalStatus)}
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index af4cfdbdc181c6..fb2dd5fccd2759 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1053,7 +1053,7 @@ "oauth_client_approved_email_body": "Great news! Your OAuth client has been approved and is now ready to use.", "oauth_client_approved_email_cta": "View Your OAuth Clients", "oauth_client_approved_email_footer": "You can now use your client ID and secret to integrate with Cal.com.", - "create_new_team_description":"Create a new team to collaborate with users.", + "create_new_team_description": "Create a new team to collaborate with users.", "create_new_team": "Create a new team", "booking_redirect_uri": "URL of your booking page", "booking_cancel_redirect_uri": "URL of the page where your users can cancel their booking", @@ -4102,5 +4102,6 @@ "booking_response": "Booking Response", "list_view": "List view", "calendar_view": "Calendar view", + "oauth_client": "OAuth Client", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } From bc6f449ea2f4a170812675773bd7eebb5eed0012 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:35:10 +0000 Subject: [PATCH 16/72] fix: address PR feedback - admin auth, dropdown styling, sidebar label - Add defense-in-depth admin authorization check in updateClientStatus handler - Fix broken dropdown menu by using DropdownItem with StartIcon prop - Fix sidebar menu label from 'oAuth' to 'oauth_clients' to match developer view Co-Authored-By: eunjae@cal.com --- .../SettingsLayoutAppDirClient.tsx | 2 +- .../admin/oauth-clients-admin-view.tsx | 60 ++++++++++++------- .../server/routers/viewer/oAuth/_router.tsx | 3 +- .../oAuth/updateClientStatus.handler.ts | 16 ++++- 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 88d71bf397215e..2aa1f14e752fad 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -150,7 +150,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { { name: "users", href: "/settings/admin/users", trackingMetadata: { section: "admin", page: "users" } }, { name: "organizations", href: "/settings/admin/organizations", trackingMetadata: { section: "admin", page: "organizations" } }, { name: "lockedSMS", href: "/settings/admin/lockedSMS", trackingMetadata: { section: "admin", page: "locked_sms" } }, - { name: "oAuth", href: "/settings/admin/oAuth", trackingMetadata: { section: "admin", page: "oauth" } }, + { name: "oauth_clients", href: "/settings/admin/oAuth", trackingMetadata: { section: "admin", page: "oauth" } }, { name: "Workspace Platforms", href: "/settings/admin/workspace-platforms", trackingMetadata: { section: "admin", page: "workspace_platforms" } }, { name: "Playground", href: "/settings/admin/playground", trackingMetadata: { section: "admin", page: "playground" } }, ], diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx index 18138d37363e32..7b2c94a5a14f16 100644 --- a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx +++ b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx @@ -19,6 +19,7 @@ import { } from "@calcom/ui/components/dialog"; import { Dropdown, + DropdownItem, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, @@ -181,36 +182,55 @@ export default function OAuthClientsAdminView() { {client.approvalStatus === "PENDING" && ( <> - handleApprove(client.clientId)}> - - {t("approve")} + + handleApprove(client.clientId)}> + {t("approve")} + - handleReject(client.clientId)}> - - {t("reject")} + + handleReject(client.clientId)}> + {t("reject")} + )} {client.approvalStatus === "REJECTED" && ( - handleApprove(client.clientId)}> - - {t("approve")} + + handleApprove(client.clientId)}> + {t("approve")} + )} {client.approvalStatus === "APPROVED" && ( - handleReject(client.clientId)}> - - {t("reject")} + + handleReject(client.clientId)}> + {t("reject")} + )} - { - copyToClipboard(client.clientId, { - onSuccess: () => showToast(t("client_id_copied"), "success"), - }); - }}> - - {t("copy_client_id")} + + { + copyToClipboard(client.clientId, { + onSuccess: () => showToast(t("client_id_copied"), "success"), + }); + }}> + {t("copy_client_id")} + diff --git a/packages/trpc/server/routers/viewer/oAuth/_router.tsx b/packages/trpc/server/routers/viewer/oAuth/_router.tsx index ddb912769a28c1..622d4803dbe733 100644 --- a/packages/trpc/server/routers/viewer/oAuth/_router.tsx +++ b/packages/trpc/server/routers/viewer/oAuth/_router.tsx @@ -69,10 +69,11 @@ export const oAuthRouter = router({ }); }), - updateClientStatus: authedAdminProcedure.input(ZUpdateClientStatusInputSchema).mutation(async ({ input }) => { + updateClientStatus: authedAdminProcedure.input(ZUpdateClientStatusInputSchema).mutation(async ({ ctx, input }) => { const { updateClientStatusHandler } = await import("./updateClientStatus.handler"); return updateClientStatusHandler({ + ctx, input, }); }), diff --git a/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts index 49de8ef490a741..5bd9940499545c 100644 --- a/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts @@ -1,16 +1,30 @@ +import { TRPCError } from "@trpc/server"; + import { sendOAuthClientApprovedNotification } from "@calcom/emails/oauth-email-service"; import { getTranslation } from "@calcom/lib/server/i18n"; import { OAuthClientRepository } from "@calcom/lib/server/repository/oAuthClient"; +import { UserPermissionRole } from "@calcom/prisma/enums"; import type { TUpdateClientStatusInputSchema } from "./updateClientStatus.schema"; type UpdateClientStatusOptions = { + ctx: { + user: { + id: number; + role: UserPermissionRole; + }; + }; input: TUpdateClientStatusInputSchema; }; -export const updateClientStatusHandler = async ({ input }: UpdateClientStatusOptions) => { +export const updateClientStatusHandler = async ({ ctx, input }: UpdateClientStatusOptions) => { const { clientId, status } = input; + // Defense-in-depth: Only instance admins can update OAuth client status + if (ctx.user.role !== UserPermissionRole.ADMIN) { + throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can update OAuth client status" }); + } + const oAuthClientRepository = await OAuthClientRepository.withGlobalPrisma(); // Get client with user info before updating From 523db2fa43da6d1a1103043b1c89325634324bf3 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 9 Dec 2025 15:55:21 +0100 Subject: [PATCH 17/72] update common.json --- apps/web/public/static/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fb2dd5fccd2759..097516dae951be 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4103,5 +4103,6 @@ "list_view": "List view", "calendar_view": "Calendar view", "oauth_client": "OAuth Client", + "actions": "Actions", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } From 661ffa758fbd330ecaebaad3f4b43217188008ad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 10:05:56 +0000 Subject: [PATCH 18/72] feat: show client secret in approval email for confidential OAuth clients - Add regenerateSecret method to OAuthClientRepository - Regenerate secret when admin approves a PENDING confidential client - Include client secret in approval notification email - Add one-time warning message about storing the secret securely - Only regenerate on first approval (not re-approvals) Co-Authored-By: eunjae@cal.com --- apps/web/public/static/locales/en/common.json | 1 + .../OAuthClientApprovedNotificationEmail.tsx | 35 +++++++++++++++++-- .../oauth-client-approved-notification.ts | 2 ++ packages/lib/server/repository/oAuthClient.ts | 14 ++++++++ .../oAuth/updateClientStatus.handler.ts | 13 +++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 097516dae951be..9eeb3dcf39ce12 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1053,6 +1053,7 @@ "oauth_client_approved_email_body": "Great news! Your OAuth client has been approved and is now ready to use.", "oauth_client_approved_email_cta": "View Your OAuth Clients", "oauth_client_approved_email_footer": "You can now use your client ID and secret to integrate with Cal.com.", + "oauth_client_secret_one_time_warning": "Important: This client secret is shown only once. Store it securely now - you will not be able to view it again.", "create_new_team_description": "Create a new team to collaborate with users.", "create_new_team": "Create a new team", "booking_redirect_uri": "URL of your booking page", diff --git a/packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx b/packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx index bad702bfd2ccff..bdc8ca6f6a6239 100644 --- a/packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx +++ b/packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx @@ -9,12 +9,14 @@ type OAuthClientApprovedNotification = { userName: string | null; clientName: string; clientId: string; + clientSecret?: string; }; export const OAuthClientApprovedNotificationEmail = ({ userName, clientName, clientId, + clientSecret, language, }: OAuthClientApprovedNotification) => { return ( @@ -66,13 +68,42 @@ export const OAuthClientApprovedNotificationEmail = ({ {clientName} - {language("client_id")} - + + {language("client_id")} + + {clientId} + {clientSecret && ( + + {language("client_secret")} + + {clientSecret} + + + )} + {clientSecret && ( +

+ {language("oauth_client_secret_one_time_warning")} +

+ )}

{language("oauth_client_approved_email_footer")}

diff --git a/packages/emails/templates/oauth-client-approved-notification.ts b/packages/emails/templates/oauth-client-approved-notification.ts index b476efd57ac0eb..a3cf5d2afc26c9 100644 --- a/packages/emails/templates/oauth-client-approved-notification.ts +++ b/packages/emails/templates/oauth-client-approved-notification.ts @@ -11,6 +11,7 @@ export type OAuthClientApprovedNotification = { userName: string | null; clientName: string; clientId: string; + clientSecret?: string; }; export default class OAuthClientApprovedEmail extends BaseEmail { @@ -31,6 +32,7 @@ export default class OAuthClientApprovedEmail extends BaseEmail { userName: this.input.userName, clientName: this.input.clientName, clientId: this.input.clientId, + clientSecret: this.input.clientSecret, language: this.input.t, }), text: this.getTextBody(), diff --git a/packages/lib/server/repository/oAuthClient.ts b/packages/lib/server/repository/oAuthClient.ts index 58738c36d3f6ee..e783bf2e8bc87b 100644 --- a/packages/lib/server/repository/oAuthClient.ts +++ b/packages/lib/server/repository/oAuthClient.ts @@ -160,6 +160,20 @@ export class OAuthClientRepository { }); } + async regenerateSecret(clientId: string) { + const [hashed, plain] = generateSecret(); + const updated = await this.prismaClient.oAuthClient.update({ + where: { clientId }, + data: { clientSecret: hashed }, + select: { + clientId: true, + name: true, + clientType: true, + }, + }); + return { ...updated, clientSecret: plain }; + } + async update( clientId: string, data: { diff --git a/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts index 5bd9940499545c..146ed30e8869cb 100644 --- a/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/updateClientStatus.handler.ts @@ -35,12 +35,25 @@ export const updateClientStatusHandler = async ({ ctx, input }: UpdateClientStat // Send approval notification email to user if approved if (status === "APPROVED" && clientWithUser?.user) { const t = await getTranslation("en", "common"); + + // Only regenerate secret for confidential clients that are being approved for the first time + // (transitioning from non-APPROVED status to APPROVED) + const isFirstApproval = clientWithUser.approvalStatus !== "APPROVED"; + const isConfidentialClient = updatedClient.clientType === "CONFIDENTIAL"; + + let clientSecret: string | undefined; + if (isFirstApproval && isConfidentialClient) { + const regenerated = await oAuthClientRepository.regenerateSecret(clientId); + clientSecret = regenerated.clientSecret; + } + await sendOAuthClientApprovedNotification({ t, userEmail: clientWithUser.user.email, userName: clientWithUser.user.name, clientName: updatedClient.name, clientId: updatedClient.clientId, + clientSecret, }); } From 5b0b738310303e48937a26a8ed80f118f1b21f55 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:46:59 +0000 Subject: [PATCH 19/72] feat: add Website URL field, fix logo styling, show client secret after approval - Add Website URL field to OAuth client forms (admin and developer views) - Fix Upload Logo section styling by wrapping in Label div with proper gap - Display client secret in dialog after admin approves a confidential OAuth client - Add websiteUrl field to Prisma schema with migration - Update tRPC handlers and repository to support websiteUrl - Add translation keys for new UI elements Co-Authored-By: peer@cal.com --- .../admin/oauth-clients-admin-view.tsx | 124 +++++++++++++++--- .../settings/developer/oauth-clients-view.tsx | 15 ++- apps/web/public/static/locales/en/common.json | 3 + packages/lib/server/repository/oAuthClient.ts | 7 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../routers/viewer/oAuth/addClient.handler.ts | 3 +- .../routers/viewer/oAuth/addClient.schema.ts | 1 + .../viewer/oAuth/submitClient.handler.ts | 3 +- .../viewer/oAuth/submitClient.schema.ts | 1 + .../oAuth/updateClientStatus.handler.ts | 1 + 11 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 packages/prisma/migrations/20251222143926_add_website_url_to_oauth_client/migration.sql diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx index 7b2c94a5a14f16..64fa4c9ecdc94a 100644 --- a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx +++ b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx @@ -34,6 +34,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip"; type FormValues = { name: string; redirectUri: string; + websiteUrl: string; logo: string; enablePkce: boolean; }; @@ -49,11 +50,17 @@ export default function OAuthClientsAdminView() { clientSecret?: string; name: string; } | null>(null); + const [approvedClient, setApprovedClient] = useState<{ + clientId: string; + clientSecret?: string; + name: string; + } | null>(null); const oAuthForm = useForm({ defaultValues: { name: "", redirectUri: "", + websiteUrl: "", logo: "", enablePkce: false, }, @@ -83,6 +90,14 @@ export default function OAuthClientsAdminView() { "success" ); utils.viewer.oAuth.listClients.invalidate(); + // Show client secret dialog if a secret was generated during approval + if (data.clientSecret) { + setApprovedClient({ + clientId: data.clientId, + clientSecret: data.clientSecret, + name: data.name, + }); + } }, onError: (error) => { showToast(`${t("oauth_client_status_update_error")}: ${error.message}`, "error"); @@ -93,6 +108,7 @@ export default function OAuthClientsAdminView() { addMutation.mutate({ name: values.name, redirectUri: values.redirectUri, + websiteUrl: values.websiteUrl || undefined, logo: values.logo, enablePkce: values.enablePkce, }); @@ -105,6 +121,10 @@ export default function OAuthClientsAdminView() { oAuthForm.reset(); }; + const handleCloseApprovedDialog = () => { + setApprovedClient(null); + }; + const handleApprove = (clientId: string) => { updateStatusMutation.mutate({ clientId, status: "APPROVED" }); }; @@ -279,6 +299,14 @@ export default function OAuthClientsAdminView() { required /> + +
-
- } - className="mr-5 items-center" - imageSrc={logo} - size="lg" - /> - { - setLogo(newLogo); - oAuthForm.setValue("logo", newLogo); - }} - imageSrc={logo} - /> +
+ +
+ } + imageSrc={logo} + size="lg" + /> + { + setLogo(newLogo); + oAuthForm.setValue("logo", newLogo); + }} + imageSrc={logo} + /> +
@@ -374,6 +404,64 @@ export default function OAuthClientsAdminView() { )}
+ + !open && handleCloseApprovedDialog()}> + + {approvedClient && ( +
+
{approvedClient.name}
+
{t("client_id")}
+
+ + {approvedClient.clientId} + + + + +
+ {approvedClient.clientSecret && ( + <> +
{t("client_secret")}
+
+ + {approvedClient.clientSecret} + + + + +
+
{t("copy_client_secret_info")}
+ + )} + + + +
+ )} +
+
); } diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx index f9c83258f0c905..51182399912391 100644 --- a/apps/web/modules/settings/developer/oauth-clients-view.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -23,6 +23,7 @@ import { OAuthClientsSkeleton } from "./oauth-clients-skeleton"; type FormValues = { name: string; redirectUri: string; + websiteUrl: string; logo: string; enablePkce: boolean; }; @@ -51,6 +52,7 @@ const OAuthClientsView = () => { defaultValues: { name: "", redirectUri: "", + websiteUrl: "", logo: "", enablePkce: false, }, @@ -82,6 +84,7 @@ const OAuthClientsView = () => { submitMutation.mutate({ name: values.name, redirectUri: values.redirectUri, + websiteUrl: values.websiteUrl, logo: values.logo, enablePkce: values.enablePkce, }); @@ -197,6 +200,15 @@ const OAuthClientsView = () => { required /> + +