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:
co-authored by
bot_apk
Devin AI
parent
f3f5523f31
commit
f86767c125
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user