diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index ebbef8e401..039908543e 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -1601,7 +1601,8 @@
"attendee_email_variable": "Attendee email",
"attendee_email_info": "The person booking's email",
"kbar_search_placeholder": "Type a command or search...",
- "invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
+ "invalid_credential": "It looks like permissions expired or were revoked for {{appName}}.",
+ "invalid_credential_action": "Reinstall app",
"reschedule_reason": "Reschedule reason",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx
index c31f6a5554..a26db7f1d5 100644
--- a/packages/features/shell/Shell.tsx
+++ b/packages/features/shell/Shell.tsx
@@ -29,6 +29,10 @@ import AdminPasswordBanner, {
import CalendarCredentialBanner, {
type CalendarCredentialBannerProps,
} from "@calcom/features/users/components/CalendarCredentialBanner";
+import {
+ InvalidAppCredentialBanners,
+ type InvalidAppCredentialBannersProps,
+} from "@calcom/features/users/components/InvalidAppCredentialsBanner";
import UserV2OptInBanner from "@calcom/features/users/components/UserV2OptInBanner";
import VerifyEmailBanner, {
type VerifyEmailBannerProps,
@@ -163,6 +167,7 @@ type BannerTypeProps = {
adminPasswordBanner: AdminPasswordBannerProps;
impersonationBanner: ImpersonatingBannerProps;
calendarCredentialBanner: CalendarCredentialBannerProps;
+ invalidAppCredentialBanners: InvalidAppCredentialBannersProps;
};
type BannerType = keyof BannerTypeProps;
@@ -178,6 +183,9 @@ const BannerComponent: BannerComponent = {
adminPasswordBanner: (props: AdminPasswordBannerProps) => ,
impersonationBanner: (props: ImpersonatingBannerProps) => ,
calendarCredentialBanner: (props: CalendarCredentialBannerProps) => ,
+ invalidAppCredentialBanners: (props: InvalidAppCredentialBannersProps) => (
+
+ ),
};
function useRedirectToOnboardingIfNeeded() {
@@ -255,6 +263,7 @@ const Layout = (props: LayoutProps) => {
{/* todo: only run this if timezone is different */}
+
{banners && (
@@ -278,6 +287,9 @@ const Layout = (props: LayoutProps) => {
} else if (key === "calendarCredentialBanner") {
const Banner = BannerComponent[key];
return ;
+ } else if (key === "invalidAppCredentialBanners") {
+ const Banner = BannerComponent[key];
+ return ;
}
})}
diff --git a/packages/features/users/components/InvalidAppCredentialsBanner.tsx b/packages/features/users/components/InvalidAppCredentialsBanner.tsx
new file mode 100644
index 0000000000..e97c708eee
--- /dev/null
+++ b/packages/features/users/components/InvalidAppCredentialsBanner.tsx
@@ -0,0 +1,49 @@
+import { useRouter } from "next/navigation";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { type RouterOutputs } from "@calcom/trpc";
+import { TopBanner } from "@calcom/ui";
+
+export type InvalidAppCredentialBannersProps = {
+ data: RouterOutputs["viewer"]["getUserTopBanners"]["invalidAppCredentialBanners"];
+};
+
+export function InvalidAppCredentialBanners({ data }: InvalidAppCredentialBannersProps) {
+ if (data.length === 0) {
+ return null; // No need to show banner if the array is empty
+ }
+
+ return (
+
+ {data.map((app) => (
+
+ ))}
+
+ );
+}
+
+export type InvalidAppCredentialBannerProps = {
+ name: string;
+ slug: string;
+};
+
+export function InvalidAppCredentialBanner({ name, slug }: InvalidAppCredentialBannerProps) {
+ const { t } = useLocale();
+ const router = useRouter();
+
+ const handleClick = () => {
+ router.push(`/apps/${slug}`);
+ };
+
+ return (
+
+ {t("invalid_credential_action")}
+
+ }
+ />
+ );
+}
diff --git a/packages/trpc/server/routers/loggedInViewer/checkForInvalidAppCredentials.ts b/packages/trpc/server/routers/loggedInViewer/checkForInvalidAppCredentials.ts
new file mode 100644
index 0000000000..edc095296a
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/checkForInvalidAppCredentials.ts
@@ -0,0 +1,52 @@
+import { getAppFromSlug } from "@calcom/app-store/utils";
+import { type InvalidAppCredentialBannerProps } from "@calcom/features/users/components/InvalidAppCredentialsBanner";
+import { prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/client";
+import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
+
+type checkInvalidAppCredentialsOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+};
+
+export const checkInvalidAppCredentials = async ({ ctx }: checkInvalidAppCredentialsOptions) => {
+ const userId = ctx.user.id;
+
+ const apps = await prisma.credential.findMany({
+ where: {
+ OR: [
+ {
+ userId: userId,
+ },
+ {
+ team: {
+ members: {
+ some: {
+ userId: userId,
+ accepted: true,
+ role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
+ },
+ },
+ },
+ },
+ ],
+ invalid: true,
+ },
+ select: {
+ appId: true,
+ },
+ });
+
+ const appNamesAndSlugs: InvalidAppCredentialBannerProps[] = [];
+ for (const app of apps) {
+ if (app.appId) {
+ const appId = app.appId;
+ const appMeta = await getAppFromSlug(appId);
+ const name = appMeta ? appMeta.name : appId;
+ appNamesAndSlugs.push({ slug: appId, name });
+ }
+ }
+
+ return appNamesAndSlugs;
+};
diff --git a/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts b/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts
index c81a00452f..b9b3313c68 100644
--- a/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts
+++ b/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts
@@ -5,6 +5,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { checkIfOrgNeedsUpgradeHandler } from "../viewer/organizations/checkIfOrgNeedsUpgrade.handler";
import { getUpgradeableHandler } from "../viewer/teams/getUpgradeable.handler";
+import { checkInvalidAppCredentials } from "./checkForInvalidAppCredentials";
import { shouldVerifyEmailHandler } from "./shouldVerifyEmail.handler";
type Props = {
@@ -38,14 +39,21 @@ export const getUserTopBannersHandler = async ({ ctx }: Props) => {
const upgradeableOrgMememberships = checkIfOrgNeedsUpgradeHandler({ ctx });
const shouldEmailVerify = shouldVerifyEmailHandler({ ctx });
const isInvalidCalendarCredential = checkInvalidGoogleCalendarCredentials({ ctx });
+ const appsWithInavlidCredentials = checkInvalidAppCredentials({ ctx });
- const [teamUpgradeBanner, orgUpgradeBanner, verifyEmailBanner, calendarCredentialBanner] =
- await Promise.allSettled([
- upgradeableTeamMememberships,
- upgradeableOrgMememberships,
- shouldEmailVerify,
- isInvalidCalendarCredential,
- ]);
+ const [
+ teamUpgradeBanner,
+ orgUpgradeBanner,
+ verifyEmailBanner,
+ calendarCredentialBanner,
+ invalidAppCredentialBanners,
+ ] = await Promise.allSettled([
+ upgradeableTeamMememberships,
+ upgradeableOrgMememberships,
+ shouldEmailVerify,
+ isInvalidCalendarCredential,
+ appsWithInavlidCredentials,
+ ]);
return {
teamUpgradeBanner: teamUpgradeBanner.status === "fulfilled" ? teamUpgradeBanner.value : [],
@@ -53,5 +61,7 @@ export const getUserTopBannersHandler = async ({ ctx }: Props) => {
verifyEmailBanner: verifyEmailBanner.status === "fulfilled" ? !verifyEmailBanner.value.isVerified : false,
calendarCredentialBanner:
calendarCredentialBanner.status === "fulfilled" ? calendarCredentialBanner.value : false,
+ invalidAppCredentialBanners:
+ invalidAppCredentialBanners.status === "fulfilled" ? invalidAppCredentialBanners.value : [],
};
};