feat: admin feature flags v3 + assign/unassign (#24428)

* Feature grouping

* Add assign/unassign logic for teams feature flags

* Fix types
This commit is contained in:
sean-brydon
2025-10-15 12:12:39 +00:00
committed by GitHub
parent 64297f027c
commit 67843adab9
10 changed files with 458 additions and 17 deletions
@@ -1189,6 +1189,8 @@
"featured_categories": "Featured Categories",
"feature_flags": "Feature Flags",
"admin_flags_description": "Here you can toggle your Cal.com instance features.",
"feature_assigned_successfully": "Feature assigned successfully",
"feature_unassigned_successfully": "Feature unassigned successfully",
"popular_categories": "Popular Categories",
"number_apps_one": "{{count}} App",
"number_apps_other": "{{count}} Apps",
@@ -0,0 +1,208 @@
"use client";
import { useState, useEffect } from "react";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Checkbox, TextField } from "@calcom/ui/components/form";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
SheetFooter,
} from "@calcom/ui/components/sheet";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
type Flag = RouterOutputs["viewer"]["features"]["list"][number];
interface AssignFeatureSheetProps {
flag: Flag;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AssignFeatureSheet({ flag, open, onOpenChange }: AssignFeatureSheetProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
trpc.viewer.admin.getTeamsForFeature.useInfiniteQuery(
{
featureId: flag.slug,
limit: 20,
searchTerm: debouncedSearchTerm || undefined,
},
{
enabled: open,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const teams = data?.pages.flatMap((page) => page.teams) ?? [];
useEffect(() => {
if (!open) {
setSearchTerm("");
}
}, [open]);
const assignMutation = trpc.viewer.admin.assignFeatureToTeam.useMutation({
onSuccess: () => {
utils.viewer.admin.getTeamsForFeature.invalidate({ featureId: flag.slug });
showToast(t("feature_assigned_successfully"), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const unassignMutation = trpc.viewer.admin.unassignFeatureFromTeam.useMutation({
onSuccess: () => {
utils.viewer.admin.getTeamsForFeature.invalidate({ featureId: flag.slug });
showToast(t("feature_unassigned_successfully"), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const handleToggleTeam = (teamId: number, currentlyHasFeature: boolean) => {
if (currentlyHasFeature) {
unassignMutation.mutate({
teamId,
featureId: flag.slug,
});
} else {
assignMutation.mutate({
teamId,
featureId: flag.slug,
});
}
};
const handleClose = () => {
onOpenChange(false);
};
const isLoading = assignMutation.isPending || unassignMutation.isPending;
return (
<Sheet open={open} onOpenChange={handleClose}>
<SheetContent className="bg-muted">
<SheetHeader>
<SheetTitle>Assign: {flag.slug}</SheetTitle>
</SheetHeader>
<SheetBody>
<div className="mb-4">
<TextField
type="text"
placeholder={t("search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{isPending ? (
<SkeletonContainer>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<SkeletonText key={i} className="h-16 w-full" />
))}
</div>
</SkeletonContainer>
) : teams && teams.length > 0 ? (
<>
<div className="space-y-2">
{teams.map((team) => (
<button
key={team.id}
type="button"
onClick={() => handleToggleTeam(team.id, team.hasFeature)}
disabled={isLoading}
className="bg-default border-subtle hover:bg-muted flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50">
<div className="flex items-center gap-3">
<div className="relative">
{team.isOrganization ? (
<div className="h-8 w-8 overflow-hidden rounded">
{team.logoUrl ? (
<img
src={team.logoUrl}
alt={team.name || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis text-default flex h-full w-full items-center justify-center text-xs font-semibold">
{team.name?.charAt(0).toUpperCase()}
</div>
)}
</div>
) : (
<Avatar size="sm" alt={team.name || ""} imageSrc={team.logoUrl} />
)}
{team.parent && team.parentId && (
<div className="border-emphasis absolute -bottom-1 -right-1 h-4 w-4 overflow-hidden rounded border">
{team.parent.logoUrl ? (
<img
src={team.parent.logoUrl}
alt={team.parent.name || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis text-default flex h-full w-full items-center justify-center text-[8px] font-semibold">
{team.parent.name?.charAt(0).toUpperCase()}
</div>
)}
</div>
)}
</div>
<div>
<p className="text-emphasis text-sm font-medium">{team.name}</p>
{team.slug && <p className="text-subtle text-xs">{team.slug}</p>}
{team.parent && (
<p className="text-subtle text-xs">
{t("organization")}: {team.parent.name}
</p>
)}
</div>
</div>
<Checkbox
checked={team.hasFeature}
disabled={isLoading}
onCheckedChange={() => {}}
/>
</button>
))}
</div>
{hasNextPage && (
<div className="mt-4 flex justify-center">
<Button
color="secondary"
onClick={() => fetchNextPage()}
loading={isFetchingNextPage}
disabled={isFetchingNextPage}>
{t("load_more")}
</Button>
</div>
)}
</>
) : (
<p className="text-subtle text-center text-sm">{t("no_teams_found")}</p>
)}
</SheetBody>
<SheetFooter>
<Button color="secondary" onClick={handleClose}>
{t("close")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -1,31 +1,69 @@
import { useState } from "react";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import { Switch } from "@calcom/ui/components/form";
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/components/list";
import { List } from "@calcom/ui/components/list";
import { showToast } from "@calcom/ui/components/toast";
import { AssignFeatureSheet } from "./AssignFeatureSheet";
export const FlagAdminList = () => {
const [data] = trpc.viewer.features.list.useSuspenseQuery();
const [selectedFlag, setSelectedFlag] = useState<Flag | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const groupedFlags = data.reduce((acc, flag) => {
const type = flag.type || "OTHER";
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(flag);
return acc;
}, {} as Record<string, typeof data>);
const sortedTypes = Object.keys(groupedFlags).sort();
const handleAssignClick = (flag: Flag) => {
setSelectedFlag(flag);
setSheetOpen(true);
};
return (
<List roundContainer noBorderTreatment>
{data.map((flag) => (
<ListItem key={flag.slug} rounded={false}>
<div className="flex flex-1 flex-col">
<ListItemTitle component="h3">
{flag.slug}
&nbsp;&nbsp;
<Badge variant="green">{flag.type?.replace("_", " ")}</Badge>
</ListItemTitle>
<ListItemText component="p">{flag.description}</ListItemText>
</div>
<div className="flex py-2">
<FlagToggle flag={flag} />
</div>
</ListItem>
))}
</List>
<>
<div className="space-y-4">
{sortedTypes.map((type) => (
<PanelCard key={type} title={type.replace(/_/g, " ")} collapsible defaultCollapsed={false}>
<List roundContainer noBorderTreatment>
{groupedFlags[type].map((flag: Flag, index: number) => (
<ListItem key={flag.slug} rounded={index === 0 || index === groupedFlags[type].length - 1}>
<div className="flex flex-1 flex-col">
<ListItemTitle component="h3">{flag.slug}</ListItemTitle>
<ListItemText component="p">{flag.description}</ListItemText>
</div>
<div className="flex items-center gap-2 py-2">
<FlagToggle flag={flag} />
<Button
color="secondary"
size="sm"
variant="icon"
onClick={() => handleAssignClick(flag)}
StartIcon="users"></Button>
</div>
</ListItem>
))}
</List>
</PanelCard>
))}
</div>
{selectedFlag && (
<AssignFeatureSheet flag={selectedFlag} open={sheetOpen} onOpenChange={setSheetOpen} />
)}
</>
);
};
@@ -1,12 +1,15 @@
import { authedAdminProcedure } from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema";
import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema";
import { ZAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema";
import { ZListMembersSchema } from "./listPaginated.schema";
import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema";
import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema";
import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema";
import { ZSetSMSLockState } from "./setSMSLockState.schema";
import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure";
import { ZAdminUnassignFeatureFromTeamSchema } from "./unassignFeatureFromTeam.schema";
import { ZAdminVerifyWorkflowsSchema } from "./verifyWorkflows.schema";
import { ZWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema";
import {
@@ -60,6 +63,24 @@ export const adminRouter = router({
const { default: handler } = await import("./whitelistUserWorkflows.handler");
return handler(opts);
}),
getTeamsForFeature: authedAdminProcedure
.input(ZAdminGetTeamsForFeatureSchema)
.query(async (opts) => {
const { default: handler } = await import("./getTeamsForFeature.handler");
return handler(opts);
}),
assignFeatureToTeam: authedAdminProcedure
.input(ZAdminAssignFeatureToTeamSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./assignFeatureToTeam.handler");
return handler(opts);
}),
unassignFeatureFromTeam: authedAdminProcedure
.input(ZAdminUnassignFeatureFromTeamSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./unassignFeatureFromTeam.handler");
return handler(opts);
}),
workspacePlatform: router({
list: authedAdminProcedure.query(async () => {
const { default: handler } = await import("./workspacePlatform/list.handler");
@@ -0,0 +1,36 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema";
type AssignFeatureOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TAdminAssignFeatureToTeamSchema;
};
export const assignFeatureToTeamHandler = async ({ ctx, input }: AssignFeatureOptions) => {
const { prisma, user } = ctx;
const { teamId, featureId } = input;
await prisma.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
create: {
teamId,
featureId,
assignedBy: `user:${user.id}`,
},
update: {},
});
return { success: true };
};
export default assignFeatureToTeamHandler;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZAdminAssignFeatureToTeamSchema = z.object({
teamId: z.number(),
featureId: z.string(),
});
export type TAdminAssignFeatureToTeamSchema = z.infer<typeof ZAdminAssignFeatureToTeamSchema>;
@@ -0,0 +1,80 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema";
type GetTeamsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TAdminGetTeamsForFeatureSchema;
};
export const getTeamsForFeatureHandler = async ({ ctx, input }: GetTeamsOptions) => {
const { prisma } = ctx;
const { featureId, limit = 10, cursor, searchTerm } = input;
const whereClause = {
isPlatform: false,
...(searchTerm
? {
OR: [
{ name: { contains: searchTerm, mode: "insensitive" as const } },
{ slug: { contains: searchTerm, mode: "insensitive" as const } },
],
}
: {}),
};
const [teams, assignedTeams] = await Promise.all([
prisma.team.findMany({
where: whereClause,
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
parentId: true,
isOrganization: true,
parent: {
select: {
name: true,
logoUrl: true,
},
},
},
orderBy: {
name: "asc",
},
take: limit + 1,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
}),
prisma.teamFeatures.findMany({
where: {
featureId,
},
select: {
teamId: true,
},
}),
]);
const assignedTeamIds = new Set(assignedTeams.map((tf) => tf.teamId));
let nextCursor: typeof cursor | undefined = undefined;
if (teams.length > limit) {
const nextItem = teams.pop();
nextCursor = nextItem?.id;
}
return {
teams: teams.map((team) => ({
...team,
hasFeature: assignedTeamIds.has(team.id),
})),
nextCursor,
};
};
export default getTeamsForFeatureHandler;
@@ -0,0 +1,10 @@
import { z } from "zod";
export const ZAdminGetTeamsForFeatureSchema = z.object({
featureId: z.string(),
limit: z.number().min(1).max(100).default(10),
cursor: z.number().nullish(),
searchTerm: z.string().optional(),
});
export type TAdminGetTeamsForFeatureSchema = z.infer<typeof ZAdminGetTeamsForFeatureSchema>;
@@ -0,0 +1,30 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TAdminUnassignFeatureFromTeamSchema } from "./unassignFeatureFromTeam.schema";
type UnassignFeatureOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TAdminUnassignFeatureFromTeamSchema;
};
export const unassignFeatureFromTeamHandler = async ({ ctx, input }: UnassignFeatureOptions) => {
const { prisma } = ctx;
const { teamId, featureId } = input;
await prisma.teamFeatures.delete({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
});
return { success: true };
};
export default unassignFeatureFromTeamHandler;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZAdminUnassignFeatureFromTeamSchema = z.object({
teamId: z.number(),
featureId: z.string(),
});
export type TAdminUnassignFeatureFromTeamSchema = z.infer<typeof ZAdminUnassignFeatureFromTeamSchema>;