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:
@@ -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}
|
||||
|
||||
<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>;
|
||||
Reference in New Issue
Block a user