Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,16 @@ const getTabs = (
name: "guest_notifications",
href: "/settings/organizations/guest-notifications",
},
{
name: "invites",
href: "/settings/organizations/invites",
trackingMetadata: { section: "organization", page: "invites" },
},
...(orgBranding
? [
{
name: "members",
href: `${WEBAPP_URL}/settings/organizations/${orgBranding?.slug}/members`,
isExternalLink: true,
href: "/settings/organizations/members",
trackingMetadata: { section: "organization", page: "members" },
},
]
Expand Down Expand Up @@ -252,7 +256,6 @@ const getTabs = (

// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = [
"privacy",
"privacy_and_security",
Expand All @@ -267,6 +270,8 @@ interface SettingsPermissions {
canUpdateOrganization?: boolean;
}

const availableOrganizationSettingsPages = new Set(["profile", "general", "invites", "members"]);

const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
Expand All @@ -278,7 +283,18 @@ const useTabs = ({
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
const orgBranding = null as { id?: number; slug?: string; name?: string; logoUrl?: string | null } | null;
const { data: pendingInvites } = trpc.viewer.organizations.listPendingInvites.useQuery();
const pendingInviteCount = pendingInvites?.length ?? 0;
const organization = user?.organization;
const orgBranding =
organization && !organization.isPlatform && organization.id != null && organization.id > 0 && "name" in organization
? {
id: organization.id ?? undefined,
slug: organization.slug ?? undefined,
name: organization.name ?? undefined,
logoUrl: "logoUrl" in organization ? (organization.logoUrl as string | null) ?? null : null,
}
: null;
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;

const processTabsMemod = useMemo(() => {
Expand All @@ -291,9 +307,10 @@ const useTabs = ({
avatar: getUserAvatarUrl(user),
};
} else if (tab.href === "/settings/organizations") {
const newArray = (tab?.children ?? []).filter(
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
);
const newArray = (tab?.children ?? []).filter((child) => {
if (!availableOrganizationSettingsPages.has(child.name)) return false;
return permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name);
});

// Add delegation-credential menu item only if feature flag is enabled
if (isDelegationCredentialEnabled) {
Expand Down Expand Up @@ -340,9 +357,16 @@ const useTabs = ({
}
}

const childrenWithBadges = newArray.map((child) => {
if (child.name === "invites" && pendingInviteCount > 0) {
return { ...child, isBadged: true };
}
return child;
});

return {
...tab,
children: newArray,
children: childrenWithBadges,
name: orgBranding?.name || "organization",
avatar: getPlaceholderAvatar(orgBranding?.logoUrl, orgBranding?.name),
};
Expand All @@ -367,13 +391,13 @@ const useTabs = ({

// check if name is in adminRequiredKeys
return processedTabs.filter((tab) => {
if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding;
if (tab.href === "/settings/organizations") return true;
if (tab.name === "other_teams" && !permissions?.canUpdateOrganization) return false;

if (isAdmin) return true;
return !adminRequiredKeys.includes(tab.name);
});
}, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
}, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions, pendingInviteCount]);

return processTabsMemod;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import slugify from "@calcom/lib/slugify";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Form, TextAreaField, TextField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { useEffect } from "react";
import { useForm } from "react-hook-form";

type OrganizationGeneralFormValues = {
name: string;
slug: string;
bio: string;
};

export default function OrganizationGeneralPage() {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data: organization, isLoading } = trpc.viewer.organizations.getCurrent.useQuery();

const form = useForm<OrganizationGeneralFormValues>({
defaultValues: {
name: "",
slug: "",
bio: "",
},
});

const {
formState: { isDirty, isSubmitting },
reset,
watch,
setValue,
} = form;

useEffect(() => {
if (!organization) return;

reset({
name: organization.name,
slug: organization.slug || "",
bio: organization.bio || "",
});
}, [organization, reset]);

const createMutation = trpc.viewer.organizations.create.useMutation({
onSuccess: async (createdOrganization) => {
await utils.viewer.organizations.getCurrent.invalidate();
await utils.viewer.me.get.invalidate();
reset({
name: createdOrganization.name,
slug: createdOrganization.slug || "",
bio: createdOrganization.bio || "",
});
showToast(t("settings_updated_successfully"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});

const updateMutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async (updatedOrganization) => {
await utils.viewer.organizations.getCurrent.invalidate();
await utils.viewer.me.get.invalidate();
reset({
name: updatedOrganization.name,
slug: updatedOrganization.slug || "",
bio: updatedOrganization.bio || "",
});
showToast(t("settings_updated_successfully"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});

const watchedName = watch("name");
const canUpdate = !organization || organization.canUpdate;
const isSaving = createMutation.isPending || updateMutation.isPending || isSubmitting;
const isSubmitDisabled = isLoading || isSaving || !canUpdate || (!!organization && !isDirty);

return (
<SettingsHeader
title={t("general")}
description={organization ? t("organization_general_description") : t("organizations_description")}
borderInShellHeader={true}>
<Form
form={form}
handleSubmit={async (values) => {
const payload = {
name: values.name,
slug: slugify(values.slug || values.name).toLowerCase(),
bio: values.bio || null,
};

if (organization) {
await updateMutation.mutateAsync(payload);
} else {
await createMutation.mutateAsync(payload);
}
}}>
<div className="border-subtle space-y-6 border-x border-y-0 px-4 py-8 sm:px-6">
<TextField
{...form.register("name", { required: true })}
data-testid="org-name-input"
label={t("organization_name")}
placeholder={t("organization_name")}
disabled={!canUpdate || isLoading}
required
/>

<TextField
data-testid="org-slug-input"
{...form.register("slug", {
required: true,
onChange: (event) => {
setValue("slug", slugify(event.target.value).toLowerCase(), {
shouldDirty: true,
shouldValidate: true,
});
},
})}
label={t("organization_url")}
placeholder={slugify(watchedName || "acme").toLowerCase()}
disabled={!canUpdate || isLoading}
required
/>

<TextAreaField
{...form.register("bio")}
label={t("organization_about_description")}
placeholder={t("organization_about_description")}
disabled={!canUpdate || isLoading}
rows={4}
/>

{!canUpdate && (
<p className="text-sm text-subtle">{t("org_admin_only_settings")}</p>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
<SectionBottomActions align="end">
<Button data-testid="org-submit-btn" type="submit" loading={isSaving} disabled={isSubmitDisabled}>
{organization ? t("save") : t("create_org")}
</Button>
</SectionBottomActions>
</Form>
</SettingsHeader>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
import { useRouter } from "next/navigation";

export default function OrganizationInvitesPage() {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useUtils();

const { data: invites, isLoading } = trpc.viewer.organizations.listPendingInvites.useQuery();

const acceptMutation = trpc.viewer.organizations.acceptInvite.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listPendingInvites.invalidate();
await utils.viewer.me.get.invalidate();
showToast(t("org_invite_joined"), "success");
router.push("/settings/organizations/general");
},
onError: (err) => showToast(err.message, "error"),
});

const declineMutation = trpc.viewer.organizations.declineInvite.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listPendingInvites.invalidate();
showToast(t("invite_declined"), "success");
},
onError: (err) => showToast(err.message, "error"),
});

const pendingInvites = invites ?? [];
const isBusy = acceptMutation.isPending || declineMutation.isPending;

return (
<SettingsHeader
title={t("org_invites")}
description={t("org_invites_description")}
borderInShellHeader={true}>
<div className="border-subtle border-x border-y-0 px-4 py-6 sm:px-6">
{isLoading ? (
<p className="text-subtle text-sm">{t("loading")}</p>
) : pendingInvites.length === 0 ? (
<p className="text-subtle text-sm">{t("no_pending_invites")}</p>
) : (
<ul className="divide-subtle divide-y">
{pendingInvites.map(({ team }) => (
<li key={team.id} data-testid={`invite-item-${team.id}`} className="flex items-center gap-3 py-4">
<Avatar
alt={team.name}
imageSrc={team.logoUrl ?? undefined}
size="md"
fallback={team.name[0]?.toUpperCase()}
/>
<div className="min-w-0 flex-1">
<p className="text-default text-sm font-semibold">{team.name}</p>
{team.slug && <p className="text-subtle text-xs">{team.slug}</p>}
</div>
<div className="flex shrink-0 gap-2">
<Button
data-testid={`decline-invite-${team.id}`}
color="minimal"
size="sm"
disabled={isBusy}
onClick={() => declineMutation.mutate({ teamId: team.id })}>
{t("decline")}
</Button>
<Button
data-testid={`accept-invite-${team.id}`}
size="sm"
disabled={isBusy}
loading={acceptMutation.isPending}
onClick={() => acceptMutation.mutate({ teamId: team.id })}>
{t("accept")}
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</SettingsHeader>
);
}
Loading
Loading