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;
|
||||
+20
-9
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
-6
@@ -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>;
|
||||
Reference in new issue
Block a user