feat: from org team invite (#9569)

* Initial commit

* Adding feature flag

* Desktop first banner, mobile pending

* Removing dead code and img

* AppInstallButtonBase

* WIP

* Adds Email verification template+translations for organizations (#9202)

* feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209)

* Change scopedMembers to orgMembers

* Change to orgUsers

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Session logic to show org label

* Step 2 done, avatar not working

* List orgs and list teams specific if orgs exist

* Conditionally show org - fix settings layout - add labels for all pages

* Profile Page + update

* Org specific team creation

* appearance page

* Ensure members cant of org cant update settings in UI

* Fix update handler imports

* hide billing on sub teams

* Update profile slug page

* Letting duplicate slugs for teams to support orgs

* Add slug coliisions for org

* Covering null on unique clauses

* Covering null on unique clauses

* Extract to utils

* Update settings to use subdomain path in team url , team + org

* Supporting having the orgId in the session cookie

* Onboarding admins step

* Last step to create teams

* Update handler comments

* Upgrade ORG banner - disabled team banner for child teams

* Handle publishing ORGS

* Fix licenese issue

* Update packages/trpc/server/routers/viewer/teams/create.handler.ts

* Split into function calls to make this file more explisit

* Update parents stripe sub not teamID

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Create org membership also - billing portal page

* A11ly

* Hide create team if no valid permisisons

* Get Org members router

* Handle updating subscription if orgId

* Fix double upgrade banner

* Update constants

* Feedback

* Copy change

* Making an org avatar (temp)

* Add slug colission detection for user and team name

* Fix Import

* Remove update password func

* Fix module import over relative

* feat: organization event type filter (#9253)

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Missing changes to support orgs schema changes

* Fix import again

* Throw no team found before auth error

* Check if invited found user is already in differnt org

* feat: organization settings general page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* feat: add members page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use invalidate

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Move to for of loop to throw errors in usenamelist

* fix: delete mutation

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: remove organization id

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Remove app install button sa its in 9337

* Remove i18n key not being used

* feat: Onboarding process to create an organization (#9184)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Making sure we check requestedSlug now

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* feat: [CAL-1816] Organization subdomain support (#9345)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* Covering users and subteams, excluding non-org users

* Unpublished teams shows correctly

* Create subdomain in Vercel

* feedback

* Renaming Vercel env vars

* Vercel domain check before creation

* Supporting cal-staging.com

* Change to have vercel detect it

* vercel domain check data message error

* Remove check domain

* Making sure we check requestedSlug now

* Feedback and unneeded code

* Reverting unneeded changes

* Unneeded changes

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Vercel subdomain creation in PROD only

* Fix router

* fix: use zod schema

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* feat: organization settings general and members page (#9266)

* feat: organization settings general page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* feat: add members page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use invalidate

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: delete mutation

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: remove organization id

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use zod schema

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Type fixes

* Use org Stripe product when upgrading

* Removed unused code

* UI completed

* Reverting changes

* Update UsernameTextfield.tsx

* More reverts

* Update next-auth-options.ts

* Update common.json

* Type fixes

* Include invite token for orgs

* Update org schema

* Make token settings optional as it isnt used in orgs yet

* Reverts

* Add correct array logic and move to controlled component

* Fix toggle group default | Update toggle group bg

* Darkmode toggle group

* Distinct user

* Hide modal if no org members

* Extract toggle logic

* Update packages/features/ee/organizations/components/TeamInviteFromOrg.tsx

* remove yarn.lock from commit

* Fix types

* Add getMember router back

* As a query lol

* Fix types

* Fix accepted param defaulting to true as we want both

* Fix list not pulling back people who have not joined the org yet

* Fix tests to handle invite a existing org user to a team

* Fix test

* Fix an error sometimes when existing org user is invited

* Updates radix & fixes bug

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
pull/10041/head
sean-brydon 2023-07-10 10:46:51 +01:00 committed by GitHub
parent 7c4b7209c2
commit 650d0082a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 458 additions and 707 deletions

View File

@ -12,7 +12,7 @@
"@calcom/ui": "*",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-id": "^1.0.0",
"@radix-ui/react-popover": "^1.0.2",

View File

@ -47,7 +47,7 @@
"@next/bundle-analyzer": "^13.1.6",
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-collapsible": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-id": "^1.0.0",
"@radix-ui/react-popover": "^1.0.2",

View File

@ -0,0 +1,103 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import type { RouterOutputs } from "@calcom/trpc";
import { Avatar, TextField } from "@calcom/ui";
type TeamInviteFromOrgProps = PropsWithChildren<{
selectedEmails?: string | string[];
handleOnChecked: (usersEmail: string) => void;
orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"];
}>;
const keysToCheck = ["name", "email", "username"] as const; // array of keys to check
export default function TeamInviteFromOrg({
handleOnChecked,
selectedEmails,
orgMembers,
}: TeamInviteFromOrgProps) {
const [searchQuery, setSearchQuery] = useState("");
const filteredMembers = orgMembers?.filter((member) => {
if (!searchQuery) {
return true; // return all members if searchQuery is empty
}
const { user } = member ?? {}; // destructuring with default value in case member is undefined
return keysToCheck.some((key) => user?.[key]?.toLowerCase().includes(searchQuery.toLowerCase()));
});
return (
<div className="bg-muted border-subtle flex flex-col rounded-md border p-4">
<div className="-my-1">
<TextField placeholder="Search..." onChange={(e) => setSearchQuery(e.target.value)} />
</div>
<hr className="border-subtle -mx-4 mt-2" />
<div className="scrollbar min-h-48 flex max-h-48 flex-col space-y-0.5 overflow-y-scroll pt-2">
<>
{filteredMembers &&
filteredMembers.map((member) => {
const isSelected = Array.isArray(selectedEmails)
? selectedEmails.includes(member.user.email)
: selectedEmails === member.user.email;
return (
<UserToInviteItem
key={member.user.id}
member={member}
isSelected={isSelected}
onChange={() => handleOnChecked(member.user.email)}
/>
);
})}
</>
</div>
</div>
);
}
function UserToInviteItem({
member,
isSelected,
onChange,
}: {
member: RouterOutputs["viewer"]["organizations"]["getMembers"][number];
isSelected: boolean;
onChange: () => void;
}) {
return (
<div
key={member.userId}
onClick={() => onChange()} // We handle this on click on the div also - for a11y we handle it with label and checkbox below
className={classNames(
"flex cursor-pointer items-center rounded-md py-1 px-2",
isSelected ? "bg-emphasis" : "hover:bg-subtle "
)}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<Avatar
size="sm"
alt="Users avatar"
asChild
imageSrc={`/api/user/${member.user.username}`}
gravatarFallbackMd5="hash"
/>
<label
htmlFor={`${member.user.id}`}
className="text-emphasis cursor-pointer text-sm font-medium leading-none">
{member.user.name || member.user.email || "Nameless User"}
</label>
</div>
<div className="ml-auto">
<input
id={`${member.user.id}`}
checked={isSelected}
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default hover:bg-subtle inline-flex h-4 w-4 place-self-center justify-self-end rounded checked:bg-gray-800"
onChange={() => {
onChange();
}}
/>
</div>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import type { FormEvent } from "react";
import { Controller, useForm } from "react-hook-form";
import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg";
import { classNames } from "@calcom/lib";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -52,17 +53,24 @@ export interface NewMemberForm {
sendInviteEmail: boolean;
}
type ModalMode = "INDIVIDUAL" | "BULK";
type ModalMode = "INDIVIDUAL" | "BULK" | "ORGANIZATION";
interface FileEvent<T = Element> extends FormEvent<T> {
target: EventTarget & T;
}
function toggleElementInArray(value: string[] | string | undefined, element: string): string[] {
const array = value ? (Array.isArray(value) ? value : [value]) : [];
return array.includes(element) ? array.filter((item) => item !== element) : [...array, element];
}
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const { t } = useLocale();
const trpcContext = trpc.useContext();
const [modalImportMode, setModalInputMode] = useState<ModalMode>("INDIVIDUAL");
const [modalImportMode, setModalInputMode] = useState<ModalMode>(
props?.orgMembers && props.orgMembers?.length > 0 ? "ORGANIZATION" : "INDIVIDUAL"
);
const createInviteMutation = trpc.viewer.teams.createInvite.useMutation({
onSuccess(token) {
@ -98,7 +106,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
},
{ value: "BULK", label: t("invite_team_bulk_segment"), iconLeft: <Users /> },
];
if (props.orgMembers) {
if (props?.orgMembers && props.orgMembers?.length > 0) {
array.unshift({
value: "ORGANIZATION",
label: t("organization"),
@ -170,7 +178,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
<ToggleGroup
isFullWidth={true}
onValueChange={(val) => setModalInputMode(val as ModalMode)}
defaultValue="INDIVIDUAL"
defaultValue={modalImportMode}
options={toggleGroupOptions}
/>
</div>
@ -260,6 +268,29 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
</Button>
</div>
)}
{modalImportMode === "ORGANIZATION" && (
<Controller
name="emailOrUsername"
control={newMemberFormMethods.control}
rules={{
required: t("enter_email_or_username"),
}}
render={({ field: { onChange, value } }) => (
<>
<TeamInviteFromOrg
selectedEmails={value}
handleOnChecked={(userEmail) => {
// If 'value' is not an array, create a new array with 'userEmail' to allow future updates to the array.
// If 'value' is an array, update the array by either adding or removing 'userEmail'.
const newValue = toggleElementInArray(value, userEmail);
onChange(newValue);
}}
orgMembers={props.orgMembers}
/>
</>
)}
/>
)}
<Controller
name="role"
control={newMemberFormMethods.control}

View File

@ -266,7 +266,7 @@ export default function MemberListItem(props: Props) {
</div>
{editMode && (
<Dialog open={showDeleteModal} onOpenChange={() => setShowDeleteModal(false)}>
<Dialog open={showDeleteModal} onOpenChange={() => setShowDeleteModal((prev) => !prev)}>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}

View File

@ -70,13 +70,25 @@ const MembersView = () => {
const session = useSession();
const utils = trpc.useContext();
const teamId = Number(router.query.id);
const showDialog = router.query.inviteModal === "true";
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
const { data: orgMembersNotInThisTeam, isLoading: isOrgListLoading } =
trpc.viewer.organizations.getMembers.useQuery(
{
teamIdToExclude: teamId,
distinctUser: true,
},
{
enabled: router.isReady,
}
);
const { data: team, isLoading: isTeamsLoading } = trpc.viewer.teams.get.useQuery(
{ teamId },
{
onError: () => {
@ -85,6 +97,8 @@ const MembersView = () => {
}
);
const isLoading = isOrgListLoading || isTeamsLoading;
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
const isInviteOpen = !team?.membership.accepted;
@ -161,6 +175,7 @@ const MembersView = () => {
<MemberInvitationModal
isLoading={inviteMemberMutation.isLoading}
isOpen={showMemberInvitationModal}
orgMembers={orgMembersNotInThisTeam}
members={team.members}
teamId={team.id}
token={team.inviteToken?.token}

View File

@ -19,9 +19,9 @@ type OrganizationsRouterHandlerCache = {
setPassword?: typeof import("./setPassword.handler").setPasswordHandler;
adminGetUnverified?: typeof import("./adminGetUnverified.handler").adminGetUnverifiedHandler;
adminVerify?: typeof import("./adminVerify.handler").adminVerifyHandler;
getMembers?: typeof import("./getMembers.handler").getMembersHandler;
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
getMembers?: typeof import("./getMembers.handler").getMembersHandler;
};
const UNSTABLE_HANDLER_CACHE: OrganizationsRouterHandlerCache = {};
@ -146,6 +146,23 @@ export const viewerOrganizationsRouter = router({
input,
});
}),
getMembers: authedProcedure.input(ZGetMembersInput).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
UNSTABLE_HANDLER_CACHE.getMembers = await import("./getMembers.handler").then(
(mod) => mod.getMembersHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getMembers({
ctx,
input,
});
}),
adminGetUnverified: authedAdminProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.adminGetUnverified) {
UNSTABLE_HANDLER_CACHE.adminGetUnverified = await import("./adminGetUnverified.handler").then(
@ -207,21 +224,4 @@ export const viewerOrganizationsRouter = router({
ctx,
});
}),
getMembers: authedProcedure.input(ZGetMembersInput).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
UNSTABLE_HANDLER_CACHE.getMembers = await import("./getMembers.handler").then(
(mod) => mod.getMembersHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getMembers({
ctx,
input,
});
}),
});

View File

@ -11,32 +11,44 @@ type CreateOptions = {
};
export const getMembersHandler = async ({ input, ctx }: CreateOptions) => {
const { teamIdToExclude } = input;
const { teamIdToExclude, accepted, distinctUser } = input;
if (!ctx.user.organizationId) return null;
if (!ctx.user.organizationId) return [];
const users = await prisma.membership.findMany({
const teamQuery = await prisma.team.findUnique({
where: {
user: {
organizationId: ctx.user.organizationId,
id: ctx.user.organizationId,
},
...(teamIdToExclude && {
select: {
members: {
where: {
teamId: {
not: teamIdToExclude,
},
}),
accepted,
},
include: {
select: {
accepted: true,
disableImpersonation: true,
id: true,
teamId: true,
role: true,
userId: true,
user: {
select: {
id: true,
username: true,
email: true,
completedOnboarding: true,
name: true,
},
},
},
...(distinctUser && {
distinct: ["userId"],
}),
},
},
});
return users;
return teamQuery?.members || [];
};

View File

@ -2,6 +2,8 @@ import { z } from "zod";
export const ZGetMembersInput = z.object({
teamIdToExclude: z.number().optional(),
accepted: z.boolean().optional(),
distinctUser: z.boolean().optional().default(false),
});
export type TGetMembersInputSchema = z.infer<typeof ZGetMembersInput>;

View File

@ -40,7 +40,8 @@ const mockedTeam: TeamWithParent = {
timeFormat: null,
metadata: null,
parentId: null,
parent: null
parent: null,
isPrivate:false
};
const mockUser: User = {
@ -263,7 +264,8 @@ describe("Invite Member Utils", () => {
};
const isOrg = false;
it("should throw a TRPCError with code FORBIDDEN if the invitee is already a member of another organization", () => {
it("should not throw when inviting an existing user to the same organization", () => {
const inviteeWithOrg: User = {
...invitee,
organizationId: 2,
@ -272,6 +274,19 @@ describe("Invite Member Utils", () => {
...mockedTeam,
parentId: 2,
}
expect(() =>
throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)
).not.toThrow();
});
it("should throw a TRPCError with code FORBIDDEN if the invitee is already a member of another organization", () => {
const inviteeWithOrg: User = {
...invitee,
organizationId: 2,
};
const teamWithOrg = {
...mockedTeam,
parentId: 3,
}
expect(() =>
throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)
).toThrow(TRPCError);

View File

@ -120,7 +120,6 @@ export function getOrgConnectionInfo({
if (usersEmail.split("@")[1] == orgAutoAcceptDomain) {
autoAccept = orgVerified ?? true;
} else {
// No longer throw error - not needed we just dont auto accept them
orgId = undefined;
autoAccept = false;
}
@ -279,12 +278,17 @@ export async function sendVerificationEmail({
}
export function throwIfInviteIsToOrgAndUserExists(invitee: User, team: TeamWithParent, isOrg: boolean) {
if (invitee.organizationId && invitee.organizationId === team.parentId) {
return;
}
if (invitee.organizationId && invitee.organizationId !== team.parentId) {
throw new TRPCError({
code: "FORBIDDEN",
message: `User ${invitee.username} is already a member of another organization.`,
});
}
if ((invitee && isOrg) || (team.parentId && invitee)) {
throw new TRPCError({
code: "FORBIDDEN",

View File

@ -40,7 +40,7 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T
{...props}
onValueChange={onValueChange}
className={classNames(
"min-h-9 border-default relative inline-flex gap-0.5 rounded-md border p-1",
"min-h-9 border-default bg-default relative inline-flex gap-0.5 rounded-md border p-1",
props.className,
isFullWidth && "w-full"
)}>

View File

@ -22,7 +22,7 @@
"@calcom/lib": "*",
"@calcom/trpc": "*",
"@formkit/auto-animate": "^1.0.0-beta.5",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-popover": "^1.0.2",
"@radix-ui/react-portal": "^1.0.0",
"@radix-ui/react-select": "^0.1.1",

881
yarn.lock

File diff suppressed because it is too large Load Diff