diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx index 8be1a816367fc0..28bc8c212f3a7b 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/oAuth/page.tsx @@ -1,25 +1,31 @@ import { _generateMetadata, getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; -import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import LegacyPage from "~/settings/admin/oauth-view"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import OAuthClientsAdminView from "~/settings/admin/oauth-clients-admin-view"; + +const Page = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + await getTranslate(); + + if (!session) { + redirect("/auth/login?callbackUrl=/settings/admin/oauth"); + } + + return ; +}; export const generateMetadata = async () => await _generateMetadata( - (t) => t("oAuth"), - (t) => t("admin_oAuth_description"), + (t) => t("oauth_clients_admin"), + (t) => t("oauth_clients_admin_description"), undefined, undefined, - "/settings/admin/oAuth" + "/settings/admin/oauth" ); -const Page = async () => { - const t = await getTranslate(); - return ( - - - - ); -}; - export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 61b40b91c03629..221a6c91628bfe 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -146,6 +146,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", @@ -280,7 +285,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { }, { name: "oAuth", - href: "/settings/admin/oAuth", + href: "/settings/admin/oauth", trackingMetadata: { section: "admin", page: "oauth" }, }, { @@ -297,7 +302,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", @@ -307,21 +312,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; }; @@ -337,7 +342,7 @@ const organizationAdminKeys = [ "delegation_credential", ]; -export interface SettingsPermissions { +interface SettingsPermissions { canViewRoles?: boolean; canViewOrganizationBilling?: boolean; canUpdateOrganization?: boolean; @@ -553,7 +558,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record teamState.teamId === team.id)) + if (teamMenuState.some((teamState) => teamState.teamId === team.id)) { return ( ); + } + + return null; })} ); @@ -1033,14 +1041,14 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) => ); }; -export type SettingsLayoutProps = { +type SettingsLayoutProps = { children: React.ReactNode; containerClassName?: string; teamFeatures?: Record; permissions?: SettingsPermissions; } & ComponentProps; -export default function SettingsLayoutAppDirClient({ +function SettingsLayoutAppDirClient({ children, teamFeatures, permissions, @@ -1129,3 +1137,6 @@ const SidebarContainerElement = ({ ); }; + +export type { SettingsLayoutProps, SettingsPermissions }; +export default SettingsLayoutAppDirClient; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx new file mode 100644 index 00000000000000..c622db5626a75b --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/oauth/page.tsx @@ -0,0 +1,33 @@ +import { _generateMetadata, getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import OAuthClientsView from "~/settings/developer/oauth-clients-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("oauth_clients"), + (t) => t("oauth_clients_description"), + undefined, + undefined, + "/settings/developer/oauth" + ); + +const Page = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const t = await getTranslate(); + + if (!session) { + redirect("/auth/login?callbackUrl=/settings/developer/oauth"); + } + + return ( + + ); +}; + +export default Page; diff --git a/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts index 3edd45fff184b8..11b6c220923f26 100644 --- a/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts +++ b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts @@ -149,6 +149,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, + status: "APPROVED" as const, } as const; const mockAccessCode = { @@ -279,6 +280,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: "hashed_secret", clientType: "CONFIDENTIAL" as const, + status: "APPROVED" as const, } as const; const mockAccessCode = { @@ -494,6 +496,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, + status: "APPROVED" as const, } as Awaited>); const tokenRequest = createTokenRequest({ @@ -517,6 +520,7 @@ describe("POST /api/auth/oauth/token", () => { redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, + status: "APPROVED" as const, } as Awaited>); prismaMock.accessCode.findFirst.mockResolvedValue(null); diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-skeleton.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-skeleton.tsx new file mode 100644 index 00000000000000..c3e028851b0df5 --- /dev/null +++ b/apps/web/modules/settings/admin/oauth-clients-admin-skeleton.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; + +export const OAuthClientsAdminSkeleton = () => { + return ( + +
+ +
+ +
+
+
+ +
+ {[1, 2, 3].map((section) => ( +
+ +
+ {[1, 2, 3].map((row) => ( +
+
+
+ +
+
+ +
+
+
+ ))} +
+
+ ))} +
+ + ); +}; diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx new file mode 100644 index 00000000000000..427f63e251106c --- /dev/null +++ b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; + +import { OAuthClientsAdminSkeleton } from "./oauth-clients-admin-skeleton"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { showToast } from "@calcom/ui/components/toast"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; + +import type { OAuthClientCreateFormValues } from "../oauth/create/OAuthClientCreateModal"; +import { OAuthClientCreateDialog } from "../oauth/create/OAuthClientCreateModal"; +import { OAuthClientPreviewDialog } from "../oauth/create/OAuthClientPreviewDialog"; +import { OAuthClientDetailsDialog, type OAuthClientDetails } from "../oauth/view/OAuthClientDetailsDialog"; +import { OAuthClientsList } from "../oauth/OAuthClientsList"; +import { NewOAuthClientButton } from "../oauth/create/NewOAuthClientButton"; + +export default function OAuthClientsAdminView() { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [isCreatingClient, setIsCreatingClient] = useState(false); + const [createdClient, setCreatedClient] = useState(null); + const [selectedClient, setSelectedClient] = useState(null); + + const { data: pendingClients, isLoading: isPendingClientsLoading } = trpc.viewer.oAuth.listClients.useQuery({ + status: "PENDING", + }); + const { data: rejectedClients, isLoading: isRejectedClientsLoading } = trpc.viewer.oAuth.listClients.useQuery({ + status: "REJECTED", + }); + const { data: approvedClients, isLoading: isApprovedClientsLoading } = trpc.viewer.oAuth.listClients.useQuery({ + status: "APPROVED", + }); + + const createMutation = trpc.viewer.oAuth.createClient.useMutation({ + onSuccess: async (data) => { + setCreatedClient({ + clientId: data.clientId, + clientSecret: data.clientSecret, + name: data.name, + purpose: data.purpose ?? "", + status: data.status ?? "APPROVED", + 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 handleAddClient = (values: OAuthClientCreateFormValues) => { + createMutation.mutate({ + name: values.name, + purpose: values.purpose, + redirectUri: values.redirectUri, + websiteUrl: values.websiteUrl, + logo: values.logo, + enablePkce: values.enablePkce, + }); + }; + + const handleCloseDialog = () => { + setIsCreatingClient(false); + setCreatedClient(null); + }; + + const handleCloseClientDialog = () => { + setSelectedClient(null); + }; + + const handleApprove = (clientId: string) => { + updateStatusMutation.mutate({ clientId, status: "APPROVED" }); + }; + + const handleReject = (input: { clientId: string; rejectionReason: string }) => { + updateStatusMutation.mutate({ clientId: input.clientId, status: "REJECTED", rejectionReason: input.rejectionReason }); + }; + + if (isPendingClientsLoading || isRejectedClientsLoading || isApprovedClientsLoading) { + return ; + } + + const newOAuthClientButton = ( + setIsCreatingClient(true)} + /> + ); + + const hasClients = + (pendingClients && pendingClients.length > 0) || + (rejectedClients && rejectedClients.length > 0) || + (approvedClients && approvedClients.length > 0); + + return ( + + {hasClients ? ( +
+
+

{t("pending")}

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

{t("rejected")}

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

{t("approved")}

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

{t("confirm_delete_oauth_client")}

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