Expand standalone team settings
Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged / Release (push) Has been cancelled
Run i18n AI automation / Run i18n (push) Has been cancelled
Next.js Bundle Analysis / analyze (push) Has been cancelled

This commit is contained in:
Zachariah K. Sharma
2026-06-08 11:58:15 -06:00
parent 7673a170b1
commit 43969cd00c
19 changed files with 1388 additions and 29 deletions
@@ -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;
};
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+45
View File
@@ -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>;