diff --git a/apps/web/modules/settings/security/two-factor-auth-view.tsx b/apps/web/modules/settings/security/two-factor-auth-view.tsx
index 1159a658aa..0ba0997113 100644
--- a/apps/web/modules/settings/security/two-factor-auth-view.tsx
+++ b/apps/web/modules/settings/security/two-factor-auth-view.tsx
@@ -1,16 +1,15 @@
"use client";
-import { useState } from "react";
-
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
+import { Alert } from "@calcom/ui/components/alert";
import { Badge } from "@calcom/ui/components/badge";
import { SettingsToggle } from "@calcom/ui/components/form";
-import { Alert } from "@calcom/ui/components/alert";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
-
import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal";
import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal";
+import { signOut, useSession } from "next-auth/react";
+import { useState } from "react";
const SkeletonLoader = () => {
return (
@@ -27,6 +26,7 @@ const SkeletonLoader = () => {
const TwoFactorAuthView = () => {
const utils = trpc.useUtils();
+ const { data: sessionData } = useSession();
const { t } = useLocale();
const { data: user, isPending } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
@@ -63,7 +63,13 @@ const TwoFactorAuthView = () => {
onOpenChange={() => setEnableModalOpen(!enableModalOpen)}
onEnable={() => {
setEnableModalOpen(false);
- utils.viewer.me.invalidate();
+ if (sessionData?.user.role === "INACTIVE_ADMIN") {
+ // Session role is set at login time, so we need to sign out
+ // and sign back in to refresh the role and dismiss the banner
+ signOut({ callbackUrl: "/auth/login" });
+ } else {
+ utils.viewer.me.invalidate();
+ }
}}
onCancel={() => {
setEnableModalOpen(false);
diff --git a/apps/web/modules/users/components/AdminPasswordBanner.test.tsx b/apps/web/modules/users/components/AdminPasswordBanner.test.tsx
new file mode 100644
index 0000000000..840ecd39ca
--- /dev/null
+++ b/apps/web/modules/users/components/AdminPasswordBanner.test.tsx
@@ -0,0 +1,91 @@
+import { render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import type { SessionContextValue } from "next-auth/react";
+import { describe, expect, it, vi } from "vitest";
+
+import AdminPasswordBanner from "./AdminPasswordBanner";
+
+vi.mock("@calcom/lib/hooks/useLocale", () => ({
+ useLocale: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("next/link", () => ({
+ default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@calcom/ui/components/top-banner", () => ({
+ TopBanner: ({ text, actions }: { text: string; actions?: ReactNode }) => (
+
+ {text}
+ {actions}
+
+ ),
+}));
+
+function buildSession(overrides?: Partial>): NonNullable<
+ SessionContextValue["data"]
+> {
+ return {
+ expires: "2099-01-01T00:00:00.000Z",
+ hasValidLicense: true,
+ upId: "usr_test",
+ profileId: 1,
+ user: {
+ id: 1,
+ uuid: "uuid_test",
+ email: "test@example.com",
+ name: "Test User",
+ username: "testuser",
+ role: "INACTIVE_ADMIN",
+ inactiveAdminReason: "both",
+ },
+ ...overrides,
+ };
+}
+
+describe("AdminPasswordBanner", () => {
+ it("does not render for non-INACTIVE_ADMIN users", () => {
+ const data = buildSession({ user: { ...buildSession().user, role: "USER" } });
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders 2FA-only message and links to 2FA settings", () => {
+ const data = buildSession({ user: { ...buildSession().user, inactiveAdminReason: "2fa" } });
+
+ render();
+
+ expect(screen.getByText("invalid_admin_password_2fa_only")).toBeInTheDocument();
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "/settings/security/two-factor-auth");
+ expect(link).toHaveTextContent("enable_2fa");
+ });
+
+ it("renders password-only message and links to password settings", () => {
+ const data = buildSession({ user: { ...buildSession().user, inactiveAdminReason: "password" } });
+
+ render();
+
+ expect(screen.getByText("invalid_admin_password_password_only")).toBeInTheDocument();
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "/settings/security/password");
+ expect(link).toHaveTextContent("change_password_admin");
+ });
+
+ it("defaults to both message and password settings when reason is missing", () => {
+ const data = buildSession({ user: { ...buildSession().user, inactiveAdminReason: undefined } });
+
+ render();
+
+ expect(screen.getByText("invalid_admin_password")).toBeInTheDocument();
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "/settings/security/password");
+ expect(link).toHaveTextContent("change_password_admin");
+ });
+});
diff --git a/apps/web/modules/users/components/AdminPasswordBanner.tsx b/apps/web/modules/users/components/AdminPasswordBanner.tsx
index f6aecfd991..c78eb8b1f5 100644
--- a/apps/web/modules/users/components/AdminPasswordBanner.tsx
+++ b/apps/web/modules/users/components/AdminPasswordBanner.tsx
@@ -6,19 +6,45 @@ import { TopBanner } from "@calcom/ui/components/top-banner";
export type AdminPasswordBannerProps = { data: SessionContextValue["data"] };
+function getBannerMessageKey(reason: "both" | "password" | "2fa" | undefined): string {
+ switch (reason) {
+ case "password":
+ return "invalid_admin_password_password_only";
+ case "2fa":
+ return "invalid_admin_password_2fa_only";
+ case "both":
+ default:
+ return "invalid_admin_password";
+ }
+}
+
+function getBannerAction(reason: "both" | "password" | "2fa" | undefined): { href: string; labelKey: string } {
+ switch (reason) {
+ case "2fa":
+ return { href: "/settings/security/two-factor-auth", labelKey: "enable_2fa" };
+ case "password":
+ case "both":
+ default:
+ return { href: "/settings/security/password", labelKey: "change_password_admin" };
+ }
+}
+
function AdminPasswordBanner({ data }: AdminPasswordBannerProps) {
const { t } = useLocale();
if (data?.user.role !== "INACTIVE_ADMIN") return null;
+ const messageKey = getBannerMessageKey(data.user.inactiveAdminReason);
+ const { href, labelKey } = getBannerAction(data.user.inactiveAdminReason);
+
return (
<>
- {t("change_password_admin")}
+
+ {t(labelKey)}
}
/>
diff --git a/packages/features/auth/lib/next-auth-options.test.ts b/packages/features/auth/lib/next-auth-options.test.ts
index 32f4564de5..6d0a1d0748 100644
--- a/packages/features/auth/lib/next-auth-options.test.ts
+++ b/packages/features/auth/lib/next-auth-options.test.ts
@@ -88,6 +88,10 @@ vi.mock("./next-auth-custom-adapter", () => ({
})),
}));
+vi.mock("@calcom/i18n/server", () => ({
+ getTranslation: vi.fn().mockResolvedValue((key: string) => key),
+}));
+
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
ProfileRepository: {
findAllProfilesForUserIncludingMovedUser: vi.fn(),
@@ -375,6 +379,128 @@ describe("CredentialsProvider authorize", () => {
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
});
+
+ describe("Inactive admin reason", () => {
+ it("sets reason to 'both' when password is invalid and 2FA is disabled", async () => {
+ const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
+ vi.mocked(isPasswordValid).mockReturnValue(false);
+ vi.mocked(verifyPassword).mockResolvedValue(true);
+
+ const mockUser = createMockUser({
+ role: UserPermissionRole.ADMIN,
+ identityProvider: IdentityProvider.CAL,
+ twoFactorEnabled: false,
+ });
+ mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
+
+ const result = await authorizeCredentials({
+ email: mockUser.email,
+ password: "password123",
+ totpCode: "",
+ backupCode: "",
+ });
+
+ expect(result?.role).toBe("INACTIVE_ADMIN");
+ expect(result?.inactiveAdminReason).toBe("both");
+ });
+
+ it("sets reason to '2fa' when password is valid and 2FA is disabled", async () => {
+ const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
+ vi.mocked(isPasswordValid).mockReturnValue(true);
+ vi.mocked(verifyPassword).mockResolvedValue(true);
+
+ const mockUser = createMockUser({
+ role: UserPermissionRole.ADMIN,
+ identityProvider: IdentityProvider.CAL,
+ twoFactorEnabled: false,
+ });
+ mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
+
+ const result = await authorizeCredentials({
+ email: mockUser.email,
+ password: "password123",
+ totpCode: "",
+ backupCode: "",
+ });
+
+ expect(result?.role).toBe("INACTIVE_ADMIN");
+ expect(result?.inactiveAdminReason).toBe("2fa");
+ });
+
+ it("sets reason to 'password' when password is invalid and 2FA is enabled", async () => {
+ const originalKey = process.env.CALENDSO_ENCRYPTION_KEY;
+ process.env.CALENDSO_ENCRYPTION_KEY = "test";
+
+ try {
+ const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
+ vi.mocked(isPasswordValid).mockReturnValue(false);
+ vi.mocked(verifyPassword).mockResolvedValue(true);
+
+ const { symmetricDecrypt } = await import("@calcom/lib/crypto");
+ vi.mocked(symmetricDecrypt).mockReturnValue("a".repeat(32));
+
+ const { totpAuthenticatorCheck } = await import("@calcom/lib/totp");
+ vi.mocked(totpAuthenticatorCheck).mockReturnValue(true);
+
+ const mockUser = createMockUser({
+ role: UserPermissionRole.ADMIN,
+ identityProvider: IdentityProvider.CAL,
+ twoFactorEnabled: true,
+ twoFactorSecret: "encrypted_secret",
+ });
+ mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
+
+ const result = await authorizeCredentials({
+ email: mockUser.email,
+ password: "password123",
+ totpCode: "123456",
+ backupCode: "",
+ });
+
+ expect(result?.role).toBe("INACTIVE_ADMIN");
+ expect(result?.inactiveAdminReason).toBe("password");
+ } finally {
+ process.env.CALENDSO_ENCRYPTION_KEY = originalKey;
+ }
+ });
+
+ it("does not set inactiveAdminReason when admin requirements are met", async () => {
+ const originalKey = process.env.CALENDSO_ENCRYPTION_KEY;
+ process.env.CALENDSO_ENCRYPTION_KEY = "test";
+
+ try {
+ const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
+ vi.mocked(isPasswordValid).mockReturnValue(true);
+ vi.mocked(verifyPassword).mockResolvedValue(true);
+
+ const { symmetricDecrypt } = await import("@calcom/lib/crypto");
+ vi.mocked(symmetricDecrypt).mockReturnValue("a".repeat(32));
+
+ const { totpAuthenticatorCheck } = await import("@calcom/lib/totp");
+ vi.mocked(totpAuthenticatorCheck).mockReturnValue(true);
+
+ const mockUser = createMockUser({
+ role: UserPermissionRole.ADMIN,
+ identityProvider: IdentityProvider.CAL,
+ twoFactorEnabled: true,
+ twoFactorSecret: "encrypted_secret",
+ });
+ mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
+
+ const result = await authorizeCredentials({
+ email: mockUser.email,
+ password: "password123",
+ totpCode: "123456",
+ backupCode: "",
+ });
+
+ expect(result?.role).toBe(UserPermissionRole.ADMIN);
+ expect(result?.inactiveAdminReason).toBeUndefined();
+ } finally {
+ process.env.CALENDSO_ENCRYPTION_KEY = originalKey;
+ }
+ });
+ });
});
describe("Azure AD signIn callback", () => {
diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts
index 2accd391c4..f0e6fb1eea 100644
--- a/packages/features/auth/lib/next-auth-options.ts
+++ b/packages/features/auth/lib/next-auth-options.ts
@@ -273,8 +273,27 @@ export async function authorizeCredentials(
return "INACTIVE_ADMIN";
};
- // Create a NextAuth compatible user object using our presenter
- return AdapterUserPresenter.fromCalUser(user, validateRole(user.role), hasActiveTeams);
+ const role = validateRole(user.role);
+ const baseUser = AdapterUserPresenter.fromCalUser(user, role, hasActiveTeams);
+
+ if (role === "INACTIVE_ADMIN") {
+ const passwordValid = isPasswordValid(credentials.password, false, true);
+ const has2FA = user.twoFactorEnabled;
+
+ let reason: "both" | "password" | "2fa";
+
+ if (!passwordValid && !has2FA) {
+ reason = "both";
+ } else if (!passwordValid) {
+ reason = "password";
+ } else {
+ reason = "2fa";
+ }
+
+ return { ...baseUser, inactiveAdminReason: reason };
+ }
+
+ return baseUser;
}
export const CalComCredentialsProvider = CredentialsProvider({
@@ -729,6 +748,7 @@ export const getOptions = ({
locale: user?.locale,
profileId: user.profile?.id ?? token.profileId ?? null,
upId: user.profile?.upId ?? token.upId ?? null,
+ inactiveAdminReason: user.inactiveAdminReason,
} as JWT;
}
@@ -954,6 +974,7 @@ export const getOptions = ({
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
org: token?.org,
locale: token.locale,
+ inactiveAdminReason: token.inactiveAdminReason,
},
};
return calendsoSession;
diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json
index fc6ce420a4..0bce6a5633 100644
--- a/packages/i18n/locales/en/common.json
+++ b/packages/i18n/locales/en/common.json
@@ -2425,7 +2425,9 @@
"create_your_first_team_webhook_description": "Create your first webhook for this team event type",
"create_webhook_team_event_type": "Create a webhook for this team event type",
"disable_success_page": "Disable Success Page (only works if you have a redirect URL)",
- "invalid_admin_password": "You are admin but you do not have a password length of at least 15 characters or no 2FA yet",
+ "invalid_admin_password": "You are an admin but your password must be at least 15 characters long and you must enable 2FA.",
+ "invalid_admin_password_password_only": "You are admin but your password must be at least 15 characters long",
+ "invalid_admin_password_2fa_only": "You are admin but you do not have 2FA enabled yet",
"change_password_admin": "Change your password to gain admin access",
"username_already_taken": "Username is already taken",
"assignment": "Assignment",
diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts
index 3df6fc68eb..d2dbd22e50 100644
--- a/packages/types/next-auth.d.ts
+++ b/packages/types/next-auth.d.ts
@@ -41,6 +41,8 @@ declare module "next-auth" {
orgAwareUsername?: PrismaUser["username"];
avatarUrl?: PrismaUser["avatarUrl"];
role?: PrismaUser["role"] | "INACTIVE_ADMIN";
+ /** Set when role is INACTIVE_ADMIN: why admin security requirements are not met */
+ inactiveAdminReason?: "both" | "password" | "2fa";
locale?: string | null;
profile?: UserProfile;
samlTenant?: string;
@@ -57,6 +59,7 @@ declare module "next-auth/jwt" {
upId?: string;
profileId?: number | null;
role?: UserPermissionRole | "INACTIVE_ADMIN" | null;
+ inactiveAdminReason?: "both" | "password" | "2fa";
impersonatedBy?: {
id: number;
uuid: string;