From a03722fd7f5a934f366ac43dba93e42614a5f726 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 21 Jan 2026 16:23:51 +0100 Subject: [PATCH] feat: add OAuth client developer settings page with approval workflow (#25556) * 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 * fix: re-export generateSecret for backward compatibility Co-Authored-By: peer@cal.com * feat: make logo mandatory and list items clickable for OAuth clients Co-Authored-By: peer@cal.com * fix: add missing translation keys and remove client secret from details dialog Co-Authored-By: peer@cal.com * 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 * fix: address PR review comments - fix indentation and use useCopy hook Co-Authored-By: eunjae@cal.com * fix: change react-dom/server import to fix Turbopack compatibility Co-Authored-By: eunjae@cal.com * Revert "fix: change react-dom/server import to fix Turbopack compatibility" This reverts commit c3e0b709c2d88fd221143cb4ce9cd25bb8c94277. * fix: use email service pattern for OAuth client notifications Co-Authored-By: eunjae@cal.com * fix: add try-catch around email sending to handle Turbopack react-dom/server issue Co-Authored-By: eunjae@cal.com * Revert "fix: add try-catch around email sending to handle Turbopack react-dom/server issue" This reverts commit fc9d47cd773505ebc5ee2696718aad4a8a98be77. * fix: improve OAuth client UI with skeleton loaders and smaller dialog styling - Replace 'Loading...' text with proper skeleton loaders in both developer and admin OAuth client views - Make client_id and copy button smaller in dialogs using size='sm' and text-sm styling - Add 'client_id' translation key to common.json for proper i18n Co-Authored-By: eunjae@cal.com * 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 * fix skeleton * rename the selected oauth client dialog * 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 * update common.json * 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 * 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 * fix: move clientSecret variable declaration outside if block for proper scoping Co-Authored-By: peer@cal.com * refactor: dont expose client secret in emails * refactor: dont regenerate secret upon status change * refactor: reuse existing hash function * refactor: rename admin/oAuth to admin/oauth page * refactor: deduplicate oauth repositories * refactor: remove withGlobalPrisma from oauth repository * refactor: developer oauth page * refactor: oauth status by default accepted * refactor: request oauth status when creating * refactor ux * fix: address Cubic AI code review feedback - Add purpose field to plain text email body for accessibility - Convert NewOAuthClientButton to inline JSX to avoid React anti-pattern - Trigger re-approval when redirectUri changes for security - Add e.preventDefault() for Space key to prevent page scroll - Change default approvalStatus to PENDING for defense-in-depth - Use oauth_clients translation key for consistency - Add meaningful alt text to Avatar for accessibility - Remove onClick from DialogClose to prevent double-run close effects - Return NOT_FOUND for non-owner delete to prevent resource enumeration Co-Authored-By: unknown <> * common.json file * refactor: delete all prisma migrations * refactor: have just 1 prisma migration * revert: some devin changes * fix: typecheck * test: owner OAuth crud * test: admin OAuth approval / rejection * fix: address Cubic AI review feedback (confidence 9/10 issues) - schema.prisma: Remove @default("") from purpose field to make it required - schema.prisma: Use UTC-aware timezone expression for createdAt default - OAuthClientFormFields.tsx: Localize redirect URI placeholder using t() - common.json: Add redirect_uri_placeholder translation key Co-Authored-By: unknown <> * cubic changes * refactor: dont log sensitive info and rethrow error * cubic feedback * refactor: make oauth client purpose optional * refactor: admin/oauth not allowed if not logged in * refactor: admin view skeleton * refactor: rename state * refactor: get rid of redundant mapping * refactor: remove redundant handler * refactor: remove redundant handler * refactor: re-usable new oauth client button * refactor: dialogs * refactor: modals * refactor: handler names, dialog, skeleton * fix: purpose being null * refactor: rename handler and delete old oauth admin page * fix: purpose in submission * refactor: handler names * refactor: rename * refactor: update handler * refactor: rename approvalStatus -> status * refactor: simplify modal * refactor: name * dont require repproval if redirectUri changes * fix: remove integration sync index creation * refactor: require re-approval if redirectUri updated * fix: flaky e2e test * fix: flaky e2e test * fix: flaky e2e test * fix: remove duplicate common.json keys * refactor: replace team@cal.com with SUPPORT_MAIL_ADDRESS * refactor: generate client secret on handler level * fix: authorization code only available to approved clients * refactor: cubic review dont display exclamation * refactor: cubic review website_url in common json * fix: dont default in ui to approved status * refactor: optiona logo in schema create handler * fix: tests * fix: tests * fix: /authorize redirect if client not approved or show error * test: authorize page with invalid client id * refactor: dont allow refreshing tokens unless approved client * fix: flaky e2e test * fix: flaky e2e test * fix: flaky e2e test * fix: flaky e2e test * fix: flaky e2e test * fix: flaky e2e test * chore: warn that pending client is not usable * fix: approve and reject buttons * fix: /authorize show error if client not approved * refactor: info message about editing oauth client and status * change info alert to warning * try to fix ci test * debug: failing e2e test * fix: improve session propagation in oauth-client-admin E2E test - Add navigateToAdminOAuthPage helper that waits for listClients API call - If the API call doesn't arrive (session issue), reload page to force session refresh - This fixes the CI flakiness where admin page wasn't loading due to session not having ADMIN role Co-Authored-By: lauris@cal.com * fix: register waitForResponse before navigating in E2E test - Register the listClients waitForResponse promise BEFORE page.goto() - This ensures the response isn't missed during page load - Also register the promise before reload in the catch block Co-Authored-By: lauris@cal.com * fix: rename oAuth folder to oauth for case-sensitive filesystems The admin OAuth page route was at /settings/admin/oAuth (capital A) but the code references /settings/admin/oauth (lowercase). This caused 404 errors on case-sensitive filesystems (Linux). Also improved the E2E test navigation helper to retry with delays if the admin page doesn't load immediately, handling session propagation timing. Co-Authored-By: lauris@cal.com * test style --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: eunjae@cal.com Co-authored-by: Lauris Skraucis Co-authored-by: supalarry Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: CarinaWolli Co-authored-by: lauris@cal.com Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- .../(admin-layout)/admin/oAuth/page.tsx | 25 - .../(admin-layout)/admin/oauth/page.tsx | 31 + .../SettingsLayoutAppDirClient.tsx | 29 +- .../developer/oauth/page.tsx | 33 + .../auth/oauth/token/__tests__/route.test.ts | 4 + .../modules/auth/oauth2/authorize-view.tsx | 98 ++- .../admin/oauth-clients-admin-skeleton.tsx | 42 + .../admin/oauth-clients-admin-view.tsx | 196 +++++ .../web/modules/settings/admin/oauth-view.tsx | 180 ----- .../developer/oauth-clients-skeleton.tsx | 37 + .../settings/developer/oauth-clients-view.tsx | 183 +++++ .../settings/oauth/OAuthClientsList.tsx | 73 ++ .../oauth/create/NewOAuthClientButton.tsx | 20 + .../oauth/create/OAuthClientCreateModal.tsx | 102 +++ .../oauth/create/OAuthClientPreviewDialog.tsx | 130 +++ .../oauth/view/OAuthClientDetailsDialog.tsx | 366 +++++++++ .../oauth/view/OAuthClientFormFields.tsx | 163 ++++ apps/web/playwright/fixtures/cal2.png | Bin 0 -> 28734 bytes .../oauth-authorize-approval-status.e2e.ts | 162 ++++ .../oauth/oauth-client-admin.e2e.ts | 166 ++++ .../playwright/oauth/oauth-client-helpers.ts | 133 ++++ .../oauth/oauth-client-owner-crud.e2e.ts | 752 ++++++++++++++++++ .../oauth/oauth-refresh-tokens.e2e.ts | 75 ++ apps/web/public/static/locales/en/common.json | 88 +- packages/emails/oauth-email-service.ts | 33 + .../AdminOAuthClientNotificationEmail.tsx | 124 +++ .../OAuthClientApprovedNotificationEmail.tsx | 87 ++ .../OAuthClientRejectedNotificationEmail.tsx | 101 +++ packages/emails/src/templates/index.ts | 3 + .../admin-oauth-client-notification.test.ts | 89 +++ .../admin-oauth-client-notification.ts | 50 ++ ...oauth-client-approved-notification.test.ts | 78 ++ .../oauth-client-approved-notification.ts | 44 + ...oauth-client-rejected-notification.test.ts | 84 ++ .../oauth-client-rejected-notification.ts | 49 ++ .../repositories/OAuthClientRepository.ts | 194 +++++ .../features/oauth/services/OAuthService.ts | 41 +- .../features/oauth/utils/generateSecret.ts | 5 +- .../migration.sql | 16 + packages/prisma/schema.prisma | 32 +- .../server/routers/viewer/oAuth/_router.tsx | 82 +- .../routers/viewer/oAuth/addClient.handler.ts | 47 -- .../routers/viewer/oAuth/addClient.schema.ts | 22 - .../viewer/oAuth/createClient.handler.ts | 48 ++ .../viewer/oAuth/createClient.schema.ts | 34 + .../viewer/oAuth/deleteClient.handler.ts | 37 + .../viewer/oAuth/deleteClient.schema.ts | 7 + .../oAuth/generateAuthCode.handler.test.ts | 73 +- .../routers/viewer/oAuth/getClient.schema.ts | 7 - ...s => getClientForAuthorization.handler.ts} | 12 +- .../oAuth/getClientForAuthorization.schema.ts | 8 + .../viewer/oAuth/listClients.handler.ts | 23 + .../viewer/oAuth/listClients.schema.ts | 9 + .../viewer/oAuth/listUserClients.handler.ts | 19 + .../submitClientForReview.handler.test.ts | 122 +++ .../oAuth/submitClientForReview.handler.ts | 69 ++ .../oAuth/submitClientForReview.schema.ts | 32 + .../viewer/oAuth/updateClient.handler.test.ts | 490 ++++++++++++ .../viewer/oAuth/updateClient.handler.ts | 310 ++++++++ .../viewer/oAuth/updateClient.schema.ts | 30 + 60 files changed, 5231 insertions(+), 368 deletions(-) delete mode 100644 apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oauth/page.tsx 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-skeleton.tsx create mode 100644 apps/web/modules/settings/admin/oauth-clients-admin-view.tsx delete mode 100644 apps/web/modules/settings/admin/oauth-view.tsx create mode 100644 apps/web/modules/settings/developer/oauth-clients-skeleton.tsx create mode 100644 apps/web/modules/settings/developer/oauth-clients-view.tsx create mode 100644 apps/web/modules/settings/oauth/OAuthClientsList.tsx create mode 100644 apps/web/modules/settings/oauth/create/NewOAuthClientButton.tsx create mode 100644 apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx create mode 100644 apps/web/modules/settings/oauth/create/OAuthClientPreviewDialog.tsx create mode 100644 apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx create mode 100644 apps/web/modules/settings/oauth/view/OAuthClientFormFields.tsx create mode 100644 apps/web/playwright/fixtures/cal2.png create mode 100644 apps/web/playwright/oauth/oauth-authorize-approval-status.e2e.ts create mode 100644 apps/web/playwright/oauth/oauth-client-admin.e2e.ts create mode 100644 apps/web/playwright/oauth/oauth-client-helpers.ts create mode 100644 apps/web/playwright/oauth/oauth-client-owner-crud.e2e.ts create mode 100644 apps/web/playwright/oauth/oauth-refresh-tokens.e2e.ts create mode 100644 packages/emails/oauth-email-service.ts create mode 100644 packages/emails/src/templates/AdminOAuthClientNotificationEmail.tsx create mode 100644 packages/emails/src/templates/OAuthClientApprovedNotificationEmail.tsx create mode 100644 packages/emails/src/templates/OAuthClientRejectedNotificationEmail.tsx create mode 100644 packages/emails/templates/admin-oauth-client-notification.test.ts create mode 100644 packages/emails/templates/admin-oauth-client-notification.ts create mode 100644 packages/emails/templates/oauth-client-approved-notification.test.ts create mode 100644 packages/emails/templates/oauth-client-approved-notification.ts create mode 100644 packages/emails/templates/oauth-client-rejected-notification.test.ts create mode 100644 packages/emails/templates/oauth-client-rejected-notification.ts create mode 100644 packages/prisma/migrations/20260114154054_add_oauth_client_properties/migration.sql delete mode 100644 packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts delete mode 100644 packages/trpc/server/routers/viewer/oAuth/addClient.schema.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/createClient.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/createClient.schema.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/deleteClient.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/deleteClient.schema.ts delete mode 100644 packages/trpc/server/routers/viewer/oAuth/getClient.schema.ts rename packages/trpc/server/routers/viewer/oAuth/{getClient.handler.ts => getClientForAuthorization.handler.ts} (70%) create mode 100644 packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.schema.ts 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/submitClientForReview.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/submitClientForReview.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/submitClientForReview.schema.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/updateClient.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts create mode 100644 packages/trpc/server/routers/viewer/oAuth/updateClient.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 deleted file mode 100644 index 8be1a81636..0000000000 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { _generateMetadata, getTranslate } from "app/_utils"; - -import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; - -import LegacyPage from "~/settings/admin/oauth-view"; - -export const generateMetadata = async () => - await _generateMetadata( - (t) => t("oAuth"), - (t) => t("admin_oAuth_description"), - undefined, - undefined, - "/settings/admin/oAuth" - ); - -const Page = async () => { - const t = await getTranslate(); - return ( - - - - ); -}; - -export default Page; 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 new file mode 100644 index 0000000000..28bc8c212f --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oauth/page.tsx @@ -0,0 +1,31 @@ +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 { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import OAuthClientsAdminView from "~/settings/admin/oauth-clients-admin-view"; + +const Page = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + await getTranslate(); + + if (!session) { + redirect("/auth/login?callbackUrl=/settings/admin/oauth"); + } + + return ; +}; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("oauth_clients_admin"), + (t) => t("oauth_clients_admin_description"), + undefined, + undefined, + "/settings/admin/oauth" + ); + +export default Page; 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 70f7d4ed83..171315ce28 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 @@ -147,6 +147,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { href: "/settings/developer/api-keys", trackingMetadata: { section: "developer", page: "api_keys" }, }, + { + name: "oAuth", + href: "/settings/developer/oauth", + trackingMetadata: { section: "developer", page: "oauth_clients" } + }, { name: "admin_api", href: "/settings/organizations/admin-api", @@ -281,7 +286,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { }, { name: "oAuth", - href: "/settings/admin/oAuth", + href: "/settings/admin/oauth", trackingMetadata: { section: "admin", page: "oauth" }, }, { @@ -298,7 +303,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { }, ]; - tabs.find((tab) => { + for (const tab of tabs) { if (tab.name === "security" && !HOSTED_CAL_FEATURES) { tab.children?.push({ name: "sso_configuration", @@ -308,21 +313,21 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { // TODO: Enable dsync for self hosters // tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" }); } + if (tab.name === "admin" && IS_CALCOM) { tab.children?.push({ name: "create_org", href: "/settings/organizations/new", trackingMetadata: { section: "admin", page: "create_org" }, }); - } - if (tab.name === "admin" && IS_CALCOM) { + tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new", trackingMetadata: { section: "admin", page: "create_license_key" }, }); } - }); + } return tabs; }; @@ -338,7 +343,7 @@ const organizationAdminKeys = [ "delegation_credential", ]; -export interface SettingsPermissions { +interface SettingsPermissions { canViewRoles?: boolean; canViewOrganizationBilling?: boolean; canUpdateOrganization?: boolean; @@ -554,7 +559,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record teamState.teamId === team.id)) + if (teamMenuState.some((teamState) => teamState.teamId === team.id)) { return ( ); + } + + return null; })} ); @@ -1037,14 +1045,14 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) => ); }; -export type SettingsLayoutProps = { +type SettingsLayoutProps = { children: React.ReactNode; containerClassName?: string; teamFeatures?: Record; permissions?: SettingsPermissions; } & ComponentProps; -export default function SettingsLayoutAppDirClient({ +function SettingsLayoutAppDirClient({ children, teamFeatures, permissions, @@ -1133,3 +1141,6 @@ const SidebarContainerElement = ({ ); }; + +export type { SettingsLayoutProps, SettingsPermissions }; +export default SettingsLayoutAppDirClient; 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 0000000000..c622db5626 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx @@ -0,0 +1,33 @@ +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 { 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/app/api/auth/oauth/token/__tests__/route.test.ts b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts index 3edd45fff1..11b6c22092 100644 --- a/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts +++ b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts @@ -149,6 +149,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, + status: "APPROVED" as const, } as const; const mockAccessCode = { @@ -279,6 +280,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: "hashed_secret", clientType: "CONFIDENTIAL" as const, + status: "APPROVED" as const, } as const; const mockAccessCode = { @@ -494,6 +496,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, + status: "APPROVED" as const, } as Awaited>); const tokenRequest = createTokenRequest({ @@ -517,6 +520,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, + status: "APPROVED" as const, } as Awaited>); prismaMock.accessCode.findFirst.mockResolvedValue(null); diff --git a/apps/web/modules/auth/oauth2/authorize-view.tsx b/apps/web/modules/auth/oauth2/authorize-view.tsx index ce2ea297cb..3dd41fe5c8 100644 --- a/apps/web/modules/auth/oauth2/authorize-view.tsx +++ b/apps/web/modules/auth/oauth2/authorize-view.tsx @@ -14,7 +14,7 @@ import { Button } from "@calcom/ui/components/button"; import { Select } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; -export default function Authorize() { +export function Authorize() { const { t } = useLocale(); const { status } = useSession(); @@ -22,6 +22,7 @@ export default function Authorize() { const searchParams = useCompatSearchParams(); const client_id = (searchParams?.get("client_id") as string) || ""; + const redirect_uri = searchParams?.get("redirect_uri") as string; const state = searchParams?.get("state") as string; const scope = searchParams?.get("scope") as string; const code_challenge = searchParams?.get("code_challenge") as string; @@ -32,12 +33,17 @@ export default function Authorize() { const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>(); const scopes = scope ? scope.toString().split(",") : []; - const { data: client, isPending: isPendingGetClient } = trpc.viewer.oAuth.getClient.useQuery( + const { + data: client, + error: getClientError, + isPending: isPendingGetClient, + } = trpc.viewer.oAuth.getClientForAuthorization.useQuery( { clientId: client_id as string, + redirectUri: redirect_uri, }, { - enabled: status !== "loading", + enabled: status === "authenticated" && !!redirect_uri, } ); @@ -50,29 +56,12 @@ export default function Authorize() { data.redirectUrl ?? `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`; }, onError: (error) => { - let oauthError = "server_error"; - if (error.data?.code === "BAD_REQUEST") { - oauthError = "invalid_request"; - } else if (error.data?.code === "UNAUTHORIZED") { - oauthError = "unauthorized_client"; - } - - const errorParams = new URLSearchParams({ - error: oauthError, - }); - - if (error.message) { - errorParams.append("error_description", error.message); - } - - if (state) { - errorParams.append("state", state); - } - if (client?.redirectUri) { - window.location.href = `${client.redirectUri}${ - client.redirectUri.includes("?") ? "&" : "?" - }${errorParams.toString()}`; + redirectToOAuthError({ + redirectUri: client.redirectUri, + trpcError: error, + state, + }); } }, }); @@ -120,9 +109,11 @@ export default function Authorize() { } }, [status]); - const isPending = isPendingGetClient || isPendingProfiles || status !== "authenticated"; + if (getClientError) { + return
{getClientError.message}
; + } - if (isPending) { + if (isPendingGetClient || isPendingProfiles || status !== "authenticated") { return <>; } @@ -248,3 +239,56 @@ export default function Authorize() { ); } + +function mapTrpcCodeToOAuthError(code: string | undefined) { + if (code === "BAD_REQUEST") return "invalid_request"; + if (code === "UNAUTHORIZED") return "unauthorized_client"; + return "server_error"; +} + +function buildOAuthErrorRedirectUrl({ + redirectUri, + error, + errorDescription, + state, +}: { + redirectUri: string; + error: string; + errorDescription?: string; + state?: string; +}) { + const errorParams = new URLSearchParams({ + error, + }); + + if (errorDescription) { + errorParams.append("error_description", errorDescription); + } + + if (state) { + errorParams.append("state", state); + } + + return `${redirectUri}${redirectUri.includes("?") ? "&" : "?"}${errorParams.toString()}`; +} + +function redirectToOAuthError({ + redirectUri, + trpcError, + state, +}: { + redirectUri: string; + trpcError: { data?: { code?: string } | null; message: string }; + state?: string; +}) { + const redirectUrl = buildOAuthErrorRedirectUrl({ + redirectUri, + error: mapTrpcCodeToOAuthError(trpcError.data?.code), + errorDescription: trpcError.message, + state, + }); + + window.location.href = redirectUrl; +} + +export default Authorize; \ No newline at end of file diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-skeleton.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-skeleton.tsx new file mode 100644 index 0000000000..c3e028851b --- /dev/null +++ b/apps/web/modules/settings/admin/oauth-clients-admin-skeleton.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; + +export const OAuthClientsAdminSkeleton = () => { + return ( + +
+ +
+ +
+
+
+ +
+ {[1, 2, 3].map((section) => ( +
+ +
+ {[1, 2, 3].map((row) => ( +
+
+
+ +
+
+ +
+
+
+ ))} +
+
+ ))} +
+ + ); +}; 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 0000000000..d938e45939 --- /dev/null +++ b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; + +import { OAuthClientsAdminSkeleton } from "./oauth-clients-admin-skeleton"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { showToast } from "@calcom/ui/components/toast"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; + +import type { OAuthClientCreateFormValues } from "../oauth/create/OAuthClientCreateModal"; +import { OAuthClientCreateDialog } from "../oauth/create/OAuthClientCreateModal"; +import { OAuthClientPreviewDialog } from "../oauth/create/OAuthClientPreviewDialog"; +import { OAuthClientDetailsDialog, type OAuthClientDetails } from "../oauth/view/OAuthClientDetailsDialog"; +import { OAuthClientsList } from "../oauth/OAuthClientsList"; +import { NewOAuthClientButton } from "../oauth/create/NewOAuthClientButton"; + +export default function OAuthClientsAdminView() { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [isCreatingClient, setIsCreatingClient] = useState(false); + const [createdClient, setCreatedClient] = useState(null); + const [selectedClient, setSelectedClient] = useState(null); + + const { data: pendingClients, isLoading: isPendingClientsLoading } = trpc.viewer.oAuth.listClients.useQuery({ + status: "PENDING", + }); + const { data: rejectedClients, isLoading: isRejectedClientsLoading } = trpc.viewer.oAuth.listClients.useQuery({ + status: "REJECTED", + }); + const { data: approvedClients, isLoading: isApprovedClientsLoading } = trpc.viewer.oAuth.listClients.useQuery({ + status: "APPROVED", + }); + + const createMutation = trpc.viewer.oAuth.createClient.useMutation({ + onSuccess: async (data) => { + setCreatedClient({ + clientId: data.clientId, + clientSecret: data.clientSecret, + name: data.name, + purpose: data.purpose ?? "", + status: data.status, + redirectUri: data.redirectUri, + logo: data.logo || null, + }); + 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.updateClient.useMutation({ + onSuccess: async (data) => { + showToast( + t("oauth_client_status_updated", { name: data.name, status: data.status }), + "success" + ); + + setSelectedClient((prev) => { + if (!prev) return prev; + if (prev.clientId !== data.clientId) return prev; + return { + ...prev, + status: data.status, + rejectionReason: data.rejectionReason, + }; + }); + + utils.viewer.oAuth.listClients.invalidate(); + }, + onError: (error) => { + showToast(`${t("oauth_client_status_update_error")}: ${error.message}`, "error"); + }, + }); + + const handleCreateClient = (values: OAuthClientCreateFormValues) => { + createMutation.mutate({ + name: values.name, + purpose: values.purpose, + redirectUri: values.redirectUri, + websiteUrl: values.websiteUrl, + logo: values.logo, + enablePkce: values.enablePkce, + }); + }; + + const handleCloseDialog = () => { + setIsCreatingClient(false); + setCreatedClient(null); + }; + + const handleCloseClientDialog = () => { + setSelectedClient(null); + }; + + const handleApprove = (clientId: string) => { + updateStatusMutation.mutate({ clientId, status: "APPROVED" }); + }; + + const handleReject = (input: { clientId: string; rejectionReason: string }) => { + updateStatusMutation.mutate({ clientId: input.clientId, status: "REJECTED", rejectionReason: input.rejectionReason }); + }; + + if (isPendingClientsLoading || isRejectedClientsLoading || isApprovedClientsLoading) { + return ; + } + + const newOAuthClientButton = ( + setIsCreatingClient(true)} + /> + ); + + const hasClients = + (pendingClients && pendingClients.length > 0) || + (rejectedClients && rejectedClients.length > 0) || + (approvedClients && approvedClients.length > 0); + + return ( + + {hasClients ? ( +
+
+

{t("pending")}

+ setSelectedClient(client)} + showStatus + /> +
+ +
+

{t("rejected")}

+ setSelectedClient(client)} + showStatus + /> +
+ +
+

{t("approved")}

+ setSelectedClient(client)} + showStatus + /> +
+
+ ) : ( + + )} + + {createdClient ? ( + + ) : ( + + )} + + !open && handleCloseClientDialog()} + client={selectedClient} + onApprove={handleApprove} + onReject={handleReject} + isStatusChangePending={updateStatusMutation.isPending} + /> +
+ ); +} diff --git a/apps/web/modules/settings/admin/oauth-view.tsx b/apps/web/modules/settings/admin/oauth-view.tsx deleted file mode 100644 index a2fb75d344..0000000000 --- a/apps/web/modules/settings/admin/oauth-view.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"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 { Button } from "@calcom/ui/components/button"; -import { Form } from "@calcom/ui/components/form"; -import { Label } from "@calcom/ui/components/form"; -import { Switch } from "@calcom/ui/components/form"; -import { 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 OAuthView() { - const oAuthForm = useForm({ - defaultValues: { - logo: "", - enablePkce: false, - }, - }); - const [clientSecret, setClientSecret] = useState(""); - const [clientId, setClientId] = useState(""); - const [logo, setLogo] = useState(""); - const { t } = useLocale(); - - const mutation = trpc.viewer.oAuth.addClient.useMutation({ - onSuccess: async (data) => { - setClientSecret(data.clientSecret || ""); - setClientId(data.clientId); - showToast(`Successfully added ${data.name} as new client`, "success"); - }, - onError: (error) => { - showToast(`Adding client failed: ${error.message}`, "error"); - }, - }); - - return ( -
- {!clientId ? ( -
{ - mutation.mutate({ - name: values.name, - redirectUri: values.redirectUri, - logo: values.logo, - enablePkce: values.enablePkce, - }); - }}> -
- - - -
- -
- oAuthForm.setValue("enablePkce", checked)} - /> - - Use PKCE (recommended for mobile/SPA applications) - -
-
- -
- } - className="mr-5 items-center" - imageSrc={logo} - size="lg" - /> - { - setLogo(newLogo); - oAuthForm.setValue("logo", newLogo); - }} - imageSrc={logo} - /> -
-
- -
- ) : ( -
-
{oAuthForm.getValues("name")}
-
{t("client_id")}
-
- - {clientId} - - - - -
- {clientSecret ? ( - <> -
{t("client_secret")}
-
- - {clientSecret} - - - - -
-
{t("copy_client_secret_info")}
- - ) : ( -
- This client uses PKCE authentication (no client secret required). -
- )} - -
- )} -
- ); -} 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 0000000000..6697e907d1 --- /dev/null +++ b/apps/web/modules/settings/developer/oauth-clients-skeleton.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; + +const skeletonItems = Array(3).fill(undefined); + +export const OAuthClientsSkeleton = () => { + return ( + +
+ +
+ +
+
+
+
+ {skeletonItems.map((i, index) => ( +
+
+
+ +
+
+ +
+
+
+ ))} +
+ + ); +}; 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 0000000000..4956dddc3c --- /dev/null +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { showToast } from "@calcom/ui/components/toast"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; + +import type { OAuthClientCreateFormValues } from "../oauth/create/OAuthClientCreateModal"; +import { OAuthClientCreateDialog } from "../oauth/create/OAuthClientCreateModal"; +import { OAuthClientPreviewDialog } from "../oauth/create/OAuthClientPreviewDialog"; +import { OAuthClientDetailsDialog, type OAuthClientDetails } from "../oauth/view/OAuthClientDetailsDialog"; +import { OAuthClientsList } from "../oauth/OAuthClientsList"; +import { NewOAuthClientButton } from "../oauth/create/NewOAuthClientButton"; + +import { OAuthClientsSkeleton } from "./oauth-clients-skeleton"; + +const OAuthClientsView = () => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [isCreatingClient, setIsCreatingClient] = useState(false); + const [submittedClient, setSubmittedClient] = useState(null); + const [selectedClient, setSelectedClient] = useState(null); + + const { data: oAuthClients, isLoading } = trpc.viewer.oAuth.listUserClients.useQuery(); + + const submitForReviewMutation = trpc.viewer.oAuth.submitClientForReview.useMutation({ + onSuccess: async (data) => { + setSubmittedClient({ + clientId: data.clientId, + name: data.name, + purpose: data.purpose ?? "", + clientSecret: data.clientSecret, + status: data.status, + 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 deleteClientMutation = trpc.viewer.oAuth.deleteClient.useMutation({ + onSuccess: async () => { + showToast(t("oauth_client_deletion_message"), "success"); + setSelectedClient(null); + utils.viewer.oAuth.listUserClients.invalidate(); + }, + onError: (error) => { + showToast(error.message || t("error"), "error"); + }, + }); + + const updateClientMutation = trpc.viewer.oAuth.updateClient.useMutation({ + onSuccess: async (data) => { + showToast(t("oauth_client_updated_successfully"), "success"); + + setSelectedClient((prev) => { + if (!prev) return prev; + if (prev.clientId !== data.clientId) return prev; + return { + ...prev, + name: data.name, + purpose: data.purpose, + redirectUri: data.redirectUri, + websiteUrl: data.websiteUrl, + logo: data.logo, + status: data.status, + rejectionReason: data.rejectionReason, + }; + }); + + utils.viewer.oAuth.listUserClients.invalidate(); + }, + onError: (error) => { + showToast(`${t("updating_oauth_client_error")}: ${error.message}`, "error"); + }, + }); + + const handleSubmit = (values: OAuthClientCreateFormValues) => { + submitForReviewMutation.mutate({ + name: values.name, + purpose: values.purpose, + redirectUri: values.redirectUri, + websiteUrl: values.websiteUrl, + logo: values.logo, + enablePkce: values.enablePkce, + }); + }; + + const handleCloseCreateDialog = () => { + setIsCreatingClient(false); + setSubmittedClient(null); + }; + + const handleCloseDetailsDialog = () => { + setSelectedClient(null); + }; + + if (isLoading) { + return ; + } + + const newOAuthClientButton = ( + setIsCreatingClient(true)} /> + ); + + return ( + + {oAuthClients && oAuthClients.length > 0 ? ( + ({ + clientId: client.clientId, + name: client.name, + purpose: client.purpose, + redirectUri: client.redirectUri, + websiteUrl: client.websiteUrl, + logo: client.logo, + status: client.status, + rejectionReason: client.rejectionReason, + clientType: client.clientType, + }))} + onSelectClient={(client) => setSelectedClient(client)} + /> + ) : ( + + )} + + {submittedClient ? ( + + ) : ( + + )} + + !open && handleCloseDetailsDialog()} + client={selectedClient} + onUpdate={(values) => { + updateClientMutation.mutate({ + clientId: values.clientId, + name: values.name, + purpose: values.purpose, + redirectUri: values.redirectUri, + websiteUrl: values.websiteUrl, + logo: values.logo, + }); + }} + onDelete={(clientId) => { + deleteClientMutation.mutate({ clientId }); + }} + isUpdatePending={updateClientMutation.isPending} + isDeletePending={deleteClientMutation.isPending} + /> + + ); +}; + +export default OAuthClientsView; diff --git a/apps/web/modules/settings/oauth/OAuthClientsList.tsx b/apps/web/modules/settings/oauth/OAuthClientsList.tsx new file mode 100644 index 0000000000..932266c0ad --- /dev/null +++ b/apps/web/modules/settings/oauth/OAuthClientsList.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { Avatar } from "@calcom/ui/components/avatar"; +import { Badge } from "@calcom/ui/components/badge"; +import { Icon } from "@calcom/ui/components/icon"; + +import type { OAuthClientDetails } from "./view/OAuthClientDetailsDialog"; + +const getStatusBadge = (status: string, t: (key: string) => string): ReactNode => { + switch (status) { + case "APPROVED": + return {t("approved")}; + case "REJECTED": + return {t("rejected")}; + case "PENDING": + default: + return {t("pending")}; + } +}; + +export const OAuthClientsList = ({ + clients, + onSelectClient, + showStatus = true, +}: { + clients: OAuthClientDetails[]; + onSelectClient: (client: OAuthClientDetails) => void; + showStatus?: boolean; +}) => { + const { t } = useLocale(); + + return ( +
+ {clients.map((client, index) => ( +
onSelectClient(client)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelectClient(client); + } + }}> +
+ } + size="md" + /> +
+
{client.name}
+
+
+
+ {showStatus && client.status ? getStatusBadge(client.status, t) : null} + +
+
+ ))} +
+ ); +}; diff --git a/apps/web/modules/settings/oauth/create/NewOAuthClientButton.tsx b/apps/web/modules/settings/oauth/create/NewOAuthClientButton.tsx new file mode 100644 index 0000000000..bd59bced70 --- /dev/null +++ b/apps/web/modules/settings/oauth/create/NewOAuthClientButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; + +export const NewOAuthClientButton = ({ + onClick, + dataTestId, +}: { + onClick: () => void; + dataTestId: string; +}) => { + const { t } = useLocale(); + + return ( + + ); +}; diff --git a/apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx b/apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx new file mode 100644 index 0000000000..bc4627cbf8 --- /dev/null +++ b/apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx @@ -0,0 +1,102 @@ + +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { OAuthClientFormFields } from "../view/OAuthClientFormFields"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { Button } from "@calcom/ui/components/button"; +import { DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog"; +import { Form } from "@calcom/ui/components/form"; + +export type OAuthClientCreateFormValues = { + name: string; + purpose: string; + redirectUri: string; + websiteUrl: string; + logo: string; + enablePkce: boolean; +}; + +export type OAuthClientCreateDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + isSubmitting: boolean; + onSubmit: (values: OAuthClientCreateFormValues) => void; + onClose: () => void; +}; + +export function OAuthClientCreateDialog({ + open, + onOpenChange, + isSubmitting, + onSubmit, + onClose, +}: OAuthClientCreateDialogProps) { + const { t } = useLocale(); + const [logo, setLogo] = useState(""); + + const form = useForm({ + defaultValues: { + name: "", + purpose: "", + redirectUri: "", + websiteUrl: "", + logo: "", + enablePkce: false, + }, + }); + + const handleClose = () => { + onClose(); + setLogo(""); + form.reset(); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + handleClose(); + return; + } + onOpenChange(nextOpen); + }; + + return ( + + +
{ + onSubmit({ + name: values.name.trim() || "", + purpose: values.purpose.trim() || "", + redirectUri: values.redirectUri.trim() || "", + websiteUrl: values.websiteUrl.trim() || "", + logo: values.logo, + enablePkce: values.enablePkce, + }); + }} + className="space-y-4" + data-testid="oauth-client-create-form"> + + + + {t("close")} + + + +
+
+ ); +} + diff --git a/apps/web/modules/settings/oauth/create/OAuthClientPreviewDialog.tsx b/apps/web/modules/settings/oauth/create/OAuthClientPreviewDialog.tsx new file mode 100644 index 0000000000..51b0a6e35f --- /dev/null +++ b/apps/web/modules/settings/oauth/create/OAuthClientPreviewDialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useCopy } from "@calcom/lib/hooks/useCopy"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; + +import { Alert } from "@calcom/ui/components/alert"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { DialogContent, DialogFooter } from "@calcom/ui/components/dialog"; +import { showToast } from "@calcom/ui/components/toast"; +import { Tooltip } from "@calcom/ui/components/tooltip"; + +import type { OAuthClientDetails } from "../view/OAuthClientDetailsDialog"; + +export function OAuthClientPreviewDialog({ + open, + onOpenChange, + title, + description, + client, + onClose, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + client: OAuthClientDetails; + onClose: () => void; +}) { + const { t } = useLocale(); + const { copyToClipboard } = useCopy(); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + onClose(); + return; + } + onOpenChange(nextOpen); + }; + + const showPendingBadge = client.status === "PENDING"; + + return ( + + + <> +
+ {showPendingBadge ? ( +
+ {t("pending")} +
+ ) : null} + +
+
{t("client_name")}
+
{client.name}
+
+ +
+
{t("client_id")}
+
+ + {client.clientId} + + + + +
+
+ + {client.clientSecret ? ( +
+
{t("client_secret")}
+
+ + {client.clientSecret} + + + + +
+ +
+ ) : null} +
+ + + + + +
+
+ ); +} diff --git a/apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx b/apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx new file mode 100644 index 0000000000..efd25f6ce0 --- /dev/null +++ b/apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useCopy } from "@calcom/lib/hooks/useCopy"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { Alert } from "@calcom/ui/components/alert"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { ConfirmationDialogContent, DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog"; +import { showToast } from "@calcom/ui/components/toast"; +import { Tooltip } from "@calcom/ui/components/tooltip"; +import { Label, TextArea } from "@calcom/ui/components/form"; + +import type { OAuthClientCreateFormValues } from "../create/OAuthClientCreateModal"; +import { OAuthClientFormFields } from "./OAuthClientFormFields"; + +type OAuthClientDetails = { + clientId: string; + name: string; + purpose?: string | null; + redirectUri?: string; + websiteUrl?: string | null; + logo?: string | null; + status?: string; + rejectionReason?: string | null; + clientSecret?: string; + isPkceEnabled?: boolean; + clientType?: string; +}; + +const OAuthClientDetailsDialog = ({ + open, + onOpenChange, + client, + onApprove, + onReject, + onUpdate, + onDelete, + isStatusChangePending, + isUpdatePending, + isDeletePending, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + client: OAuthClientDetails | null; + onApprove?: (clientId: string) => void; + onReject?: (input: { clientId: string; rejectionReason: string }) => void; + onUpdate?: (input: { + clientId: string; + name: string; + purpose: string; + redirectUri: string; + websiteUrl: string; + logo: string; + }) => void; + onDelete?: (clientId: string) => void; + isStatusChangePending?: boolean; + isUpdatePending?: boolean; + isDeletePending?: boolean; +}) => { + const { t } = useLocale(); + const { copyToClipboard } = useCopy(); + + const [logo, setLogo] = useState(""); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [isRejectConfirmOpen, setIsRejectConfirmOpen] = useState(false); + const [rejectionReason, setRejectionReason] = useState(""); + const [showRejectionReasonError, setShowRejectionReasonError] = useState(false); + const form = useForm({ + defaultValues: { + name: "", + purpose: "", + redirectUri: "", + websiteUrl: "", + logo: "", + enablePkce: false, + }, + }); + + useEffect(() => { + if (open) return; + setIsDeleteConfirmOpen(false); + setIsRejectConfirmOpen(false); + setRejectionReason(""); + setShowRejectionReasonError(false); + }, [open]); + + useEffect(() => { + if (!client) return; + + const enablePkce = + client.isPkceEnabled ?? (client.clientType ? client.clientType.toUpperCase() === "PUBLIC" : false); + const nextLogo = client.logo ?? ""; + + setLogo(nextLogo); + form.reset({ + name: client.name ?? "", + purpose: client.purpose ?? "", + redirectUri: client.redirectUri ?? "", + websiteUrl: client.websiteUrl ?? "", + logo: nextLogo, + enablePkce, + }); + }, [client, form]); + + const status = client?.status; + + const showAdminActions = Boolean(onApprove) || Boolean(onReject); + const isFormDisabled = showAdminActions; + const canEdit = Boolean(onUpdate) && !isFormDisabled; + const canDelete = Boolean(onDelete) && !showAdminActions; + + const handleConfirmReject = () => { + if (!client) return; + + const trimmedReason = rejectionReason.trim(); + if (trimmedReason.length === 0) { + setShowRejectionReasonError(true); + return; + } + + onReject?.({ clientId: client.clientId, rejectionReason: trimmedReason }); + setIsRejectConfirmOpen(false); + setRejectionReason(""); + setShowRejectionReasonError(false); + }; + + const clientId = client?.clientId; + + const footerActions = (() => { + const closeButton = ( + + {t("close")} + + ); + + if (showAdminActions) { + const canReject = Boolean(onReject) && (status === "PENDING" || status === "APPROVED"); + const canApprove = Boolean(onApprove) && (status === "PENDING" || status === "REJECTED"); + + return ( +
+ {closeButton} + {canReject ? ( + + ) : null} + {canApprove ? ( + + ) : null} +
+ ); + } + + if (canEdit) { + return ( +
+ {closeButton} + +
+ ); + } + + return
{closeButton}
; + })(); + + return ( + + + {client ? ( +
{ + if (!canEdit) return; + onUpdate?.({ + clientId: client.clientId, + name: values.name.trim() || "", + purpose: values.purpose.trim() || "", + redirectUri: values.redirectUri.trim() || "", + websiteUrl: values.websiteUrl.trim() || "", + logo: values.logo, + }); + })}> + {status ? ( +
+ + {t(getStatusBadgeVariant(status).labelKey)} + +
+ ) : null} + + {status === "PENDING" ? ( + + ) : null} + + {status === "APPROVED" ? ( + + ) : null} + + {status === "REJECTED" && client.rejectionReason ? ( +
+ {t("oauth_client_rejection_reason")}: {client.rejectionReason} +
+ ) : null} + +
+
{t("client_id")}
+
+ + {client.clientId} + + + + +
+
+ + + + {canDelete ? ( +
+ + + { + if (!client) return; + onDelete?.(client.clientId); + }}> + {isDeletePending ? t("deleting") : t("delete")} + + }> +

{t("confirm_delete_oauth_client")}

+
+
+
+ ) : null} + + + {footerActions} + + + + + {t("reject")} + + }> +
+ +