Repository navigation
feat: add calendar cache status and actions (#22532) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: calendar-cache-foundation
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| "use client"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The UI now exposes destructive calendar actions from a client component, but the real security boundary is the TRPC procedures it calls. This is only safe if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| import { useState } from "react"; | ||
|
|
||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||
| import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants"; | ||
| import { trpc } from "@calcom/trpc/react"; | ||
| import { Button } from "@calcom/ui/components/button"; | ||
| import { ConfirmationDialogContent } from "@calcom/ui/components/dialog"; | ||
| import { Dialog } from "@calcom/ui/components/dialog"; | ||
| import { | ||
| Dropdown, | ||
| DropdownItem, | ||
| DropdownMenuContent, | ||
| DropdownMenuItem, | ||
| DropdownMenuTrigger, | ||
| } from "@calcom/ui/components/dropdown"; | ||
| import { showToast } from "@calcom/ui/components/toast"; | ||
|
|
||
| interface CredentialActionsDropdownProps { | ||
| credentialId: number; | ||
| integrationType: string; | ||
| cacheUpdatedAt?: Date | null; | ||
| onSuccess?: () => void; | ||
| delegationCredentialId?: string | null; | ||
| disableConnectionModification?: boolean; | ||
| } | ||
|
|
||
| export default function CredentialActionsDropdown({ | ||
| credentialId, | ||
| integrationType, | ||
| cacheUpdatedAt, | ||
| onSuccess, | ||
| delegationCredentialId, | ||
| disableConnectionModification, | ||
| }: CredentialActionsDropdownProps) { | ||
| const { t } = useLocale(); | ||
| const [dropdownOpen, setDropdownOpen] = useState(false); | ||
| const [deleteModalOpen, setDeleteModalOpen] = useState(false); | ||
| const [disconnectModalOpen, setDisconnectModalOpen] = useState(false); | ||
|
|
||
| const deleteCacheMutation = trpc.viewer.calendars.deleteCache.useMutation({ | ||
| onSuccess: () => { | ||
| showToast(t("cache_deleted_successfully"), "success"); | ||
| onSuccess?.(); | ||
| }, | ||
| onError: () => { | ||
| showToast(t("error_deleting_cache"), "error"); | ||
| }, | ||
| }); | ||
|
|
||
| const utils = trpc.useUtils(); | ||
| const disconnectMutation = trpc.viewer.credentials.delete.useMutation({ | ||
| onSuccess: () => { | ||
| showToast(t("app_removed_successfully"), "success"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 NOTE
|
||
| onSuccess?.(); | ||
| }, | ||
| onError: () => { | ||
| showToast(t("error_removing_app"), "error"); | ||
| }, | ||
| async onSettled() { | ||
| await utils.viewer.calendars.connectedCalendars.invalidate(); | ||
| await utils.viewer.apps.integrations.invalidate(); | ||
| }, | ||
| }); | ||
|
|
||
| const isGoogleCalendar = integrationType === GOOGLE_CALENDAR_TYPE; | ||
| const canDisconnect = !delegationCredentialId && !disableConnectionModification; | ||
| const hasCache = isGoogleCalendar && cacheUpdatedAt; | ||
|
|
||
| if (!canDisconnect && !hasCache) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| <Dropdown open={dropdownOpen} onOpenChange={setDropdownOpen}> | ||
| <DropdownMenuTrigger asChild> | ||
| <Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" /> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent> | ||
| {hasCache && ( | ||
| <> | ||
| <DropdownMenuItem className="focus:ring-muted"> | ||
| <div className="px-2 py-1"> | ||
| <div className="text-sm font-medium text-gray-900 dark:text-white">{t("cache_status")}</div> | ||
| <div className="text-xs text-gray-500 dark:text-white"> | ||
| {t("cache_last_updated", { | ||
| timestamp: new Intl.DateTimeFormat("en-US", { | ||
| dateStyle: "short", | ||
| timeStyle: "short", | ||
| }).format(new Date(cacheUpdatedAt)), | ||
| interpolation: { escapeValue: false }, | ||
| })} | ||
| </div> | ||
| </div> | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem className="outline-none"> | ||
| <DropdownItem | ||
| type="button" | ||
| color="destructive" | ||
| StartIcon="trash" | ||
| onClick={() => { | ||
| setDeleteModalOpen(true); | ||
| setDropdownOpen(false); | ||
| }}> | ||
| {t("delete_cached_data")} | ||
| </DropdownItem> | ||
| </DropdownMenuItem> | ||
| </> | ||
| )} | ||
| {canDisconnect && hasCache && <hr className="my-1" />} | ||
| {canDisconnect && ( | ||
| <DropdownMenuItem className="outline-none"> | ||
| <DropdownItem | ||
| type="button" | ||
| color="destructive" | ||
| StartIcon="trash" | ||
| onClick={() => { | ||
| setDisconnectModalOpen(true); | ||
| setDropdownOpen(false); | ||
| }}> | ||
| {t("remove_app")} | ||
| </DropdownItem> | ||
| </DropdownMenuItem> | ||
| )} | ||
| </DropdownMenuContent> | ||
| </Dropdown> | ||
|
|
||
| <Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}> | ||
| <ConfirmationDialogContent | ||
| variety="danger" | ||
| title={t("delete_cached_data")} | ||
| confirmBtnText={t("yes_delete_cache")} | ||
| onConfirm={() => { | ||
| deleteCacheMutation.mutate({ credentialId }); | ||
| setDeleteModalOpen(false); | ||
| }}> | ||
| {t("confirm_delete_cache")} | ||
| </ConfirmationDialogContent> | ||
| </Dialog> | ||
|
|
||
| <Dialog open={disconnectModalOpen} onOpenChange={setDisconnectModalOpen}> | ||
| <ConfirmationDialogContent | ||
| variety="danger" | ||
| title={t("remove_app")} | ||
| confirmBtnText={t("yes_remove_app")} | ||
| onConfirm={() => { | ||
| disconnectMutation.mutate({ id: credentialId }); | ||
| setDisconnectModalOpen(false); | ||
| }}> | ||
| {t("are_you_sure_you_want_to_remove_this_app")} | ||
| </ConfirmationDialogContent> | ||
| </Dialog> | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ import Link from "next/link"; | |
| import React from "react"; | ||
|
|
||
| import AppListCard from "@calcom/features/apps/components/AppListCard"; | ||
| import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; | ||
| import CredentialActionsDropdown from "@calcom/features/apps/components/CredentialActionsDropdown"; | ||
| import AdditionalCalendarSelector from "@calcom/features/calendars/AdditionalCalendarSelector"; | ||
| import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch"; | ||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||
|
|
@@ -67,18 +67,16 @@ const ConnectedCalendarList = ({ | |
| description={connectedCalendar.primary?.email ?? connectedCalendar.integration.description} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 CRITICAL This is a real nullability regression: the new prop access |
||
| className="border-subtle mt-4 rounded-lg border" | ||
| actions={ | ||
| // Delegation credential can't be disconnected | ||
| !connectedCalendar.delegationCredentialId && | ||
| !disableConnectionModification && ( | ||
| <div className="flex w-32 justify-end"> | ||
| <DisconnectIntegration | ||
| credentialId={connectedCalendar.credentialId} | ||
| trashIcon | ||
| onSuccess={onChanged} | ||
| buttonProps={{ className: "border border-default" }} | ||
| /> | ||
| </div> | ||
| ) | ||
| <div className="flex w-32 justify-end"> | ||
| <CredentialActionsDropdown | ||
| credentialId={connectedCalendar.credentialId} | ||
| integrationType={connectedCalendar.integration.type} | ||
| cacheUpdatedAt={connectedCalendar.cacheUpdatedAt} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 CRITICAL Quote: Issue: The previous UI explicitly prevented showing DisconnectIntegration when Fix: Preserve the original gating at the call site, e.g.: {!connectedCalendar.delegationCredentialId && (
<div className="flex w-32 justify-end">
<CredentialActionsDropdown ... />
</div>
)}Or ensure CredentialActionsDropdown internally blocks the disconnect action when |
||
| onSuccess={onChanged} | ||
| delegationCredentialId={connectedCalendar.delegationCredentialId} | ||
| disableConnectionModification={disableConnectionModification} | ||
| /> | ||
| </div> | ||
| }> | ||
| <div className="border-subtle border-t"> | ||
| {!fromOnboarding && ( | ||
|
|
@@ -97,7 +95,7 @@ const ConnectedCalendarList = ({ | |
| destination={cal.externalId === destinationCalendarId} | ||
| credentialId={cal.credentialId} | ||
| eventTypeId={shouldUseEventTypeScope ? eventTypeId : null} | ||
| delegationCredentialId={connectedCalendar.delegationCredentialId} | ||
| delegationCredentialId={connectedCalendar.delegationCredentialId || null} | ||
| /> | ||
| ))} | ||
| </ul> | ||
|
|
@@ -122,17 +120,16 @@ const ConnectedCalendarList = ({ | |
| } | ||
| iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5" | ||
| actions={ | ||
| // Delegation credential can't be disconnected | ||
| !connectedCalendar.delegationCredentialId && ( | ||
| <div className="flex w-32 justify-end"> | ||
| <DisconnectIntegration | ||
| credentialId={connectedCalendar.credentialId} | ||
| trashIcon | ||
| onSuccess={onChanged} | ||
| buttonProps={{ className: "border border-default" }} | ||
| /> | ||
| </div> | ||
| ) | ||
| <div className="flex w-32 justify-end"> | ||
| <CredentialActionsDropdown | ||
| credentialId={connectedCalendar.credentialId} | ||
| integrationType={connectedCalendar.integration.type} | ||
| cacheUpdatedAt={connectedCalendar.cacheUpdatedAt} | ||
| onSuccess={onChanged} | ||
| delegationCredentialId={connectedCalendar.delegationCredentialId} | ||
| disableConnectionModification={disableConnectionModification} | ||
| /> | ||
| </div> | ||
| } | ||
| /> | ||
| ); | ||
|
|
@@ -162,6 +159,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett | |
| refetchOnWindowFocus: false, | ||
| } | ||
| ); | ||
|
|
||
| const { isPending } = props; | ||
| const showScopeSelector = !!props.eventTypeId; | ||
| const isDisabled = disabledScope ? disabledScope === scope : false; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /* | ||
| Warnings: | ||
|
|
||
| - Added the required column `updatedAt` to the `CalendarCache` table without a default value. This is not possible if the table is not empty. | ||
|
|
||
| */ | ||
| -- AlterTable | ||
| -- Add the column with a default value to safely handle existing rows | ||
| ALTER TABLE "CalendarCache" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a client component that invokes
trpc.viewer.*.useMutation()directly, but the real security boundary is the server procedure. The UI gating (canDisconnect/hasCache) only hides controls; it does not prevent a caller from invoking the mutation. This is only a critical issue if the underlyingdeleteCache/credentials.deleteprocedures lack server-side authorization, so the finding should be framed as a server-auth dependency rather than a UI bypass bug.re-entry.ai