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 <peer@cal.com>

* fix: re-export generateSecret for backward compatibility

Co-Authored-By: peer@cal.com <peer@cal.com>

* feat: make logo mandatory and list items clickable for OAuth clients

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: add missing translation keys and remove client secret from details dialog

Co-Authored-By: peer@cal.com <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 <peer@cal.com>

* fix: address PR review comments - fix indentation and use useCopy hook

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: change react-dom/server import to fix Turbopack compatibility

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* fix: add try-catch around email sending to handle Turbopack react-dom/server issue

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <peer@cal.com>

* fix: move clientSecret variable declaration outside if block for proper scoping

Co-Authored-By: peer@cal.com <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 <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 <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 <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 <hey@eunjae.dev>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <1082092+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
60 files changed
+5231 -368

No files matched your search

@@ -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 (
<SettingsHeader title={t("oAuth")} description={t("admin_oAuth_description")}>
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -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 <OAuthClientsAdminView />;
};
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;
@@ -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<number, T
if (!teamMenuState[index]) {
return null;
}
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
if (teamMenuState.some((teamState) => teamState.teamId === team.id)) {
return (
<Collapsible
className="cursor-pointer"
@@ -697,6 +702,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
</CollapsibleContent>
</Collapsible>
);
}
return null;
})}
</>
);
@@ -1037,14 +1045,14 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
);
};
export type SettingsLayoutProps = {
type SettingsLayoutProps = {
children: React.ReactNode;
containerClassName?: string;
teamFeatures?: Record<number, TeamFeatures>;
permissions?: SettingsPermissions;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({
function SettingsLayoutAppDirClient({
children,
teamFeatures,
permissions,
@@ -1133,3 +1141,6 @@ const SidebarContainerElement = ({
</>
);
};
export type { SettingsLayoutProps, SettingsPermissions };
export default SettingsLayoutAppDirClient;
@@ -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 (
<OAuthClientsView />
);
};
export default Page;
@@ -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<ReturnType<typeof prismaMock.oAuthClient.findUnique>>);
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<ReturnType<typeof prismaMock.oAuthClient.findUnique>>);
prismaMock.accessCode.findFirst.mockResolvedValue(null);
+71 -27
View File
@@ -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 <div>{getClientError.message}</div>;
}
if (isPending) {
if (isPendingGetClient || isPendingProfiles || status !== "authenticated") {
return <></>;
}
@@ -248,3 +239,56 @@ export default function Authorize() {
</div>
);
}
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;
@@ -0,0 +1,42 @@
"use client";
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
export const OAuthClientsAdminSkeleton = () => {
return (
<SkeletonContainer>
<div className="mb-8">
<SkeletonText className="h-7 w-64" />
<div className="mt-2 flex items-start justify-between gap-4">
<SkeletonText className="h-4 w-full max-w-xl" />
<div className="bg-emphasis h-9 w-20 rounded-md" />
</div>
</div>
<div className="space-y-10">
{[1, 2, 3].map((section) => (
<div key={section} className="space-y-3">
<SkeletonText className="h-5 w-24" />
<div className="border-subtle rounded-lg border">
{[1, 2, 3].map((row) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`${section}-${row}`}
className={`flex items-center justify-between p-4 ${row !== 3 ? "border-subtle border-b" : ""}`}>
<div className="flex items-center gap-4">
<div className="bg-emphasis h-10 w-10 rounded-full" />
<SkeletonText className="h-4 w-40" />
</div>
<div className="flex items-center gap-4">
<SkeletonText className="h-5 w-20 rounded-full" />
<div className="bg-emphasis h-5 w-5 rounded" />
</div>
</div>
))}
</div>
</div>
))}
</div>
</SkeletonContainer>
);
};
@@ -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<OAuthClientDetails | null>(null);
const [selectedClient, setSelectedClient] = useState<OAuthClientDetails | null>(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 <OAuthClientsAdminSkeleton />;
}
const newOAuthClientButton = (
<NewOAuthClientButton
dataTestId="open-admin-oauth-client-create-dialog"
onClick={() => setIsCreatingClient(true)}
/>
);
const hasClients =
(pendingClients && pendingClients.length > 0) ||
(rejectedClients && rejectedClients.length > 0) ||
(approvedClients && approvedClients.length > 0);
return (
<SettingsHeader
title={t("oauth_clients_admin")}
description={t("oauth_clients_admin_description")}
CTA={newOAuthClientButton}>
{hasClients ? (
<div className="space-y-10">
<div className="space-y-3" data-testid="oauth-client-admin-pending-section">
<h2 className="text-emphasis text-base font-semibold">{t("pending")}</h2>
<OAuthClientsList
clients={pendingClients ?? []}
onSelectClient={(client) => setSelectedClient(client)}
showStatus
/>
</div>
<div className="space-y-3" data-testid="oauth-client-admin-rejected-section">
<h2 className="text-emphasis text-base font-semibold">{t("rejected")}</h2>
<OAuthClientsList
clients={rejectedClients ?? []}
onSelectClient={(client) => setSelectedClient(client)}
showStatus
/>
</div>
<div className="space-y-3" data-testid="oauth-client-admin-approved-section">
<h2 className="text-emphasis text-base font-semibold">{t("approved")}</h2>
<OAuthClientsList
clients={approvedClients ?? []}
onSelectClient={(client) => setSelectedClient(client)}
showStatus
/>
</div>
</div>
) : (
<EmptyScreen
Icon="key"
headline={t("no_oauth_clients")}
description={t("no_oauth_clients_admin_description")}
buttonRaw={newOAuthClientButton}
/>
)}
{createdClient ? (
<OAuthClientPreviewDialog
open={isCreatingClient}
onOpenChange={setIsCreatingClient}
title={t("oauth_client_created")}
description={t("oauth_client_created_description")}
client={createdClient}
onClose={handleCloseDialog}
/>
) : (
<OAuthClientCreateDialog
open={isCreatingClient}
onOpenChange={setIsCreatingClient}
isSubmitting={createMutation.isPending}
onSubmit={handleCreateClient}
onClose={handleCloseDialog}
/>
)}
<OAuthClientDetailsDialog
open={Boolean(selectedClient)}
onOpenChange={(open) => !open && handleCloseClientDialog()}
client={selectedClient}
onApprove={handleApprove}
onReject={handleReject}
isStatusChangePending={updateStatusMutation.isPending}
/>
</SettingsHeader>
);
}
@@ -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<FormValues>({
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 (
<div>
{!clientId ? (
<Form
form={oAuthForm}
handleSubmit={(values) => {
mutation.mutate({
name: values.name,
redirectUri: values.redirectUri,
logo: values.logo,
enablePkce: values.enablePkce,
});
}}>
<div className="">
<TextField
{...oAuthForm.register("name")}
label="Client name"
type="text"
id="name"
placeholder=""
className="mb-3"
required
/>
<TextField
{...oAuthForm.register("redirectUri")}
label="Redirect URI"
type="text"
id="redirectUri"
placeholder=""
required
/>
<div className="mb-5 mt-5">
<Label className="text-emphasis mb-2 block text-sm font-medium">Authentication Mode</Label>
<div className="flex items-center space-x-3">
<Switch
checked={oAuthForm.watch("enablePkce")}
onCheckedChange={(checked) => oAuthForm.setValue("enablePkce", checked)}
/>
<span className="text-default text-sm">
Use PKCE (recommended for mobile/SPA applications)
</span>
</div>
</div>
<div className="mb-5 mt-5 flex items-center">
<Avatar
alt=""
fallback={<Icon name="plus" className="text-subtle h-6 w-6" />}
className="mr-5 items-center"
imageSrc={logo}
size="lg"
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg="Upload Logo"
handleAvatarChange={(newLogo: string) => {
setLogo(newLogo);
oAuthForm.setValue("logo", newLogo);
}}
imageSrc={logo}
/>
</div>
</div>
<Button type="submit" className="mt-3">
{t("add_client")}
</Button>
</Form>
) : (
<div>
<div className="text-emphasis mb-5 text-xl font-semibold">{oAuthForm.getValues("name")}</div>
<div className="mb-2 font-medium">{t("client_id")}</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{clientId}
</code>
<Tooltip side="top" content="Copy to Clipboard">
<Button
onClick={() => {
navigator.clipboard.writeText(clientId);
showToast(t("client_id_copied"), "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
{clientSecret ? (
<>
<div className="mb-2 mt-4 font-medium">{t("client_secret")}</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{clientSecret}
</code>
<Tooltip side="top" content="Copy to Clipboard">
<Button
onClick={() => {
navigator.clipboard.writeText(clientSecret);
setClientSecret("");
showToast(t("client_secret_copied"), "success");
}}
type="button"
className="rounded-l-none text-base"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
<div className="text-subtle text-sm">{t("copy_client_secret_info")}</div>
</>
) : (
<div className="text-subtle mt-4 text-sm">
This client uses PKCE authentication (no client secret required).
</div>
)}
<Button
onClick={() => {
setClientId("");
setLogo("");
oAuthForm.reset();
}}
className="mt-5">
{t("add_new_client")}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
const skeletonItems = Array(3).fill(undefined);
export const OAuthClientsSkeleton = () => {
return (
<SkeletonContainer>
<div className="mb-8">
<SkeletonText className="h-7 w-64" />
<div className="mt-2 flex items-start justify-between gap-4">
<SkeletonText className="h-4 w-full max-w-xl" />
<div className="bg-emphasis h-9 w-20 rounded-md" />
</div>
</div>
<div className="border-subtle rounded-lg border">
{skeletonItems.map((i, index) => (
<div
key={`oauth-client-skeleton-${index}`}
className={`flex items-center justify-between p-4 ${
index !== skeletonItems.length - 1 ? "border-subtle border-b" : ""
}`}>
<div className="flex items-center gap-4">
<div className="bg-emphasis h-10 w-10 rounded-full" />
<SkeletonText className="h-4 w-40" />
</div>
<div className="flex items-center gap-4">
<SkeletonText className="h-5 w-20 rounded-full" />
<div className="bg-emphasis h-5 w-5 rounded" />
</div>
</div>
))}
</div>
</SkeletonContainer>
);
};
@@ -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<OAuthClientDetails | null>(null);
const [selectedClient, setSelectedClient] = useState<OAuthClientDetails | null>(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 <OAuthClientsSkeleton />;
}
const newOAuthClientButton = (
<NewOAuthClientButton dataTestId="open-oauth-client-create-dialog" onClick={() => setIsCreatingClient(true)} />
);
return (
<SettingsHeader
title={t("oauth_clients")}
description={t("oauth_clients_description")}
CTA={newOAuthClientButton}>
{oAuthClients && oAuthClients.length > 0 ? (
<OAuthClientsList
clients={oAuthClients.map((client) => ({
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)}
/>
) : (
<EmptyScreen
Icon="key"
headline={t("no_oauth_clients")}
description={t("no_oauth_clients_description")}
buttonRaw={newOAuthClientButton}
/>
)}
{submittedClient ? (
<OAuthClientPreviewDialog
open={isCreatingClient}
onOpenChange={setIsCreatingClient}
title={t("oauth_client_submitted")}
description={t("oauth_client_submitted_description")}
client={submittedClient}
onClose={handleCloseCreateDialog}
/>
) : (
<OAuthClientCreateDialog
open={isCreatingClient}
onOpenChange={setIsCreatingClient}
isSubmitting={submitForReviewMutation.isPending}
onSubmit={handleSubmit}
onClose={handleCloseCreateDialog}
/>
)}
<OAuthClientDetailsDialog
open={Boolean(selectedClient)}
onOpenChange={(open) => !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}
/>
</SettingsHeader>
);
};
export default OAuthClientsView;
@@ -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 <Badge variant="success">{t("approved")}</Badge>;
case "REJECTED":
return <Badge variant="red">{t("rejected")}</Badge>;
case "PENDING":
default:
return <Badge variant="orange">{t("pending")}</Badge>;
}
};
export const OAuthClientsList = ({
clients,
onSelectClient,
showStatus = true,
}: {
clients: OAuthClientDetails[];
onSelectClient: (client: OAuthClientDetails) => void;
showStatus?: boolean;
}) => {
const { t } = useLocale();
return (
<div className="border-subtle rounded-lg border" data-testid="oauth-clients-list">
{clients.map((client, index) => (
<div
key={client.clientId}
data-testid={`oauth-client-list-item-${client.clientId}`}
className={`hover:bg-subtle flex cursor-pointer items-center justify-between p-4 transition-colors ${
index !== clients.length - 1 ? "border-subtle border-b" : ""
}`}
onClick={() => onSelectClient(client)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectClient(client);
}
}}>
<div className="flex items-center gap-4">
<Avatar
alt={client.name}
imageSrc={client.logo || undefined}
fallback={<Icon name="key" className="text-subtle h-6 w-6" />}
size="md"
/>
<div>
<div className="text-emphasis font-medium">{client.name}</div>
</div>
</div>
<div className="flex items-center gap-4">
{showStatus && client.status ? getStatusBadge(client.status, t) : null}
<Icon name="chevron-right" className="text-subtle h-5 w-5" />
</div>
</div>
))}
</div>
);
};
@@ -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 (
<Button color="secondary" StartIcon="plus" size="sm" variant="fab" data-testid={dataTestId} onClick={onClick}>
{t("new")}
</Button>
);
};
@@ -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<OAuthClientCreateFormValues>({
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
enableOverflow
type="creation"
title={t("create_oauth_client")}
description={t("create_oauth_client_description")}>
<Form
form={form}
handleSubmit={(values) => {
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">
<OAuthClientFormFields form={form} logo={logo} setLogo={setLogo} />
<DialogFooter>
<DialogClose>{t("close")}</DialogClose>
<Button type="submit" loading={isSubmitting} data-testid="oauth-client-create-submit">
{t("create")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent enableOverflow type="creation" title={title} description={description}>
<>
<div className="space-y-4" data-testid="oauth-client-submitted-modal">
{showPendingBadge ? (
<div className="flex items-center justify-start">
<Badge variant="orange">{t("pending")}</Badge>
</div>
) : null}
<div>
<div className="text-subtle mb-1 text-sm">{t("client_name")}</div>
<div className="text-emphasis font-medium">{client.name}</div>
</div>
<div>
<div className="text-subtle mb-1 text-sm">{t("client_id")}</div>
<div className="flex">
<code
data-testid="oauth-client-submitted-client-id"
className="bg-subtle text-default w-full truncate rounded-md rounded-r-none px-2 py-1 align-middle font-mono text-sm">
{client.clientId}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
copyToClipboard(client.clientId, {
onSuccess: () => showToast(t("client_id_copied"), "success"),
onFailure: () => showToast(t("error"), "error"),
});
}}
type="button"
size="sm"
className="rounded-l-none"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
{client.clientSecret ? (
<div>
<div className="text-subtle mb-1 text-sm">{t("client_secret")}</div>
<div className="flex">
<code
data-testid="oauth-client-submitted-client-secret"
className="bg-subtle text-default w-full truncate rounded-md rounded-r-none px-2 py-1 align-middle font-mono text-sm">
{client.clientSecret}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
copyToClipboard(client.clientSecret ?? "", {
onSuccess: () => showToast(t("client_secret_copied"), "success"),
onFailure: () => showToast(t("error"), "error"),
});
}}
type="button"
size="sm"
className="rounded-l-none"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
<Alert
severity="warning"
message={t("oauth_client_client_secret_one_time_warning")}
className="mt-3"
/>
</div>
) : null}
</div>
<DialogFooter className="mt-6">
<Button type="button" onClick={onClose} data-testid="oauth-client-submitted-done">
{t("done")}
</Button>
</DialogFooter>
</>
</DialogContent>
</Dialog>
);
}
@@ -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<OAuthClientCreateFormValues>({
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 = (
<DialogClose color="minimal" data-testid="oauth-client-details-close">
{t("close")}
</DialogClose>
);
if (showAdminActions) {
const canReject = Boolean(onReject) && (status === "PENDING" || status === "APPROVED");
const canApprove = Boolean(onApprove) && (status === "PENDING" || status === "REJECTED");
return (
<div className="flex gap-2 justify-end items-center w-full">
{closeButton}
{canReject ? (
<Button
type="button"
color="primary"
StartIcon="x"
data-testid="oauth-client-details-reject-trigger"
loading={isStatusChangePending}
onClick={() => {
setIsRejectConfirmOpen(true);
setRejectionReason("");
setShowRejectionReasonError(false);
}}>
{t("reject")}
</Button>
) : null}
{canApprove ? (
<Button
type="button"
color="primary"
StartIcon="check"
data-testid="oauth-client-details-approve-trigger"
loading={isStatusChangePending}
onClick={() => {
if (!clientId) return;
onApprove?.(clientId);
}}>
{t("approve")}
</Button>
) : null}
</div>
);
}
if (canEdit) {
return (
<div className="flex gap-2 justify-end items-center w-full">
{closeButton}
<Button type="submit" loading={isUpdatePending} data-testid="oauth-client-details-save">
{t("save")}
</Button>
</div>
);
}
return <div className="flex justify-end items-center w-full">{closeButton}</div>;
})();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent enableOverflow type="creation">
{client ? (
<form
data-testid="oauth-client-details-form"
className="space-y-4"
onSubmit={form.handleSubmit((values) => {
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 ? (
<div className="flex justify-start items-center">
<Badge data-testid="oauth-client-details-status-badge" variant={getStatusBadgeVariant(status).variant}>
{t(getStatusBadgeVariant(status).labelKey)}
</Badge>
</div>
) : null}
{status === "PENDING" ? (
<Alert severity="warning" title={t("oauth_client_pending_info_description")} />
) : null}
{status === "APPROVED" ? (
<Alert severity="warning" title={t("oauth_client_approved_reapproval_info")} />
) : null}
{status === "REJECTED" && client.rejectionReason ? (
<div className="text-sm text-subtle" data-testid="oauth-client-details-rejection-reason-display">
<span className="font-medium">{t("oauth_client_rejection_reason")}:</span> {client.rejectionReason}
</div>
) : null}
<div>
<div className="mb-1 text-sm text-subtle">{t("client_id")}</div>
<div className="flex">
<code
data-testid="oauth-client-details-client-id"
className="px-2 py-1 w-full font-mono text-sm truncate align-middle rounded-md rounded-r-none bg-subtle text-default">
{client.clientId}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
copyToClipboard(client.clientId, {
onSuccess: () => showToast(t("client_id_copied"), "success"),
onFailure: () => showToast(t("error"), "error"),
});
}}
type="button"
size="sm"
className="rounded-l-none"
StartIcon="clipboard">
{t("copy")}
</Button>
</Tooltip>
</div>
</div>
<OAuthClientFormFields
form={form}
logo={logo}
setLogo={setLogo}
isClientReadOnly={isFormDisabled}
isPkceLocked
/>
{canDelete ? (
<div className="pt-2">
<Button
type="button"
color="destructive"
StartIcon="trash"
data-testid="oauth-client-details-delete-trigger"
loading={isDeletePending}
className="w-auto"
onClick={() => setIsDeleteConfirmOpen(true)}>
{t("delete_oauth_client")}
</Button>
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_oauth_client")}
cancelBtnText={t("cancel")}
isPending={isDeletePending}
confirmBtn={
<DialogClose
data-testid="oauth-client-details-delete-confirm"
color="primary"
loading={isDeletePending}
onClick={() => {
if (!client) return;
onDelete?.(client.clientId);
}}>
{isDeletePending ? t("deleting") : t("delete")}
</DialogClose>
}>
<p className="mb-4">{t("confirm_delete_oauth_client")}</p>
</ConfirmationDialogContent>
</Dialog>
</div>
) : null}
<DialogFooter className="mt-6">
{footerActions}
</DialogFooter>
<Dialog open={isRejectConfirmOpen} onOpenChange={setIsRejectConfirmOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("reject_oauth_client")}
cancelBtnText={t("cancel")}
confirmBtn={
<Button
type="button"
color="primary"
StartIcon="x"
data-testid="oauth-client-details-reject-confirm"
loading={isStatusChangePending}
className="not-disabled:hover:!bg-error not-disabled:hover:!text-white not-disabled:hover:!border-semantic-error"
onClick={handleConfirmReject}>
{t("reject")}
</Button>
}>
<div className="mt-4 space-y-2">
<Label htmlFor="oauth-rejection-reason">{t("reason_for_rejection")}</Label>
<TextArea
id="oauth-rejection-reason"
data-testid="oauth-client-details-rejection-reason"
value={rejectionReason}
onChange={(e) => {
setRejectionReason(e.target.value);
if (showRejectionReasonError && e.target.value.trim().length > 0) {
setShowRejectionReasonError(false);
}
}}
className={showRejectionReasonError ? "border-error" : undefined}
/>
{showRejectionReasonError ? (
<p className="text-sm text-error">{t("is_required")}</p>
) : null}
</div>
</ConfirmationDialogContent>
</Dialog>
</form>
) : null}
</DialogContent>
</Dialog>
);
};
function getStatusBadgeVariant(status: string) {
switch (status) {
case "APPROVED":
return { variant: "success" as const, labelKey: "approved" as const };
case "REJECTED":
return { variant: "red" as const, labelKey: "rejected" as const };
case "PENDING":
default:
return { variant: "orange" as const, labelKey: "pending" as const };
}
};
export type { OAuthClientDetails };
export { OAuthClientDetailsDialog };
@@ -0,0 +1,163 @@
"use client";
import { useMemo } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { RegisterOptions, UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Label, Switch, TextArea, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { OAuthClientCreateFormValues } from "../create/OAuthClientCreateModal";
export const OAuthClientFormFields = ({
form,
logo,
setLogo,
isClientReadOnly,
isPkceLocked,
}: {
form: UseFormReturn<OAuthClientCreateFormValues>;
logo: string;
setLogo: Dispatch<SetStateAction<string>>;
isClientReadOnly?: boolean;
isPkceLocked?: boolean;
}) => {
const { t } = useLocale();
const isFormDisabled = Boolean(isClientReadOnly);
const allowUploadingLogo = !isFormDisabled;
const redirectUriValidation: RegisterOptions<OAuthClientCreateFormValues, "redirectUri"> = useMemo(
() => ({
required: true,
validate: (value: string) => {
try {
new URL(value);
return true;
} catch {
return t("invalid_url");
}
},
}),
[t]
);
const websiteUrlValidation: RegisterOptions<OAuthClientCreateFormValues, "websiteUrl"> = useMemo(
() => ({
validate: (value: string) => {
if (!value) return true;
try {
new URL(value);
return true;
} catch {
return t("invalid_url");
}
},
}),
[t]
);
return (
<>
<TextField
{...form.register("name", { required: true })}
label={t("client_name")}
type="text"
id="name"
placeholder={t("client_name_placeholder")}
required
disabled={isFormDisabled}
/>
<div>
<Label htmlFor="purpose" className="text-emphasis mb-2 flex items-center gap-1 text-sm font-medium">
{t("purpose")}
<Tooltip content={t("purpose_tooltip")}>
<span>
<Icon name="info" className="text-subtle h-4 w-4" />
</span>
</Tooltip>
</Label>
<TextArea
{...form.register("purpose", { required: true })}
id="purpose"
placeholder={t("purpose_placeholder")}
required
disabled={isFormDisabled}
/>
</div>
<TextField
{...form.register("redirectUri", redirectUriValidation)}
label={t("redirect_uri")}
type="url"
id="redirectUri"
placeholder={t("redirect_uri_placeholder")}
required
disabled={isFormDisabled}
/>
<TextField
{...form.register("websiteUrl", websiteUrlValidation)}
label={
<span className="flex items-center gap-1">
{t("website_url")}
<Tooltip content={t("website_url_tooltip")}>
<span>
<Icon name="info" className="text-subtle h-4 w-4" />
</span>
</Tooltip>
</span>
}
type="url"
id="websiteUrl"
placeholder={isFormDisabled ? undefined : t("website_url_placeholder")}
disabled={isFormDisabled}
/>
<div>
<Label className="text-emphasis mb-2 block text-sm font-medium">{t("authentication_mode")}</Label>
<div className="flex items-center space-x-3">
<Switch
data-testid="oauth-client-pkce-toggle"
checked={form.watch("enablePkce")}
onCheckedChange={(checked) => form.setValue("enablePkce", checked)}
disabled={isFormDisabled || isPkceLocked}
/>
<span className="text-default text-sm">{t("use_pkce")}</span>
</div>
</div>
<div>
<Label className="text-emphasis mb-2 block text-sm font-medium">{t("logo")}</Label>
<div className="space-y-3">
<div className="flex items-center gap-4">
<Avatar
alt={t("logo")}
fallback={<Icon name="key" className="text-subtle h-6 w-6" />}
imageSrc={logo}
size="lg"
/>
{allowUploadingLogo ? (
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_logo")}
testId="oauth-client-logo"
handleAvatarChange={(newLogo: string) => {
setLogo(newLogo);
form.setValue("logo", newLogo);
}}
imageSrc={logo}
disabled={isFormDisabled}
/>
) : null}
</div>
</div>
</div>
</>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,162 @@
import { expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService";
import type { PrismaClient } from "@calcom/prisma";
import { test } from "../lib/fixtures";
test.describe("OAuth authorize - client approval status", () => {
test.afterEach(async ({ prisma, users }, testInfo) => {
const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`;
await prisma.oAuthClient.deleteMany({
where: {
name: {
startsWith: testPrefix,
},
},
});
await users.deleteAll();
});
async function createOAuthClient({
prisma,
name,
status,
}: {
prisma: PrismaClient;
name: string;
status: "PENDING" | "APPROVED" | "REJECTED";
}) {
const clientId = randomBytes(32).toString("hex");
const client = await prisma.oAuthClient.create({
data: {
clientId,
name,
redirectUri: "https://example.com",
clientSecret: null,
clientType: "CONFIDENTIAL",
status,
},
});
return client;
}
test("PENDING client renders error on authorize page (no redirect)", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-authorize-pending" });
await user.apiLogin();
const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}pending-${Date.now()}`,
status: "PENDING",
});
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234`
);
// Should NOT redirect, should show error on page
await expect(page).not.toHaveURL(/^https:\/\/example\.com/);
// Check error message is displayed
await expect(page.getByText(OAUTH_ERROR_REASONS["client_not_approved"])).toBeVisible();
});
test("REJECTED client renders error on authorize page (no redirect)", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-authorize-rejected" });
await user.apiLogin();
const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}rejected-${Date.now()}`,
status: "REJECTED",
});
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234`
);
// Should NOT redirect, should show error on page
await expect(page).not.toHaveURL(/^https:\/\/example\.com/);
// Check error message is displayed
await expect(page.getByText(OAUTH_ERROR_REASONS["client_not_approved"])).toBeVisible();
});
test("APPROVED client redirects to redirectUri with code", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-authorize-approved" });
await user.apiLogin();
const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}approved-${Date.now()}`,
status: "APPROVED",
});
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234`
);
await page.waitForSelector('[data-testid="allow-button"]');
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
await expect(page).toHaveURL(/^https:\/\/example\.com/);
const url = new URL(page.url());
expect(url.searchParams.get("code")).toBeTruthy();
expect(url.searchParams.get("state")).toBe("1234");
expect(url.searchParams.get("error")).toBeNull();
});
test("redirect_uri mismatch renders error on authorize page (no redirect)", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-authorize-redirect-uri-mismatch" });
await user.apiLogin();
const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}mismatch-${Date.now()}`,
status: "APPROVED",
});
const mismatchedRedirectUri = "https://example.com/mismatch";
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${encodeURIComponent(
mismatchedRedirectUri
)}&state=1234`
);
await expect(page).toHaveURL(/\/auth\/oauth2\/authorize/);
await expect(page.getByText(OAUTH_ERROR_REASONS["redirect_uri_mismatch"])).toBeVisible();
});
test("invalid client_id renders error on authorize page (no redirect)", async ({ page, users }) => {
const user = await users.create({ username: "oauth-authorize-invalid-client" });
await user.apiLogin();
const invalidClientId = "invalid-client-id-" + Date.now();
const redirectUri = "https://example.com";
await page.goto(
`auth/oauth2/authorize?client_id=${invalidClientId}&redirect_uri=${encodeURIComponent(
redirectUri
)}&state=1234`
);
await expect(page).toHaveURL(/\/auth\/oauth2\/authorize/);
await expect(page.getByText(OAUTH_ERROR_REASONS["client_not_found"])).toBeVisible();
});
});
@@ -0,0 +1,166 @@
import type { PrismaClient } from "@calcom/prisma";
import { expect, type Page } from "@playwright/test";
import { test } from "../lib/fixtures";
import {
closeOAuthClientDetails,
createApprovedOAuthClientAsAdmin,
createPendingOAuthClient,
openOAuthClientDetailsFromList,
goToAdminOAuthSettings
} from "./oauth-client-helpers";
async function expectClientStatusInDb(
prisma: PrismaClient,
clientId: string,
status: "PENDING" | "APPROVED" | "REJECTED"
) {
await expect
.poll(async () => {
const row = await prisma.oAuthClient.findUnique({
where: { clientId },
select: { status: true },
});
return row?.status ?? null;
})
.toBe(status);
}
async function waitForAdminSection(page: Page, sectionTestId: string) {
const section = page.getByTestId(sectionTestId);
await expect(section).toBeVisible({ timeout: 60_000 });
return section;
}
async function expectClientInAdminSection(
page: Page,
sectionTestId: string,
clientId: string
): Promise<void> {
const section = await waitForAdminSection(page, sectionTestId);
await expect(section.getByTestId(`oauth-client-list-item-${clientId}`)).toBeVisible({ timeout: 60_000 });
}
async function expectClientNotInAdminSection(
page: Page,
sectionTestId: string,
clientId: string
): Promise<void> {
const section = await waitForAdminSection(page, sectionTestId);
await expect(section.getByTestId(`oauth-client-list-item-${clientId}`)).toHaveCount(0, { timeout: 60_000 });
}
test.describe.configure({ mode: "parallel" });
test.describe("OAuth clients admin", () => {
test.afterEach(async ({ users, prisma }, testInfo) => {
const testPrefix = `e2e-oauth-client-admin-${testInfo.testId}-`;
await prisma.oAuthClient.deleteMany({
where: {
name: {
startsWith: testPrefix,
},
},
});
await users.deleteAll();
});
test("can manage pending/approved/rejected clients and can create approved clients as admin", async ({
page,
prisma,
users,
}, testInfo) => {
const adminUser = await users.create({ role: "ADMIN" });
await adminUser.apiLogin();
const testPrefix = `e2e-oauth-client-admin-${testInfo.testId}-`;
const makeClientInput = (name: string) => ({
name,
purpose: "Used for E2E testing (admin)",
redirectUri: "https://example.com/callback",
websiteUrl: "https://example.com",
logoFileName: "cal.png",
});
const pending1Name = `${testPrefix}pending-1-${Date.now()}`;
const pending2Name = `${testPrefix}pending-2-${Date.now()}`;
const pending3Name = `${testPrefix}pending-3-${Date.now()}`;
const toBeApproved = await createPendingOAuthClient(page, makeClientInput(pending1Name));
const toBeRejected = await createPendingOAuthClient(page, makeClientInput(pending2Name));
const staysPending = await createPendingOAuthClient(page, makeClientInput(pending3Name));
await goToAdminOAuthSettings(page);
await expectClientInAdminSection(page, "oauth-client-admin-pending-section", toBeApproved.clientId);
await expectClientInAdminSection(page, "oauth-client-admin-pending-section", toBeRejected.clientId);
await expectClientInAdminSection(page, "oauth-client-admin-pending-section", staysPending.clientId);
// Preview a pending client (readonly)
const pendingDetails = await openOAuthClientDetailsFromList(page, toBeApproved.clientId);
await expect(pendingDetails.locator("#name")).toBeDisabled();
await expect(pendingDetails.locator("#purpose")).toBeDisabled();
await expect(pendingDetails.locator("#redirectUri")).toBeDisabled();
await expect(pendingDetails.locator("#websiteUrl")).toBeDisabled();
// Upload/delete actions should be hidden for admin view
await expect(page.getByTestId("open-upload-oauth-client-logo-dialog")).toHaveCount(0);
await expect(page.getByTestId("oauth-client-details-delete-trigger")).toHaveCount(0);
// Approve pending1
await page.getByTestId("oauth-client-details-approve-trigger").click();
await closeOAuthClientDetails(page);
await expectClientStatusInDb(prisma, toBeApproved.clientId, "APPROVED");
await expectClientNotInAdminSection(page, "oauth-client-admin-pending-section", toBeApproved.clientId);
await expectClientInAdminSection(page, "oauth-client-admin-approved-section", toBeApproved.clientId);
await expectClientInAdminSection(page, "oauth-client-admin-pending-section", staysPending.clientId);
// Reject pending2
const rejectionReason = `Not acceptable (${testPrefix}reason-${Date.now()})`;
await openOAuthClientDetailsFromList(page, toBeRejected.clientId);
await page.getByTestId("oauth-client-details-reject-trigger").click();
await page.getByTestId("oauth-client-details-rejection-reason").fill(rejectionReason);
await page.getByTestId("oauth-client-details-reject-confirm").click();
await closeOAuthClientDetails(page);
await expectClientStatusInDb(prisma, toBeRejected.clientId, "REJECTED");
await expectClientNotInAdminSection(page, "oauth-client-admin-pending-section", toBeRejected.clientId);
await expectClientInAdminSection(page, "oauth-client-admin-rejected-section", toBeRejected.clientId);
await page
.getByTestId("oauth-client-admin-rejected-section")
.getByTestId(`oauth-client-list-item-${toBeRejected.clientId}`)
.click();
const rejectedDetails = page.getByTestId("oauth-client-details-form");
await expect(rejectedDetails).toBeVisible();
await expect(rejectedDetails.getByTestId("oauth-client-details-rejection-reason-display")).toContainText(
rejectionReason
);
await closeOAuthClientDetails(page);
await expectClientInAdminSection(page, "oauth-client-admin-pending-section", staysPending.clientId);
// Reload to ensure list queries show consistent counts
await page.reload();
await expectClientInAdminSection(page, "oauth-client-admin-approved-section", toBeApproved.clientId);
await expectClientInAdminSection(page, "oauth-client-admin-rejected-section", toBeRejected.clientId);
await expectClientInAdminSection(page, "oauth-client-admin-pending-section", staysPending.clientId);
// Admin can create an approved client from admin page
const adminCreatedName = `${testPrefix}admin-created-${Date.now()}`;
const adminCreated = await createApprovedOAuthClientAsAdmin(page, makeClientInput(adminCreatedName));
await expectClientStatusInDb(prisma, adminCreated.clientId, "APPROVED");
// Should now be in approved list
await expect(page.getByTestId(`oauth-client-list-item-${adminCreated.clientId}`)).toBeVisible();
});
});
@@ -0,0 +1,133 @@
import { expect, type Locator, type Page } from "@playwright/test";
import path from "node:path";
export async function loginAsSeededAdmin(page: Page) {
await page.goto("/auth/login");
await page.getByTestId("login-form").locator("#email").fill("admin@example.com");
await page.getByTestId("login-form").locator("#password").fill("ADMINadmin2022!");
const responsePromise = page.waitForResponse(/\/api\/auth\/callback\/credentials/);
await page.getByTestId("login-form").locator('[type="submit"]').click();
await responsePromise;
}
export async function goToDeveloperOAuthSettings(page: Page): Promise<void> {
if (page.url().includes("/settings/developer/oauth")) return;
await page.goto("/settings/developer/oauth");
await page.waitForLoadState();
}
export async function goToAdminOAuthSettings(page: Page): Promise<void> {
if (page.url().includes("/settings/admin/oauth")) return;
await page.goto("/settings/admin/oauth");
await page.waitForLoadState();
}
type CreateOAuthClientInput = {
name: string;
purpose: string;
redirectUri: string;
websiteUrl: string;
logoFileName: string;
};
type CreateOAuthClientResult = {
clientId: string;
clientSecret: string;
};
export async function createPendingOAuthClient(page: Page, input: CreateOAuthClientInput): Promise<CreateOAuthClientResult> {
await goToDeveloperOAuthSettings(page);
await page.locator("header").getByTestId("open-oauth-client-create-dialog").click();
const form = page.getByTestId("oauth-client-create-form");
await form.locator("#name").fill(input.name);
await form.locator("#purpose").fill(input.purpose);
await form.locator("#redirectUri").fill(input.redirectUri);
await form.locator("#websiteUrl").fill(input.websiteUrl);
await uploadOAuthClientLogo(page, input.logoFileName);
const pkceToggle = page.getByTestId("oauth-client-pkce-toggle");
await expect(pkceToggle).toHaveAttribute("data-state", "unchecked");
await page.getByTestId("oauth-client-create-submit").click();
const submitted = page.getByTestId("oauth-client-submitted-modal");
await expect(submitted).toBeVisible();
const clientId = ((await page.getByTestId("oauth-client-submitted-client-id").textContent()) ?? "").trim();
expect(clientId.length).toBeGreaterThan(1);
const clientSecret = ((await page.getByTestId("oauth-client-submitted-client-secret").textContent()) ?? "").trim();
expect(clientSecret.length).toBeGreaterThan(1);
await page.getByTestId("oauth-client-submitted-done").click();
return { clientId, clientSecret };
}
export async function createApprovedOAuthClientAsAdmin(
page: Page,
input: CreateOAuthClientInput
): Promise<CreateOAuthClientResult> {
await goToAdminOAuthSettings(page);
await page.getByTestId("open-admin-oauth-client-create-dialog").click();
const form = page.getByTestId("oauth-client-create-form");
await form.locator("#name").fill(input.name);
await form.locator("#purpose").fill(input.purpose);
await form.locator("#redirectUri").fill(input.redirectUri);
await form.locator("#websiteUrl").fill(input.websiteUrl);
await uploadOAuthClientLogo(page, input.logoFileName);
const pkceToggle = page.getByTestId("oauth-client-pkce-toggle");
await expect(pkceToggle).toHaveAttribute("data-state", "unchecked");
await page.getByTestId("oauth-client-create-submit").click();
const submitted = page.getByTestId("oauth-client-submitted-modal");
await expect(submitted).toBeVisible();
const clientId = ((await page.getByTestId("oauth-client-submitted-client-id").textContent()) ?? "").trim();
expect(clientId.length).toBeGreaterThan(1);
const clientSecret = ((await page.getByTestId("oauth-client-submitted-client-secret").textContent()) ?? "").trim();
expect(clientSecret.length).toBeGreaterThan(1);
await page.getByTestId("oauth-client-submitted-done").click();
return { clientId, clientSecret };
}
export async function openOAuthClientDetailsFromList(page: Page, clientId: string): Promise<Locator> {
const details = page.getByTestId("oauth-client-details-form");
await page.getByTestId(`oauth-client-list-item-${clientId}`).click();
await expect(details).toBeVisible();
await expect(details.getByTestId("oauth-client-details-client-id")).toHaveText(clientId);
return details;
}
export async function closeOAuthClientDetails(page: Page): Promise<void> {
const details = page.getByTestId("oauth-client-details-form");
if (!(await details.isVisible())) return;
await page.getByTestId("oauth-client-details-close").click();
await expect(details).toHaveCount(0);
}
async function uploadOAuthClientLogo(page: Page, fileName: string): Promise<void> {
await page.getByTestId("open-upload-oauth-client-logo-dialog").click();
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByTestId("open-upload-oauth-client-logo-filechooser").click(),
]);
await fileChooser.setFiles(getFixturePath(fileName));
await page.getByTestId("upload-oauth-client-logo").click();
}
function getFixturePath(fileName: string): string {
return path.join(path.dirname(__filename), "..", "fixtures", fileName);
}
@@ -0,0 +1,752 @@
import { expect, type Locator, type Page } from "@playwright/test";
import path from "node:path";
import type { PrismaClient } from "@calcom/prisma";
import type { OAuthClientType } from "@calcom/prisma/enums";
import { test } from "../lib/fixtures";
async function loginAsSeededAdminAndGoToOAuthSettings(page: Page) {
await page.goto("/auth/login");
// Seeded admin user from scripts/seed.ts
await page.getByTestId("login-form").locator("#email").fill("admin@example.com");
await page.getByTestId("login-form").locator("#password").fill("ADMINadmin2022!");
const responsePromise = page.waitForResponse(/\/api\/auth\/callback\/credentials/);
await page.getByTestId("login-form").locator('[type="submit"]').click();
await responsePromise;
await page.goto("/settings/developer/oauth");
}
type CreateOAuthClientInput = {
name: string;
purpose: string;
redirectUri: string;
websiteUrl?: string;
enablePkce: boolean;
logoFileName?: string;
};
type CreateOAuthClientResult = {
clientId: string;
clientSecret: string | null;
};
type UpdateOAuthClientInput = {
clientId: string;
name: string;
purpose: string;
redirectUri: string;
websiteUrl: string;
logoFileName?: string;
};
type ExpectedOAuthClientDetails = {
clientId: string;
name: string;
purpose: string;
redirectUri: string;
websiteUrl: string;
statusLabel: "Approved" | "Rejected" | "Pending";
pkceEnabled: boolean;
hasLogo: boolean;
};
type TruthyExpectation = { kind: "truthy" };
const TRUTHY: TruthyExpectation = { kind: "truthy" };
type OAuthClientDbExpectations = {
name?: string;
purpose?: string;
redirectUri?: string;
websiteUrl?: string | null;
status?: string;
clientType?: OAuthClientType;
clientSecret?: string | null | TruthyExpectation;
logo?: string | null | TruthyExpectation;
};
const oAuthClientSelect = {
clientId: true,
name: true,
purpose: true,
redirectUri: true,
websiteUrl: true,
status: true,
clientType: true,
clientSecret: true,
logo: true,
} as const;
async function createOAuthClient(page: Page, input: CreateOAuthClientInput): Promise<CreateOAuthClientResult> {
await goToOAuthSettings(page);
await page.locator("header").getByTestId("open-oauth-client-create-dialog").click();
const form = getOAuthClientCreateForm(page);
await getOAuthClientCreateNameInput(form).fill(input.name);
await getOAuthClientCreatePurposeInput(form).fill(input.purpose);
await getOAuthClientCreateRedirectUriInput(form).fill(input.redirectUri);
if (input.websiteUrl) {
await getOAuthClientCreateWebsiteUrlInput(form).fill(input.websiteUrl);
}
if (input.logoFileName) {
await uploadOAuthClientLogo(page, input.logoFileName);
}
const pkceToggle = getOAuthClientPkceToggle(page);
if (input.enablePkce) {
await pkceToggle.click();
await expect(pkceToggle).toHaveAttribute("data-state", "checked");
} else {
await expect(pkceToggle).toHaveAttribute("data-state", "unchecked");
}
await page.getByTestId("oauth-client-create-submit").click();
const submitted = getOAuthClientSubmittedModal(page);
await expect(submitted).toBeVisible();
await expect(page.getByTestId("dialog-title")).toHaveText("OAuth Client Submitted");
await expect(submitted.getByText(input.name)).toBeVisible();
const clientId = ((await page.getByTestId("oauth-client-submitted-client-id").textContent()) ?? "").trim();
expect(clientId.length).toBeGreaterThan(1);
const secretLocator = page.getByTestId("oauth-client-submitted-client-secret");
const clientSecret = input.enablePkce
? null
: ((await secretLocator.textContent()) ?? "").trim();
if (input.enablePkce) {
await expect(secretLocator).toHaveCount(0);
} else {
expect(clientSecret?.length).toBeGreaterThan(1);
}
await page.getByTestId("oauth-client-submitted-done").click();
await expectOAuthClientInList(page, { clientId, name: input.name });
return { clientId, clientSecret };
}
async function updateOAuthClient(page: Page, input: UpdateOAuthClientInput): Promise<void> {
await goToOAuthSettings(page);
const details = await openOAuthClientDetails(page, input.clientId);
await getOAuthClientDetailsNameInput(details).fill(input.name);
await getOAuthClientDetailsPurposeInput(details).fill(input.purpose);
await getOAuthClientDetailsRedirectUriInput(details).fill(input.redirectUri);
await getOAuthClientDetailsWebsiteUrlInput(details).fill(input.websiteUrl);
if (input.logoFileName) {
await uploadOAuthClientLogo(page, input.logoFileName);
}
const updateResponsePromise = page.waitForResponse((res) => res.url().includes("/api/trpc/oAuth/updateClient"));
await page.getByTestId("oauth-client-details-save").click();
const updateResponse = await updateResponsePromise;
expect(updateResponse.ok()).toBe(true);
await closeOAuthClientDetails(page);
await expectOAuthClientInList(page, { clientId: input.clientId, name: input.name });
}
async function deleteOAuthClient(page: Page, clientId: string, expectedNameToDisappear: string): Promise<void> {
await goToOAuthSettings(page);
await openOAuthClientDetails(page, clientId);
await page.getByTestId("oauth-client-details-delete-trigger").click();
await expect(page.getByTestId("oauth-client-details-delete-confirm")).toBeVisible();
const deleteResponsePromise = page.waitForResponse((res) => res.url().includes("/api/trpc/oAuth/deleteClient"));
await page.getByTestId("oauth-client-details-delete-confirm").click();
const deleteResponse = await deleteResponsePromise;
expect(deleteResponse.ok()).toBe(true);
await expect(getOAuthClientDetailsForm(page)).toHaveCount(0);
await expect(getOAuthClientList(page).getByText(expectedNameToDisappear)).toHaveCount(0);
await expect(getOAuthClientListItem(page, clientId)).toHaveCount(0);
}
async function closeOAuthClientDetails(page: Page): Promise<void> {
const details = getOAuthClientDetailsForm(page);
if (!(await details.isVisible())) return;
await page.getByTestId("oauth-client-details-close").click();
await expect(details).toHaveCount(0);
}
async function openOAuthClientDetails(page: Page, clientId: string): Promise<Locator> {
const details = getOAuthClientDetailsForm(page);
if (await details.isVisible()) {
const visibleClientId = ((await details.getByTestId("oauth-client-details-client-id").textContent()) ?? "").trim();
if (visibleClientId === clientId) return details;
await closeOAuthClientDetails(page);
}
await getOAuthClientListItem(page, clientId).click();
await expect(details).toBeVisible();
await expect(details.getByTestId("oauth-client-details-client-id")).toHaveText(clientId);
return details;
}
async function expectOAuthClientDeletedInDb(prisma: PrismaClient, clientId: string): Promise<void> {
const dbClientAfterDelete = await prisma.oAuthClient.findUnique({ where: { clientId }, select: { clientId: true } });
expect(dbClientAfterDelete).toBeNull();
}
async function expectOAuthClientInDb(
prisma: PrismaClient,
clientId: string,
expected: OAuthClientDbExpectations
): Promise<void> {
const dbClient = await prisma.oAuthClient.findUniqueOrThrow({ where: { clientId }, select: oAuthClientSelect });
if (expected.name !== undefined) expect(dbClient.name).toBe(expected.name);
if (expected.purpose !== undefined) expect(dbClient.purpose).toBe(expected.purpose);
if (expected.redirectUri !== undefined) expect(dbClient.redirectUri).toBe(expected.redirectUri);
if (expected.websiteUrl !== undefined) expect(dbClient.websiteUrl).toBe(expected.websiteUrl);
if (expected.status !== undefined) expect(dbClient.status).toBe(expected.status);
if (expected.clientType !== undefined) expect(dbClient.clientType as OAuthClientType).toBe(expected.clientType);
if (expected.clientSecret !== undefined) {
if (typeof expected.clientSecret === "object" && expected.clientSecret?.kind === "truthy") {
expect(dbClient.clientSecret).toBeTruthy();
} else {
expect(dbClient.clientSecret).toBe(expected.clientSecret);
}
}
if (expected.logo !== undefined) {
if (typeof expected.logo === "object" && expected.logo?.kind === "truthy") {
expect(dbClient.logo).toBeTruthy();
} else {
expect(dbClient.logo).toBe(expected.logo);
}
}
}
async function expectOAuthClientDetails(details: Locator, expected: ExpectedOAuthClientDetails): Promise<void> {
await expect(details.getByTestId("oauth-client-details-status-badge")).toHaveText(expected.statusLabel);
await expect(details.getByTestId("oauth-client-details-client-id")).toHaveText(expected.clientId);
await expect(getOAuthClientDetailsNameInput(details)).toHaveValue(expected.name);
await expect(getOAuthClientDetailsPurposeInput(details)).toHaveValue(expected.purpose);
await expect(getOAuthClientDetailsRedirectUriInput(details)).toHaveValue(expected.redirectUri);
await expect(getOAuthClientDetailsWebsiteUrlInput(details)).toHaveValue(expected.websiteUrl);
const pkceToggle = details.page().getByTestId("oauth-client-pkce-toggle");
await expect(pkceToggle).toHaveAttribute("data-state", expected.pkceEnabled ? "checked" : "unchecked");
await expect(details.locator('img[alt="Logo"][src]')).toHaveCount(expected.hasLogo ? 1 : 0);
}
async function expectOAuthClientInList(page: Page, input: { clientId: string; name: string }): Promise<void> {
const row = getOAuthClientListItem(page, input.clientId);
await expect(row).toBeVisible();
await expect(row.getByText(input.name)).toBeVisible();
}
async function goToOAuthSettings(page: Page): Promise<void> {
if (page.url().includes("/settings/developer/oauth")) return;
await page.goto("/settings/developer/oauth");
}
async function uploadOAuthClientLogo(page: Page, fileName: string): Promise<void> {
await page.getByTestId("open-upload-oauth-client-logo-dialog").click();
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByTestId("open-upload-oauth-client-logo-filechooser").click(),
]);
await fileChooser.setFiles(getFixturePath(fileName));
await page.getByTestId("upload-oauth-client-logo").click();
}
test.describe.configure({ mode: "parallel" });
test.describe("OAuth client creation", () => {
test.afterEach(async ({ users, prisma }, testInfo) => {
const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`;
// Clean up any clients created by the e2e tests (by naming convention)
await prisma.oAuthClient.deleteMany({
where: {
name: {
startsWith: testPrefix,
},
},
});
await users.deleteAll();
});
test("cannot be created without required fields (name, purpose, redirect uri)", async ({ page }) => {
await loginAsSeededAdminAndGoToOAuthSettings(page);
await page.locator("header").getByTestId("open-oauth-client-create-dialog").click();
const form = page.getByTestId("oauth-client-create-form");
await expect(form).toBeVisible();
// Submit immediately - should trigger native browser required field validation
await page.getByTestId("oauth-client-create-submit").click();
// Playwright can assert native validation message via validationMessage.
// This matches Chrome's default: "Please fill out this field."
const nameInput = form.locator("#name");
const purposeInput = form.locator("#purpose");
const redirectUriInput = form.locator("#redirectUri");
await expect(nameInput).toHaveJSProperty("validationMessage", "Please fill out this field.");
// Fill name, submit -> purpose should be flagged
await nameInput.fill("Test OAuth Client");
await page.getByTestId("oauth-client-create-submit").click();
await expect(purposeInput).toHaveJSProperty("validationMessage", "Please fill out this field.");
// Fill purpose, submit -> redirect URI should be flagged
await purposeInput.fill("Test purpose");
await page.getByTestId("oauth-client-create-submit").click();
await expect(redirectUriInput).toHaveJSProperty("validationMessage", "Please fill out this field.");
});
test("creates a private (confidential) OAuth client with minimal fields; submitted modal shows id+secret; list/details/DB reflect values", async ({ page, prisma }, testInfo) => {
await loginAsSeededAdminAndGoToOAuthSettings(page);
const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`;
const clientName = `${testPrefix}private-minimal-${Date.now()}`;
const purpose = "Used for E2E testing (minimal)";
const redirectUri = "https://example.com/callback";
// Ensure PKCE is off (private/confidential)
const { clientId, clientSecret } = await createOAuthClient(page, {
name: clientName,
purpose,
redirectUri,
enablePkce: false,
});
expect(clientSecret?.length).toBeGreaterThan(1);
const details = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(details, {
clientId,
name: clientName,
purpose,
redirectUri,
websiteUrl: "",
statusLabel: "Pending",
pkceEnabled: false,
hasLogo: false,
});
await expectOAuthClientInDb(prisma, clientId, {
name: clientName,
purpose,
redirectUri,
websiteUrl: null,
status: "PENDING",
clientType: "CONFIDENTIAL",
clientSecret: TRUTHY,
logo: null,
});
const editedName = `${testPrefix}private-minimal-edited-${Date.now()}`;
const editedPurpose = "Updated purpose (minimal)";
const editedRedirectUri = "https://example.com/updated-callback";
const editedWebsiteUrl = "https://updated.example.com";
await updateOAuthClient(page, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
logoFileName: "cal2.png",
});
const editedDetails = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(editedDetails, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
statusLabel: "Pending",
pkceEnabled: false,
hasLogo: true,
});
await closeOAuthClientDetails(page);
await page.reload();
const editedDetailsAfterReload = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(editedDetailsAfterReload, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
statusLabel: "Pending",
pkceEnabled: false,
hasLogo: true,
});
await closeOAuthClientDetails(page);
await expectOAuthClientInDb(prisma, clientId, {
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
status: "PENDING",
clientType: "CONFIDENTIAL",
clientSecret: TRUTHY,
logo: TRUTHY,
});
await deleteOAuthClient(page, clientId, editedName);
await expectOAuthClientDeletedInDb(prisma, clientId);
});
test("editing redirect uri of an approved client triggers reapproval (status becomes PENDING)", async ({ page, prisma, users }, testInfo) => {
const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`;
const user = await users.create();
await user.apiLogin("/settings/developer/oauth");
const clientName = `${testPrefix}approved-client-${Date.now()}`;
const clientId = `${testPrefix.replaceAll("-", "")}approved${Date.now()}`;
const initialRedirectUri = "https://example.com/approved-callback";
await prisma.oAuthClient.create({
data: {
clientId,
name: clientName,
purpose: "Approved client for redirectUri reapproval test",
redirectUri: initialRedirectUri,
websiteUrl: null,
logo: null,
clientType: "PUBLIC",
clientSecret: null,
status: "APPROVED",
userId: user.id,
},
});
await page.reload();
const detailsBeforeUpdate = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(detailsBeforeUpdate, {
clientId,
name: clientName,
purpose: "Approved client for redirectUri reapproval test",
redirectUri: initialRedirectUri,
websiteUrl: "",
statusLabel: "Approved",
pkceEnabled: true,
hasLogo: false,
});
await expectOAuthClientInDb(prisma, clientId, {
status: "APPROVED",
redirectUri: initialRedirectUri,
clientType: "PUBLIC",
clientSecret: null,
});
const updatedRedirectUri = `https://example.com/approved-updated-${Date.now()}`;
await getOAuthClientDetailsRedirectUriInput(detailsBeforeUpdate).fill(updatedRedirectUri);
const updateResponsePromise = page.waitForResponse((res) => res.url().includes("/api/trpc/oAuth/updateClient"));
await page.getByTestId("oauth-client-details-save").click();
const updateResponse = await updateResponsePromise;
expect(updateResponse.ok()).toBe(true);
await closeOAuthClientDetails(page);
const detailsAfterUpdate = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(detailsAfterUpdate, {
clientId,
name: clientName,
purpose: "Approved client for redirectUri reapproval test",
redirectUri: updatedRedirectUri,
websiteUrl: "",
statusLabel: "Pending",
pkceEnabled: true,
hasLogo: false,
});
await closeOAuthClientDetails(page);
await expectOAuthClientInDb(prisma, clientId, {
status: "PENDING",
redirectUri: updatedRedirectUri,
clientType: "PUBLIC",
clientSecret: null,
});
});
test("creates a private (confidential) OAuth client; submitted modal shows id+secret; list/details/DB reflect values", async ({ page, prisma }, testInfo) => {
await loginAsSeededAdminAndGoToOAuthSettings(page);
const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`;
const clientName = `${testPrefix}private-${Date.now()}`;
const purpose = "Used for E2E testing";
const redirectUri = "https://example.com/callback";
const websiteUrl = "https://example.com";
// Upload logo using ImageUploader testIds
// Ensure PKCE is off (private/confidential)
const { clientId, clientSecret } = await createOAuthClient(page, {
name: clientName,
purpose,
redirectUri,
websiteUrl,
enablePkce: false,
logoFileName: "cal.png",
});
expect(clientSecret?.length).toBeGreaterThan(1);
const details = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(details, {
clientId,
name: clientName,
purpose,
redirectUri,
websiteUrl,
statusLabel: "Pending",
pkceEnabled: false,
hasLogo: true,
});
await expectOAuthClientInDb(prisma, clientId, {
name: clientName,
purpose,
redirectUri,
websiteUrl,
status: "PENDING",
clientType: "CONFIDENTIAL",
clientSecret: TRUTHY,
logo: TRUTHY,
});
const editedName = `${testPrefix}private-edited-${Date.now()}`;
const editedPurpose = "Updated purpose (full private)";
const editedRedirectUri = "https://example.com/full-private-updated";
const editedWebsiteUrl = "https://full-private.example.com";
await updateOAuthClient(page, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
logoFileName: "cal2.png",
});
const editedDetails = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(editedDetails, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
statusLabel: "Pending",
pkceEnabled: false,
hasLogo: true,
});
await closeOAuthClientDetails(page);
await page.reload();
const editedDetailsAfterReload = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(editedDetailsAfterReload, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
statusLabel: "Pending",
pkceEnabled: false,
hasLogo: true,
});
await closeOAuthClientDetails(page);
await expectOAuthClientInDb(prisma, clientId, {
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
status: "PENDING",
clientType: "CONFIDENTIAL",
clientSecret: TRUTHY,
logo: TRUTHY,
});
await deleteOAuthClient(page, clientId, editedName);
await expectOAuthClientDeletedInDb(prisma, clientId);
});
test("creates a public (PKCE) OAuth client; submitted modal shows id but no secret; list/details/DB reflect values", async ({ page, prisma }, testInfo) => {
await loginAsSeededAdminAndGoToOAuthSettings(page);
const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`;
const clientName = `${testPrefix}public-${Date.now()}`;
const purpose = "Used for E2E testing (public)";
const redirectUri = "https://example.com/callback";
const websiteUrl = "https://example.com";
// Enable PKCE
// For public (PKCE) clients we should not show a client secret
const { clientId } = await createOAuthClient(page, {
name: clientName,
purpose,
redirectUri,
websiteUrl,
enablePkce: true,
});
const details = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(details, {
clientId,
name: clientName,
purpose,
redirectUri,
websiteUrl,
statusLabel: "Pending",
pkceEnabled: true,
hasLogo: false,
});
await expectOAuthClientInDb(prisma, clientId, {
name: clientName,
purpose,
redirectUri,
websiteUrl,
status: "PENDING",
clientType: "PUBLIC",
clientSecret: null,
logo: null,
});
const editedName = `${testPrefix}public-edited-${Date.now()}`;
const editedPurpose = "Updated purpose (public)";
const editedRedirectUri = "https://example.com/public-updated";
const editedWebsiteUrl = "https://public-updated.example.com";
await updateOAuthClient(page, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
logoFileName: "cal2.png",
});
const editedDetails = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(editedDetails, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
statusLabel: "Pending",
pkceEnabled: true,
hasLogo: true,
});
await closeOAuthClientDetails(page);
await page.reload();
const editedDetailsAfterReload = await openOAuthClientDetails(page, clientId);
await expectOAuthClientDetails(editedDetailsAfterReload, {
clientId,
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
statusLabel: "Pending",
pkceEnabled: true,
hasLogo: true,
});
await closeOAuthClientDetails(page);
await expectOAuthClientInDb(prisma, clientId, {
name: editedName,
purpose: editedPurpose,
redirectUri: editedRedirectUri,
websiteUrl: editedWebsiteUrl,
status: "PENDING",
clientType: "PUBLIC",
clientSecret: null,
logo: TRUTHY,
});
await deleteOAuthClient(page, clientId, editedName);
await expectOAuthClientDeletedInDb(prisma, clientId);
});
});
function getFixturePath(fileName: string): string {
return path.join(path.dirname(__filename), "..", "fixtures", fileName);
}
function getOAuthClientCreateForm(page: Page): Locator {
return page.getByTestId("oauth-client-create-form");
}
function getOAuthClientSubmittedModal(page: Page): Locator {
return page.getByTestId("oauth-client-submitted-modal");
}
function getOAuthClientList(page: Page): Locator {
return page.getByTestId("oauth-clients-list");
}
function getOAuthClientListItem(page: Page, clientId: string): Locator {
return page.getByTestId(`oauth-client-list-item-${clientId}`);
}
function getOAuthClientDetailsForm(page: Page): Locator {
return page.getByTestId("oauth-client-details-form");
}
function getOAuthClientPkceToggle(page: Page): Locator {
return page.getByTestId("oauth-client-pkce-toggle");
}
function getOAuthClientCreateNameInput(form: Locator): Locator {
return form.locator("#name");
}
function getOAuthClientCreatePurposeInput(form: Locator): Locator {
return form.locator("#purpose");
}
function getOAuthClientCreateRedirectUriInput(form: Locator): Locator {
return form.locator("#redirectUri");
}
function getOAuthClientCreateWebsiteUrlInput(form: Locator): Locator {
return form.locator("#websiteUrl");
}
function getOAuthClientDetailsNameInput(details: Locator): Locator {
return details.locator("#name");
}
function getOAuthClientDetailsPurposeInput(details: Locator): Locator {
return details.locator("#purpose");
}
function getOAuthClientDetailsRedirectUriInput(details: Locator): Locator {
return details.locator("#redirectUri");
}
function getOAuthClientDetailsWebsiteUrlInput(details: Locator): Locator {
return details.locator("#websiteUrl");
}
@@ -0,0 +1,75 @@
import { expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { test } from "../lib/fixtures";
import type { PrismaClient } from "@calcom/prisma";
test.describe("OAuth - refresh tokens", () => {
test.afterEach(async ({ prisma, users }, testInfo) => {
const testPrefix = `e2e-oauth-refresh-status-${testInfo.testId}-`;
await prisma.oAuthClient.deleteMany({
where: {
name: {
startsWith: testPrefix,
},
},
});
await users.deleteAll();
});
async function createOAuthClient({
prisma,
name,
status,
clientType,
}: {
prisma: PrismaClient;
name: string;
status: "PENDING" | "APPROVED" | "REJECTED";
clientType: "PUBLIC" | "CONFIDENTIAL";
}) {
const clientId = randomBytes(32).toString("hex");
const client = await prisma.oAuthClient.create({
data: {
clientId,
name,
redirectUri: "https://example.com",
clientSecret: null,
clientType,
status,
},
});
return client;
}
test("token refresh fails if client is not approved", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-refresh-status-check" });
await user.apiLogin();
const testPrefix = `e2e-oauth-refresh-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}pending-${Date.now()}`,
status: "PENDING",
clientType: "PUBLIC",
});
const refreshResponse = await page.request.post("/api/auth/oauth/refreshToken", {
form: {
grant_type: "refresh_token",
client_id: client.clientId,
refresh_token: "fake-refresh-token",
},
});
expect(refreshResponse.status()).toBe(401);
const refreshJson = await refreshResponse.json();
expect(refreshJson.error).toBe("unauthorized_client");
});
});
+80 -8
View File
@@ -308,7 +308,6 @@
"required": "Required",
"optional": "Optional",
"input_type": "Input type",
"rejected": "Rejected",
"unconfirmed": "Unconfirmed",
"guests": "Guests",
"guest": "Guest",
@@ -634,7 +633,6 @@
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
"edit_logo": "Edit logo",
"upload_a_logo": "Upload a logo",
"upload_logo": "Upload logo",
"remove_logo": "Remove logo",
"enable": "Enable",
"code": "Code",
@@ -1040,12 +1038,85 @@
"file_not_named": "File is not named [idOrSlug]/[user]",
"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",
"nameless_team": "Nameless Team",
"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",
"delete_oauth_client": "Delete OAuth client",
"confirm_delete_oauth_client": "Are you sure you want to delete this OAuth client? This action cannot be undone.",
"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. You will receive an email if it is approved or rejected. The OAuth client can't be used unless approved.",
"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.",
"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",
"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": "Name",
"client_name_placeholder": "My OAuth App",
"purpose": "Purpose",
"purpose_placeholder": "Explain what this OAuth client is for and how it will be used",
"purpose_tooltip": "Please explain how and what this OAuth client will be used for. This helps us review and approve your request.",
"redirect_uri": "Redirect URI",
"redirect_uri_placeholder": "https://example.com/callback",
"website_url": "Website URL",
"website_url_placeholder": "https://example.com",
"website_url_tooltip": "For development, you can use a localhost URL (e.g. http://localhost:3000).",
"authentication_mode": "Authentication Mode",
"use_pkce": "Use PKCE (recommended for mobile/SPA applications)",
"upload_logo": "Upload Logo",
"client_id": "Client ID",
"client_secret": "Client Secret",
"oauth_client_client_secret_one_time_warning": "This client secret is shown only once. Copy it now — you won’t be able to view it again after closing this dialog.",
"copy_client_id": "Copy Client ID",
"client_id_copied": "Client ID copied to clipboard",
"submitted_by": "Submitted By",
"approve": "Approve",
"approved": "Approved",
"rejected": "Rejected",
"hi_user": "Hi {{name}}",
"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": "OAuth Client Approved",
"oauth_client_approved_description": "The OAuth client has been approved and the client secret has been generated.",
"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.",
"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.",
"reason_for_rejection": "Reason for rejection",
"oauth_client_rejection_reason": "Rejection reason",
"oauth_client_approved_reapproval_info": "Changing the client's name, logo, website URL, or redirect URIs will trigger a re-review by our admins, and the client will be unusable until approved again.",
"oauth_client_pending_info_description": "This OAuth client is pending approval and cannot be used yet.",
"reject_oauth_client": "Reject OAuth client",
"oauth_client_rejected_email_subject": "Your OAuth Client {{clientName}} Has Been Rejected",
"oauth_client_rejected_email_title": "OAuth Client Rejected: {{clientName}}",
"oauth_client_rejected_email_body": "Unfortunately, your OAuth client submission was rejected.",
"oauth_client_rejected_email_cta": "View Your OAuth Clients",
"oauth_client_rejected_email_footer": "You can update your OAuth client submission and resubmit it for review.",
"oauth_client_rejected_email_reason_label": "Reason for rejection",
"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",
@@ -1091,7 +1162,6 @@
"copy_times_to": "Copy times to",
"copy_times_to_tooltip": "Copy times to …",
"change_weekly_schedule": "Change your weekly schedule",
"logo": "Logo",
"error": "Error",
"at_least_characters_one": "Please enter at least one character",
"at_least_characters_other": "Please enter at least {{count}} characters",
@@ -1670,6 +1740,7 @@
"organizer_name_variable": "Organizer name",
"app_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.",
"invalid_number": "Invalid phone number",
"invalid_url": "Invalid URL",
"invalid_url_error_message": "Invalid URL for {{label}}. Sample URL: {{sampleUrl}}",
"navigate": "Navigate",
"open": "Open",
@@ -3615,7 +3686,6 @@
"invalid_link": "Invalid link",
"currently_subscribed_to_plan": "You are currently subscribed to {{planFirstLetter}}{{planRest}} plan",
"user_team": "User/Team",
"client_id_copied": "Client ID copied!",
"client_secret_copied": "Client secret copied!",
"maintenance_message": "The Cal.com team are performing scheduled maintenance. If you have any questions, please contact support.",
"your_payment_failed": "Your payment failed",
@@ -4256,6 +4326,8 @@
"booking_response": "Booking Response",
"list_view": "List view",
"calendar_view": "Calendar view",
"oauth_client": "OAuth Client",
"actions": "Actions",
"holidays": "Holidays",
"holidays_description": "We will automatically mark you as unavailable for the selected holidays",
"country_for_holidays": "Country for holidays",
+33
View File
@@ -0,0 +1,33 @@
import type BaseEmail from "@calcom/emails/templates/_base-email";
import type { OAuthClientNotification } from "./templates/admin-oauth-client-notification";
import AdminOAuthClientNotification from "./templates/admin-oauth-client-notification";
import type { OAuthClientApprovedNotification } from "./templates/oauth-client-approved-notification";
import OAuthClientApprovedEmail from "./templates/oauth-client-approved-notification";
import type { OAuthClientRejectedNotification } from "./templates/oauth-client-rejected-notification";
import OAuthClientRejectedEmail from "./templates/oauth-client-rejected-notification";
const sendEmail = async (prepare: () => BaseEmail) => {
let email: BaseEmail | undefined;
try {
email = prepare();
return await email.sendEmail();
} catch (e) {
const errorName = e instanceof Error ? e.name : "UnknownError";
console.error(`${email?.constructor?.name ?? "Email"}.sendEmail oauth-email-service failed (${errorName})`, e);
throw e;
}
};
export const sendAdminOAuthClientNotification = async (input: OAuthClientNotification) => {
await sendEmail(() => new AdminOAuthClientNotification(input));
};
export const sendOAuthClientApprovedNotification = async (input: OAuthClientApprovedNotification) => {
await sendEmail(() => new OAuthClientApprovedEmail(input));
};
export const sendOAuthClientRejectedNotification = async (input: OAuthClientRejectedNotification) => {
await sendEmail(() => new OAuthClientRejectedEmail(input));
};
@@ -0,0 +1,124 @@
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;
purpose: string | null;
clientId: string;
redirectUri: string;
submitterEmail: string;
submitterName: string | null;
};
export const AdminOAuthClientNotificationEmail = ({
clientName,
purpose,
clientId,
redirectUri,
submitterEmail,
submitterName,
language,
}: AdminOAuthClientNotification) => {
return (
<BaseEmailHtml
subject={language("admin_oauth_notification_email_subject", { clientName, appName: APP_NAME })}
callToAction={
<CallToAction
label={language("admin_oauth_notification_email_cta")}
href={`${WEBAPP_URL}/settings/admin/oauth`}
endIconName="white-arrow-right"
/>
}>
<p
style={{
fontWeight: 600,
fontSize: "24px",
lineHeight: "38px",
}}>
<>{language("admin_oauth_notification_email_title", { clientName })}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{language("hi_admin")}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{language("admin_oauth_notification_email_body", { submitterEmail })}
</p>
<table
role="presentation"
border={0}
cellSpacing="0"
cellPadding="0"
style={{
verticalAlign: "top",
marginTop: "10px",
borderRadius: "6px",
borderCollapse: "separate",
border: "solid #e5e7eb 1px",
}}
width="100%">
<tbody>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
width: "30%",
}}>
{language("client_name")}
</td>
<td style={{ padding: "12px", borderBottom: "1px solid #e5e7eb" }}>{clientName}</td>
</tr>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
}}>
{language("purpose")}
</td>
<td style={{ padding: "12px", borderBottom: "1px solid #e5e7eb" }}>{purpose ?? ""}</td>
</tr>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
}}>
{language("client_id")}
</td>
<td style={{ padding: "12px", borderBottom: "1px solid #e5e7eb" }}>
<code style={{ fontSize: "12px" }}>{clientId}</code>
</td>
</tr>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
}}>
{language("redirect_uri")}
</td>
<td style={{ padding: "12px", borderBottom: "1px solid #e5e7eb" }}>{redirectUri}</td>
</tr>
<tr style={{ lineHeight: "24px" }}>
<td style={{ padding: "12px", fontWeight: 600 }}>{language("submitted_by")}</td>
<td style={{ padding: "12px" }}>
{submitterName ? `${submitterName} (${submitterEmail})` : submitterEmail}
</td>
</tr>
</tbody>
</table>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "20px" }}>
{language("admin_oauth_notification_email_footer")}
</p>
</BaseEmailHtml>
);
};
@@ -0,0 +1,87 @@
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 (
<BaseEmailHtml
subject={language("oauth_client_approved_email_subject", { clientName, appName: APP_NAME })}
callToAction={
<CallToAction
label={language("oauth_client_approved_email_cta")}
href={`${WEBAPP_URL}/settings/developer/oauth`}
endIconName="white-arrow-right"
/>
}>
<p
style={{
fontWeight: 600,
fontSize: "24px",
lineHeight: "38px",
}}>
{language("oauth_client_approved_email_title", { clientName })}
</p>
<p style={{ fontWeight: 400 }}>
{language("hi_user", { name: userName || language("there") })}!
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>{language("oauth_client_approved_email_body")}</p>
<table
role="presentation"
border={0}
cellSpacing="0"
cellPadding="0"
style={{
verticalAlign: "top",
marginTop: "10px",
borderRadius: "6px",
borderCollapse: "separate",
border: "solid #e5e7eb 1px",
}}
width="100%">
<tbody>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
width: "30%",
}}>
{language("client_name")}
</td>
<td style={{ padding: "12px", borderBottom: "1px solid #e5e7eb" }}>{clientName}</td>
</tr>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
fontWeight: 600,
}}>
{language("client_id")}
</td>
<td style={{ padding: "12px" }}>
<code style={{ fontSize: "12px" }}>{clientId}</code>
</td>
</tr>
</tbody>
</table>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "20px" }}>
{language("oauth_client_approved_email_footer")}
</p>
</BaseEmailHtml>
);
};
@@ -0,0 +1,101 @@
import type { TFunction } from "i18next";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
type OAuthClientRejectedNotification = {
language: TFunction;
userName: string | null;
clientName: string;
clientId: string;
rejectionReason: string;
};
export const OAuthClientRejectedNotificationEmail = ({
userName,
clientName,
clientId,
rejectionReason,
language,
}: OAuthClientRejectedNotification) => {
return (
<BaseEmailHtml
subject={language("oauth_client_rejected_email_subject", { clientName, appName: APP_NAME })}
callToAction={
<CallToAction
label={language("oauth_client_rejected_email_cta")}
href={`${WEBAPP_URL}/settings/developer/oauth`}
endIconName="white-arrow-right"
/>
}>
<p
style={{
fontWeight: 600,
fontSize: "24px",
lineHeight: "38px",
}}>
{language("oauth_client_rejected_email_title", { clientName })}
</p>
<p style={{ fontWeight: 400 }}>{language("hi_user", { name: userName || language("there") })}!</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>{language("oauth_client_rejected_email_body")}</p>
<table
role="presentation"
border={0}
cellSpacing="0"
cellPadding="0"
style={{
verticalAlign: "top",
marginTop: "10px",
borderRadius: "6px",
borderCollapse: "separate",
border: "solid #e5e7eb 1px",
}}
width="100%">
<tbody>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
width: "30%",
}}>
{language("client_name")}
</td>
<td style={{ padding: "12px", borderBottom: "1px solid #e5e7eb" }}>{clientName}</td>
</tr>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
borderBottom: "1px solid #e5e7eb",
fontWeight: 600,
width: "30%",
}}>
{language("client_id")}
</td>
<td style={{ padding: "12px", borderBottom: "1px solid #e5e7eb" }}>
<code style={{ fontSize: "12px" }}>{clientId}</code>
</td>
</tr>
<tr style={{ lineHeight: "24px" }}>
<td
style={{
padding: "12px",
fontWeight: 600,
}}>
{language("oauth_client_rejected_email_reason_label")}
</td>
<td style={{ padding: "12px" }}>
<span style={{ whiteSpace: "pre-wrap" }}>{rejectionReason}</span>
</td>
</tr>
</tbody>
</table>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "20px" }}>
{language("oauth_client_rejected_email_footer")}
</p>
</BaseEmailHtml>
);
};
+3
View File
@@ -37,6 +37,9 @@ 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 { OAuthClientRejectedNotificationEmail } from "./OAuthClientRejectedNotificationEmail";
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import { SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import AdminOAuthClientNotification from "./admin-oauth-client-notification";
class TestAdminOAuthClientNotification extends AdminOAuthClientNotification {
public async getPayload() {
return await this.getNodeMailerPayload();
}
}
describe("AdminOAuthClientNotification", () => {
it("renders a node mailer payload containing the submitted client information", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "admin_oauth_notification_email_subject") {
return `New OAuth Client: ${String(vars?.clientName ?? "")}`;
}
if (key === "admin_oauth_notification_email_title") {
return `New OAuth Client: ${String(vars?.clientName ?? "")}`;
}
if (key === "hi_admin") return "Hi Administrator";
if (key === "admin_oauth_notification_email_body") {
return `A new OAuth client has been submitted by ${String(vars?.submitterEmail ?? "")} and is awaiting your review.`;
}
if (key === "admin_oauth_notification_email_cta") return "Review OAuth Clients";
if (key === "admin_oauth_notification_email_footer") {
return "Please review this submission and approve or reject it in the admin dashboard.";
}
if (key === "client_name") return "Name";
if (key === "purpose") return "Purpose";
if (key === "client_id") return "Client ID";
if (key === "redirect_uri") return "Redirect URI";
if (key === "submitted_by") return "Submitted By";
return key;
}) as unknown as TFunction;
const input = {
t,
clientName: "My Client",
purpose: "My Purpose",
clientId: "client_123",
redirectUri: "https://example.com/callback",
submitterEmail: "submitter@example.com",
submitterName: "Submitter Name",
};
const email = new TestAdminOAuthClientNotification(input);
const payload = await email.getPayload();
expect(payload).toEqual(
expect.objectContaining({
to: SUPPORT_MAIL_ADDRESS,
subject: `New OAuth Client: ${input.clientName}`,
})
);
const html = "html" in payload ? (payload.html as string) : "";
expect(html).toContain(`New OAuth Client: ${input.clientName}`);
expect(html).toContain("Hi Administrator");
expect(html).toContain(
`A new OAuth client has been submitted by ${input.submitterEmail} and is awaiting your review.`
);
expect(html).toContain("Review OAuth Clients");
expect(html).toContain("Please review this submission and approve or reject it in the admin dashboard.");
expect(html).toContain(">Name<");
expect(html).toContain(input.clientName);
expect(html).toContain(">Purpose<");
expect(html).toContain(input.purpose);
expect(html).toContain(">Client ID<");
expect(html).toContain(input.clientId);
expect(html).toContain(">Redirect URI<");
expect(html).toContain(input.redirectUri);
expect(html).toContain(">Submitted By<");
expect(html).toContain(`${input.submitterName} (${input.submitterEmail})`);
});
});
@@ -0,0 +1,50 @@
import type { TFunction } from "i18next";
import { EMAIL_FROM_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
export type OAuthClientNotification = {
t: TFunction;
clientName: string;
purpose: string | null;
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<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: SUPPORT_MAIL_ADDRESS,
subject: `${this.input.t("admin_oauth_notification_email_subject", { clientName: this.input.clientName })}`,
html: await renderEmail("AdminOAuthClientNotificationEmail", {
clientName: this.input.clientName,
purpose: this.input.purpose,
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 })}
${this.input.t("purpose")}: ${this.input.purpose ?? ""}`.trim();
}
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import OAuthClientApprovedEmail from "./oauth-client-approved-notification";
class TestOAuthClientApprovedEmail extends OAuthClientApprovedEmail {
public async getPayload() {
return await this.getNodeMailerPayload();
}
}
describe("OAuthClientApprovedEmail", () => {
it("renders a node mailer payload containing the approved client information", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "oauth_client_approved_email_subject") {
return `OAuth Client Approved: ${String(vars?.clientName ?? "")}`;
}
if (key === "oauth_client_approved_email_title") {
return `OAuth Client Approved: ${String(vars?.clientName ?? "")}`;
}
if (key === "hi_user") return `Hi ${String(vars?.name ?? "")}`;
if (key === "there") return "there";
if (key === "oauth_client_approved_email_body") {
return "Great news! Your OAuth client has been approved and is now ready to use.";
}
if (key === "oauth_client_approved_email_footer") {
return "You can now use your client ID and secret to integrate with Cal.com.";
}
if (key === "oauth_client_approved_email_cta") return "View Your OAuth Clients";
if (key === "client_name") return "Name";
if (key === "client_id") return "Client ID";
return key;
}) as unknown as TFunction;
const input = {
t,
userEmail: "admin@example.com",
userName: "Admin Example",
clientName: "pkce",
clientId: "client_123",
};
const email = new TestOAuthClientApprovedEmail(input);
const payload = await email.getPayload();
expect(payload).toEqual(
expect.objectContaining({
to: input.userEmail,
subject: `OAuth Client Approved: ${input.clientName}`,
})
);
const html = "html" in payload ? (payload.html as string) : "";
expect(html).toContain(`OAuth Client Approved: ${input.clientName}`);
expect(html).toContain("Hi Admin Example!");
expect(html).toContain("Great news! Your OAuth client has been approved and is now ready to use.");
expect(html).toContain(">Name<");
expect(html).toContain(input.clientName);
expect(html).toContain(">Client ID<");
expect(html).toContain(input.clientId);
expect(html).toContain("View Your OAuth Clients");
expect(html).toContain("/settings/developer/oauth");
expect(html).toContain("You can now use your client ID and secret to integrate with Cal.com.");
});
});
@@ -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<Record<string, unknown>> {
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();
}
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import OAuthClientRejectedEmail from "./oauth-client-rejected-notification";
class TestOAuthClientRejectedEmail extends OAuthClientRejectedEmail {
public async getPayload() {
return await this.getNodeMailerPayload();
}
}
describe("OAuthClientRejectedEmail", () => {
it("renders a node mailer payload containing the rejected client information and rejection reason", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "oauth_client_rejected_email_subject") {
return `OAuth Client Rejected: ${String(vars?.clientName ?? "")}`;
}
if (key === "oauth_client_rejected_email_title") {
return `OAuth Client Rejected: ${String(vars?.clientName ?? "")}`;
}
if (key === "hi_user") return `Hi ${String(vars?.name ?? "")}`;
if (key === "there") return "there";
if (key === "oauth_client_rejected_email_body") {
return "Unfortunately, your OAuth client submission was rejected.";
}
if (key === "oauth_client_rejected_email_reason_label") return "Reason for rejection";
if (key === "oauth_client_rejected_email_footer") {
return "You can update your OAuth client submission and resubmit it for review.";
}
if (key === "oauth_client_rejected_email_cta") return "View Your OAuth Clients";
if (key === "client_name") return "Name";
if (key === "client_id") return "Client ID";
return key;
}) as unknown as TFunction;
const input = {
t,
userEmail: "admin@example.com",
userName: "Admin Example",
clientName: "pkce",
clientId: "client_123",
rejectionReason: "we dont support your usecase",
};
const email = new TestOAuthClientRejectedEmail(input);
const payload = await email.getPayload();
expect(payload).toEqual(
expect.objectContaining({
to: input.userEmail,
subject: `OAuth Client Rejected: ${input.clientName}`,
})
);
const html = "html" in payload ? (payload.html as string) : "";
expect(html).toContain(`OAuth Client Rejected: ${input.clientName}`);
expect(html).toContain("Hi Admin Example!");
expect(html).toContain("Unfortunately, your OAuth client submission was rejected.");
expect(html).toContain(">Name<");
expect(html).toContain(input.clientName);
expect(html).toContain(">Client ID<");
expect(html).toContain(input.clientId);
expect(html).toContain("Reason for rejection");
expect(html).toContain(input.rejectionReason);
expect(html).toContain("View Your OAuth Clients");
expect(html).toContain("/settings/developer/oauth");
expect(html).toContain("You can update your OAuth client submission and resubmit it for review.");
});
});
@@ -0,0 +1,49 @@
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 OAuthClientRejectedNotification = {
t: TFunction;
userEmail: string;
userName: string | null;
clientName: string;
clientId: string;
rejectionReason: string;
};
export default class OAuthClientRejectedEmail extends BaseEmail {
input: OAuthClientRejectedNotification;
constructor(input: OAuthClientRejectedNotification) {
super();
this.name = "SEND_OAUTH_CLIENT_REJECTED_NOTIFICATION";
this.input = input;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.input.userEmail,
subject: `${this.input.t("oauth_client_rejected_email_subject", { clientName: this.input.clientName })}`,
html: await renderEmail("OAuthClientRejectedNotificationEmail", {
userName: this.input.userName,
clientName: this.input.clientName,
clientId: this.input.clientId,
rejectionReason: this.input.rejectionReason,
language: this.input.t,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `${this.input.t("oauth_client_rejected_email_title", { clientName: this.input.clientName })}
${this.input.t("oauth_client_rejected_email_body")}
${this.input.t("oauth_client_rejected_email_reason_label")}
${this.input.rejectionReason}`.trim();
}
}
@@ -1,4 +1,8 @@
import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@calcom/prisma";
import type { OAuthClientStatus } from "@calcom/prisma/enums";
export class OAuthClientRepository {
constructor(private readonly prisma: PrismaClient) {}
@@ -12,9 +16,15 @@ export class OAuthClientRepository {
redirectUri: true,
clientType: true,
name: true,
purpose: true,
logo: true,
clientId: true,
isTrusted: true,
websiteUrl: true,
rejectionReason: true,
status: true,
userId: true,
createdAt: true,
},
});
}
@@ -27,7 +37,191 @@ export class OAuthClientRepository {
redirectUri: true,
clientSecret: true,
clientType: true,
status: true,
},
});
}
async findByClientIdIncludeUser(clientId: string) {
return this.prisma.oAuthClient.findUnique({
where: { clientId },
select: {
clientId: true,
redirectUri: true,
clientType: true,
name: true,
purpose: true,
logo: true,
websiteUrl: true,
rejectionReason: true,
isTrusted: true,
status: true,
userId: true,
createdAt: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
}
async findByUserId(userId: number) {
return this.prisma.oAuthClient.findMany({
where: { userId },
select: {
clientId: true,
redirectUri: true,
name: true,
purpose: true,
logo: true,
websiteUrl: true,
rejectionReason: true,
clientType: true,
status: true,
userId: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
}
async findByUserIdAndStatus(userId: number, status: OAuthClientStatus) {
return this.prisma.oAuthClient.findMany({
where: { userId, status },
orderBy: { createdAt: "desc" },
});
}
async findAll() {
return this.prisma.oAuthClient.findMany({
select: {
clientId: true,
redirectUri: true,
name: true,
purpose: true,
logo: true,
websiteUrl: true,
rejectionReason: true,
clientType: true,
status: true,
userId: true,
createdAt: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
});
}
async findByStatus(status: OAuthClientStatus) {
return this.prisma.oAuthClient.findMany({
where: { status },
select: {
clientId: true,
redirectUri: true,
name: true,
purpose: true,
logo: true,
websiteUrl: true,
rejectionReason: true,
clientType: true,
status: true,
userId: true,
createdAt: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
});
}
async create(data: {
name: string;
purpose: string;
redirectUri: string;
clientSecret?: string;
logo?: string;
websiteUrl?: string;
enablePkce?: boolean;
userId?: number;
status: OAuthClientStatus;
}) {
const { name, purpose, redirectUri, clientSecret, logo, websiteUrl, enablePkce, userId, status } = data;
const clientId = randomBytes(32).toString("hex");
const client = await this.prisma.oAuthClient.create({
data: {
name,
purpose,
redirectUri,
clientId,
clientType: enablePkce ? "PUBLIC" : "CONFIDENTIAL",
logo,
websiteUrl,
status,
clientSecret,
...(userId && {
user: {
connect: { id: userId },
},
}),
},
});
return {
clientId: client.clientId,
name: client.name,
purpose: client.purpose,
redirectUri: client.redirectUri,
logo: client.logo,
clientType: client.clientType,
clientSecret: client.clientSecret,
isPkceEnabled: enablePkce,
status: client.status,
};
}
async updateStatus(clientId: string, status: OAuthClientStatus) {
return this.prisma.oAuthClient.update({
where: { clientId },
data: { status },
});
}
async update(
clientId: string,
data: {
name?: string;
purpose?: string;
redirectUri?: string;
logo?: string;
websiteUrl?: string;
}
) {
return this.prisma.oAuthClient.update({
where: { clientId },
data,
});
}
async delete(clientId: string) {
return this.prisma.oAuthClient.delete({
where: { clientId },
});
}
}
@@ -1,4 +1,4 @@
import { randomBytes } from "crypto";
import { randomBytes } from "node:crypto";
import jwt from "jsonwebtoken";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
@@ -8,6 +8,7 @@ import { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { verifyCodeChallenge } from "@calcom/lib/pkce";
import { OAuthClientStatus } from "@calcom/prisma/enums";
import type { AccessScope, OAuthClientType } from "@calcom/prisma/enums";
export interface OAuth2Client {
@@ -51,6 +52,7 @@ export class OAuthService {
private readonly accessCodeRepository: AccessCodeRepository;
private readonly teamsRepository: TeamRepository;
private readonly oAuthClientRepository: OAuthClientRepository;
constructor(
private readonly deps: {
oAuthClientRepository: OAuthClientRepository;
@@ -67,7 +69,7 @@ export class OAuthService {
const client = await this.oAuthClientRepository.findByClientId(clientId);
if (!client) {
throw new ErrorWithCode(ErrorCode.NotFound, "unauthorized_client");
throw new ErrorWithCode(ErrorCode.NotFound, "unauthorized_client", { reason: "client_not_found" });
}
return {
@@ -80,6 +82,27 @@ export class OAuthService {
};
}
async getClientForAuthorization(clientId: string, redirectUri: string): Promise<OAuth2Client> {
const client = await this.oAuthClientRepository.findByClientId(clientId);
if (!client) {
throw new ErrorWithCode(ErrorCode.NotFound, "unauthorized_client", { reason: "client_not_found" });
}
this.validateRedirectUri(client.redirectUri, redirectUri);
this.ensureClientIsApproved(client);
return {
clientId: client.clientId,
redirectUri: client.redirectUri,
name: client.name,
logo: client.logo,
isTrusted: client.isTrusted,
clientType: client.clientType,
};
}
async generateAuthorizationCode(
clientId: string,
userId: number,
@@ -96,6 +119,8 @@ export class OAuthService {
throw new ErrorWithCode(ErrorCode.Unauthorized, "unauthorized_client", { reason: "client_not_found" });
}
this.ensureClientIsApproved(client);
// RFC 6749 4.1.2.1: Redirect URI mismatch on Auth step is 'invalid_request'
this.validateRedirectUri(client.redirectUri, redirectUri);
@@ -148,6 +173,12 @@ export class OAuthService {
return { redirectUrl, authorizationCode, client };
}
private ensureClientIsApproved(client: { status: OAuthClientStatus }): void {
if (client.status !== OAuthClientStatus.APPROVED) {
throw new ErrorWithCode(ErrorCode.Unauthorized, "unauthorized_client", { reason: "client_not_approved" });
}
}
private validateRedirectUri(registeredUri: string, providedUri: string): void {
if (providedUri !== registeredUri) {
throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_request", { reason: "redirect_uri_mismatch" });
@@ -285,6 +316,8 @@ export class OAuthService {
});
}
this.ensureClientIsApproved(client);
const decodedToken = this.verifyRefreshToken(refreshToken);
if (!decodedToken || decodedToken.token_type !== "Refresh Token") {
@@ -423,6 +456,7 @@ export class OAuthService {
export type OAuthErrorReason =
| "client_not_found"
| "client_not_approved"
| "redirect_uri_mismatch"
| "pkce_required"
| "invalid_code_challenge_method"
@@ -439,7 +473,8 @@ export type OAuthErrorReason =
// Mapping of OAuth error reasons to descriptive messages, keeping previous messages for compatibility
export const OAUTH_ERROR_REASONS: Record<OAuthErrorReason, string> = {
client_not_found: "OAuth client with ID not found",
redirect_uri_mismatch: "redirect_uri does not match registered redirect URI",
client_not_approved: "OAuth client is not approved",
redirect_uri_mismatch: "redirect_uri does not match OAuth client's redirect URI",
pkce_required: "code_challenge required for public clients",
invalid_code_challenge_method: "code_challenge_method must be S256",
team_not_found_or_no_access: "Team not found or user is not an admin/owner",
@@ -1,6 +1,5 @@
import { randomBytes, createHash } from "crypto";
import { randomBytes, createHash } from "node:crypto";
const hashSecretKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
export 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];
@@ -0,0 +1,16 @@
-- CreateEnum
CREATE TYPE "public"."OAuthClientStatus" AS ENUM ('pending', 'approved', 'rejected');
-- AlterTable
ALTER TABLE "public"."OAuthClient" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "purpose" TEXT,
ADD COLUMN "rejectionReason" TEXT,
ADD COLUMN "status" "public"."OAuthClientStatus" NOT NULL DEFAULT 'approved',
ADD COLUMN "userId" INTEGER,
ADD COLUMN "websiteUrl" TEXT;
-- CreateIndex
CREATE INDEX "OAuthClient_userId_idx" ON "public"."OAuthClient"("userId");
-- AddForeignKey
ALTER TABLE "public"."OAuthClient" ADD CONSTRAINT "OAuthClient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+24 -8
View File
@@ -484,6 +484,7 @@ model User {
whitelistWorkflows Boolean @default(false)
calAiPhoneNumbers CalAiPhoneNumber[]
agents Agent[]
oAuthClients OAuthClient[]
holidaySettings UserHolidaySettings?
autoOptInFeatures Boolean @default(false)
@@ -1774,15 +1775,30 @@ enum OAuthClientType {
PUBLIC @map("public")
}
enum OAuthClientStatus {
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?
isTrusted Boolean @default(false)
accessCodes AccessCode[]
clientId String @id @unique
redirectUri String
clientSecret String?
clientType OAuthClientType @default(CONFIDENTIAL)
name String
purpose String?
logo String?
websiteUrl String?
rejectionReason String?
isTrusted Boolean @default(false)
accessCodes AccessCode[]
status OAuthClientStatus @default(APPROVED)
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([userId])
}
model AccessCode {
@@ -1,29 +1,30 @@
import authedProcedure, { authedAdminProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAddClientInputSchema } from "./addClient.schema";
import { ZCreateClientInputSchema } from "./createClient.schema";
import { ZGenerateAuthCodeInputSchema } from "./generateAuthCode.schema";
import { ZGetClientInputSchema } from "./getClient.schema";
type OAuthRouterHandlerCache = {
getClient?: typeof import("./getClient.handler").getClientHandler;
addClient?: typeof import("./addClient.handler").addClientHandler;
generateAuthCode?: typeof import("./generateAuthCode.handler").generateAuthCodeHandler;
};
import { ZGetClientForAuthorizationInputSchema } from "./getClientForAuthorization.schema";
import { ZListClientsInputSchema } from "./listClients.schema";
import { ZSubmitClientInputSchema, ZSubmitClientOutputSchema } from "./submitClientForReview.schema";
import { ZUpdateClientInputSchema } from "./updateClient.schema";
import { ZDeleteClientInputSchema } from "./deleteClient.schema";
export const oAuthRouter = router({
getClient: authedProcedure.input(ZGetClientInputSchema).query(async ({ input }) => {
const { getClientHandler } = await import("./getClient.handler");
getClientForAuthorization: authedProcedure
.input(ZGetClientForAuthorizationInputSchema)
.query(async ({ input }) => {
const { getClientForAuthorizationHandler } = await import("./getClientForAuthorization.handler");
return getClientHandler({
input,
});
}),
return getClientForAuthorizationHandler({
input,
});
}),
addClient: authedAdminProcedure.input(ZAddClientInputSchema).mutation(async ({ input }) => {
const { addClientHandler } = await import("./addClient.handler");
createClient: authedAdminProcedure.input(ZCreateClientInputSchema).mutation(async ({ ctx, input }) => {
const { createClientHandler } = await import("./createClient.handler");
return addClientHandler({
return createClientHandler({
ctx,
input,
});
}),
@@ -36,4 +37,51 @@ export const oAuthRouter = router({
input,
});
}),
submitClientForReview: authedProcedure
.input(ZSubmitClientInputSchema)
.output(ZSubmitClientOutputSchema)
.mutation(async ({ ctx, input }) => {
const { submitClientForReviewHandler } = await import("./submitClientForReview.handler");
return submitClientForReviewHandler({
ctx,
input,
});
}),
listClients: authedAdminProcedure.input(ZListClientsInputSchema).query(async ({ ctx, input }) => {
const { listClientsHandler } = await import("./listClients.handler");
return listClientsHandler({
ctx,
input,
});
}),
listUserClients: authedProcedure.query(async ({ ctx }) => {
const { listUserClientsHandler } = await import("./listUserClients.handler");
return listUserClientsHandler({
ctx,
});
}),
updateClient: authedProcedure.input(ZUpdateClientInputSchema).mutation(async ({ ctx, input }) => {
const { updateClientHandler } = await import("./updateClient.handler");
return updateClientHandler({
ctx,
input,
});
}),
deleteClient: authedProcedure.input(ZDeleteClientInputSchema).mutation(async ({ ctx, input }) => {
const { deleteClientHandler } = await import("./deleteClient.handler");
return deleteClientHandler({
ctx,
input,
});
}),
});
@@ -1,47 +0,0 @@
import { randomBytes } from "node:crypto";
import { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { TAddClientInputSchema } from "./addClient.schema";
type AddClientOptions = {
input: TAddClientInputSchema;
};
export const addClientHandler = async ({ input }: AddClientOptions) => {
const { name, redirectUri, logo, enablePkce } = input;
const clientId = randomBytes(32).toString("hex");
const clientType = enablePkce ? "PUBLIC" : "CONFIDENTIAL";
// Only generate client secret for confidential clients
const clientData: Prisma.OAuthClientCreateInput = {
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,
});
return {
clientId: client.clientId,
name: client.name,
redirectUri: client.redirectUri,
logo: client.logo,
clientType: client.clientType,
clientSecret: secret, // Only return plain secret for confidential clients
isPkceEnabled: enablePkce,
};
};
@@ -1,22 +0,0 @@
import { z } from "zod";
export type TAddClientInputSchemaInput = {
name: string;
redirectUri: string;
logo: string;
enablePkce?: boolean;
};
export type TAddClientInputSchema = {
name: string;
redirectUri: string;
logo: string;
enablePkce: boolean;
};
export const ZAddClientInputSchema: z.ZodType<TAddClientInputSchema, z.ZodTypeDef, TAddClientInputSchemaInput> = z.object({
name: z.string(),
redirectUri: z.string(),
logo: z.string(),
enablePkce: z.boolean().optional().default(false),
});
@@ -0,0 +1,48 @@
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import type { PrismaClient } from "@calcom/prisma";
import { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
import type { TCreateClientInputSchema } from "./createClient.schema";
type AddClientOptions = {
ctx: {
prisma: PrismaClient;
};
input: TCreateClientInputSchema;
};
export const createClientHandler = async ({ ctx, input }: AddClientOptions) => {
const { name, purpose, redirectUri, logo, websiteUrl, enablePkce } = input;
const oAuthClientRepository = new OAuthClientRepository(ctx.prisma);
let plainSecret: string | undefined;
let hashedSecret: string | undefined;
if (!enablePkce) {
const [hashed, plain] = generateSecret();
hashedSecret = hashed;
plainSecret = plain;
}
const client = await oAuthClientRepository.create({
name,
purpose,
redirectUri,
clientSecret: hashedSecret,
logo,
websiteUrl,
enablePkce,
status: "APPROVED",
});
return {
clientId: client.clientId,
name: client.name,
purpose: client.purpose,
redirectUri: client.redirectUri,
logo: client.logo,
clientType: client.clientType,
clientSecret: plainSecret,
isPkceEnabled: enablePkce,
status: client.status,
};
};
@@ -0,0 +1,34 @@
import { z } from "zod";
export type TCreateClientInputSchemaInput = {
name: string;
purpose: string;
redirectUri: string;
logo?: string;
websiteUrl?: string;
enablePkce?: boolean;
};
export type TCreateClientInputSchema = {
name: string;
purpose: string;
redirectUri: string;
logo?: string;
websiteUrl?: string;
enablePkce: boolean;
};
export const ZCreateClientInputSchema: z.ZodType<TCreateClientInputSchema, z.ZodTypeDef, TCreateClientInputSchemaInput> = z.object({
name: z.string(),
purpose: z.string().min(1),
redirectUri: z.string(),
logo: z
.string()
.optional()
.transform((value) => (typeof value === "string" && value.trim() === "" ? undefined : value)),
websiteUrl: z
.union([z.literal(""), z.string().url("Must be a valid URL")])
.optional()
.transform((value) => (value === "" ? undefined : value)),
enablePkce: z.boolean().optional().default(false),
});
@@ -0,0 +1,37 @@
import { TRPCError } from "@trpc/server";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import type { PrismaClient } from "@calcom/prisma";
import type { TDeleteClientInputSchema } from "./deleteClient.schema";
type DeleteClientOptions = {
ctx: {
user: {
id: number;
};
prisma: PrismaClient;
};
input: TDeleteClientInputSchema;
};
export const deleteClientHandler = async ({ ctx, input }: DeleteClientOptions) => {
const { clientId } = input;
const oAuthClientRepository = new OAuthClientRepository(ctx.prisma);
const existingClient = await oAuthClientRepository.findByClientId(clientId);
if (!existingClient) {
throw new TRPCError({ code: "NOT_FOUND", message: "OAuth Client not found" });
}
const isOwner = existingClient.userId != null && existingClient.userId === ctx.user.id;
if (!isOwner) {
throw new TRPCError({ code: "NOT_FOUND", message: "OAuth Client not found" });
}
await oAuthClientRepository.delete(clientId);
return { clientId };
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZDeleteClientInputSchema = z.object({
clientId: z.string().min(1),
});
export type TDeleteClientInputSchema = z.infer<typeof ZDeleteClientInputSchema>;
@@ -4,17 +4,15 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { generateAuthCodeHandler } from "./generateAuthCode.handler";
const mockUser = {
id: 1,
email: "test@example.com",
name: "Test User",
};
const mockUser = { id: 1 } as unknown as NonNullable<TrpcSessionUser>;
const mockCtx = {
const mockCtx: { user: NonNullable<TrpcSessionUser> } = {
user: mockUser,
};
@@ -29,6 +27,7 @@ describe("generateAuthCodeHandler", () => {
redirectUri: "https://app.example.com/callback",
name: "Test Public Client",
clientType: "PUBLIC" as const,
status: "APPROVED" as const,
};
it("should generate authorization code for PUBLIC client with valid PKCE", async () => {
@@ -164,6 +163,7 @@ describe("generateAuthCodeHandler", () => {
clientType: "CONFIDENTIAL" as const,
isTrusted: undefined,
logo: undefined,
status: "APPROVED" as const,
};
it("should generate authorization code for CONFIDENTIAL client without PKCE", async () => {
@@ -303,6 +303,7 @@ describe("generateAuthCodeHandler", () => {
redirectUri: "https://app.example.com/callback",
name: "Test Public Client",
clientType: "PUBLIC" as const,
status: "APPROVED" as const,
};
prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPublicClient);
@@ -370,5 +371,65 @@ describe("generateAuthCodeHandler", () => {
})
);
});
it("should reject PENDING client", async () => {
const mockPendingClient = {
clientId: "pending_client_123",
redirectUri: "https://app.example.com/callback",
name: "Test Pending Client",
clientType: "CONFIDENTIAL" as const,
status: "PENDING" as const,
};
prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPendingClient);
const input = {
clientId: "pending_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
redirectUri: "https://app.example.com/callback",
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "UNAUTHORIZED",
message: OAUTH_ERROR_REASONS.client_not_approved,
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
it("should reject REJECTED client", async () => {
const mockRejectedClient = {
clientId: "rejected_client_123",
redirectUri: "https://app.example.com/callback",
name: "Test Rejected Client",
clientType: "CONFIDENTIAL" as const,
status: "REJECTED" as const,
};
prismaMock.oAuthClient.findFirst.mockResolvedValue(mockRejectedClient);
const input = {
clientId: "rejected_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
redirectUri: "https://app.example.com/callback",
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "UNAUTHORIZED",
message: OAUTH_ERROR_REASONS.client_not_approved,
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
});
});
@@ -1,7 +0,0 @@
import { z } from "zod";
export const ZGetClientInputSchema = z.object({
clientId: z.string(),
});
export type TGetClientInputSchema = z.infer<typeof ZGetClientInputSchema>;
@@ -6,19 +6,19 @@ import { httpStatusToTrpcCode } from "@calcom/trpc/server/lib/toTRPCError";
import { TRPCError } from "@trpc/server";
import type { TGetClientInputSchema } from "./getClient.schema";
import type { TGetClientForAuthorizationInputSchema } from "./getClientForAuthorization.schema";
type GetClientOptions = {
input: TGetClientInputSchema;
type GetClientForAuthorizationOptions = {
input: TGetClientForAuthorizationInputSchema;
};
export const getClientHandler = async ({ input }: GetClientOptions) => {
export const getClientForAuthorizationHandler = async ({ input }: GetClientForAuthorizationOptions) => {
try {
const { clientId } = input;
const { clientId, redirectUri } = input;
const oAuthService = getOAuthService();
const oAuthClient = await oAuthService.getClient(clientId);
const oAuthClient = await oAuthService.getClientForAuthorization(clientId, redirectUri);
return {
clientId: oAuthClient.clientId,
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZGetClientForAuthorizationInputSchema = z.object({
clientId: z.string(),
redirectUri: z.string().url("Must be a valid URL"),
});
export type TGetClientForAuthorizationInputSchema = z.infer<typeof ZGetClientForAuthorizationInputSchema>;
@@ -0,0 +1,23 @@
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import type { PrismaClient } from "@calcom/prisma";
import type { TListClientsInputSchema } from "./listClients.schema";
type ListClientsOptions = {
ctx: {
prisma: PrismaClient;
};
input: TListClientsInputSchema;
};
export const listClientsHandler = async ({ ctx, input }: ListClientsOptions) => {
const { status } = input;
const oAuthClientRepository = new OAuthClientRepository(ctx.prisma);
if (status) {
return oAuthClientRepository.findByStatus(status);
}
return oAuthClientRepository.findAll();
};
@@ -0,0 +1,9 @@
import { z } from "zod";
import { OAuthClientStatus } from "@calcom/prisma/enums";
export const ZListClientsInputSchema = z.object({
status: z.nativeEnum(OAuthClientStatus).optional(),
});
export type TListClientsInputSchema = z.infer<typeof ZListClientsInputSchema>;
@@ -0,0 +1,19 @@
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import type { PrismaClient } from "@calcom/prisma";
type ListUserClientsOptions = {
ctx: {
user: {
id: number;
};
prisma: PrismaClient;
};
};
export const listUserClientsHandler = async ({ ctx }: ListUserClientsOptions) => {
const userId = ctx.user.id;
const oAuthClientRepository = new OAuthClientRepository(ctx.prisma);
return oAuthClientRepository.findByUserId(userId);
};
@@ -0,0 +1,122 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { TFunction } from "i18next";
import type { PrismaClient } from "@calcom/prisma";
import { submitClientForReviewHandler } from "./submitClientForReview.handler";
const mocks = vi.hoisted(() => {
return {
createOAuthClient: vi.fn(),
sendAdminOAuthClientNotification: vi.fn(),
getTranslation: vi.fn(),
generateSecret: vi.fn(),
};
});
vi.mock("@calcom/features/oauth/repositories/OAuthClientRepository", () => ({
OAuthClientRepository: class {
constructor() {}
create = mocks.createOAuthClient;
},
}));
vi.mock("@calcom/emails/oauth-email-service", () => ({
sendAdminOAuthClientNotification: mocks.sendAdminOAuthClientNotification,
}));
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: mocks.getTranslation,
}));
vi.mock("@calcom/features/oauth/utils/generateSecret", () => ({
generateSecret: mocks.generateSecret,
}));
describe("submitClientHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates an OAuth client and sends the admin notification email with expected parameters", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "admin_oauth_notification_email_subject") {
return `admin_oauth_notification_email_subject:${String(vars?.clientName ?? "")}`;
}
return key;
}) as unknown as TFunction;
mocks.getTranslation.mockResolvedValue(t);
mocks.generateSecret.mockReturnValue(["hashed-secret", "plain-secret"]);
const createdClient = {
clientId: "client_123",
name: "My Test Client",
purpose: "My test purpose",
redirectUri: "https://example.com/callback",
logo: "https://example.com/logo.png",
clientType: "CONFIDENTIAL",
clientSecret: "hashed-secret",
isPkceEnabled: false,
status: "PENDING",
};
mocks.createOAuthClient.mockResolvedValue(createdClient);
mocks.sendAdminOAuthClientNotification.mockResolvedValue({});
const ctx = {
user: {
id: 42,
email: "submitter@example.com",
name: "Submitter Name",
},
prisma: {} as unknown as PrismaClient,
};
const input = {
name: createdClient.name,
purpose: createdClient.purpose,
redirectUri: createdClient.redirectUri,
logo: createdClient.logo,
websiteUrl: "https://example.com",
enablePkce: false,
};
const result = await submitClientForReviewHandler({ ctx, input });
expect(mocks.createOAuthClient).toHaveBeenCalledWith({
name: input.name,
purpose: input.purpose,
redirectUri: input.redirectUri,
clientSecret: "hashed-secret",
logo: input.logo,
websiteUrl: input.websiteUrl,
enablePkce: input.enablePkce,
userId: ctx.user.id,
status: "PENDING",
});
expect(mocks.sendAdminOAuthClientNotification).toHaveBeenCalledWith({
t,
clientName: createdClient.name,
purpose: createdClient.purpose,
clientId: createdClient.clientId,
redirectUri: createdClient.redirectUri,
submitterEmail: ctx.user.email,
submitterName: ctx.user.name,
});
expect(result).toEqual({
clientId: createdClient.clientId,
name: createdClient.name,
purpose: createdClient.purpose,
clientSecret: "plain-secret",
redirectUri: createdClient.redirectUri,
logo: createdClient.logo,
clientType: createdClient.clientType,
status: createdClient.status,
isPkceEnabled: input.enablePkce,
});
});
});
@@ -0,0 +1,69 @@
import { sendAdminOAuthClientNotification } from "@calcom/emails/oauth-email-service";
import { getTranslation } from "@calcom/lib/server/i18n";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
import type { PrismaClient } from "@calcom/prisma";
import type { TSubmitClientInputSchema } from "./submitClientForReview.schema";
type SubmitClientOptions = {
ctx: {
user: {
id: number;
email: string;
name: string | null;
};
prisma: PrismaClient;
};
input: TSubmitClientInputSchema;
};
export const submitClientForReviewHandler = async ({ ctx, input }: SubmitClientOptions) => {
const { name, purpose, redirectUri, logo, websiteUrl, enablePkce } = input;
const userId = ctx.user.id;
const oAuthClientRepository = new OAuthClientRepository(ctx.prisma);
let plainSecret: string | undefined;
let hashedSecret: string | undefined;
if (!enablePkce) {
const [hashed, plain] = generateSecret();
hashedSecret = hashed;
plainSecret = plain;
}
const client = await oAuthClientRepository.create({
name,
purpose,
redirectUri,
clientSecret: hashedSecret,
logo,
websiteUrl,
enablePkce,
userId,
status: "PENDING",
});
const t = await getTranslation("en", "common");
await sendAdminOAuthClientNotification({
t,
clientName: client.name,
purpose: client.purpose,
clientId: client.clientId,
redirectUri: client.redirectUri,
submitterEmail: ctx.user.email,
submitterName: ctx.user.name,
});
return {
clientId: client.clientId,
name: client.name,
purpose: client.purpose,
clientSecret: plainSecret,
redirectUri: client.redirectUri,
logo: client.logo,
clientType: client.clientType,
status: client.status,
isPkceEnabled: enablePkce,
};
};
@@ -0,0 +1,32 @@
import { z } from "zod";
export const ZSubmitClientInputSchema = z.object({
name: z.string().min(1, "Client name is required"),
purpose: z.string().min(1, "Purpose is required"),
redirectUri: z.string().url("Must be a valid URL"),
logo: z
.string()
.optional()
.transform((value) => (typeof value === "string" && value.trim() === "" ? undefined : value)),
websiteUrl: z
.union([z.literal(""), z.string().url("Must be a valid URL")])
.optional()
.transform((value) => (value === "" ? undefined : value)),
enablePkce: z.boolean().optional().default(false),
});
export const ZSubmitClientOutputSchema = z.object({
clientId: z.string(),
name: z.string(),
purpose: z.string().nullable(),
clientSecret: z.string().optional(),
redirectUri: z.string(),
logo: z.string().nullable(),
clientType: z.string(),
status: z.string(),
isPkceEnabled: z.boolean().optional(),
});
export type TSubmitClientInputSchema = z.infer<typeof ZSubmitClientInputSchema>;
export type TSubmitClientOutputSchema = z.infer<typeof ZSubmitClientOutputSchema>;
@@ -0,0 +1,490 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TFunction } from "i18next";
import type { PrismaClient } from "@calcom/prisma";
import { OAuthClientStatus, UserPermissionRole } from "@calcom/prisma/enums";
import { updateClientHandler } from "./updateClient.handler";
const mocks = vi.hoisted(() => {
return {
findByClientId: vi.fn(),
findByClientIdIncludeUser: vi.fn(),
sendOAuthClientApprovedNotification: vi.fn(),
sendOAuthClientRejectedNotification: vi.fn(),
getTranslation: vi.fn(),
};
});
const CLIENT_ID = "client_123";
const OWNER_USER_ID = 42;
const USER_EMAIL = "admin@example.com";
const USER_NAME = "Admin Example";
const CLIENT_NAME = "My Client";
const CLIENT_PURPOSE = "My purpose";
const REDIRECT_URI = "https://example.com/callback";
const REJECTION_REASON_TRIMMED = "we dont support your usecase";
const REJECTION_REASON_RAW = " we dont support your usecase ";
vi.mock("@calcom/features/oauth/repositories/OAuthClientRepository", () => ({
OAuthClientRepository: class {
constructor() {}
findByClientId = mocks.findByClientId;
findByClientIdIncludeUser = mocks.findByClientIdIncludeUser;
},
}));
vi.mock("@calcom/emails/oauth-email-service", () => ({
sendOAuthClientApprovedNotification: mocks.sendOAuthClientApprovedNotification,
sendOAuthClientRejectedNotification: mocks.sendOAuthClientRejectedNotification,
}));
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: mocks.getTranslation,
}));
describe("updateClientHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updates status to APPROVED (admin) and sends approved notification email with expected parameters", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "oauth_client_approved_email_subject") {
return `OAuth Client Approved: ${String(vars?.clientName ?? "")}`;
}
return key;
}) as unknown as TFunction;
mocks.getTranslation.mockResolvedValue(t);
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: {
email: USER_EMAIL,
name: USER_NAME,
},
});
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "APPROVED",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
const ctx = {
user: {
id: 1,
role: UserPermissionRole.ADMIN,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
const input = {
clientId: CLIENT_ID,
status: OAuthClientStatus.APPROVED,
};
const result = await updateClientHandler({ ctx, input });
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: input.clientId },
data: {
status: "APPROVED",
rejectionReason: null,
},
})
);
expect(mocks.getTranslation).toHaveBeenCalledWith("en", "common");
expect(mocks.sendOAuthClientApprovedNotification).toHaveBeenCalledWith({
t,
userEmail: USER_EMAIL,
userName: USER_NAME,
clientName: CLIENT_NAME,
clientId: CLIENT_ID,
});
expect(result).toEqual({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "APPROVED",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
});
it("updates status to REJECTED (admin) and sends rejected notification email with trimmed rejection reason", async () => {
const t = ((key: string, vars?: Record<string, unknown>) => {
if (key === "oauth_client_rejected_email_subject") {
return `OAuth Client Rejected: ${String(vars?.clientName ?? "")}`;
}
return key;
}) as unknown as TFunction;
mocks.getTranslation.mockResolvedValue(t);
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: {
email: USER_EMAIL,
name: USER_NAME,
},
});
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "REJECTED",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: REJECTION_REASON_TRIMMED,
});
const ctx = {
user: {
id: 1,
role: UserPermissionRole.ADMIN,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
const input = {
clientId: CLIENT_ID,
status: OAuthClientStatus.REJECTED,
rejectionReason: REJECTION_REASON_RAW,
};
await updateClientHandler({ ctx, input });
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: input.clientId },
data: {
status: "REJECTED",
rejectionReason: REJECTION_REASON_TRIMMED,
},
})
);
expect(mocks.getTranslation).toHaveBeenCalledWith("en", "common");
expect(mocks.sendOAuthClientRejectedNotification).toHaveBeenCalledWith({
t,
userEmail: USER_EMAIL,
userName: USER_NAME,
clientName: CLIENT_NAME,
clientId: CLIENT_ID,
rejectionReason: REJECTION_REASON_TRIMMED,
});
});
it("throws FORBIDDEN when a non-admin attempts to set rejectionReason", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 2,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
const input = {
clientId: CLIENT_ID,
rejectionReason: "nope",
};
await expect(updateClientHandler({ ctx, input })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Only admins can set a rejection reason",
});
});
it("throws BAD_REQUEST when rejecting without a non-empty rejectionReason", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 1,
role: UserPermissionRole.ADMIN,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
await expect(
updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
status: OAuthClientStatus.REJECTED,
rejectionReason: " ",
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Rejection reason is required",
});
});
it("throws FORBIDDEN when a non-admin attempts to update status", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 2,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
await expect(
updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
status: OAuthClientStatus.APPROVED,
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Only admins can update OAuth client status",
});
});
it("throws FORBIDDEN when a non-owner, non-admin attempts to update client fields", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "PENDING",
user: null,
});
const ctx = {
user: {
id: 999,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: vi.fn(),
},
} as unknown as PrismaClient,
};
await expect(
updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
name: "New Name",
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You do not have permission to update this OAuth client",
});
});
it("sets status to PENDING when an owner edits a previously approved client (reapproval flow)", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "APPROVED",
user: null,
});
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: "My Client v2",
purpose: CLIENT_PURPOSE,
status: "PENDING",
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
const ctx = {
user: {
id: 42,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
await updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
name: "My Client v2",
},
});
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: CLIENT_ID },
data: {
name: "My Client v2",
status: "PENDING",
rejectionReason: null,
},
})
);
expect(mocks.sendOAuthClientApprovedNotification).not.toHaveBeenCalled();
expect(mocks.sendOAuthClientRejectedNotification).not.toHaveBeenCalled();
});
it("sets status to PENDING when an owner updates redirectUri (reapproval flow)", async () => {
mocks.findByClientIdIncludeUser.mockResolvedValue({
clientId: CLIENT_ID,
userId: OWNER_USER_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
redirectUri: REDIRECT_URI,
websiteUrl: null,
logo: null,
status: "APPROVED",
user: null,
});
const updatedRedirectUri = "https://example.com/new-callback";
const prismaUpdate = vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
name: CLIENT_NAME,
purpose: CLIENT_PURPOSE,
status: "PENDING",
redirectUri: updatedRedirectUri,
websiteUrl: null,
logo: null,
rejectionReason: null,
});
const ctx = {
user: {
id: OWNER_USER_ID,
role: UserPermissionRole.USER,
},
prisma: {
oAuthClient: {
update: prismaUpdate,
},
} as unknown as PrismaClient,
};
await updateClientHandler({
ctx,
input: {
clientId: CLIENT_ID,
redirectUri: updatedRedirectUri,
},
});
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { clientId: CLIENT_ID },
data: {
redirectUri: updatedRedirectUri,
status: "PENDING",
rejectionReason: null,
},
})
);
});
});
@@ -0,0 +1,310 @@
import { TRPCError } from "@trpc/server";
import { sendOAuthClientApprovedNotification, sendOAuthClientRejectedNotification } from "@calcom/emails/oauth-email-service";
import { getTranslation } from "@calcom/lib/server/i18n";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import type { PrismaClient } from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import type { OAuthClientStatus } from "@calcom/prisma/enums";
import type { TUpdateClientInputSchema } from "./updateClient.schema";
type UpdateClientOptions = {
ctx: {
user: {
id: number;
role: UserPermissionRole;
};
prisma: PrismaClient;
};
input: TUpdateClientInputSchema;
};
type UpdateClientOutput = {
clientId: string;
name: string;
purpose: string | null;
status: OAuthClientStatus;
redirectUri: string;
websiteUrl: string | null;
logo: string | null;
rejectionReason: string | null;
};
const updateClientHandler = async ({
ctx,
input,
}: UpdateClientOptions): Promise<UpdateClientOutput> => {
const {
clientId,
status: requestedStatus,
rejectionReason,
name,
purpose,
redirectUri,
websiteUrl,
logo,
} = input;
const oAuthClientRepository = new OAuthClientRepository(ctx.prisma);
const isAdmin = ctx.user.role === UserPermissionRole.ADMIN;
const clientWithUser = await oAuthClientRepository.findByClientIdIncludeUser(clientId);
if (!clientWithUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "OAuth Client not found" });
}
const isOwner = clientWithUser.userId != null && clientWithUser.userId === ctx.user.id;
if (rejectionReason !== undefined && !isAdmin) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can set a rejection reason" });
}
if (requestedStatus === "REJECTED" && (!rejectionReason || rejectionReason.trim().length === 0)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Rejection reason is required" });
}
const isUpdatingFields = hasAnyFieldsChanged({ name, purpose, redirectUri, websiteUrl, logo });
const isUpdatingStatus = requestedStatus !== undefined;
if (isUpdatingStatus && !isAdmin) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can update OAuth client status" });
}
if (isUpdatingFields && !isAdmin && !isOwner) {
throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission to update this OAuth client" });
}
const shouldTriggerReapprovalForOwnerEdit = triggersReapprovalForOwnerEdit({
isAdmin,
isOwner,
currentClient: {
name: clientWithUser.name,
logo: clientWithUser.logo,
websiteUrl: clientWithUser.websiteUrl,
redirectUri: clientWithUser.redirectUri,
},
proposedUpdates: {
name,
logo,
websiteUrl,
redirectUri,
},
});
const nextStatus = computeNextStatus({
requestedStatus,
triggersReapprovalForOwnerEdit: shouldTriggerReapprovalForOwnerEdit,
currentStatus: clientWithUser.status,
});
const updateData = buildUpdateClientUpdateData({
name,
purpose,
redirectUri,
logo,
websiteUrl,
requestedStatus,
rejectionReason,
nextStatus,
currentStatus: clientWithUser.status,
});
const updatedClient = await ctx.prisma.oAuthClient.update({
where: { clientId },
data: updateData,
select: {
clientId: true,
name: true,
purpose: true,
status: true,
redirectUri: true,
websiteUrl: true,
logo: true,
rejectionReason: true,
},
});
await notifyOwnerAboutAdminReview({
isAdmin,
requestedStatus,
clientWithUser,
updatedClient: {
clientId: updatedClient.clientId,
name: updatedClient.name,
},
rejectionReason,
});
return {
clientId: updatedClient.clientId,
name: updatedClient.name,
purpose: updatedClient.purpose,
status: updatedClient.status,
redirectUri: updatedClient.redirectUri,
websiteUrl: updatedClient.websiteUrl,
logo: updatedClient.logo,
rejectionReason: updatedClient.rejectionReason,
};
};
function toNullableString(value: string | null | undefined) {
return value ?? null;
}
function hasAnyFieldsChanged(fields: Record<string, unknown>) {
return Object.values(fields).some((value) => value !== undefined);
}
type ClientFieldsForReapprovalCheck = {
name: string;
logo: string | null;
websiteUrl: string | null;
redirectUri: string;
};
function triggersReapprovalForOwnerEdit(params: {
isAdmin: boolean;
isOwner: boolean;
currentClient: ClientFieldsForReapprovalCheck;
proposedUpdates: Partial<{
name: string;
logo: string | null;
websiteUrl: string | null;
redirectUri: string;
}>;
}) {
const { isAdmin, isOwner, currentClient, proposedUpdates } = params;
if (isAdmin) return false;
if (!isOwner) return false;
if (proposedUpdates.name !== undefined && proposedUpdates.name !== currentClient.name) {
return true;
}
if (
proposedUpdates.logo !== undefined &&
toNullableString(proposedUpdates.logo) !== toNullableString(currentClient.logo)
) {
return true;
}
if (
proposedUpdates.websiteUrl !== undefined &&
toNullableString(proposedUpdates.websiteUrl) !== toNullableString(currentClient.websiteUrl)
) {
return true;
}
if (proposedUpdates.redirectUri !== undefined && proposedUpdates.redirectUri !== currentClient.redirectUri) {
return true;
}
return false;
}
function computeNextStatus(params: {
requestedStatus: OAuthClientStatus | undefined;
triggersReapprovalForOwnerEdit: boolean;
currentStatus: OAuthClientStatus;
}): OAuthClientStatus {
const { requestedStatus, triggersReapprovalForOwnerEdit, currentStatus } = params;
if (requestedStatus !== undefined) return requestedStatus;
if (triggersReapprovalForOwnerEdit) return "PENDING";
return currentStatus;
}
type NotifyOwnerAboutAdminReviewParams = {
isAdmin: boolean;
requestedStatus: OAuthClientStatus | undefined;
clientWithUser: Awaited<ReturnType<OAuthClientRepository["findByClientIdIncludeUser"]>> | null;
updatedClient: {
clientId: string;
name: string;
};
rejectionReason: string | undefined;
};
async function notifyOwnerAboutAdminReview(params: NotifyOwnerAboutAdminReviewParams) {
const { isAdmin, requestedStatus, clientWithUser, updatedClient, rejectionReason } = params;
if (!isAdmin) return;
if (requestedStatus !== "APPROVED" && requestedStatus !== "REJECTED") return;
if (!clientWithUser?.user) return;
const t = await getTranslation("en", "common");
if (requestedStatus === "APPROVED") {
await sendOAuthClientApprovedNotification({
t,
userEmail: clientWithUser.user.email,
userName: clientWithUser.user.name,
clientName: updatedClient.name,
clientId: updatedClient.clientId,
});
return;
}
await sendOAuthClientRejectedNotification({
t,
userEmail: clientWithUser.user.email,
userName: clientWithUser.user.name,
clientName: updatedClient.name,
clientId: updatedClient.clientId,
rejectionReason: rejectionReason?.trim() ?? "",
});
}
type UpdateOAuthClientData = {
name?: string;
purpose?: string;
redirectUri?: string;
logo?: string | null;
websiteUrl?: string | null;
status?: OAuthClientStatus;
rejectionReason?: string | null;
};
function buildUpdateClientUpdateData(params: {
name: string | undefined;
purpose: string | undefined;
redirectUri: string | undefined;
logo: string | null | undefined;
websiteUrl: string | null | undefined;
requestedStatus: OAuthClientStatus | undefined;
rejectionReason: string | undefined;
nextStatus: OAuthClientStatus;
currentStatus: OAuthClientStatus;
}): UpdateOAuthClientData {
const {
name,
purpose,
redirectUri,
logo,
websiteUrl,
requestedStatus,
rejectionReason,
nextStatus,
currentStatus,
} = params;
const updateData: UpdateOAuthClientData = {};
if (name !== undefined) updateData.name = name;
if (purpose !== undefined) updateData.purpose = purpose;
if (redirectUri !== undefined) updateData.redirectUri = redirectUri;
if (logo !== undefined) updateData.logo = logo;
if (websiteUrl !== undefined) updateData.websiteUrl = websiteUrl;
if (nextStatus !== currentStatus) updateData.status = nextStatus;
if (requestedStatus === "REJECTED") {
updateData.rejectionReason = rejectionReason?.trim() ?? null;
} else if (requestedStatus !== undefined) {
updateData.rejectionReason = null;
} else if (nextStatus !== currentStatus && nextStatus !== "REJECTED") {
updateData.rejectionReason = null;
}
return updateData;
}
export { updateClientHandler };
@@ -0,0 +1,30 @@
import { z } from "zod";
import { OAuthClientStatus } from "@calcom/prisma/enums";
export const ZUpdateClientInputSchema = z.object({
clientId: z.string(),
status: z.nativeEnum(OAuthClientStatus).optional(),
rejectionReason: z.string().min(1).optional(),
name: z.string().min(1).optional(),
purpose: z.string().min(1).optional(),
redirectUri: z.string().url().optional(),
logo: z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? null : value),
z.string().nullable().optional()
),
websiteUrl: z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? null : value),
z.string().url().nullable().optional()
),
}).superRefine((val, ctx) => {
if (val.status === OAuthClientStatus.REJECTED && !val.rejectionReason) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["rejectionReason"],
message: "Rejection reason is required",
});
}
});
export type TUpdateClientInputSchema = z.infer<typeof ZUpdateClientInputSchema>;