V2 settings teams (Profil, Members, Appearance View) (#4350)

* add team profile

* first version for team members page

* finish up design of member list item

* fix dialog buttons

* add missing seats and upgrading information

* add v2 dialog for changing role

* finish basic version of member's schedule

* remove modalContainer

* design fixes team profile page

* show only team info to non admins

* allow all member to check availabilities

* make time available heading sticky

* add dropdown for mobile view

* create team appearance view

* finish appearance page

* use settings layout and add danger zone for member

* add fallback logo

* Add teams to sidebar and fix UI

* add team invitations

* Clean up

* code clean up

* add impersontation and disable autofocus on calendar

* improve team info

* refactor teaminvitelist code and fix leaving a team

* add team pages to settings shell

* add link to create new team

* small fixes

* clean up comments

* V2 Multi-select (Team Select) (#4324)

* --init

* design improved

* further fine tuning

* more fixes

* removed extra JSX tag

* added story

* NIT

* revert to use of CheckedTeamSelect

* Removes comments

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* fix: toggle alligment (#4361)

* fix: add checked tranform for switch (#4357)

* fixed input size on mobile, fixed settings (#4360)

* fix image uploader button in safari

* code clean up

* fixing type errors

* Moved v2 team components to features

Adds deprecation notices

* Update SettingsLayout.tsx

* Migrated to features and build fixes

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Carina Wollendorfer 2022-09-12 18:04:33 -04:00 committed by GitHub
parent 50f63ef000
commit 7e917cdcbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1917 additions and 31 deletions

View File

@ -63,6 +63,7 @@ function CropContainer({
);
}
/** @deprecated Use `packages/ui/v2/core/ImageUploader.tsx` */
export default function ImageUploader({
target,
id,

View File

@ -7,9 +7,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { User } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast, TextArea } from "@calcom/ui/v2";
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
import { AvatarSSR } from "@components/ui/AvatarSSR";
import ImageUploader from "@components/v2/settings/ImageUploader";
interface IUserProfile {
user?: User;

View File

@ -4,6 +4,7 @@ import { trpc } from "@calcom/trpc/react";
import Badge from "@calcom/ui/Badge";
import Button from "@calcom/ui/Button";
/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
const { t } = useLocale();

View File

@ -13,6 +13,7 @@ type MembershipRoleOption = {
value: MembershipRole;
};
/** @deprecated Use `packages/features/ee/teams/components/MemberChangeRoleModal.tsx` */
export default function MemberChangeRoleModal(props: {
isOpen: boolean;
currentMember: MembershipRole;

View File

@ -26,6 +26,7 @@ type MembershipRoleOption = {
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
/** @deprecated Use `packages/features/ee/teams/components/MemberInvitationModal.tsx` */
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const [errorMessage, setErrorMessage] = useState("");
const { t, i18n } = useLocale();

View File

@ -33,6 +33,7 @@ interface Props {
member: inferQueryOutput<"viewer.teams.get">["members"][number];
}
/** @deprecated Use `packages/features/ee/teams/components/MemberListItem.tsx` */
export default function MemberListItem(props: Props) {
const { t } = useLocale();

View File

@ -10,6 +10,7 @@ interface Props {
color?: PillColor;
}
/** @deprecated Use `packages/features/ee/teams/components/TeamPill.tsx` */
export default function TeamPill(props: Props) {
return (
<div

View File

@ -19,6 +19,7 @@ interface Props {
teamId: number;
}
/** @deprecated Use `packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx` */
export function UpgradeToFlexibleProModal(props: Props) {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState<string | null>(null);

View File

@ -11,6 +11,7 @@ const V2_WHITELIST = [
"/settings/developer/api-keys",
"/settings/my-account",
"/settings/security",
"/settings/teams",
"/availability",
"/bookings",
"/event-types",

View File

@ -15,6 +15,7 @@ import { Alert } from "@calcom/ui/Alert";
import Avatar from "@calcom/ui/v2/core/Avatar";
import { Button } from "@calcom/ui/v2/core/Button";
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
import Meta from "@calcom/ui/v2/core/Meta";
import { Form, Label, TextField, PasswordField } from "@calcom/ui/v2/core/form/fields";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
@ -23,7 +24,6 @@ import showToast from "@calcom/ui/v2/core/notifications";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import TwoFactor from "@components/auth/TwoFactor";
import ImageUploader from "@components/v2/settings/ImageUploader";
interface DeleteAccountValues {
totpCode: string;

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/teams/pages/team-appearance-view";

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/teams/pages/team-members-view";

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/teams/pages/team-profile-view";

View File

@ -481,12 +481,12 @@
"leave": "Leave",
"profile": "Profile",
"my_team_url": "My team URL",
"team_name": "Team name",
"team_name": "Team Name",
"your_team_name": "Your team name",
"team_updated_successfully": "Team updated successfully",
"your_team_updated_successfully": "Your team has been updated successfully.",
"about": "About",
"team_description": "A few sentences about your team. This will appear on your team's URL page.",
"team_description": "A few sentences about your team. This will appear on your team's url page.",
"members": "Members",
"member": "Member",
"owner": "Owner",
@ -498,9 +498,9 @@
"invite_new_member": "Invite a new member",
"invite_new_team_member": "Invite someone to your team.",
"change_member_role": "Change team member role",
"disable_cal_branding": "Disable Cal.com branding",
"disable_cal_branding": "Disable Cal branding",
"disable_cal_branding_description": "Hide all Cal.com branding from your public pages.",
"danger_zone": "Danger Zone",
"danger_zone": "Danger zone",
"back": "Back",
"cancel": "Cancel",
"cancel_all_remaining": "Cancel all remaining",
@ -877,7 +877,7 @@
"impersonate": "Impersonate",
"user_impersonation_heading": "User Impersonation",
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
"team_impersonation_description": "Allows your team admins to temporarily sign in as you.",
"team_impersonation_description": "Allows your team members to temporarily sign in as you.",
"impersonate_user_tip": "All uses of this feature is audited.",
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "<0>Click Here to stop</0>.",
@ -1171,6 +1171,11 @@
"for_a_maximum_of": "For a maximum of",
"event_one": "event",
"event_other": "events",
"profile_team_description": "Manage settings for your team profile",
"members_team_description": "Users that are in the group. Apes together strong!",
"team_url": "Team URL",
"delete_team": "Delete Team",
"team_members": "Team members",
"more": "More",
"more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complication actions, please refer back to the web application.",
"workflow_example_1": "Send email reminder 24 hours before event starts to host",
@ -1184,6 +1189,13 @@
"connect_calendar_later": "I'll connect my calendar later",
"set_my_availability_later": "I'll set my availability later",
"problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support.",
"purchase_missing_seats": "Purchase missing seats",
"slot_length": "Slot length",
"booking_appearance": "Booking Appearance",
"appearance_team_description": "Manage settings for your team's booking appearance",
"only_owner_change": "Only the owner of this team can make changes to the team's booking ",
"team_disable_cal_branding_description": "Removes any Cal related brandings, i.e. 'Powered by Cal'",
"invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}",
"token_invalid_expired": "Token is either invalid or expired.",
"routing_forms_description": "You can see all forms and routes you have created here.",
"add_new_form": "Add new form",

View File

@ -0,0 +1,63 @@
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast, Switch } from "@calcom/ui/v2/core";
const DisableTeamImpersonation = ({
teamId,
memberId,
disabled,
}: {
teamId: number;
memberId: number;
disabled: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
const mutation = trpc.useMutation("viewer.teams.updateMembership", {
onSuccess: async () => {
showToast(t("your_user_profile_updated_successfully"), "success");
await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
},
async onSettled() {
await utils.invalidateQueries(["viewer.public.i18n"]);
},
});
if (query.isLoading) return <></>;
return (
<>
<div className="flex flex-col justify-between sm:flex-row">
<div>
<div className="flex flex-row items-center">
<h2
className={classNames(
"font-cal mb-0.5 text-sm font-semibold leading-6",
disabled ? "text-gray-400 " : "text-gray-900 "
)}>
{t("user_impersonation_heading")}
</h2>
</div>
<p className={classNames("text-sm leading-5 ", disabled ? "text-gray-300" : "text-gray-600")}>
{t("team_impersonation_description")}
</p>
</div>
<div className="mt-5 sm:mt-0 sm:self-center">
<Switch
disabled={disabled}
defaultChecked={query.data?.disableImpersonation}
onCheckedChange={(isChecked) => {
mutation.mutate({ teamId, memberId, disableImpersonation: isChecked });
}}
/>
</div>
</div>
</>
);
};
export default DisableTeamImpersonation;

View File

@ -0,0 +1,113 @@
import { MembershipRole } from "@prisma/client";
import { SyntheticEvent, useMemo, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Dialog, DialogContent, Select } from "@calcom/ui/v2";
type MembershipRoleOption = {
label: string;
value: MembershipRole;
};
export default function MemberChangeRoleModal(props: {
isOpen: boolean;
currentMember: MembershipRole;
memberId: number;
teamId: number;
initialRole: MembershipRole;
onExit: () => void;
}) {
const { t } = useLocale();
const options = useMemo(() => {
return [
{
label: t("member"),
value: MembershipRole.MEMBER,
},
{
label: t("admin"),
value: MembershipRole.ADMIN,
},
{
label: t("owner"),
value: MembershipRole.OWNER,
},
].filter(({ value }) => value !== MembershipRole.OWNER || props.currentMember === MembershipRole.OWNER);
}, [t, props.currentMember]);
const [role, setRole] = useState<MembershipRoleOption>(
options.find((option) => option.value === props.initialRole) || {
label: t("member"),
value: MembershipRole.MEMBER,
}
);
const [errorMessage, setErrorMessage] = useState("");
const utils = trpc.useContext();
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
function changeRole(e: SyntheticEvent) {
e.preventDefault();
changeRoleMutation.mutate({
teamId: props.teamId,
memberId: props.memberId,
role: role.value,
});
}
return (
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
<DialogContent type="creation" useOwnActionButtons size="md">
<>
<div className="mb-4 sm:flex sm:items-start">
<div className="text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("change_member_role")}
</h3>
</div>
</div>
<form onSubmit={changeRole}>
<div className="mb-4">
<label className="mb-2 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
<Select
isSearchable={false}
options={options}
value={role}
onChange={(option) => option && setRole(option)}
id="role"
className="mt-1 block w-full rounded-md border-gray-300 text-sm"
/>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
{t("save")}
</Button>
<Button type="button" color="secondary" onClick={props.onExit}>
{t("cancel")}
</Button>
</div>
</form>
</>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,153 @@
import { MembershipRole } from "@prisma/client";
import React, { useState, SyntheticEvent, useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { Button, Dialog, DialogContent, DialogFooter, Select, TextField } from "@calcom/ui/v2";
type MemberInvitationModalProps = {
isOpen: boolean;
team: TeamWithMembers | null;
currentMember: MembershipRole;
onExit: () => void;
};
type MembershipRoleOption = {
value: MembershipRole;
label?: string;
};
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const [errorMessage, setErrorMessage] = useState("");
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const options = useMemo(() => {
_options.forEach((option, i) => {
_options[i].label = t(option.value.toLowerCase());
});
return _options;
}, [t]);
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
props.onExit();
},
async onError(err) {
setErrorMessage(err.message);
},
});
function inviteMember(e: SyntheticEvent) {
e.preventDefault();
if (!props.team) return;
const target = e.target as typeof e.target & {
elements: {
role: { value: MembershipRole };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
inviteMemberMutation.mutate({
teamId: props.team.id,
language: i18n.language,
role: target.elements["role"].value,
usernameOrEmail: target.elements["inviteUser"].value,
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
});
}
return (
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
<DialogContent type="creation" useOwnActionButtons>
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<Icon.FiUser className="text-brandcontrast h-6 w-6" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("invite_new_member")}
</h3>
<div>
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
</div>
</div>
</div>
<form onSubmit={inviteMember}>
<div className="space-y-4">
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
/>
<div>
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
{t("role")}
</label>
<Select
defaultValue={options[0]}
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
id="role"
name="role"
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
type="checkbox"
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
className="rounded-sm border-gray-300 text-sm text-black"
/>
</div>
<div className="text-sm ltr:ml-2 rtl:mr-2">
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
{t("send_invite_email")}
</label>
</div>
</div>
<div className="flex flex-row rounded-md bg-gray-50 px-3 py-2">
<Icon.FiInfo className="h-5 w-5 flex-shrink-0 fill-gray-400" aria-hidden="true" />
<span className="ml-2 text-sm leading-tight text-gray-500">
Note: This will cost an extra seat ($12/m) on your subscription if this invitee does not have
a pro account.{" "}
{/* <a href="#" className="underline">
Learn More
</a> */}
</span>
</div>
</div>
{errorMessage && (
<p className="text-sm text-red-700">
<span className="font-bold">Error: </span>
{errorMessage}
</p>
)}
<DialogFooter>
<Button type="button" color="secondary" onClick={props.onExit}>
{t("cancel")}
</Button>
<Button
type="submit"
color="primary"
className="ltr:ml-2 rtl:mr-2"
data-testid="invite-new-member-button">
{t("invite")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,275 @@
import { MembershipRole } from "@prisma/client";
import classNames from "classnames";
import { useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Icon } from "@calcom/ui/Icon";
import {
Button,
ButtonGroup,
Dialog,
DialogContent,
DialogTrigger,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
showToast,
Tooltip,
} from "@calcom/ui/v2/core";
import Avatar from "@calcom/ui/v2/core/Avatar";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamPill, { TeamRole } from "./TeamPill";
import TeamAvailabilityModal from "./v2/TeamAvailabilityModal";
interface Props {
team: inferQueryOutput<"viewer.teams.get">;
member: inferQueryOutput<"viewer.teams.get">["members"][number];
}
/** TODO: Migrate the one in apps/web to tRPC package */
const useCurrentUserId = () => {
const query = useMeQuery();
const user = query.data;
return user?.id;
};
export default function MemberListItem(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const ownersInTeam = () => {
const { members } = props.team;
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
return owners.length;
};
const currentUserId = useCurrentUserId();
const name =
props.member.name ||
(() => {
const emailName = props.member.email.split("@")[0];
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
})();
const removeMember = () =>
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
const editMode =
(props.team.membership.role === MembershipRole.OWNER &&
(props.member.role !== MembershipRole.OWNER ||
ownersInTeam() > 1 ||
props.member.id !== currentUserId)) ||
(props.team.membership.role === MembershipRole.ADMIN && props.member.role !== MembershipRole.OWNER);
return (
<li className="divide-y px-5">
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between sm:flex-row">
<div className="flex">
<Avatar
size="sm"
imageSrc={WEBAPP_URL + "/" + props.member.username + "/avatar.png"}
alt={name || ""}
className="h-10 w-10 rounded-full"
/>
<div className="ml-3 inline-block">
<div className="mb-1 flex">
<span className="mr-1 text-sm font-bold leading-4">{name}</span>
{props.member.isMissingSeat && <TeamPill color="red" text={t("hidden")} />}
{!props.member.accepted && <TeamPill color="orange" text={t("pending")} />}
{props.member.role && <TeamRole role={props.member.role} />}
</div>
<span
className="block text-sm text-gray-600"
data-testid="member-email"
data-email={props.member.email}>
{props.member.email}
</span>
</div>
</div>
</div>
{props.team.membership.accepted && (
<div className="flex items-center justify-center">
<ButtonGroup combined containerProps={{ className: "border-gray-300 hidden md:flex" }}>
<Tooltip
content={
props.member.accepted
? t("team_view_user_availability")
: t("team_view_user_availability_disabled")
}>
<Button
disabled={!props.member.accepted}
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
color="secondary"
size="icon"
StartIcon={Icon.FiClock}
combined
/>
</Tooltip>
<Tooltip content={t("view_public_page")}>
<Button
target="_blank"
href={"/" + props.member.username}
color="secondary"
className={classNames(!editMode ? "rounded-r-md" : "")}
size="icon"
StartIcon={Icon.FiExternalLink}
combined
/>
</Tooltip>
{editMode && (
<Dropdown>
<DropdownMenuTrigger className="h-[36px] w-[36px] bg-transparent px-0 py-0 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0">
<Button
color="secondary"
size="icon"
className="rounded-r-md"
StartIcon={Icon.FiMoreHorizontal}
combined
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => setShowChangeMemberRoleModal(true)}
StartIcon={Icon.FiEdit2}>
{t("edit") as string}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild className="p-0">
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="destructive"
StartIcon={Icon.FiTrash}
className="px-3 py-2 font-normal">
{t("delete")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={removeMember}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
)}
</ButtonGroup>
<div className="flex md:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" size="icon" color="minimal" StartIcon={Icon.FiMoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.member.accepted && (
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" StartIcon={Icon.FiClock}>
{t("team_view_user_availability")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" StartIcon={Icon.FiExternalLink}>
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
{editMode && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => setShowChangeMemberRoleModal(true)}
StartIcon={Icon.FiEdit2}>
{t("edit") as string}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild className="p-0">
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="destructive"
StartIcon={Icon.FiTrash}
className="px-3 py-2 font-normal">
{t("delete")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={removeMember}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</div>
)}
</div>
{showChangeMemberRoleModal && (
<MemberChangeRoleModal
isOpen={showChangeMemberRoleModal}
currentMember={props.team.membership.role}
teamId={props.team?.id}
memberId={props.member.id}
initialRole={props.member.role as MembershipRole}
onExit={() => setShowChangeMemberRoleModal(false)}
/>
)}
{showTeamAvailabilityModal && (
<Dialog open={showTeamAvailabilityModal} onOpenChange={() => setShowTeamAvailabilityModal(false)}>
<DialogContent type="creation" useOwnActionButtons size="md">
<TeamAvailabilityModal team={props.team} member={props.member} />
<div className="flex justify-end border-t pt-5">
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
</div>
</DialogContent>
</Dialog>
)}
</li>
);
}

View File

@ -0,0 +1,64 @@
import { useState } from "react";
import { MembershipRole } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/v2";
import TeamInviteListItem from "./TeamInviteListItem";
interface Props {
teams: {
id?: number;
name?: string | null;
slug?: string | null;
logo?: string | null;
bio?: string | null;
hideBranding?: boolean | undefined;
role: MembershipRole;
accepted: boolean;
}[];
}
export default function TeamInviteList(props: Props) {
const utils = trpc.useContext();
const [hideDropdown, setHideDropdown] = useState(false);
function selectAction(action: string, teamId: number) {
switch (action) {
case "disband":
deleteTeam(teamId);
break;
}
}
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.list"]);
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam(teamId: number) {
deleteTeamMutation.mutate({ teamId });
}
return (
<div>
<ul className="mb-8 divide-y divide-neutral-200 rounded bg-white">
{props.teams.map((team) => (
<TeamInviteListItem
key={team?.id as number}
team={team}
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
isLoading={deleteTeamMutation.isLoading}
hideDropdown={hideDropdown}
setHideDropdown={setHideDropdown}
/>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,141 @@
import { MembershipRole } from "@prisma/client";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import {
Avatar,
Button,
Dropdown,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/v2";
interface Props {
team: {
id?: number;
name?: string | null;
slug?: string | null;
logo?: string | null;
bio?: string | null;
hideBranding?: boolean | undefined;
role: MembershipRole;
accepted: boolean;
};
key: number;
onActionSelect: (text: string) => void;
isLoading?: boolean;
hideDropdown: boolean;
setHideDropdown: (value: boolean) => void;
}
export default function TeamInviteListItem(props: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const team = props.team;
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.teams.get"]);
await utils.invalidateQueries(["viewer.teams.list"]);
},
});
function acceptOrLeave(accept: boolean) {
acceptOrLeaveMutation.mutate({
teamId: team?.id as number,
accept,
});
}
const acceptInvite = () => acceptOrLeave(true);
const declineInvite = () => acceptOrLeave(false);
const isOwner = props.team.role === MembershipRole.OWNER;
const isInvitee = !props.team.accepted;
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
const { hideDropdown, setHideDropdown } = props;
if (!team) return <></>;
const teamInfo = (
<div className="flex">
<Avatar
size="mdLg"
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className=""
/>
<div className="ml-3 inline-block">
<span className="text-sm font-semibold text-black">{team.name}</span>
<span className="block text-sm leading-5 text-gray-700">
{t("invited_by_team", { teamName: team.name, role: t(team.role.toLocaleLowerCase()) })}
</span>
</div>
</div>
);
return (
<li className="divide-y rounded-md border border-gray-400 bg-gray-100 px-5 py-4">
<div
className={classNames(
"flex items-center justify-between",
!isInvitee && "group hover:bg-neutral-50"
)}>
{teamInfo}
<div>
<>
<div className="hidden sm:flex">
<Button
type="button"
className="mr-3 border-gray-700"
size="icon"
color="secondary"
onClick={declineInvite}
StartIcon={Icon.FiSlash}
/>
<Button
type="button"
className="border-gray-700"
size="icon"
color="secondary"
onClick={acceptInvite}
StartIcon={Icon.FiCheck}
/>
</div>
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button
color="destructive"
className="w-full rounded-none font-medium"
StartIcon={Icon.FiCheck}
onClick={acceptInvite}>
{t("accept")}
</Button>
</DropdownMenuItem>
<DropdownMenuItem>
<Button
color="destructive"
className="w-full rounded-none font-medium"
StartIcon={Icon.FiX}
onClick={declineInvite}>
{t("reject")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</>
</div>
</div>
</li>
);
}

View File

@ -0,0 +1,35 @@
import { MembershipRole } from "@prisma/client";
import classNames from "classnames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
type PillColor = "blue" | "green" | "red" | "orange";
interface Props {
text: string;
color?: PillColor;
}
export default function TeamPill(props: Props) {
return (
<div
className={classNames("text-medium self-center rounded-md px-1 py-0.5 text-xs ltr:mr-1 rtl:ml-1", {
" bg-gray-100 text-gray-800": !props.color,
" bg-blue-100 text-blue-800": props.color === "blue",
" bg-red-100 text-red-800 ": props.color === "red",
" bg-orange-100 text-orange-800": props.color === "orange",
})}>
{props.text}
</div>
);
}
export function TeamRole(props: { role: MembershipRole }) {
const { t } = useLocale();
const keys: Record<MembershipRole, PillColor | undefined> = {
[MembershipRole.OWNER]: "blue",
[MembershipRole.ADMIN]: "red",
[MembershipRole.MEMBER]: undefined,
};
return <TeamPill text={t(props.role.toLowerCase())} color={keys[props.role]} />;
}

View File

@ -0,0 +1,68 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Dialog, DialogContent, DialogTrigger, showToast } from "@calcom/ui/v2/core";
interface Props {
teamId: number;
}
export function UpgradeToFlexibleProModal(props: Props) {
const { t } = useLocale();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const utils = trpc.useContext();
const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
onError: (err) => {
setErrorMessage(err.message);
},
});
const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
onSuccess: (data) => {
// if the user does not already have a Stripe subscription, this wi
if (data?.url) {
window.location.href = data.url;
}
if (data?.success) {
utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("team_upgraded_successfully"), "success");
}
},
onError: (err) => {
setErrorMessage(err.message);
},
});
function upgrade() {
setErrorMessage(null);
mutation.mutate({ teamId: props.teamId });
}
return (
<Dialog>
<DialogTrigger asChild>
<a className="cursor-pointer underline">Upgrade Now</a>
</DialogTrigger>
<DialogContent
type="creation"
title={t("purchase_missing_seats")}
actionText={t("upgrade_to_per_seat")}
actionOnClick={() => upgrade()}>
<p className="mt-6 text-sm text-gray-600">{t("changed_team_billing_info")}test</p>
{data && (
<p className="mt-2 text-sm italic text-gray-700">
{t("team_upgrade_seats_details", {
memberCount: data.totalMembers,
unpaidCount: data.missingSeats,
seatPrice: 12,
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
})}
</p>
)}
{errorMessage && (
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,103 @@
import React, { useState, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import TimezoneSelect, { ITimezone } from "@calcom/ui/form/TimezoneSelect";
import { Avatar, Label, Select } from "@calcom/ui/v2";
import { DatePicker } from "@calcom/ui/v2";
import LicenseRequired from "../../../common/components/LicenseRequired";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
interface Props {
team?: inferQueryOutput<"viewer.teams.get">;
member?: inferQueryOutput<"viewer.teams.get">["members"][number];
}
export default function TeamAvailabilityModal(props: Props) {
const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
);
const { t } = useLocale();
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
}, [utils, selectedTimeZone, selectedDate]);
return (
<LicenseRequired>
<>
<div className="grid h-[400px] w-[36.7rem] grid-cols-2 space-x-11 rtl:space-x-reverse">
<div className="col-span-1">
<div className="flex">
<Avatar
size="md"
imageSrc={WEBAPP_URL + "/" + props.member?.username + "/avatar.png"}
alt={props.member?.name || ""}
/>
<div className="flex items-center justify-center ">
<span className="ml-2 text-base font-semibold leading-4 text-gray-500">
{props.member?.name}
</span>
</div>
</div>
<div>
<div className="text-brand-900 mt-4 mb-5 text-2xl font-semibold">{t("availability")}</div>
<DatePicker
minDate={new Date()}
date={selectedDate.toDate() || dayjs().toDate()}
onDatesChange={(newDate) => {
setSelectedDate(dayjs(newDate));
}}
/>
<Label className="mt-4">{t("timezone")}</Label>
<TimezoneSelect
id="timeZone"
autoFocus
value={selectedTimeZone}
className="w-64 rounded-md"
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
classNamePrefix="react-select"
/>
</div>
<div className="mt-3">
<Label>{t("slot_length")}</Label>
<Select
options={[
{ value: 15, label: "15 minutes" },
{ value: 30, label: "30 minutes" },
{ value: 60, label: "60 minutes" },
]}
isSearchable={false}
classNamePrefix="react-select"
className="w-64"
value={{ value: frequency, label: `${frequency} minutes` }}
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/>
</div>
</div>
<div className="col-span-1 max-h-[500px]">
{props.team && props.member && (
<TeamAvailabilityTimes
teamId={props.team.id}
memberId={props.member.id}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}
/>
)}
</div>
</div>
</>
</LicenseRequired>
);
}

View File

@ -0,0 +1,118 @@
import React, { useState, useEffect, CSSProperties } from "react";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import dayjs from "@calcom/dayjs";
import { CAL_URL } from "@calcom/lib/constants";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Select from "@calcom/ui/form/Select";
import TimezoneSelect, { ITimezone } from "@calcom/ui/form/TimezoneSelect";
import { Avatar } from "@calcom/ui/v2";
import DatePicker from "@calcom/ui/v2/core/form/DatePicker";
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
interface Props {
team?: inferQueryOutput<"viewer.teams.get">;
}
export default function TeamAvailabilityScreen(props: Props) {
const utils = trpc.useContext();
const [selectedDate, setSelectedDate] = useState(dayjs());
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
);
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
useEffect(() => {
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTimeZone, selectedDate]);
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
const member = props.team?.members?.[index];
if (!member) return <></>;
return (
<div key={member.id} style={style} className="flex border-r border-gray-200 pl-4 ">
<TeamAvailabilityTimes
teamId={props.team?.id as number}
memberId={member.id}
frequency={frequency}
selectedDate={selectedDate}
selectedTimeZone={selectedTimeZone}
HeaderComponent={
<div className="mb-6 flex items-center">
<Avatar
size="sm"
imageSrc={CAL_URL + "/" + member.username + "/avatar.png"}
alt={member?.name || ""}
className="min-w-10 min-h-10 mt-1 h-10 w-10 rounded-full"
/>
<div className="ml-3 inline-block overflow-hidden pt-1">
<span className="truncate text-lg font-bold text-neutral-700">{member?.name}</span>
<span className="-mt-1 block truncate text-sm text-gray-400">{member?.email}</span>
</div>
</div>
}
/>
</div>
);
};
return (
<div className="flex flex-1 flex-col rounded-sm border border-neutral-200 bg-white">
<div className="flex w-full space-x-5 border-b border-gray-200 p-4 rtl:space-x-reverse">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-700">Date</span>
<DatePicker
date={selectedDate.toDate()}
className="p-1.5"
onDatesChange={(newDate) => {
setSelectedDate(dayjs(newDate));
}}
/>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-700">Timezone</span>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
classNamePrefix="react-select"
className="react-select-container w-full rounded-sm border border-gray-300 text-sm"
/>
</div>
<div className="hidden sm:block">
<span className="text-sm font-medium text-neutral-700">Slot Length</span>
<Select
options={[
{ value: 15, label: "15 minutes" },
{ value: 30, label: "30 minutes" },
{ value: 60, label: "60 minutes" },
]}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
value={{ value: frequency, label: `${frequency} minutes` }}
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
/>
</div>
</div>
<div className="flex h-full flex-1">
<AutoSizer>
{({ height, width }) => (
<List
itemSize={240}
itemCount={props.team?.members?.length ?? 0}
className="List"
height={height}
layout="horizontal"
width={width}>
{Item}
</List>
)}
</AutoSizer>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import classNames from "classnames";
import React from "react";
import { ITimezone } from "react-timezone-select";
import { Dayjs } from "@calcom/dayjs";
import getSlots from "@calcom/lib/slots";
import { trpc } from "@calcom/trpc/react";
import { Loader } from "@calcom/ui/v2";
interface Props {
teamId: number;
memberId: number;
selectedDate: Dayjs;
selectedTimeZone: ITimezone;
frequency: number;
HeaderComponent?: React.ReactNode;
className?: string;
}
export default function TeamAvailabilityTimes(props: Props) {
const { data, isLoading } = trpc.useQuery(
[
"viewer.teams.getMemberAvailability",
{
teamId: props.teamId,
memberId: props.memberId,
dateFrom: props.selectedDate.toString(),
dateTo: props.selectedDate.add(1, "day").toString(),
timezone: `${props.selectedTimeZone.toString()}`,
},
],
{
refetchOnWindowFocus: false,
}
);
const times = !isLoading
? getSlots({
frequency: props.frequency,
inviteeDate: props.selectedDate,
workingHours: data?.workingHours || [],
minimumBookingNotice: 0,
eventLength: props.frequency,
})
: [];
return (
<div className={classNames("min-w-60 flex-grow pl-0", props.className)}>
{props.HeaderComponent}
{isLoading && times.length === 0 && <Loader />}
{!isLoading && times.length === 0 ? (
<div className="flex flex-col items-center justify-center pt-4">
<span className="text-sm text-gray-500">No Available slots</span>
</div>
) : (
<>{!isLoading && <p className="mb-3 text-sm text-gray-600">Time available</p>}</>
)}
<div className="max-h-[390px] overflow-scroll">
{times.map((time) => (
<div key={time.format()} className="flex flex-row items-center ">
<a
className="min-w-48 border-brand text-bookingdarker mb-2 mr-3 block flex-grow rounded-md border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 "
data-testid="time">
{time.tz(props.selectedTimeZone.toString()).format("HH:mm")}
</a>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,101 @@
import { MembershipRole } from "@prisma/client";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Form, showToast, Switch } from "@calcom/ui/v2/core";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
interface TeamAppearanceValues {
hideBranding: boolean;
}
const ProfileView = () => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const mutation = trpc.useMutation("viewer.teams.update", {
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("your_team_updated_successfully"), "success");
},
});
const form = useForm<TeamAppearanceValues>();
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
onError: () => {
router.push("/settings");
},
onSuccess: (team) => {
if (team) {
form.setValue("hideBranding", team.hideBranding);
}
},
});
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
return (
<>
<Meta title="booking_appearance" description="appearance_team_description" />
{!isLoading && (
<>
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
if (team) {
const hideBranding = form.getValues("hideBranding");
if (team.hideBranding !== hideBranding) {
mutation.mutate({ id: team.id, hideBranding });
}
}
}}>
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding")}
</label>
<p className="text-gray-500">{t("team_disable_cal_branding_description")}</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
name="hideBranding"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBranding", isChecked);
}}
/>
)}
/>
</div>
</div>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
</Form>
) : (
<div className="rounded-md border border-gray-200 p-5">
<span className="text-sm text-gray-600">{t("only_owner_change")}</span>
</div>
)}
</>
)}
</>
);
};
ProfileView.getLayout = getLayout;
export default ProfileView;

View File

@ -0,0 +1,146 @@
import { MembershipRole } from "@prisma/client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import { Alert, Button } from "@calcom/ui/v2/core";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
import MemberInvitationModal from "../components/MemberInvitationModal";
import MemberListItem from "../components/MemberListItem";
import TeamInviteList from "../components/TeamInviteList";
import { UpgradeToFlexibleProModal } from "../components/UpgradeToFlexibleProModal";
const MembersView = () => {
const { t } = useLocale();
const router = useRouter();
const session = useSession();
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
onError: () => {
router.push("/settings");
},
});
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const isInviteOpen = !team?.membership.accepted;
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
return (
<>
<Meta title="team_members" description="members_team_description" />
{!isLoading && (
<>
<div>
{team && (
<>
{isInviteOpen && (
<TeamInviteList
teams={[
{
id: team.id,
accepted: team.membership.accepted || false,
logo: team.logo,
name: team.name,
slug: team.slug,
role: team.membership.role,
},
]}
/>
)}
{team.membership.role === MembershipRole.OWNER &&
team.membership.isMissingSeat &&
team.requiresUpgrade ? (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={
<>
{t("hidden_team_owner_message")} <UpgradeToFlexibleProModal teamId={team.id} />
</>
}
className="mb-4 "
/>
) : (
<>
{team.membership.isMissingSeat && (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={t("hidden_team_member_message")}
className="mb-4 "
/>
)}
{team.membership.role === MembershipRole.OWNER && team.requiresUpgrade && (
<Alert
severity="warning"
title={t("upgrade_to_flexible_pro_title")}
message={
<span>
{t("upgrade_to_flexible_pro_message")} <br />
<UpgradeToFlexibleProModal teamId={team.id} />
</span>
}
className="mb-4"
/>
)}
</>
)}
</>
)}
{isAdmin && (
<div className="relative mb-5 flex w-full items-center ">
<Button
type="button"
color="primary"
StartIcon={Icon.FiPlus}
className="ml-auto"
onClick={() => setShowMemberInvitationModal(true)}
data-testid="new-member-button">
{t("add")}
</Button>
</div>
)}
<div>
<ul className="divide-y divide-gray-200 rounded-md border ">
{team?.members.map((member) => {
return <MemberListItem key={member.id} team={team} member={member} />;
})}
</ul>
</div>
<hr className="my-8 border-gray-200" />
{team && session.data && (
<DisableTeamImpersonation
teamId={team.id}
memberId={session.data.user.id}
disabled={isInviteOpen}
/>
)}
<hr className="my-8 border-gray-200" />
</div>
{showMemberInvitationModal && team && (
<MemberInvitationModal
isOpen={showMemberInvitationModal}
team={team}
currentMember={team.membership.role}
onExit={() => setShowMemberInvitationModal(false)}
/>
)}
</>
)}
</>
);
};
MembersView.getLayout = getLayout;
export default MembersView;

View File

@ -0,0 +1,279 @@
import { MembershipRole } from "@prisma/client";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import objectKeys from "@calcom/lib/objectKeys";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import {
Button,
Dialog,
DialogTrigger,
Form,
LinkIconButton,
showToast,
TextField,
} from "@calcom/ui/v2/core";
import Avatar from "@calcom/ui/v2/core/Avatar";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
import Meta from "@calcom/ui/v2/core/Meta";
import { Label, TextArea } from "@calcom/ui/v2/core/form/fields";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
interface TeamProfileValues {
name: string;
url: string;
logo: string;
bio: string;
}
const ProfileView = () => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const session = useSession();
const mutation = trpc.useMutation("viewer.teams.update", {
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
showToast(t("your_team_updated_successfully"), "success");
},
});
const form = useForm<TeamProfileValues>();
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
onError: () => {
router.push("/settings");
},
onSuccess: (team) => {
if (team) {
form.setValue("name", team.name || "");
form.setValue("url", team.slug || "");
form.setValue("logo", team.logo || "");
form.setValue("bio", team.bio || "");
}
},
});
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
const permalink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team?.slug}`;
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
await utils.invalidateQueries(["viewer.teams.list"]);
router.push(`/settings`);
showToast(t("your_team_updated_successfully"), "success");
},
});
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
async onSuccess() {
await utils.invalidateQueries(["viewer.teams.get"]);
await utils.invalidateQueries(["viewer.teams.list"]);
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam() {
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
}
function leaveTeam() {
if (team?.id && session.data)
removeMemberMutation.mutate({
teamId: team.id,
memberId: session.data.user.id,
});
}
return (
<>
<Meta title="profile" description="profile_team_description" />
{!isLoading && (
<>
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
if (team) {
const variables = {
logo: values.logo,
name: values.name,
slug: values.url,
bio: values.bio,
};
objectKeys(variables).forEach((key) => {
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
});
mutation.mutate({ id: team.id, ...variables });
}
}}>
<div className="flex items-center">
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={getPlaceholderAvatar(value, team?.name as string)} size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo);
}}
imageSrc={value}
/>
</div>
</>
)}
/>
</div>
<hr className="my-8 border-gray-200" />
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value);
}}
/>
</div>
)}
/>
<Controller
control={form.control}
name="url"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="url"
label={t("team_url")}
value={value}
addOnLeading="https://cal.com/"
onChange={(e) => {
form.setValue("url", e?.target.value);
}}
/>
</div>
)}
/>
<Controller
control={form.control}
name="bio"
render={({ field: { value } }) => (
<div className="mt-8">
<Label>{t("about")}</Label>
<TextArea
name="bio"
value={value}
className="h-14"
onChange={(e) => {
form.setValue("bio", e?.target.value);
}}
/>
</div>
)}
/>
<p className="mt-2 text-sm text-gray-600">{t("team_description")}</p>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
</Form>
) : (
<div className="flex">
<div className="flex-grow">
<div>
<Label className="text-black">{t("team_name")}</Label>
<p className="text-sm text-gray-800">{team?.name}</p>
</div>
{team?.bio && (
<>
<Label className="mt-5 text-black">{t("about")}</Label>
<p className="text-sm text-gray-800">{team.bio}</p>
</>
)}
</div>
<div className="">
<Link href={permalink} passHref={true}>
<a target="_blank">
<LinkIconButton Icon={Icon.FiExternalLink}>{t("preview")}</LinkIconButton>
</a>
</Link>
<LinkIconButton
Icon={Icon.FiLink}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
</div>
</div>
)}
<hr className="border-1 my-8 border-gray-200" />
<div className="mb-3 text-base font-semibold">{t("danger_zone")}</div>
{team?.membership.role === "OWNER" ? (
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Icon.FiTrash2}>
{t("delete_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={deleteTeam}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
) : (
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Icon.FiLogOut}>
{t("leave_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("leave_team")}
confirmBtnText={t("confirm_leave_team")}
onConfirm={leaveTeam}>
{t("leave_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
)}
</>
)}
</>
);
};
ProfileView.getLayout = getLayout;
export default ProfileView;

View File

@ -88,3 +88,12 @@ export async function isTeamOwner(userId: number, teamId: number) {
},
}));
}
export async function isTeamMember(userId: number, teamId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId,
},
}));
}

View File

@ -14,7 +14,7 @@ import { getUserAvailability } from "@calcom/core/getUserAvailability";
import { sendTeamInviteEmail } from "@calcom/emails";
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import { getTeamWithMembers, isTeamAdmin, isTeamOwner, isTeamMember } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import {
closeComDeleteTeam,
@ -40,11 +40,13 @@ export const viewerTeamsRouter = createProtectedRouter()
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
}
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
return {
...team,
membership: {
role: membership?.role as MembershipRole,
isMissingSeat: membership?.plan === UserPlan.FREE,
accepted: membership?.accepted,
},
requiresUpgrade: HOSTED_CAL_FEATURES ? !!team.members.find((m) => m.plan !== UserPlan.PRO) : false,
};
@ -194,14 +196,15 @@ export const viewerTeamsRouter = createProtectedRouter()
memberId: z.number(),
}),
async resolve({ ctx, input }) {
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const isAdmin = await isTeamAdmin(ctx.user?.id, input.teamId);
if (!isAdmin && ctx.user?.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" });
// Only a team owner can remove another team owner.
if (
(await isTeamOwner(input.memberId, input.teamId)) &&
!(await isTeamOwner(ctx.user?.id, input.teamId))
)
throw new TRPCError({ code: "UNAUTHORIZED" });
if (ctx.user?.id === input.memberId)
if (ctx.user?.id === input.memberId && isAdmin)
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
@ -449,7 +452,7 @@ export const viewerTeamsRouter = createProtectedRouter()
dateTo: z.string(),
}),
async resolve({ ctx, input }) {
const team = await isTeamAdmin(ctx.user?.id, input.teamId);
const team = await isTeamMember(ctx.user?.id, input.teamId);
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
// verify member is in team

View File

@ -9,7 +9,7 @@ import { Maybe } from "@trpc/server";
export type AvatarProps = {
className?: string;
size: "sm" | "md" | "lg";
size: "sm" | "md" | "mdLg" | "lg";
imageSrc?: Maybe<string>;
title?: string;
alt: string;
@ -20,6 +20,7 @@ export type AvatarProps = {
const sizesPropsBySize = {
sm: "w-6", // 24px
md: "w-8", // 32px
mdLg: "w-10", //40px
lg: "w-16", // 64px
} as const;
@ -31,7 +32,7 @@ export default function Avatar(props: AvatarProps) {
<AvatarPrimitive.Root
className={classNames(
sizeClassname,
"dark:bg-darkgray-300 relative inline-block aspect-square overflow-hidden rounded-full bg-gray-300"
"dark:bg-darkgray-300 relative inline-block aspect-square overflow-hidden rounded-full"
)}>
<AvatarPrimitive.Image src={imageSrc ?? undefined} alt={alt} className={rootClass} />
<AvatarPrimitive.Fallback delayMs={600}>
@ -57,7 +58,7 @@ export default function Avatar(props: AvatarProps) {
<Tooltip.Provider>
<Tooltip.Tooltip delayDuration={300}>
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white shadow-sm">
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white shadow-sm">
<Tooltip.Arrow />
{title}
</Tooltip.Content>

View File

@ -61,7 +61,7 @@ export function Dialog(props: DialogProps) {
);
}
type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]> & {
size?: "xl" | "lg";
size?: "xl" | "lg" | "md";
type: "creation" | "confirmation";
title?: string;
description?: string | undefined;
@ -88,6 +88,8 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
? "p-0.5 sm:max-w-[98vw]"
: props.size == "lg"
? "p-8 sm:max-w-[70rem]"
: props.size == "md"
? "p-8 sm:max-w-[40rem]"
: "p-8 sm:max-w-[35rem]",
"max-h-[560px] overflow-visible overscroll-auto md:h-auto md:max-h-[inherit]",
`${props.className || ""}`

View File

@ -1,14 +1,61 @@
import * as SliderPrimitive from "@radix-ui/react-slider";
import { FormEvent, useCallback, useEffect, useState } from "react";
import Cropper from "react-easy-crop";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import Button from "@calcom/ui/v2/core/Button";
import { Area, getCroppedImg } from "@lib/cropImage";
import { useFileReader } from "@lib/hooks/useFileReader";
import { useLocale } from "@lib/hooks/useLocale";
type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString";
import Slider from "@components/Slider";
type UseFileReaderProps = {
method: ReadAsMethod;
onLoad?: (result: unknown) => void;
};
type Area = {
width: number;
height: number;
x: number;
y: number;
};
const MAX_IMAGE_SIZE = 512;
const useFileReader = (options: UseFileReaderProps) => {
const { method = "readAsText", onLoad } = options;
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<DOMException | null>(null);
const [result, setResult] = useState<string | ArrayBuffer | null>(null);
useEffect(() => {
if (!file && result) {
setResult(null);
}
}, [file, result]);
useEffect(() => {
if (!file) {
return;
}
const reader = new FileReader();
reader.onloadstart = () => setLoading(true);
reader.onloadend = () => setLoading(false);
reader.onerror = () => setError(reader.error);
reader.onload = (e: ProgressEvent<FileReader>) => {
setResult(e.target?.result ?? null);
if (onLoad) {
onLoad(e.target?.result ?? null);
}
};
reader[method](file);
}, [file, method, onLoad]);
return [{ result, error, file, loading }, setFile] as const;
};
type ImageUploaderProps = {
id: string;
@ -112,11 +159,9 @@ export default function ImageUploader({
(opened) => !opened && setFile(null) // unset file on close
}>
<DialogTrigger asChild>
<div className="flex items-center">
<Button color="secondary" type="button" className="py-1 text-sm">
{buttonMsg}
</Button>
</div>
<Button color="secondary" type="button" className="py-1 text-sm">
{buttonMsg}
</Button>
</DialogTrigger>
<DialogContent>
<div className="mb-4 sm:flex sm:items-start">
@ -167,3 +212,75 @@ export default function ImageUploader({
</Dialog>
);
}
const createImage = (url: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", (error) => reject(error));
image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string> {
const image = await createImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Context is null, this should never happen.");
const maxSize = Math.max(image.naturalWidth, image.naturalHeight);
const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1;
// huh, what? - Having this turned off actually improves image quality as otherwise anti-aliasing is applied
// this reduces the quality of the image overall because it anti-aliases the existing, copied image; blur results
ctx.imageSmoothingEnabled = false;
// pixelCrop is always 1:1 - width = height
canvas.width = canvas.height = Math.min(maxSize * resizeRatio, pixelCrop.width);
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
canvas.width,
canvas.height
);
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
if (resizeRatio <= 0.75) {
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
return getCroppedImg(canvas.toDataURL("image/png"), {
width: canvas.width,
height: canvas.height,
x: 0,
y: 0,
});
}
return canvas.toDataURL("image/png");
}
const Slider = ({
value,
label,
changeHandler,
...props
}: Omit<SliderPrimitive.SliderProps, "value"> & {
value: number;
label: string;
changeHandler: (value: number) => void;
}) => (
<SliderPrimitive.Root
className="slider mt-2"
value={[value]}
aria-label={label}
onValueChange={(value: number[]) => changeHandler(value[0] ?? value)}
{...props}>
<SliderPrimitive.Track className="slider-track">
<SliderPrimitive.Range className="slider-range" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb" />
</SliderPrimitive.Root>
);

View File

@ -12,7 +12,7 @@ export default function LinkIconButton(props: LinkIconButtonProps) {
<button
type="button"
{...props}
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
className="text-md flex items-center rounded-md px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
<props.Icon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{props.children}
</button>

View File

@ -17,11 +17,12 @@ const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props
return (
<PrimitiveDatePicker
className={classNames(
"focus:ring-primary-500 focus:border-primary-500 rounded-sm border border-gray-300 p-1 pl-2 shadow-sm sm:text-sm",
"focus:ring-primary-500 focus:border-primary-500 rounded-md border border-gray-300 p-1 pl-2 shadow-sm sm:text-sm",
className
)}
calendarClassName="rounded-md"
clearIcon={null}
calendarIcon={<Calendar className="h-5 w-5 text-gray-500" />}
calendarIcon={<Calendar className="h-5 w-5 rounded-md text-gray-500" />}
value={date}
minDate={minDate}
disabled={disabled}

View File

@ -88,12 +88,12 @@ function HintsOrErrors<T extends FieldValues = FieldValues>(props: {
className={error !== undefined ? (submitted ? "text-red-700" : "") : "text-green-600"}>
{error !== undefined ? (
submitted ? (
<X size="12" strokeWidth="3" className="-ml-1 mr-2 inline-block" />
<X size="12" strokeWidth="3" className="mr-2 -ml-1 inline-block" />
) : (
<Circle fill="currentColor" size="5" className="mr-2 inline-block" />
)
) : (
<Check size="12" strokeWidth="3" className="-ml-1 mr-2 inline-block" />
<Check size="12" strokeWidth="3" className="mr-2 -ml-1 inline-block" />
)}
{t(`${fieldName}_hint_${key}`)}
</li>
@ -126,7 +126,7 @@ function HintsOrErrors<T extends FieldValues = FieldValues>(props: {
return (
<li key={key} className={!!dirty ? "text-green-600" : ""}>
{!!dirty ? (
<Check size="12" strokeWidth="3" className="-ml-1 mr-2 inline-block" />
<Check size="12" strokeWidth="3" className="mr-2 -ml-1 inline-block" />
) : (
<Circle fill="currentColor" size="5" className="mr-2 inline-block" />
)}
@ -215,7 +215,7 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
"flex h-full flex-col justify-center px-1 text-sm",
props.error && "text-red-900"
)}>
<span className="whitespace-nowrap">{addOnLeading || addOnSuffix}</span>
<span className="whitespace-nowrap py-2.5 px-3">{addOnLeading || addOnSuffix}</span>
</div>
</div>
<Input