Expand standalone team settings
This commit is contained in:
+49
-1
@@ -127,6 +127,12 @@ const getTabs = (
|
||||
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "teams",
|
||||
href: "/settings/teams",
|
||||
icon: "users",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
name: "organization",
|
||||
href: "/settings/organizations",
|
||||
@@ -267,6 +273,16 @@ interface SettingsPermissions {
|
||||
canUpdateOrganization?: boolean;
|
||||
}
|
||||
|
||||
const TEAM_SUBPAGES = ["profile", "members", "general", "danger"] as const;
|
||||
|
||||
function extractTeamIdFromPathname(pathname: string | null): number | null {
|
||||
if (!pathname) return null;
|
||||
const match = pathname.match(/^\/settings\/teams\/(\d+)(?:\/|$)/);
|
||||
if (!match) return null;
|
||||
const id = Number(match[1]);
|
||||
return Number.isFinite(id) ? id : null;
|
||||
}
|
||||
|
||||
const useTabs = ({
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
@@ -277,12 +293,35 @@ const useTabs = ({
|
||||
permissions?: SettingsPermissions;
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const pathname = usePathname();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
||||
const activeTeamId = extractTeamIdFromPathname(pathname);
|
||||
const teamsQuery = trpc.viewer.teams.list.useQuery(undefined, {
|
||||
enabled: Boolean(session.data?.user?.id),
|
||||
});
|
||||
const activeTeam = activeTeamId ? teamsQuery.data?.find((team) => team.id === activeTeamId) : undefined;
|
||||
const orgBranding = null as { id?: number; slug?: string; name?: string; logoUrl?: string | null } | null;
|
||||
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
|
||||
|
||||
const processTabsMemod = useMemo(() => {
|
||||
const processedTabs = getTabs(orgBranding).map((tab) => {
|
||||
if (tab.href === "/settings/teams") {
|
||||
if (activeTeamId) {
|
||||
const children: VerticalTabItemProps[] = TEAM_SUBPAGES.map((sub) => ({
|
||||
name: `team_${sub}`,
|
||||
href: `/settings/teams/${activeTeamId}/${sub}`,
|
||||
trackingMetadata: { section: "teams", page: sub },
|
||||
}));
|
||||
return {
|
||||
...tab,
|
||||
name: activeTeam?.name || "teams",
|
||||
icon: undefined,
|
||||
avatar: getPlaceholderAvatar(activeTeam?.logoUrl ?? null, activeTeam?.name ?? "Team"),
|
||||
children,
|
||||
};
|
||||
}
|
||||
return { ...tab, children: [] };
|
||||
}
|
||||
if (tab.href === "/settings/my-account") {
|
||||
return {
|
||||
...tab,
|
||||
@@ -373,7 +412,16 @@ const useTabs = ({
|
||||
if (isAdmin) return true;
|
||||
return !adminRequiredKeys.includes(tab.name);
|
||||
});
|
||||
}, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
|
||||
}, [
|
||||
isAdmin,
|
||||
orgBranding,
|
||||
user,
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
permissions,
|
||||
activeTeamId,
|
||||
activeTeam,
|
||||
]);
|
||||
|
||||
return processTabsMemod;
|
||||
};
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { teamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TeamDangerView } from "~/teams/views/TeamDangerView";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("team_danger_zone"),
|
||||
(t) => t("team_danger_zone_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/teams"
|
||||
);
|
||||
|
||||
const Page = async ({ params }: { params: Promise<{ teamId: string }> }) => {
|
||||
const { teamId: teamIdParam } = await params;
|
||||
const teamId = Number(teamIdParam);
|
||||
if (!Number.isFinite(teamId)) return notFound();
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect(`/auth/login?callbackUrl=/settings/teams/${teamId}/danger`);
|
||||
}
|
||||
|
||||
const caller = await createRouterCaller(teamsRouter);
|
||||
try {
|
||||
await caller.get({ teamId });
|
||||
} catch {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("team_danger_zone")}
|
||||
description={t("team_danger_zone_description")}
|
||||
borderInShellHeader={false}>
|
||||
<TeamDangerView teamId={teamId} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { teamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TeamGeneralView } from "~/teams/views/TeamGeneralView";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("team_general"),
|
||||
(t) => t("team_general_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/teams"
|
||||
);
|
||||
|
||||
const Page = async ({ params }: { params: Promise<{ teamId: string }> }) => {
|
||||
const { teamId: teamIdParam } = await params;
|
||||
const teamId = Number(teamIdParam);
|
||||
if (!Number.isFinite(teamId)) return notFound();
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect(`/auth/login?callbackUrl=/settings/teams/${teamId}/general`);
|
||||
}
|
||||
|
||||
const caller = await createRouterCaller(teamsRouter);
|
||||
try {
|
||||
await caller.get({ teamId });
|
||||
} catch {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("team_general")}
|
||||
description={t("team_general_description")}
|
||||
borderInShellHeader={false}>
|
||||
<TeamGeneralView teamId={teamId} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+35
-6
@@ -1,19 +1,48 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { TeamMembers } from "~/teams/TeamMembers";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { teamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TeamMembersView } from "~/teams/views/TeamMembersView";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Team members",
|
||||
() => "Manage team members",
|
||||
(t) => t("team_members"),
|
||||
(t) => t("team_members_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/teams"
|
||||
);
|
||||
|
||||
const Page = async ({ params }: { params: Promise<{ teamId: string }> }) => {
|
||||
const { teamId } = await params;
|
||||
const { teamId: teamIdParam } = await params;
|
||||
const teamId = Number(teamIdParam);
|
||||
if (!Number.isFinite(teamId)) return notFound();
|
||||
|
||||
return <TeamMembers teamId={Number(teamId)} />;
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect(`/auth/login?callbackUrl=/settings/teams/${teamId}/members`);
|
||||
}
|
||||
|
||||
const caller = await createRouterCaller(teamsRouter);
|
||||
try {
|
||||
await caller.get({ teamId });
|
||||
} catch {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("team_members")}
|
||||
description={t("team_members_description")}
|
||||
borderInShellHeader={false}>
|
||||
<TeamMembersView teamId={teamId} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+39
-10
@@ -1,19 +1,48 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { TeamForm } from "~/teams/TeamForm";
|
||||
|
||||
const Page = async ({ params }: { params: Promise<{ teamId: string }> }) => {
|
||||
const { teamId } = await params;
|
||||
|
||||
return <TeamForm teamId={Number(teamId)} />;
|
||||
};
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { teamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TeamProfileView } from "~/teams/views/TeamProfileView";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Team profile",
|
||||
() => "Edit team profile",
|
||||
(t) => t("team_profile"),
|
||||
(t) => t("team_profile_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/teams"
|
||||
);
|
||||
|
||||
const Page = async ({ params }: { params: Promise<{ teamId: string }> }) => {
|
||||
const { teamId: teamIdParam } = await params;
|
||||
const teamId = Number(teamIdParam);
|
||||
if (!Number.isFinite(teamId)) return notFound();
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect(`/auth/login?callbackUrl=/settings/teams/${teamId}/profile`);
|
||||
}
|
||||
|
||||
const caller = await createRouterCaller(teamsRouter);
|
||||
try {
|
||||
await caller.get({ teamId });
|
||||
} catch {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("team_profile")}
|
||||
description={t("team_profile_description")}
|
||||
borderInShellHeader={false}>
|
||||
<TeamProfileView teamId={teamId} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TeamsListView } from "~/teams/views/TeamsListView";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("teams"),
|
||||
(t) => t("teams_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/teams"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login?callbackUrl=/settings/teams");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("my_teams")} description={t("teams_description")} borderInShellHeader={false}>
|
||||
<TeamsListView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { Select, TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
type Team = RouterOutputs["viewer"]["teams"]["get"];
|
||||
|
||||
function isOwner(team: Team | undefined, userId: number | undefined) {
|
||||
if (!team || !userId) return false;
|
||||
const m = team.members.find((member) => member.userId === userId);
|
||||
return Boolean(m?.accepted && m.role === MembershipRole.OWNER);
|
||||
}
|
||||
|
||||
export function TeamDangerView({ teamId }: { teamId: number }) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId });
|
||||
const [confirmName, setConfirmName] = useState("");
|
||||
const [transferTarget, setTransferTarget] = useState<number | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showTransferDialog, setShowTransferDialog] = useState(false);
|
||||
|
||||
const team = teamQuery.data;
|
||||
const userId = session.data?.user?.id;
|
||||
const isOwnerNow = isOwner(team, userId);
|
||||
const ownerCount = team?.members.filter((m) => m.accepted && m.role === MembershipRole.OWNER).length ?? 0;
|
||||
const isFinalOwner = isOwnerNow && ownerCount <= 1;
|
||||
|
||||
const transferableMembers = team?.members.filter((m) => m.accepted && m.userId !== userId) ?? [];
|
||||
|
||||
const deleteMutation = trpc.viewer.teams.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
showToast(t("team_deleted"), "success");
|
||||
router.push("/settings/teams");
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
const transferMutation = trpc.viewer.teams.transferOwnership.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.teams.get.invalidate({ teamId });
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
showToast(t("ownership_transferred"), "success");
|
||||
setShowTransferDialog(false);
|
||||
setTransferTarget(null);
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
const leaveMutation = trpc.viewer.teams.leave.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
showToast(t("left_team"), "success");
|
||||
router.push("/settings/teams");
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
if (teamQuery.isPending) {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="space-y-3 p-6">
|
||||
<SkeletonText className="h-16 w-full" />
|
||||
<SkeletonText className="h-16 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (teamQuery.error || !team) {
|
||||
return <Alert severity="error" title={teamQuery.error?.message ?? t("something_went_wrong")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="border-subtle rounded-md border bg-default p-4" data-testid="leave-team-section">
|
||||
<h2 className="text-emphasis text-base font-semibold">{t("leave_team")}</h2>
|
||||
<p className="text-subtle mt-1 text-sm">{t("leave_team_description")}</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isFinalOwner || leaveMutation.isPending}
|
||||
onClick={() => leaveMutation.mutate({ teamId })}
|
||||
data-testid="leave-team-button">
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
{isFinalOwner ? (
|
||||
<p className="text-subtle mt-2 text-xs">{t("final_owner_must_transfer_first")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isOwnerNow ? (
|
||||
<section
|
||||
className="border-attention rounded-md border bg-default p-4"
|
||||
data-testid="transfer-team-section">
|
||||
<h2 className="text-emphasis text-base font-semibold">{t("transfer_ownership")}</h2>
|
||||
<p className="text-subtle mt-1 text-sm">{t("transfer_ownership_description")}</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={transferableMembers.length === 0}
|
||||
onClick={() => setShowTransferDialog(true)}
|
||||
data-testid="transfer-ownership-button">
|
||||
{t("transfer_ownership")}
|
||||
</Button>
|
||||
{transferableMembers.length === 0 ? (
|
||||
<p className="text-subtle mt-2 text-xs">{t("no_eligible_transfer_target")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isOwnerNow ? (
|
||||
<section className="border-error rounded-md border bg-default p-4" data-testid="delete-team-section">
|
||||
<h2 className="text-emphasis text-base font-semibold">{t("delete_team")}</h2>
|
||||
<p className="text-subtle mt-1 text-sm">{t("delete_team_warning")}</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
color="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
data-testid="delete-team-button">
|
||||
{t("delete_team")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent title={t("delete_team")} description={t("delete_team_confirm_description")}>
|
||||
<TextField
|
||||
label={t("type_team_name_to_confirm", { teamName: team.name })}
|
||||
value={confirmName}
|
||||
onChange={(e) => setConfirmName(e.target.value)}
|
||||
data-testid="delete-team-confirm-input"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button color="secondary" onClick={() => setShowDeleteDialog(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="destructive"
|
||||
loading={deleteMutation.isPending}
|
||||
disabled={confirmName !== team.name}
|
||||
onClick={() => deleteMutation.mutate({ teamId })}
|
||||
data-testid="delete-team-confirm">
|
||||
{t("delete_team")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showTransferDialog} onOpenChange={setShowTransferDialog}>
|
||||
<DialogContent
|
||||
title={t("transfer_ownership")}
|
||||
description={t("transfer_ownership_confirm_description")}>
|
||||
<Select<{ value: number; label: string }>
|
||||
placeholder={t("transfer_ownership_to")}
|
||||
value={
|
||||
transferTarget
|
||||
? {
|
||||
value: transferTarget,
|
||||
label:
|
||||
transferableMembers.find((m) => m.userId === transferTarget)?.user.name ??
|
||||
transferableMembers.find((m) => m.userId === transferTarget)?.user.email ??
|
||||
String(transferTarget),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(option) => setTransferTarget(option?.value ?? null)}
|
||||
options={transferableMembers.map((m) => ({
|
||||
value: m.userId,
|
||||
label: m.user.name || m.user.email || `User ${m.userId}`,
|
||||
}))}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button color="secondary" onClick={() => setShowTransferDialog(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={transferMutation.isPending}
|
||||
disabled={!transferTarget}
|
||||
onClick={() =>
|
||||
transferTarget && transferMutation.mutate({ teamId, newOwnerUserId: transferTarget })
|
||||
}
|
||||
data-testid="transfer-ownership-confirm">
|
||||
{t("transfer_ownership")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, Select, SettingsToggle, TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
type Team = RouterOutputs["viewer"]["teams"]["get"];
|
||||
type ThemeValue = "light" | "dark" | "system";
|
||||
|
||||
type FormValues = {
|
||||
isPrivate: boolean;
|
||||
hideTeamProfileLink: boolean;
|
||||
hideBranding: boolean;
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
theme: ThemeValue;
|
||||
};
|
||||
|
||||
function isManager(team: Team | undefined, userId: number | undefined) {
|
||||
if (!team || !userId) return false;
|
||||
const member = team.members.find((m) => m.userId === userId);
|
||||
return Boolean(member?.accepted && (member.role === "OWNER" || member.role === "ADMIN"));
|
||||
}
|
||||
|
||||
export function TeamGeneralView({ teamId }: { teamId: number }) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId });
|
||||
const canManage = isManager(teamQuery.data, session.data?.user?.id);
|
||||
|
||||
const themeOptions: { value: ThemeValue; label: string }[] = [
|
||||
{ value: "system", label: t("system_default") },
|
||||
{ value: "light", label: t("light") },
|
||||
{ value: "dark", label: t("dark") },
|
||||
];
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
isPrivate: false,
|
||||
hideTeamProfileLink: false,
|
||||
hideBranding: false,
|
||||
brandColor: "#292929",
|
||||
darkBrandColor: "#fafafa",
|
||||
theme: "system",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const data = teamQuery.data;
|
||||
if (!data) return;
|
||||
form.reset({
|
||||
isPrivate: Boolean(data.isPrivate),
|
||||
hideTeamProfileLink: Boolean(data.hideTeamProfileLink),
|
||||
hideBranding: Boolean(data.hideBranding),
|
||||
brandColor: data.brandColor ?? "#292929",
|
||||
darkBrandColor: data.darkBrandColor ?? "#fafafa",
|
||||
theme: (data.theme as ThemeValue | null) ?? "system",
|
||||
});
|
||||
}, [teamQuery.data, form]);
|
||||
|
||||
const updateMutation = trpc.viewer.teams.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.teams.get.invalidate({ teamId });
|
||||
showToast(t("team_updated_successfully"), "success");
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
if (teamQuery.isPending) {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle stack-y-6 border-x px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (teamQuery.error) {
|
||||
return <Alert severity="error" title={teamQuery.error.message} />;
|
||||
}
|
||||
|
||||
const themeValue = form.watch("theme");
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
if (!canManage) return;
|
||||
updateMutation.mutate({
|
||||
teamId,
|
||||
isPrivate: values.isPrivate,
|
||||
hideTeamProfileLink: values.hideTeamProfileLink,
|
||||
hideBranding: values.hideBranding,
|
||||
brandColor: values.brandColor || null,
|
||||
darkBrandColor: values.darkBrandColor || null,
|
||||
theme: values.theme === "system" ? null : values.theme,
|
||||
});
|
||||
}}>
|
||||
{!canManage ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
className="mb-4"
|
||||
title={t("you_are_a_member")}
|
||||
message={t("only_admins_can_edit")}
|
||||
/>
|
||||
) : null}
|
||||
<div className="border-subtle stack-y-6 border-x px-4 py-6 sm:px-6">
|
||||
<SettingsToggle
|
||||
title={t("team_is_private")}
|
||||
description={t("team_is_private_description")}
|
||||
disabled={!canManage}
|
||||
checked={form.watch("isPrivate")}
|
||||
onCheckedChange={(checked) => form.setValue("isPrivate", checked, { shouldDirty: true })}
|
||||
/>
|
||||
<SettingsToggle
|
||||
title={t("hide_team_profile_link")}
|
||||
description={t("hide_team_profile_link_description")}
|
||||
disabled={!canManage}
|
||||
checked={form.watch("hideTeamProfileLink")}
|
||||
onCheckedChange={(checked) => form.setValue("hideTeamProfileLink", checked, { shouldDirty: true })}
|
||||
/>
|
||||
<SettingsToggle
|
||||
title={t("hide_branding")}
|
||||
description={t("hide_branding_description")}
|
||||
disabled={!canManage}
|
||||
checked={form.watch("hideBranding")}
|
||||
onCheckedChange={(checked) => form.setValue("hideBranding", checked, { shouldDirty: true })}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextField
|
||||
{...form.register("brandColor", { pattern: /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ })}
|
||||
label={t("brand_color")}
|
||||
placeholder="#292929"
|
||||
disabled={!canManage}
|
||||
/>
|
||||
<TextField
|
||||
{...form.register("darkBrandColor", { pattern: /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ })}
|
||||
label={t("dark_brand_color")}
|
||||
placeholder="#fafafa"
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-emphasis text-sm font-medium">{t("theme")}</label>
|
||||
<Select<{ value: ThemeValue; label: string }>
|
||||
isDisabled={!canManage}
|
||||
value={themeOptions.find((opt) => opt.value === themeValue) ?? themeOptions[0]}
|
||||
onChange={(option) => {
|
||||
if (option) form.setValue("theme", option.value, { shouldDirty: true });
|
||||
}}
|
||||
options={themeOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
loading={updateMutation.isPending}
|
||||
disabled={!canManage || !form.formState.isDirty}
|
||||
data-testid="team-general-save">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select, TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Team = RouterOutputs["viewer"]["teams"]["get"];
|
||||
type Member = Team["members"][number];
|
||||
|
||||
const ROLE_OPTIONS: MembershipRole[] = [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER];
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function memberLabel(member: Member) {
|
||||
return member.user.name || member.user.email || member.user.username || `User ${member.userId}`;
|
||||
}
|
||||
|
||||
export function TeamMembersView({ teamId }: { teamId: number }) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId });
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState<MembershipRole>(MembershipRole.MEMBER);
|
||||
const debouncedSearch = useDebouncedValue(search.trim(), 300);
|
||||
const searchEnabled = debouncedSearch.length >= 2;
|
||||
const searchQuery = trpc.viewer.teams.searchUsers.useQuery(
|
||||
{ teamId, search: debouncedSearch, limit: 10 },
|
||||
{ enabled: searchEnabled }
|
||||
);
|
||||
|
||||
const team = teamQuery.data;
|
||||
const currentMember = team?.members.find((m) => m.userId === session.data?.user?.id);
|
||||
const canManage = Boolean(
|
||||
currentMember?.accepted && (currentMember.role === "OWNER" || currentMember.role === "ADMIN")
|
||||
);
|
||||
const ownerCount = useMemo(
|
||||
() => team?.members.filter((m) => m.accepted && m.role === MembershipRole.OWNER).length ?? 0,
|
||||
[team?.members]
|
||||
);
|
||||
|
||||
const invalidateTeam = async () => {
|
||||
await utils.viewer.teams.get.invalidate({ teamId });
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
};
|
||||
|
||||
const addMember = trpc.viewer.teams.addMember.useMutation({
|
||||
onSuccess: async () => {
|
||||
setSearch("");
|
||||
await invalidateTeam();
|
||||
showToast(t("member_added"), "success");
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
const changeRole = trpc.viewer.teams.changeRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidateTeam();
|
||||
showToast(t("role_updated"), "success");
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
const removeMember = trpc.viewer.teams.removeMember.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidateTeam();
|
||||
showToast(t("member_removed"), "success");
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
if (teamQuery.isPending) {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle stack-y-3 border-x px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (teamQuery.error || !team) {
|
||||
return <Alert severity="error" title={teamQuery.error?.message ?? t("something_went_wrong")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="team-members">
|
||||
{!canManage ? (
|
||||
<Alert severity="info" title={t("you_are_a_member")} message={t("only_admins_can_edit_members")} />
|
||||
) : (
|
||||
<section className="border-subtle rounded-md border bg-default p-4">
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_180px_auto] md:items-end">
|
||||
<TextField
|
||||
label={t("search_users")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("search_users_placeholder")}
|
||||
data-testid="team-user-search"
|
||||
/>
|
||||
<div>
|
||||
<label className="text-emphasis text-sm font-medium">{t("role")}</label>
|
||||
<Select<{ value: MembershipRole; label: string }>
|
||||
value={{ value: selectedRole, label: t(selectedRole.toLowerCase()) }}
|
||||
onChange={(option) => option && setSelectedRole(option.value)}
|
||||
options={ROLE_OPTIONS.map((role) => ({ value: role, label: t(role.toLowerCase()) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{searchEnabled ? (
|
||||
<div className="divide-subtle mt-4 divide-y">
|
||||
{(searchQuery.data?.items ?? []).map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-emphasis truncate text-sm font-medium">
|
||||
{user.name || user.email || user.username}
|
||||
</p>
|
||||
<p className="text-subtle truncate text-xs">{user.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addMember.mutate({ teamId, userId: user.id, role: selectedRole })}
|
||||
loading={addMember.isPending}
|
||||
data-testid={`add-user-${user.id}`}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{searchQuery.data?.items?.length === 0 && !searchQuery.isPending ? (
|
||||
<p className="text-subtle py-3 text-sm">{t("no_users_found")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-subtle mt-3 text-sm">{t("type_two_chars_to_search")}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ul className="border-subtle divide-subtle divide-y overflow-hidden rounded-md border">
|
||||
{team.members.map((member) => {
|
||||
const isFinalOwner = member.accepted && member.role === MembershipRole.OWNER && ownerCount <= 1;
|
||||
const canEditRole = canManage && !isFinalOwner;
|
||||
const canRemove = canManage && !isFinalOwner;
|
||||
return (
|
||||
<li
|
||||
key={member.userId}
|
||||
className="bg-default flex flex-col gap-3 p-4 md:flex-row md:items-center"
|
||||
data-testid={`member-row-${member.userId}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{memberLabel(member)}</p>
|
||||
<p className="text-subtle truncate text-xs">{member.user.email}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="w-40">
|
||||
<Select<{ value: MembershipRole; label: string }>
|
||||
isDisabled={!canEditRole || changeRole.isPending}
|
||||
value={{ value: member.role, label: t(member.role.toLowerCase()) }}
|
||||
onChange={(option) =>
|
||||
option && changeRole.mutate({ teamId, userId: member.userId, role: option.value })
|
||||
}
|
||||
options={ROLE_OPTIONS.map((role) => ({
|
||||
value: role,
|
||||
label: t(role.toLowerCase()),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={!canRemove || removeMember.isPending}
|
||||
onClick={() => removeMember.mutate({ teamId, userId: member.userId })}
|
||||
data-testid={`remove-user-${member.userId}`}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
{isFinalOwner ? (
|
||||
<span className="text-subtle text-xs">{t("final_owner_cannot_be_removed")}</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, TextAreaField, TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
type Team = RouterOutputs["viewer"]["teams"]["get"];
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
slug: string;
|
||||
bio: string;
|
||||
logoUrl: string;
|
||||
};
|
||||
|
||||
function isManager(team: Team | undefined, userId: number | undefined) {
|
||||
if (!team || !userId) return false;
|
||||
const member = team.members.find((m) => m.userId === userId);
|
||||
return Boolean(member?.accepted && (member.role === "OWNER" || member.role === "ADMIN"));
|
||||
}
|
||||
|
||||
export function TeamProfileView({ teamId }: { teamId: number }) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId });
|
||||
const canManage = isManager(teamQuery.data, session.data?.user?.id);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: { name: "", slug: "", bio: "", logoUrl: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamQuery.data) return;
|
||||
form.reset({
|
||||
name: teamQuery.data.name ?? "",
|
||||
slug: teamQuery.data.slug ?? "",
|
||||
bio: teamQuery.data.bio ?? "",
|
||||
logoUrl: teamQuery.data.logoUrl ?? "",
|
||||
});
|
||||
}, [teamQuery.data, form]);
|
||||
|
||||
const updateMutation = trpc.viewer.teams.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.teams.get.invalidate({ teamId });
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
showToast(t("team_updated_successfully"), "success");
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
if (teamQuery.isPending) {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle stack-y-6 border-x px-4 py-8 sm:px-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (teamQuery.error) {
|
||||
return <Alert severity="error" title={teamQuery.error.message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
if (!canManage) return;
|
||||
updateMutation.mutate({
|
||||
teamId,
|
||||
name: values.name.trim(),
|
||||
slug: values.slug.trim(),
|
||||
bio: values.bio.trim() || null,
|
||||
logoUrl: values.logoUrl.trim() || null,
|
||||
});
|
||||
}}>
|
||||
{!canManage ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
className="mb-4"
|
||||
title={t("you_are_a_member")}
|
||||
message={t("only_admins_can_edit")}
|
||||
/>
|
||||
) : null}
|
||||
<div className="border-subtle border-x px-4 py-6 sm:px-6">
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
{...form.register("name", { required: true, maxLength: 100 })}
|
||||
label={t("team_name")}
|
||||
disabled={!canManage}
|
||||
data-testid="team-profile-name"
|
||||
/>
|
||||
<TextField
|
||||
{...form.register("slug", { required: true, pattern: /^[a-z0-9-]+$/ })}
|
||||
label={t("team_url")}
|
||||
addOnLeading="/"
|
||||
disabled={!canManage}
|
||||
data-testid="team-profile-slug"
|
||||
/>
|
||||
<TextField
|
||||
{...form.register("logoUrl")}
|
||||
label={t("logo_url")}
|
||||
placeholder="https://example.com/logo.png"
|
||||
disabled={!canManage}
|
||||
/>
|
||||
<TextAreaField
|
||||
{...form.register("bio", { maxLength: 500 })}
|
||||
label={t("about")}
|
||||
placeholder={t("team_description_placeholder")}
|
||||
disabled={!canManage}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
loading={updateMutation.isPending}
|
||||
disabled={!canManage || !form.formState.isDirty}
|
||||
data-testid="team-profile-save">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
type Team = RouterOutputs["viewer"]["teams"]["list"][number];
|
||||
|
||||
type CreateTeamFormValues = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
function roleBadgeClass(role: string | undefined) {
|
||||
if (role === "OWNER") return "bg-info text-info";
|
||||
if (role === "ADMIN") return "bg-attention text-attention";
|
||||
return "bg-muted text-subtle";
|
||||
}
|
||||
|
||||
function memberCount(team: Team) {
|
||||
return team.members.filter((member) => member.accepted).length;
|
||||
}
|
||||
|
||||
function getCurrentRole(team: Team, userId?: number) {
|
||||
return team.members.find((member) => member.userId === userId)?.role;
|
||||
}
|
||||
|
||||
export function TeamsListView() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const teamsQuery = trpc.viewer.teams.list.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.viewer.teams.create.useMutation({
|
||||
onSuccess: async (team) => {
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
showToast(t("team_updated_successfully"), "success");
|
||||
setIsCreateOpen(false);
|
||||
router.push(`/settings/teams/${team.id}/profile`);
|
||||
},
|
||||
onError: (error) => showToast(error.message, "error"),
|
||||
});
|
||||
|
||||
const form = useForm<CreateTeamFormValues>({ defaultValues: { name: "", slug: "" } });
|
||||
|
||||
if (teamsQuery.isPending) {
|
||||
return (
|
||||
<div className="space-y-3" data-testid="teams-loading">
|
||||
<SkeletonText className="h-16 w-full" />
|
||||
<SkeletonText className="h-16 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const teams = teamsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button data-testid="new-team" onClick={() => setIsCreateOpen(true)}>
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<EmptyScreen
|
||||
headline={t("no_teams_yet")}
|
||||
description={t("no_teams_yet_description")}
|
||||
buttonRaw={<Button onClick={() => setIsCreateOpen(true)}>{t("create_team")}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<ul
|
||||
className="border-subtle divide-subtle divide-y overflow-hidden rounded-md border"
|
||||
data-testid="teams">
|
||||
{teams.map((team) => {
|
||||
const role = getCurrentRole(team, user?.id);
|
||||
return (
|
||||
<li
|
||||
key={team.id}
|
||||
className="bg-default flex flex-col gap-3 p-4 sm:flex-row sm:items-center"
|
||||
data-testid={`team-row-${team.id}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-emphasis truncate text-base font-medium">{team.name}</h3>
|
||||
{role ? (
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
data-testid={`team-role-${team.id}`}>
|
||||
{t(role.toLowerCase())}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-subtle truncate text-sm">
|
||||
/{team.slug} · {memberCount(team)} {t("members").toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button color="secondary" href={`/settings/teams/${team.id}/profile`}>
|
||||
{t("manage_team")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogContent title={t("create_team")} description={t("create_team_description")}>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) =>
|
||||
createMutation.mutate({ name: values.name.trim(), slug: values.slug.trim() })
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
{...form.register("name", { required: true })}
|
||||
label={t("team_name")}
|
||||
placeholder={t("your_team_name")}
|
||||
data-testid="team-name-input"
|
||||
/>
|
||||
<TextField
|
||||
{...form.register("slug", {
|
||||
required: true,
|
||||
pattern: /^[a-z0-9-]+$/,
|
||||
})}
|
||||
label={t("team_url")}
|
||||
addOnLeading="/"
|
||||
placeholder="acme"
|
||||
data-testid="team-slug-input"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={() => setIsCreateOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={createMutation.isPending} data-testid="create-team-submit">
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export type TeamUpdateData = Pick<
|
||||
| "logoUrl"
|
||||
| "hideTeamProfileLink"
|
||||
| "isPrivate"
|
||||
| "hideBranding"
|
||||
| "brandColor"
|
||||
| "darkBrandColor"
|
||||
| "theme"
|
||||
| "timeFormat"
|
||||
| "timeZone"
|
||||
| "weekStart"
|
||||
@@ -23,6 +27,10 @@ const teamSelect = {
|
||||
logoUrl: true,
|
||||
isPrivate: true,
|
||||
hideTeamProfileLink: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
isOrganization: true,
|
||||
parentId: true,
|
||||
pendingPayment: true,
|
||||
@@ -144,6 +152,32 @@ export class TeamRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async transferOwnership(input: { teamId: number; currentOwnerUserId: number; newOwnerUserId: number }) {
|
||||
return this.prismaClient.$transaction(
|
||||
async (tx) => {
|
||||
const newOwnerMembership = await tx.membership.findUnique({
|
||||
where: { userId_teamId: { teamId: input.teamId, userId: input.newOwnerUserId } },
|
||||
select: { accepted: true, role: true },
|
||||
});
|
||||
if (!newOwnerMembership?.accepted) {
|
||||
throw Object.assign(new Error("Target user must be an accepted team member"), {
|
||||
code: "INVALID_TRANSFER_TARGET",
|
||||
});
|
||||
}
|
||||
await tx.membership.update({
|
||||
where: { userId_teamId: { teamId: input.teamId, userId: input.newOwnerUserId } },
|
||||
data: { role: MembershipRole.OWNER },
|
||||
});
|
||||
await tx.membership.update({
|
||||
where: { userId_teamId: { teamId: input.teamId, userId: input.currentOwnerUserId } },
|
||||
data: { role: MembershipRole.ADMIN },
|
||||
});
|
||||
return { teamId: input.teamId, newOwnerUserId: input.newOwnerUserId };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
|
||||
);
|
||||
}
|
||||
|
||||
async update(input: { teamId: number; data: TeamUpdateData }) {
|
||||
return this.prismaClient.team.update({
|
||||
where: { id: input.teamId },
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("TeamService", () => {
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
searchExistingUsers: vi.fn(),
|
||||
transferOwnership: vi.fn(),
|
||||
};
|
||||
const membershipService = {
|
||||
hasAnyRole: vi.fn(),
|
||||
@@ -221,4 +222,66 @@ describe("TeamService", () => {
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferOwnership", () => {
|
||||
it("transfers ownership when actor is the OWNER", async () => {
|
||||
repository.transferOwnership.mockResolvedValue({ teamId: 10, newOwnerUserId: 2 });
|
||||
|
||||
await service.transferOwnership({ actorId: 1, teamId: 10, newOwnerUserId: 2 });
|
||||
|
||||
expect(membershipService.hasAnyRole).toHaveBeenCalledWith(10, 1, [MembershipRole.OWNER]);
|
||||
expect(repository.transferOwnership).toHaveBeenCalledWith({
|
||||
teamId: 10,
|
||||
currentOwnerUserId: 1,
|
||||
newOwnerUserId: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects transfer when the actor is not the OWNER", async () => {
|
||||
membershipService.hasAnyRole.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.transferOwnership({ actorId: 4, teamId: 10, newOwnerUserId: 2 })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(repository.transferOwnership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects transferring to self", async () => {
|
||||
await expect(
|
||||
service.transferOwnership({ actorId: 1, teamId: 10, newOwnerUserId: 1 })
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(repository.transferOwnership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps an invalid-target repository error to BAD_REQUEST", async () => {
|
||||
repository.transferOwnership.mockRejectedValue({ code: "INVALID_TRANSFER_TARGET" });
|
||||
|
||||
await expect(
|
||||
service.transferOwnership({ actorId: 1, teamId: 10, newOwnerUserId: 99 })
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("leave", () => {
|
||||
it("lets a member leave a team", async () => {
|
||||
await service.leave({ actorId: 2, teamId: 10 });
|
||||
|
||||
expect(repository.removeMembership).toHaveBeenCalledWith({ teamId: 10, userId: 2 });
|
||||
});
|
||||
|
||||
it("blocks the final owner from leaving", async () => {
|
||||
repository.findAccessible.mockResolvedValue({
|
||||
id: 10,
|
||||
parentId: null,
|
||||
isOrganization: false,
|
||||
members: [{ userId: 1, accepted: true, role: MembershipRole.OWNER }],
|
||||
});
|
||||
repository.countAcceptedOwners.mockResolvedValue(1);
|
||||
|
||||
await expect(service.leave({ actorId: 1, teamId: 10 })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
expect(repository.removeMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ const MANAGER_ROLES = [MembershipRole.OWNER, MembershipRole.ADMIN];
|
||||
type TeamServiceErrorCode = "BAD_REQUEST" | "CONFLICT" | "FORBIDDEN" | "NOT_FOUND";
|
||||
|
||||
export class TeamServiceError extends Error {
|
||||
constructor(readonly code: TeamServiceErrorCode, message: string) {
|
||||
constructor(
|
||||
readonly code: TeamServiceErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TeamServiceError";
|
||||
}
|
||||
@@ -87,6 +90,36 @@ export class TeamService {
|
||||
}
|
||||
}
|
||||
|
||||
async transferOwnership(input: { actorId: number; teamId: number; newOwnerUserId: number }) {
|
||||
await this.assertStandaloneTeam(input.teamId, input.actorId);
|
||||
await this.assertHasRole(input.teamId, input.actorId, [MembershipRole.OWNER]);
|
||||
if (input.newOwnerUserId === input.actorId) {
|
||||
throw new TeamServiceError("BAD_REQUEST", "You already own this team");
|
||||
}
|
||||
try {
|
||||
return await this.repository.transferOwnership({
|
||||
teamId: input.teamId,
|
||||
currentOwnerUserId: input.actorId,
|
||||
newOwnerUserId: input.newOwnerUserId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isPrismaErrorWithCode(error, "INVALID_TRANSFER_TARGET")) {
|
||||
throw new TeamServiceError("BAD_REQUEST", "Target user must be an accepted team member");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async leave(input: { actorId: number; teamId: number }) {
|
||||
const team = await this.assertStandaloneTeam(input.teamId, input.actorId);
|
||||
await this.assertDoesNotRemoveFinalOwner(team, input.teamId, input.actorId);
|
||||
try {
|
||||
return await this.repository.removeMembership({ teamId: input.teamId, userId: input.actorId });
|
||||
} catch (error) {
|
||||
this.rethrowMembershipMutationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async searchExistingUsers(input: {
|
||||
actorId: number;
|
||||
teamId: number;
|
||||
|
||||
@@ -1176,6 +1176,51 @@
|
||||
"create_first_team_and_invite_others": "Create your first team and invite other users to work together.",
|
||||
"create_team_to_get_started": "Create a team to get started",
|
||||
"teams": "Teams",
|
||||
"teams_description": "Create teams to share availability and coordinate scheduling with collaborators.",
|
||||
"create_team_description": "Pick a name and URL. You can edit team details after creation.",
|
||||
"no_teams_yet": "No teams yet",
|
||||
"no_teams_yet_description": "Create a team to share availability and route bookings to the right person.",
|
||||
"manage_team": "Manage team",
|
||||
"team_profile": "Team profile",
|
||||
"team_profile_description": "Name, slug, logo, and bio shown on your team's booking page.",
|
||||
"team_general": "General settings",
|
||||
"team_general_description": "Privacy, theme, and branding for your team.",
|
||||
"team_members_description": "Manage who can access this team and their roles.",
|
||||
"team_danger_zone": "Danger zone",
|
||||
"team_danger_zone_description": "Destructive actions for this team.",
|
||||
"team_profile_general": "General settings",
|
||||
"team_general_settings": "General settings",
|
||||
"team_description_placeholder": "A short description of your team",
|
||||
"you_are_a_member": "You are a team member",
|
||||
"only_admins_can_edit": "Only team admins and owners can edit these settings.",
|
||||
"only_admins_can_edit_members": "Only team admins and owners can change team membership.",
|
||||
"logo_url": "Logo URL",
|
||||
"team_is_private": "Private team",
|
||||
"team_is_private_description": "Hide team member list from the public team page.",
|
||||
"hide_branding_description": "Remove Cal.diy branding from the team booking pages.",
|
||||
"system_default": "System default",
|
||||
"search_users": "Search users",
|
||||
"search_users_placeholder": "Search by name, email, or username",
|
||||
"no_users_found": "No existing users found.",
|
||||
"type_two_chars_to_search": "Type at least two characters to search existing users.",
|
||||
"member_added": "Member added",
|
||||
"role_updated": "Role updated",
|
||||
"final_owner_cannot_be_removed": "Final owner",
|
||||
"leave_team_description": "Remove yourself from this team. You will lose access to its events and bookings.",
|
||||
"final_owner_must_transfer_first": "Transfer ownership to another member before leaving.",
|
||||
"left_team": "Left team",
|
||||
"transfer_ownership": "Transfer ownership",
|
||||
"transfer_ownership_description": "Promote another team member to OWNER. You will be demoted to ADMIN.",
|
||||
"transfer_ownership_to": "Transfer ownership to",
|
||||
"transfer_ownership_confirm_description": "Pick the team member to promote to owner. You will become an admin.",
|
||||
"no_eligible_transfer_target": "Add another team member before transferring ownership.",
|
||||
"ownership_transferred": "Ownership transferred",
|
||||
"delete_team": "Delete team",
|
||||
"delete_team_warning": "Permanently delete this team. This cannot be undone.",
|
||||
"delete_team_confirm_description": "Type the team name to confirm deletion. This cannot be undone.",
|
||||
"type_team_name_to_confirm": "Type \"{teamName}\" to confirm",
|
||||
"team_deleted": "Team deleted",
|
||||
"team_danger": "Danger zone",
|
||||
"upgrade_feature_round_robin": "Round-robin, fixed round-robin",
|
||||
"upgrade_feature_collective_events": "Collective events",
|
||||
"upgrade_feature_workflows": "Teams workflows",
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
ZCreateTeamInputSchema,
|
||||
ZDeleteTeamInputSchema,
|
||||
ZGetTeamInputSchema,
|
||||
ZLeaveTeamInputSchema,
|
||||
ZListTeamsInputSchema,
|
||||
ZRemoveMemberInputSchema,
|
||||
ZSearchUsersInputSchema,
|
||||
ZTransferOwnershipInputSchema,
|
||||
ZUpdateTeamInputSchema,
|
||||
} from "./team.schema";
|
||||
|
||||
@@ -66,4 +68,16 @@ export const teamsRouter = router({
|
||||
|
||||
return removeMemberHandler({ ctx, input });
|
||||
}),
|
||||
|
||||
transferOwnership: authedProcedure.input(ZTransferOwnershipInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { transferOwnershipHandler } = await import("./team.handler");
|
||||
|
||||
return transferOwnershipHandler({ ctx, input });
|
||||
}),
|
||||
|
||||
leave: authedProcedure.input(ZLeaveTeamInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { leaveHandler } = await import("./team.handler");
|
||||
|
||||
return leaveHandler({ ctx, input });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -284,9 +284,7 @@ describe("team schemas", () => {
|
||||
});
|
||||
|
||||
it("rejects unsafe logo URLs", () => {
|
||||
expect(() =>
|
||||
ZUpdateTeamInputSchema.parse({ teamId: 2, logoUrl: "javascript:alert(1)" })
|
||||
).toThrow();
|
||||
expect(() => ZUpdateTeamInputSchema.parse({ teamId: 2, logoUrl: "javascript:alert(1)" })).toThrow();
|
||||
expect(() => ZUpdateTeamInputSchema.parse({ teamId: 2, logoUrl: "data:text/plain,logo" })).toThrow();
|
||||
expect(
|
||||
ZUpdateTeamInputSchema.parse({ teamId: 2, logoUrl: "https://example.com/logo.png" })
|
||||
@@ -296,11 +294,12 @@ describe("team schemas", () => {
|
||||
it("validates team time settings consistently", () => {
|
||||
expect(() => ZUpdateTeamInputSchema.parse({ teamId: 2, timeZone: "Not/AZone" })).toThrow();
|
||||
expect(() => ZUpdateTeamInputSchema.parse({ teamId: 2, weekStart: "Funday" })).toThrow();
|
||||
expect(ZUpdateTeamInputSchema.parse({ teamId: 2, timeZone: "+00:00", weekStart: "Monday" }))
|
||||
.toMatchObject({
|
||||
timeZone: "UTC",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
expect(
|
||||
ZUpdateTeamInputSchema.parse({ teamId: 2, timeZone: "+00:00", weekStart: "Monday" })
|
||||
).toMatchObject({
|
||||
timeZone: "UTC",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,9 +324,7 @@ describe("team handler real service path", () => {
|
||||
const { searchUsersHandler: realSearchUsersHandler } = await importRealTeamHandlers();
|
||||
const fakePrisma = createFakePrisma({
|
||||
membership: { accepted: true, role: MembershipRole.ADMIN },
|
||||
users: [
|
||||
{ id: 44, email: "member@example.com", name: "Member", username: "member", avatarUrl: null },
|
||||
],
|
||||
users: [{ id: 44, email: "member@example.com", name: "Member", username: "member", avatarUrl: null }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -10,8 +10,10 @@ import type {
|
||||
TCreateTeamInputSchema,
|
||||
TDeleteTeamInputSchema,
|
||||
TGetTeamInputSchema,
|
||||
TLeaveTeamInputSchema,
|
||||
TRemoveMemberInputSchema,
|
||||
TSearchUsersInputSchema,
|
||||
TTransferOwnershipInputSchema,
|
||||
TUpdateTeamInputSchema,
|
||||
} from "./team.schema";
|
||||
|
||||
@@ -161,3 +163,31 @@ export const removeMemberHandler = async ({
|
||||
createTeamService(ctx).removeMember({ actorId: ctx.user.id, teamId: input.teamId, userId: input.userId })
|
||||
);
|
||||
};
|
||||
|
||||
export const transferOwnershipHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: {
|
||||
ctx: TeamHandlerContext;
|
||||
input: TTransferOwnershipInputSchema;
|
||||
}) => {
|
||||
return withTeamServiceErrorMapping(() =>
|
||||
createTeamService(ctx).transferOwnership({
|
||||
actorId: ctx.user.id,
|
||||
teamId: input.teamId,
|
||||
newOwnerUserId: input.newOwnerUserId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const leaveHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: {
|
||||
ctx: TeamHandlerContext;
|
||||
input: TLeaveTeamInputSchema;
|
||||
}) => {
|
||||
return withTeamServiceErrorMapping(() =>
|
||||
createTeamService(ctx).leave({ actorId: ctx.user.id, teamId: input.teamId })
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,6 +43,13 @@ export const ZGetTeamInputSchema = z.object({
|
||||
teamId: teamIdSchema,
|
||||
});
|
||||
|
||||
const hexColorSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, { message: "Must be a hex color (e.g. #1A2B3C)" });
|
||||
|
||||
const themeSchema = z.enum(["light", "dark"]);
|
||||
|
||||
export const ZUpdateTeamInputSchema = z
|
||||
.object({
|
||||
teamId: teamIdSchema,
|
||||
@@ -52,6 +59,10 @@ export const ZUpdateTeamInputSchema = z
|
||||
logoUrl: httpUrlSchema.nullable().optional(),
|
||||
hideTeamProfileLink: z.boolean().optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
brandColor: hexColorSchema.nullable().optional(),
|
||||
darkBrandColor: hexColorSchema.nullable().optional(),
|
||||
theme: themeSchema.nullable().optional(),
|
||||
timeFormat: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -68,6 +79,15 @@ export const ZDeleteTeamInputSchema = z.object({
|
||||
teamId: teamIdSchema,
|
||||
});
|
||||
|
||||
export const ZTransferOwnershipInputSchema = z.object({
|
||||
teamId: teamIdSchema,
|
||||
newOwnerUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const ZLeaveTeamInputSchema = z.object({
|
||||
teamId: teamIdSchema,
|
||||
});
|
||||
|
||||
export const ZSearchUsersInputSchema = z.object({
|
||||
teamId: teamIdSchema,
|
||||
search: z.string().trim().min(2).max(100),
|
||||
@@ -96,3 +116,5 @@ export type TSearchUsersInputSchema = z.infer<typeof ZSearchUsersInputSchema>;
|
||||
export type TAddMemberInputSchema = z.infer<typeof ZAddMemberInputSchema>;
|
||||
export type TChangeRoleInputSchema = z.infer<typeof ZChangeRoleInputSchema>;
|
||||
export type TRemoveMemberInputSchema = z.infer<typeof ZRemoveMemberInputSchema>;
|
||||
export type TTransferOwnershipInputSchema = z.infer<typeof ZTransferOwnershipInputSchema>;
|
||||
export type TLeaveTeamInputSchema = z.infer<typeof ZLeaveTeamInputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user