fix: correct admin password banner message and auto-sign-out after 2FA enable (#28129)

* fix: correct admin password banner message to require both password length and 2FA

The banner message incorrectly used 'or' implying only one condition was needed,
but the code requires BOTH a password of at least 15 characters AND 2FA enabled.

Updated the message to clearly state both requirements and added a hint that
users need to log out and log back in after updating their security settings.

Fixes #9527

Co-Authored-By: unknown <>

* fix: auto-sign-out INACTIVE_ADMIN users after enabling 2FA

When an INACTIVE_ADMIN user enables 2FA, automatically sign them out so
they can log back in with refreshed session role, dismissing the banner.
This matches the existing behavior for password changes.

Co-Authored-By: unknown <>

* feat: add dynamic admin banner message based on inactiveAdminReason

Co-Authored-By: unknown <>

* Remove and add various localization strings

* Update common.json

* Add cookie consent checkbox message and remove entries

* test: add unit tests for AdminPasswordBanner and inactiveAdminReason logic

Co-Authored-By: unknown <>

* fix: add expires field to session mock to fix type check

Co-Authored-By: unknown <>

* fix: wrap CALENDSO_ENCRYPTION_KEY mutations in try/finally to prevent env state leaks

Addresses Cubic AI review feedback (confidence 9/10): when a test fails
early, the CALENDSO_ENCRYPTION_KEY env var was not being restored, which
could leak state into subsequent tests. Wrapped the env mutation in
try/finally blocks to guarantee cleanup.

Co-Authored-By: bot_apk <apk@cognition.ai>

* fix: add missing 'expires' field to buildSession in AdminPasswordBanner test

The Session type requires 'expires' to be a string, but buildSession was
not providing it, causing a type error caught by CI type-check.

Co-Authored-By: bot_apk <apk@cognition.ai>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: bot_apk <apk@cognition.ai>
This commit is contained in:
Anik Dhabal Babu
2026-03-10 12:46:31 -03:00
committed by GitHub
co-authored by bot_apk Devin AI
parent f3f5523f31
commit f86767c125
7 changed files with 286 additions and 11 deletions
@@ -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);
@@ -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 }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("@calcom/ui/components/top-banner", () => ({
TopBanner: ({ text, actions }: { text: string; actions?: ReactNode }) => (
<div>
<span>{text}</span>
{actions}
</div>
),
}));
function buildSession(overrides?: Partial<NonNullable<SessionContextValue["data"]>>): 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(<AdminPasswordBanner data={data} />);
expect(container).toBeEmptyDOMElement();
});
it("renders 2FA-only message and links to 2FA settings", () => {
const data = buildSession({ user: { ...buildSession().user, inactiveAdminReason: "2fa" } });
render(<AdminPasswordBanner data={data} />);
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(<AdminPasswordBanner data={data} />);
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(<AdminPasswordBanner data={data} />);
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");
});
});
@@ -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 (
<>
<TopBanner
text={t("invalid_admin_password", { user: data.user.username })}
text={t(messageKey, { user: data.user.username })}
variant="warning"
actions={
<Link href="/settings/security/password" className="border-b border-b-black">
{t("change_password_admin")}
<Link href={href} className="border-b border-b-black">
{t(labelKey)}
</Link>
}
/>
@@ -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", () => {
@@ -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;
+3 -1
View File
@@ -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",
+3
View File
@@ -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;