fix: Add adminReview option to allow impersonating org members (#14275)

* added layouts for moving teams step

* added admin section for org creation step

* extended org creation to also move existing teams

* wip

* wip

* wip

* further changes

* Add checkout for org in onboarding

* Fix ts errors

* Self review feedback

* Self review addressed

* Fix unit tests

* Fix ts error

* Seans feedback addressed

* feat: fix correct accounts pending

* fix: unit tests for new invite member permissions

* tests: org admin onboarding tests for existing user

* tests: Inital user self serve flow

* chore: update teamAndUserFixture to create X amount of teams

* chore: add testId to card actionButton

* test: add test-Id to continue or checkout button

* tests: add tests for migrating existing teams

* feat: match new designs

* fix: isAdminCheck

* chore: fix pricing copy

* fix: flacky tests

* Fix tests?

* More test fixes

* Check all checkboxes

* Fix type error

* Fix failing test and typescript issues

* Fix unpaid org allowing auto-add users

* Add self-serve flag

* Skip tests

* Add adminReview option

* Fixes

* Add server checks for impersonation

* Improved error

* feat: extract - and disalow updating from org uset list if not reviewed

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2024-04-04 04:48:41 +00:00
committed by GitHub
co-authored by Peer Richelsen Alex van Andel Peer Richelsen sean-brydon Joe Au-Yeung Joe Au-Yeung
parent 3f02de8214
commit d30fb213d2
14 changed files with 98 additions and 18 deletions
@@ -2362,5 +2362,8 @@
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
"cookie_consent_checkbox": "I consent to our privacy policy and cookie usage",
"make_a_call": "Make a Call",
"review": "Review",
"reviewed": "Reviewed",
"unreviewed": "Unreviewed",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -3,6 +3,7 @@ import type { Session } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { z } from "zod";
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
import { getSession } from "@calcom/features/auth/lib/getSession";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
@@ -281,7 +282,9 @@ const ImpersonationProvider = CredentialsProvider({
);
}
if (!teamId) throw new Error("You do not have permission to do this.");
await ensureOrganizationIsReviewed(session?.user.org?.id);
if (!teamId) throw new Error("Error-teamNotFound: You do not have permission to do this.");
// Check session
const sessionUserFromDb = await prisma.user.findUnique({
@@ -312,7 +315,7 @@ const ImpersonationProvider = CredentialsProvider({
});
if (sessionUserFromDb?.teams.length === 0 || impersonatedUser.teams.length === 0) {
throw new Error("You do not have permission to do this.");
throw new Error("Error-UserHasNoTeams: You do not have permission to do this.");
}
// We find team by ID so we know there is only one team in the array
@@ -329,6 +332,7 @@ const ImpersonationProvider = CredentialsProvider({
});
export default ImpersonationProvider;
async function findProfile(returningUser: { id: number; username: string | null }) {
const allOrgProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser({
id: returningUser.id,
@@ -0,0 +1,22 @@
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
/**
* It assumes that a user can only impersonate the members of the organization he is logged in to.
* Note: Ensuring that one organization's member can't impersonate other organization's member isn't the job of this function
*/
export async function ensureOrganizationIsReviewed(loggedInUserOrgId: number | undefined) {
if (loggedInUserOrgId) {
const org = await OrganizationRepository.findByIdIncludeOrganizationSettings({
id: loggedInUserOrgId,
});
if (!org) {
throw new Error("Error-OrgNotFound: You do not have permission to do this.");
}
if (!org.organizationSettings?.isAdminReviewed) {
// If the org is not reviewed, we can't allow impersonation
throw new Error("Error-OrgNotReviewed: You do not have permission to do this.");
}
}
}
@@ -78,14 +78,13 @@ function AdminOrgTable() {
<Header>
<ColumnTitle widthClassNames="w-auto">{t("organization")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("owner")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("verified")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("reviewed")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("dns_configured")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">{t("published")}</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">{t("edit")}</span>
</ColumnTitle>
</Header>
<Body>
{data.map((org) => (
<Row key={org.id}>
@@ -105,10 +104,10 @@ function AdminOrgTable() {
</Cell>
<Cell>
<div className="space-x-2">
{!org.organizationSettings?.isOrganizationVerified ? (
<Badge variant="red">{t("unverified")}</Badge>
{!org.organizationSettings?.isAdminReviewed ? (
<Badge variant="red">{t("unreviewed")}</Badge>
) : (
<Badge variant="blue">{t("verified")}</Badge>
<Badge variant="green">{t("reviewed")}</Badge>
)}
</div>
</Cell>
@@ -134,14 +133,17 @@ function AdminOrgTable() {
<div className="flex w-full justify-end">
<DropdownActions
actions={[
...(!org.organizationSettings?.isOrganizationVerified
...(!org.organizationSettings?.isAdminReviewed
? [
{
id: "verify",
label: t("verify"),
id: "review",
label: t("review"),
onClick: () => {
verifyMutation.mutate({
orgId: org.id,
updateMutation.mutate({
id: org.id,
organizationSettings: {
isAdminReviewed: true,
},
});
},
icon: "check" as const,
@@ -108,7 +108,7 @@ function reducer(state: State, action: Action): State {
export function UserListTable() {
const { data: session } = useSession();
const { data: currentMembership } = trpc.viewer.organizations.listCurrent.useQuery();
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
const tableContainerRef = useRef<HTMLDivElement>(null);
const [state, dispatch] = useReducer(reducer, initialState);
@@ -126,9 +126,8 @@ export function UserListTable() {
placeholderData: keepPreviousData,
}
);
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
const adminOrOwner = currentMembership?.user.role === "ADMIN" || currentMembership?.user.role === "OWNER";
const adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER";
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const memorisedColumns = useMemo(() => {
@@ -265,7 +264,8 @@ export function UserListTable() {
const permissionsForUser = {
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
canRemove: permissionsRaw.canRemove && !isSelf,
canImpersonate: user.accepted && !user.disableImpersonation && !isSelf,
canImpersonate:
user.accepted && !user.disableImpersonation && !isSelf && !!org?.canAdminImpersonate,
canLeave: user.accepted && isSelf,
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
};
@@ -6,6 +6,12 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { createAProfileForAnExistingUser } from "../../createAProfileForAnExistingUser";
const orgSelect = {
id: true,
name: true,
slug: true,
logoUrl: true,
};
export class OrganizationRepository {
static async createWithOwner({
orgData,
@@ -15,6 +21,7 @@ export class OrganizationRepository {
name: string;
slug: string;
isOrganizationConfigured: boolean;
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
pricePerSeat: number | null;
@@ -33,6 +40,7 @@ export class OrganizationRepository {
...(!IS_TEAM_BILLING_ENABLED ? { slug: orgData.slug } : {}),
organizationSettings: {
create: {
isAdminReviewed: orgData.isOrganizationAdminReviewed,
isOrganizationVerified: true,
isOrganizationConfigured: orgData.isOrganizationConfigured,
orgAutoAcceptEmail: orgData.autoAcceptEmail,
@@ -65,4 +73,27 @@ export class OrganizationRepository {
});
return { organization, ownerProfile };
}
static async findById({ id }: { id: number }) {
return prisma.team.findUnique({
where: {
id,
isOrganization: true,
},
select: orgSelect,
});
}
static async findByIdIncludeOrganizationSettings({ id }: { id: number }) {
return prisma.team.findUnique({
where: {
id,
isOrganization: true,
},
select: {
...orgSelect,
organizationSettings: true,
},
});
}
}
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OrganizationSettings" ADD COLUMN "isAdminReviewed" BOOLEAN NOT NULL DEFAULT false;
-- User written Query: Update OrganizationSettings table to mark all existing organizations as reviewed as all organizations are created by ADMIN only so far.
UPDATE "OrganizationSettings" SET "isAdminReviewed" = true;
+4
View File
@@ -392,9 +392,13 @@ model OrganizationSettings {
organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId Int @unique
isOrganizationConfigured Boolean @default(false)
// It decides if new organization members can be auto-accepted or not
isOrganizationVerified Boolean @default(false)
orgAutoAcceptEmail String
lockEventTypeCreationForUsers Boolean @default(false)
// It decides if instance ADMIN has reviewed the organization or not.
// It is used to allow super sensitive operations like 'impersonation of Org members by Org admin'
isAdminReviewed Boolean @default(false)
dSyncData DSyncData?
}
+1
View File
@@ -353,6 +353,7 @@ export const orgSettingsSchema = z
.object({
isOrganizationVerified: z.boolean().optional(),
isOrganizationConfigured: z.boolean().optional(),
isAdminReviewed: z.boolean().optional(),
orgAutoAcceptEmail: z.string().optional(),
})
.nullable();
@@ -71,6 +71,7 @@ export const adminUpdateHandler = async ({ input }: AdminUpdateOptions) => {
isOrganizationVerified:
organizationSettings?.isOrganizationVerified ||
existingOrg.organizationSettings?.isOrganizationVerified,
isAdminReviewed: organizationSettings?.isAdminReviewed,
orgAutoAcceptEmail:
organizationSettings?.orgAutoAcceptEmail || existingOrg.organizationSettings?.orgAutoAcceptEmail,
},
@@ -34,8 +34,8 @@ const getIPAddress = async (url: string): Promise<string> => {
export const createHandler = async ({ input, ctx }: CreateOptions) => {
const { slug, name, orgOwnerEmail, seats, pricePerSeat, isPlatform } = input;
if (!ORG_SELF_SERVE_ENABLED && ctx.user.role !== UserPermissionRole.ADMIN) {
const IS_USER_ADMIN = ctx.user.role === UserPermissionRole.ADMIN;
if (!ORG_SELF_SERVE_ENABLED && !IS_USER_ADMIN) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can create organizations" });
}
@@ -106,6 +106,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
name,
slug,
isOrganizationConfigured,
isOrganizationAdminReviewed: IS_USER_ADMIN,
autoAcceptEmail,
seats: seats ?? null,
pricePerSeat: pricePerSeat ?? null,
@@ -35,6 +35,7 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
},
select: {
lockEventTypeCreationForUsers: true,
isAdminReviewed: true,
},
});
@@ -48,6 +49,7 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
const metadata = teamMetadataSchema.parse(membership?.team.metadata);
return {
canAdminImpersonate: !!organizationSettings?.isAdminReviewed,
organizationSettings: {
lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers,
},
@@ -1,5 +1,6 @@
import type { Prisma, PrismaPromise, User, Membership, Profile } from "@prisma/client";
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername";
import { isOrganisationAdmin, isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
@@ -42,6 +43,8 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
if (!(await isOrganisationAdmin(userId, organizationId))) throw new TRPCError({ code: "UNAUTHORIZED" });
await ensureOrganizationIsReviewed(organizationId);
const isUpdaterAnOwner = await isOrganisationOwner(userId, organizationId);
// only OWNER can update the role to OWNER
if (input.role === MembershipRole.OWNER && !isUpdaterAnOwner) {
+1
View File
@@ -401,6 +401,7 @@
"NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER",
"NEXT_PUBLIC_HEAD_SCRIPTS",
"NEXT_PUBLIC_BODY_SCRIPTS",
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
"NEXT_PUBLIC_API_V2_ROOT_URL"
]
}