fix(auth): validate IdP authority before SAML account linking (#26005)
* fix(auth): validate IdP authority before SAML account linking Adds verification that SAML IdP is authoritative for the email domain before allowing account conversion * fix(auth): deny by default on missing SAML tenant + optimize membership query - Block account conversion when tenant is missing (deny by default) - Replace JOIN + ILIKE with two indexed lookups for O(1) performance * refactor(auth): apply data minimization to security logs
This commit is contained in:
@@ -47,6 +47,8 @@ const ServerPage = async ({ searchParams }: PageProps) => {
|
||||
? "SAML (like Okta)"
|
||||
: "your original login method";
|
||||
return t("account_managed_by_identity_provider_error", { provider: providerName });
|
||||
} else if (error === "saml-idp-not-authoritative") {
|
||||
return t("saml_idp_not_authoritative_error");
|
||||
}
|
||||
return t("error_during_login") + (error ? ` Error code: ${error}` : "");
|
||||
};
|
||||
|
||||
@@ -4168,6 +4168,7 @@
|
||||
"timestamp": "Timestamp",
|
||||
"json": "JSON",
|
||||
"hubspot_ignore_guests": "Do not create new records for guests added to the booking",
|
||||
"saml_idp_not_authoritative_error": "This SAML identity provider is not authorized to manage your account. Please sign in with your original login method.",
|
||||
"audit_logs_organization_required": "You must be part of an organization to view audit logs.",
|
||||
"audit_logs_booking_not_found_or_permission_denied": "Booking not found or you do not have permission to view its audit logs.",
|
||||
"audit_logs_booking_has_no_owner": "Cannot verify permissions: booking has no associated user.",
|
||||
|
||||
@@ -47,6 +47,7 @@ import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail";
|
||||
import { ErrorCode } from "./ErrorCode";
|
||||
import { dub } from "./dub";
|
||||
import { validateSamlAccountConversion } from "./samlAccountLinking";
|
||||
import CalComAdapter from "./next-auth-custom-adapter";
|
||||
import { verifyPassword } from "./verifyPassword";
|
||||
|
||||
@@ -315,6 +316,10 @@ if (isSAMLLoginEnabled) {
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
locale?: string;
|
||||
requested?: {
|
||||
tenant?: string;
|
||||
product?: string;
|
||||
};
|
||||
}) => {
|
||||
log.debug("BoxyHQ:profile", safeStringify({ profile }));
|
||||
const userRepo = new UserRepository(prisma);
|
||||
@@ -329,6 +334,8 @@ if (isSAMLLoginEnabled) {
|
||||
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
|
||||
email_verified: true,
|
||||
locale: profile.locale,
|
||||
// Pass SAML tenant for domain authority checks in signIn callback
|
||||
samlTenant: profile.requested?.tenant,
|
||||
...(user ? { profile: user.allProfiles[0] } : {}),
|
||||
};
|
||||
},
|
||||
@@ -939,6 +946,15 @@ export const getOptions = ({
|
||||
existingUserWithEmail.emailVerified &&
|
||||
existingUserWithEmail.identityProvider !== IdentityProvider.CAL
|
||||
) {
|
||||
// Verify SAML IdP is authoritative before auto-merge
|
||||
if (idP === IdentityProvider.SAML) {
|
||||
const samlTenant = (user as { samlTenant?: string }).samlTenant;
|
||||
const validation = await validateSamlAccountConversion(samlTenant, user.email, "SelfHosted→SAML");
|
||||
if (!validation.allowed) {
|
||||
return validation.errorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail.email);
|
||||
} else {
|
||||
@@ -952,6 +968,15 @@ export const getOptions = ({
|
||||
!existingUserWithEmail.emailVerified &&
|
||||
!existingUserWithEmail.username
|
||||
) {
|
||||
// Verify SAML IdP is authoritative before claiming invited user
|
||||
if (idP === IdentityProvider.SAML) {
|
||||
const samlTenant = (user as { samlTenant?: string }).samlTenant;
|
||||
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Invite→SAML");
|
||||
if (!validation.allowed) {
|
||||
return validation.errorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: existingUserWithEmail.email,
|
||||
@@ -981,6 +1006,15 @@ export const getOptions = ({
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
|
||||
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
|
||||
) {
|
||||
// Verify SAML IdP is authoritative before converting account
|
||||
if (idP === IdentityProvider.SAML) {
|
||||
const samlTenant = (user as { samlTenant?: string }).samlTenant;
|
||||
const validation = await validateSamlAccountConversion(samlTenant, user.email, "CAL→SAML");
|
||||
if (!validation.allowed) {
|
||||
return validation.errorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email: existingUserWithEmail.email },
|
||||
// also update email to the IdP email
|
||||
@@ -1002,6 +1036,13 @@ export const getOptions = ({
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
|
||||
idP === IdentityProvider.SAML
|
||||
) {
|
||||
// Verify SAML IdP is authoritative before converting account
|
||||
const samlTenant = (user as { samlTenant?: string }).samlTenant;
|
||||
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Google→SAML");
|
||||
if (!validation.allowed) {
|
||||
return validation.errorUrl;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email: existingUserWithEmail.email },
|
||||
// also update email to the IdP email
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { OrganizationSettingsRepository } from "@calcom/features/organizations/repositories/OrganizationSettingsRepository";
|
||||
|
||||
import {
|
||||
SamlAccountLinkingService,
|
||||
getTeamIdFromSamlTenant,
|
||||
validateSamlAccountConversion,
|
||||
} from "./samlAccountLinking";
|
||||
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
getSubLogger: () => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockPrismaClient = {} as PrismaClient;
|
||||
|
||||
let hasAcceptedMembershipSpy: MockInstance;
|
||||
let getVerifiedDomainsSpy: MockInstance;
|
||||
|
||||
function setupMocks(config: { verifiedDomains?: string[]; hasMembership?: boolean }) {
|
||||
hasAcceptedMembershipSpy = vi
|
||||
.spyOn(MembershipRepository.prototype, "hasAcceptedMembershipByEmail")
|
||||
.mockResolvedValue(config.hasMembership ?? false);
|
||||
|
||||
getVerifiedDomainsSpy = vi
|
||||
.spyOn(OrganizationSettingsRepository.prototype, "getVerifiedDomains")
|
||||
.mockResolvedValue(config.verifiedDomains ?? []);
|
||||
}
|
||||
|
||||
describe("getTeamIdFromSamlTenant", () => {
|
||||
it("extracts team ID from valid tenant string", () => {
|
||||
expect(getTeamIdFromSamlTenant("team-123")).toBe(123);
|
||||
expect(getTeamIdFromSamlTenant("team-1")).toBe(1);
|
||||
expect(getTeamIdFromSamlTenant("team-999999")).toBe(999999);
|
||||
});
|
||||
|
||||
it("returns null for invalid tenant formats", () => {
|
||||
expect(getTeamIdFromSamlTenant("")).toBeNull();
|
||||
expect(getTeamIdFromSamlTenant("invalid")).toBeNull();
|
||||
expect(getTeamIdFromSamlTenant("org-123")).toBeNull();
|
||||
expect(getTeamIdFromSamlTenant("team-")).toBeNull();
|
||||
expect(getTeamIdFromSamlTenant("team-abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for tenant with non-numeric ID", () => {
|
||||
expect(getTeamIdFromSamlTenant("team-abc123")).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates decimal values (parseInt behavior)", () => {
|
||||
expect(getTeamIdFromSamlTenant("team-12.5")).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamlAccountLinkingService.isSamlIdpAuthoritativeForEmail", () => {
|
||||
let service: SamlAccountLinkingService;
|
||||
const ORG_TEAM_ID = 123;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns authoritative when email domain matches org verified domain", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: false });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@acme.com");
|
||||
|
||||
expect(result).toEqual({ authoritative: true, reason: "domain_verified" });
|
||||
expect(hasAcceptedMembershipSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("performs case-insensitive domain matching", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"] });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@ACME.COM");
|
||||
|
||||
expect(result).toEqual({ authoritative: true, reason: "domain_verified" });
|
||||
});
|
||||
|
||||
it("matches subdomains of verified domain", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"] });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@sales.acme.com");
|
||||
|
||||
expect(result).toEqual({ authoritative: true, reason: "domain_verified" });
|
||||
});
|
||||
|
||||
it("returns not authoritative when domain doesn't match and user is not member", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: false });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@different.com");
|
||||
|
||||
expect(result).toEqual({ authoritative: false, reason: "domain_mismatch" });
|
||||
});
|
||||
|
||||
it("returns authoritative for existing org member with different domain", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: true });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@personal-email.com");
|
||||
|
||||
expect(result).toEqual({ authoritative: true, reason: "existing_member" });
|
||||
});
|
||||
|
||||
it("skips membership check when domain matches", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: true });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@acme.com");
|
||||
|
||||
expect(hasAcceptedMembershipSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns not authoritative for invalid email", async () => {
|
||||
setupMocks({});
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "invalid-email");
|
||||
|
||||
expect(result).toEqual({ authoritative: false, reason: "invalid_email" });
|
||||
expect(getVerifiedDomainsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns not authoritative for empty email", async () => {
|
||||
setupMocks({});
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "");
|
||||
|
||||
expect(result).toEqual({ authoritative: false, reason: "invalid_email" });
|
||||
});
|
||||
|
||||
it("falls back to membership when org has no verified domains", async () => {
|
||||
setupMocks({ verifiedDomains: [], hasMembership: true });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@any-domain.com");
|
||||
|
||||
expect(result).toEqual({ authoritative: true, reason: "existing_member" });
|
||||
});
|
||||
|
||||
it("rejects when org has no verified domains and user is not member", async () => {
|
||||
setupMocks({ verifiedDomains: [], hasMembership: false });
|
||||
service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "attacker@evil.com");
|
||||
|
||||
expect(result).toEqual({ authoritative: false, reason: "domain_mismatch" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSamlAccountConversion", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setupMocks({ verifiedDomains: [], hasMembership: false });
|
||||
});
|
||||
|
||||
it("blocks when no SAML tenant provided (deny by default)", async () => {
|
||||
const result = await validateSamlAccountConversion(undefined, "user@example.com", "CAL→SAML");
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
errorUrl: "/auth/error?error=saml-idp-not-authoritative",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows when tenant is not org-based", async () => {
|
||||
const result = await validateSamlAccountConversion("Cal.com", "user@example.com", "CAL→SAML");
|
||||
expect(result).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("blocks when IdP is not authoritative", async () => {
|
||||
const result = await validateSamlAccountConversion("team-123", "attacker@evil.com", "CAL→SAML");
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
errorUrl: "/auth/error?error=saml-idp-not-authoritative",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows when IdP is authoritative", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"] });
|
||||
|
||||
const result = await validateSamlAccountConversion("team-123", "user@acme.com", "CAL→SAML");
|
||||
|
||||
expect(result).toEqual({ allowed: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: SAML Account Takeover Prevention", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("blocks takeover when attacker org asserts victim's email", async () => {
|
||||
setupMocks({ verifiedDomains: ["attacker-org.com"], hasMembership: false });
|
||||
|
||||
const result = await validateSamlAccountConversion("team-999", "victim@gmail.com", "Google→SAML");
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows SSO when org owns the email domain", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"] });
|
||||
|
||||
const result = await validateSamlAccountConversion("team-100", "employee@acme.com", "CAL→SAML");
|
||||
|
||||
expect(result).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("allows SSO for existing members with personal email", async () => {
|
||||
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: true });
|
||||
|
||||
const service = new SamlAccountLinkingService(mockPrismaClient);
|
||||
const result = await service.isSamlIdpAuthoritativeForEmail(100, "contractor@personal-email.com");
|
||||
|
||||
expect(result.authoritative).toBe(true);
|
||||
expect(result.reason).toBe("existing_member");
|
||||
});
|
||||
|
||||
it("blocks invite takeover when attacker org claims invited user", async () => {
|
||||
// Org A invites victim@gmail.com, attacker in Org B tries to claim via SAML
|
||||
setupMocks({ verifiedDomains: ["attacker-org.com"], hasMembership: false });
|
||||
|
||||
const result = await validateSamlAccountConversion("team-999", "victim@gmail.com", "Invite→SAML");
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { OrganizationSettingsRepository } from "@calcom/features/organizations/repositories/OrganizationSettingsRepository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { tenantPrefix } from "../../ee/sso/lib/saml";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["samlAccountLinking"] });
|
||||
const SAML_NOT_AUTHORITATIVE_ERROR_URL = "/auth/error?error=saml-idp-not-authoritative";
|
||||
|
||||
export function getTeamIdFromSamlTenant(tenant: string): number | null {
|
||||
if (!tenant.startsWith(tenantPrefix)) {
|
||||
return null;
|
||||
}
|
||||
const teamId = parseInt(tenant.replace(tenantPrefix, ""), 10);
|
||||
return isNaN(teamId) ? null : teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents account takeover via malicious SAML IdPs asserting arbitrary emails.
|
||||
* IdP is authoritative when domain matches org's verified domain or user is already a member.
|
||||
*/
|
||||
export class SamlAccountLinkingService {
|
||||
private membershipRepository: MembershipRepository;
|
||||
private orgSettingsRepository: OrganizationSettingsRepository;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.membershipRepository = new MembershipRepository(prismaClient);
|
||||
this.orgSettingsRepository = new OrganizationSettingsRepository(prismaClient);
|
||||
}
|
||||
|
||||
async isSamlIdpAuthoritativeForEmail(
|
||||
samlOrgTeamId: number,
|
||||
email: string
|
||||
): Promise<{ authoritative: boolean; reason: string }> {
|
||||
const emailDomain = email.split("@")[1]?.toLowerCase();
|
||||
|
||||
if (!emailDomain) {
|
||||
return { authoritative: false, reason: "invalid_email" };
|
||||
}
|
||||
|
||||
const verifiedDomains = await this.orgSettingsRepository.getVerifiedDomains(samlOrgTeamId);
|
||||
const domainMatches = verifiedDomains.some(
|
||||
(verified) => emailDomain === verified || emailDomain.endsWith(`.${verified}`)
|
||||
);
|
||||
if (domainMatches) {
|
||||
return { authoritative: true, reason: "domain_verified" };
|
||||
}
|
||||
|
||||
const hasMembership = await this.membershipRepository.hasAcceptedMembershipByEmail({
|
||||
email,
|
||||
teamId: samlOrgTeamId,
|
||||
});
|
||||
|
||||
if (hasMembership) {
|
||||
return { authoritative: true, reason: "existing_member" };
|
||||
}
|
||||
|
||||
return { authoritative: false, reason: "domain_mismatch" };
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountConversionValidationResult =
|
||||
| { allowed: true }
|
||||
| { allowed: false; errorUrl: string };
|
||||
|
||||
export async function validateSamlAccountConversion(
|
||||
samlTenant: string | undefined,
|
||||
email: string,
|
||||
conversionContext: string
|
||||
): Promise<AccountConversionValidationResult> {
|
||||
if (!samlTenant) {
|
||||
// Deny by default - if tenant is missing, we cannot verify IdP authority
|
||||
log.error("SAML conversion blocked - missing tenant", { emailDomain: email.split("@")[1], conversionContext });
|
||||
return { allowed: false, errorUrl: SAML_NOT_AUTHORITATIVE_ERROR_URL };
|
||||
}
|
||||
|
||||
const samlOrgTeamId = getTeamIdFromSamlTenant(samlTenant);
|
||||
if (!samlOrgTeamId) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const service = new SamlAccountLinkingService(prisma);
|
||||
const authority = await service.isSamlIdpAuthoritativeForEmail(samlOrgTeamId, email);
|
||||
|
||||
if (!authority.authoritative) {
|
||||
log.warn(`Blocking ${conversionContext} conversion - IdP not authoritative`, {
|
||||
emailDomain: email.split("@")[1],
|
||||
samlOrgTeamId,
|
||||
reason: authority.reason,
|
||||
});
|
||||
return { allowed: false, errorUrl: SAML_NOT_AUTHORITATIVE_ERROR_URL };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -560,4 +560,29 @@ export class MembershipRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Two indexed lookups instead of JOIN with ILIKE (which bypasses index)
|
||||
async hasAcceptedMembershipByEmail({
|
||||
email,
|
||||
teamId,
|
||||
}: {
|
||||
email: string;
|
||||
teamId: number;
|
||||
}): Promise<boolean> {
|
||||
const user = await this.prismaClient.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
const membership = await this.prismaClient.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: { userId: user.id, teamId },
|
||||
},
|
||||
select: { accepted: true },
|
||||
});
|
||||
|
||||
return membership?.accepted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
export class OrganizationSettingsRepository {
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
constructor(private readonly prismaClient: PrismaClient) {}
|
||||
|
||||
async getEmailSettings(organizationId: number) {
|
||||
return await this.prismaClient.organizationSettings.findUnique({
|
||||
@@ -19,4 +19,22 @@ export class OrganizationSettingsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Returns array for future multi-domain support
|
||||
async getVerifiedDomains(organizationId: number): Promise<string[]> {
|
||||
const settings = await this.prismaClient.organizationSettings.findUnique({
|
||||
where: { organizationId },
|
||||
select: {
|
||||
isOrganizationVerified: true,
|
||||
orgAutoAcceptEmail: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings?.isOrganizationVerified) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const domain = settings.orgAutoAcceptEmail;
|
||||
return domain ? [domain.toLowerCase()] : [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user