From 17f876160db2f32ca33b6e715c5d3a91f7da81f0 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 23 Sep 2025 22:58:59 +0100 Subject: [PATCH] feat: add repo funcs and share link toggle --- .../members/components/InviteMemberForm.tsx | 135 ++++++- packages/api/src/routers/member.ts | 343 +++++++++++++++++- packages/db/src/repository/inviteLink.repo.ts | 71 ++++ packages/db/src/repository/workspace.repo.ts | 14 + packages/db/src/schema/index.ts | 1 + .../db/src/schema/workspaceInviteLinks.ts | 10 + 6 files changed, 563 insertions(+), 11 deletions(-) create mode 100644 packages/db/src/repository/inviteLink.repo.ts diff --git a/apps/web/src/views/members/components/InviteMemberForm.tsx b/apps/web/src/views/members/components/InviteMemberForm.tsx index 3646ef62..cdb26545 100644 --- a/apps/web/src/views/members/components/InviteMemberForm.tsx +++ b/apps/web/src/views/members/components/InviteMemberForm.tsx @@ -3,7 +3,12 @@ import { t } from "@lingui/core/macro"; import { env } from "next-runtime-env"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { HiXMark } from "react-icons/hi2"; +import { + HiInformationCircle, + HiMiniCheck, + HiOutlineDocumentDuplicate, + HiXMark, +} from "react-icons/hi2"; import { z } from "zod"; import type { InviteMemberInput } from "@kan/api/types"; @@ -31,7 +36,11 @@ export function InviteMemberForm({ userId: string | undefined; }) { const utils = api.useUtils(); - const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false); + const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] = + useState(false); + const [inviteLink, setInviteLink] = useState(""); + const [isLoadingInviteLink, setIsLoadingInviteLink] = useState(false); + const [copied, setCopied] = useState(false); const { closeModal } = useModal(); const { workspace } = useWorkspace(); const { showPopup } = usePopup(); @@ -56,6 +65,21 @@ export function InviteMemberForm({ const refetchBoards = () => utils.board.all.refetch(); + // Fetch active invite link on component mount + const { data: activeInviteLink, refetch: refetchInviteLink } = + api.member.getActiveInviteLink.useQuery( + { workspacePublicId: workspace.publicId || "" }, + { enabled: !!workspace.publicId }, + ); + + // Set initial state based on active invite link + useEffect(() => { + if (activeInviteLink) { + setIsShareInviteLinkEnabled(activeInviteLink.isActive); + setInviteLink(activeInviteLink.inviteLink || ""); + } + }, [activeInviteLink]); + const inviteMember = api.member.invite.useMutation({ onSuccess: async () => { closeModal(); @@ -64,7 +88,7 @@ export function InviteMemberForm({ }, onError: (error) => { reset(); - if (!isCreateAnotherEnabled) closeModal(); + if (!isShareInviteLinkEnabled) closeModal(); if (error.data?.code === "CONFLICT") { showPopup({ @@ -82,6 +106,36 @@ export function InviteMemberForm({ }, }); + const createInviteLink = api.member.createInviteLink.useMutation({ + onSuccess: (data) => { + setInviteLink(data.inviteLink); + setIsLoadingInviteLink(false); + }, + onError: () => { + setIsLoadingInviteLink(false); + showPopup({ + header: t`Error creating invite link`, + message: t`Please try again later.`, + icon: "error", + }); + }, + }); + + const deactivateInviteLink = api.member.deactivateInviteLink.useMutation({ + onSuccess: () => { + setInviteLink(""); + setIsLoadingInviteLink(false); + }, + onError: () => { + setIsLoadingInviteLink(false); + showPopup({ + header: t`Error deactivating invite link`, + message: t`Please try again later.`, + icon: "error", + }); + }, + }); + const teamSubscription = getSubscriptionByPlan(subscriptions, "team"); const proSubscription = getSubscriptionByPlan(subscriptions, "pro"); @@ -109,6 +163,43 @@ export function InviteMemberForm({ inviteMember.mutate(member); }; + const handleInviteLinkToggle = async () => { + setIsLoadingInviteLink(true); + + if (isShareInviteLinkEnabled && workspace.publicId) { + // Deactivate invite link + await deactivateInviteLink.mutateAsync({ + workspacePublicId: workspace.publicId, + }); + setIsShareInviteLinkEnabled(false); + } else { + // Create new invite link + await createInviteLink.mutateAsync({ + workspacePublicId: workspace.publicId, + }); + setIsShareInviteLinkEnabled(true); + } + }; + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(inviteLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + showPopup({ + header: t`Invite link copied`, + message: t`Invite link copied to clipboard`, + icon: "success", + }); + } catch (error) { + showPopup({ + header: t`Error`, + message: t`Failed to copy invite link`, + icon: "error", + }); + } + }; + const handleUpgrade = async () => { const { data, error } = await authClient.subscription.upgrade({ plan: "team", @@ -173,6 +264,34 @@ export function InviteMemberForm({ }} errorMessage={errors.email?.message} /> + {isShareInviteLinkEnabled && inviteLink && ( +
+
+ + +
+
+ +

+ {t`Anyone with this link can join your workspace`} +

+
+
+ )} {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
@@ -205,11 +324,9 @@ export function InviteMemberForm({ {(hasTeamSubscription || hasProSubscription) && env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( - setIsCreateAnotherEnabled(!isCreateAnotherEnabled) - } + label={t`Share invite link`} + isChecked={isShareInviteLinkEnabled} + onChange={handleInviteLinkToggle} /> )}
@@ -226,7 +343,7 @@ export function InviteMemberForm({ ) : (