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;