Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
import { revalidateTag } from "next/cache";

export async function revalidateApiKeysList() {
revalidateTag("viewer.apiKeys.list", "max");
revalidateTag("viewer.apiKeys.list");
}
4 changes: 4 additions & 0 deletions apps/web/pages/api/trpc/apiKeys/[trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";

export default createNextApiHandler(apiKeysRouter);
4 changes: 4 additions & 0 deletions apps/web/pages/api/trpc/filterSegments/[trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { filterSegmentsRouter } from "@calcom/trpc/server/routers/viewer/filterSegments/_router";

export default createNextApiHandler(filterSegmentsRouter);
4 changes: 4 additions & 0 deletions apps/web/pages/api/trpc/payments/[trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { paymentsRouter } from "@calcom/trpc/server/routers/viewer/payments/_router";

export default createNextApiHandler(paymentsRouter);
78 changes: 56 additions & 22 deletions packages/features/eventtypes/components/CheckedTeamSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
"use client";

import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useState } from "react";
import type { Options, Props } from "react-select";

import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type {
PriorityDialogCustomClassNames,
WeightDialogCustomClassNames,
} from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs";
import { PriorityDialog, WeightDialog } from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs";
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
import { getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";

import type {
PriorityDialogCustomClassNames,
WeightDialogCustomClassNames,
} from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs";
import { PriorityDialog, WeightDialog } from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useState } from "react";
import type { Options, Props } from "react-select";
import Creatable from "react-select/creatable";

export type CheckedSelectOption = {
avatar: string;
Expand All @@ -31,6 +30,11 @@ export type CheckedSelectOption = {
disabled?: boolean;
defaultScheduleId?: number | null;
groupId: string | null;
/**
* When true, this entry was created by typing an email that doesn't belong to
* an existing team member. The value field holds the email address.
*/
isInvite?: boolean;
};

export type CheckedTeamSelectCustomClassNames = {
Expand All @@ -49,6 +53,13 @@ export type CheckedTeamSelectCustomClassNames = {
priorityDialog?: PriorityDialogCustomClassNames;
weightDialog?: WeightDialogCustomClassNames;
};

/** Returns true when the option value is an email address (invite entry, not a numeric userId). */
const isInviteEntry = (option: CheckedSelectOption) => !!(option.isInvite || option.value.includes("@"));

/** Validate that a string looks like an email before offering the "Create" option. */
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);

export const CheckedTeamSelect = ({
options = [],
value = [],
Expand Down Expand Up @@ -84,8 +95,8 @@ export const CheckedTeamSelect = ({

return (
<>
<Select
{...props}
<Creatable
{...(props as object)}
name={props.name}
placeholder={props.placeholder || t("select")}
isSearchable={true}
Expand All @@ -94,10 +105,26 @@ export const CheckedTeamSelect = ({
onChange={handleSelectChange}
isMulti
className={customClassNames?.hostsSelect?.select}
innerClassNames={{
...customClassNames?.hostsSelect?.innerClassNames,
control: "rounded-md",
}}
classNamePrefix="cal-select"
/** Only show the "Create" option when the typed value is a valid email. */
isValidNewOption={(inputValue) => isValidEmail(inputValue)}
/** Build a synthetic option that carries the email in `value` and flags isInvite. */
getNewOptionData={(inputValue, optionLabel) => ({
label: String(optionLabel),
value: inputValue,
avatar: "",
groupId: null,
isInvite: true,
})}
/** Render invite entries with an (Invite) badge in the dropdown. */
formatCreateLabel={(inputValue) => (
<span className="flex items-center gap-2">
<span>{inputValue}</span>
<Badge variant="blue" data-testid="invite-badge">
{t("invite")}
</Badge>
</span>
)}
/>
{/* This class name conditional looks a bit odd but it allows a seamless transition when using autoanimate
- Slides down from the top instead of just teleporting in from nowhere*/}
Expand All @@ -116,10 +143,12 @@ export const CheckedTeamSelect = ({
`flex px-3 py-2 ${index === valueFromGroup.length - 1 ? "" : "border-subtle border-b"}`,
customClassNames?.selectedHostList?.listItem?.container
)}>
{!isPlatform && <Avatar size="sm" imageSrc={option.avatar} alt={option.label} />}
{isPlatform && (
{!isPlatform && !isInviteEntry(option) && (
<Avatar size="sm" imageSrc={option.avatar} alt={option.label} />
)}
{(isPlatform || isInviteEntry(option)) && (
<Icon
name="user"
name={isInviteEntry(option) ? "mail" : "user"}
className={classNames(
"mt-0.5 h-4 w-4",
customClassNames?.selectedHostList?.listItem?.avatar
Expand All @@ -133,8 +162,13 @@ export const CheckedTeamSelect = ({
)}>
{option.label}
</p>
{isInviteEntry(option) && (
<Badge variant="blue" className="my-auto ml-2" data-testid="host-invite-badge">
{t("invite")}
</Badge>
)}
<div className="ml-auto flex items-center">
{option && !option.isFixed ? (
{option && !option.isFixed && !isInviteEntry(option) ? (
<>
<Tooltip content={t("change_priority")}>
<Button
Expand Down Expand Up @@ -176,7 +210,7 @@ export const CheckedTeamSelect = ({
name="x"
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
className={classNames(
"my-auto ml-2 h-4 w-4",
"my-auto ml-2 h-4 w-4 cursor-pointer",
customClassNames?.selectedHostList?.listItem?.removeButton
)}
/>
Expand Down
4 changes: 4 additions & 0 deletions packages/features/eventtypes/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export type HostLocation = {
export type Host = {
isFixed: boolean;
userId: number;
/** Present when this entry is a pending invite rather than an existing team member. */
inviteEmail?: string;
priority: number;
weight: number;
scheduleId?: number | null;
Expand Down Expand Up @@ -246,6 +248,8 @@ export type HostLocationInput = {
export type HostInput = {
userId: number;
profileId?: number | null;
/** Present when this entry is a pending invite — userId will be -1. */
inviteEmail?: string;
isFixed?: boolean;
priority?: number | null;
weight?: number | null;
Expand Down
2 changes: 2 additions & 0 deletions packages/trpc/server/routers/viewer/eventTypes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const hostLocationSchema = z.object({
const hostSchema: z.ZodType<HostInput> = z.object({
userId: z.number(),
profileId: z.number().or(z.null()).optional(),
/** Signals a pending team invite. When set, userId must be -1. */
inviteEmail: z.string().email().optional(),
isFixed: z.boolean().optional(),
priority: z.number().min(0).max(4).optional().nullable(),
weight: z.number().min(0).optional().nullable(),
Expand Down
21 changes: 17 additions & 4 deletions packages/trpc/server/routers/viewer/eventTypes/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@ import type { TUpdateInputSchema } from "./types";
type PermissionString = string;
class PermissionCheckService {
constructor(_prisma?: unknown) {}
async checkPermission(..._args: unknown[]) { return true; }
async hasPermission(..._args: unknown[]) { return true; }
async getTeamIdsWithPermission(..._args: unknown[]): Promise<number[]> { return []; }
async checkPermission(..._args: unknown[]) {
return true;
}
async hasPermission(..._args: unknown[]) {
return true;
}
async getTeamIdsWithPermission(..._args: unknown[]): Promise<number[]> {
return [];
}
}

type EventType = Awaited<ReturnType<EventTypeRepository["findAllByUpId"]>>[number];
Expand Down Expand Up @@ -79,7 +85,14 @@ export const eventOwnerProcedure = authedProcedure
const isAllowed = (() => {
if (event.team) {
const allTeamMembers = event.team.members.map((member) => member.userId);
return input.users.every((userId: number) => allTeamMembers.includes(userId));
// Invite-only entries (userId === -1 with inviteEmail) are not yet members —
// skip them here; the update handler processes them separately.
const hostsToCheck =
(input as { hosts?: Array<{ userId: number; inviteEmail?: string }> }).hosts?.filter(
(h) => !h.inviteEmail
) ?? [];
const userIdsToCheck = hostsToCheck.length > 0 ? hostsToCheck.map((h) => h.userId) : input.users;
return userIdsToCheck.every((userId: number) => allTeamMembers.includes(userId));
}
return input.users.every((userId: number) => userId === ctx.user.id);
})();
Expand Down
Loading