Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,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",
Expand Down
208 changes: 208 additions & 0 deletions packages/features/flags/components/AssignFeatureSheet.tsx
Original file line number Diff line number Diff line change
@@ -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={(e) => e.preventDefault()}
/>
</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>
);
}
72 changes: 55 additions & 17 deletions packages/features/flags/components/FlagAdminList.tsx
Original file line number Diff line number Diff line change
@@ -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, index) => (
<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} />
)}
</>
);
};

Expand Down
21 changes: 21 additions & 0 deletions packages/trpc/server/routers/viewer/admin/_router.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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>;
Loading
Loading