diff --git a/apps/web/src/views/members/index.tsx b/apps/web/src/views/members/index.tsx index da1dc000..a31e62d7 100644 --- a/apps/web/src/views/members/index.tsx +++ b/apps/web/src/views/members/index.tsx @@ -54,6 +54,7 @@ export default function MembersPage() { memberStatus, isLastRow, showSkeleton, + showPendingIcon, }: { memberPublicId?: string; memberId?: string | null | undefined; @@ -64,6 +65,7 @@ export default function MembersPage() { memberStatus?: string; isLastRow?: boolean; showSkeleton?: boolean; + showPendingIcon?: boolean; }) => { return ( @@ -82,6 +84,7 @@ export default function MembersPage() { name={memberName ?? ""} email={memberEmail ?? ""} imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined} + icon={showPendingIcon ? "?" : undefined} /> )} @@ -93,20 +96,26 @@ export default function MembersPage() { "mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm", showSkeleton && "md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200", + showPendingIcon && + "italic text-neutral-500 dark:text-dark-900", )} > {memberName}

-

- {memberEmail} -

+ {((workspace.role === "admin" || + data?.showEmailsToMembers === true) || + showSkeleton) && ( +

+ {memberEmail} +

+ )} @@ -246,19 +255,24 @@ export default function MembersPage() { {!isLoading && - data?.members.map((member, index) => ( - - ))} + data?.members.map((member, index) => { + const isPendingInvite = member.status === "invited"; + + return ( + + ); + })} {isLoading && ( <> diff --git a/apps/web/src/views/settings/WorkspaceSettings.tsx b/apps/web/src/views/settings/WorkspaceSettings.tsx index 1d206e07..8c20c155 100644 --- a/apps/web/src/views/settings/WorkspaceSettings.tsx +++ b/apps/web/src/views/settings/WorkspaceSettings.tsx @@ -17,6 +17,7 @@ import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation"; import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm"; +import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm"; import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm"; import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm"; import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation"; @@ -79,6 +80,14 @@ export default function WorkspaceSettings() { workspaceDescription={workspace.description ?? ""} /> +

+ {t`Email visibility`} +

+ + {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !hasActiveSubscription(subscriptions, "pro") && !hasActiveSubscription(subscriptions, "team") && ( diff --git a/apps/web/src/views/settings/components/UpdateWorkspaceEmailVisibilityForm.tsx b/apps/web/src/views/settings/components/UpdateWorkspaceEmailVisibilityForm.tsx new file mode 100644 index 00000000..c8c5ccae --- /dev/null +++ b/apps/web/src/views/settings/components/UpdateWorkspaceEmailVisibilityForm.tsx @@ -0,0 +1,53 @@ +import { t } from "@lingui/core/macro"; +import { useEffect, useState } from "react"; + +import Toggle from "~/components/Toggle"; +import { api } from "~/utils/api"; + +export default function UpdateWorkspaceEmailVisibilityForm({ + workspacePublicId, + showEmailsToMembers, +}: { + workspacePublicId: string; + showEmailsToMembers: boolean; +}) { + const utils = api.useUtils(); + const [isChecked, setIsChecked] = useState(showEmailsToMembers); + + useEffect(() => { + setIsChecked(showEmailsToMembers); + }, [showEmailsToMembers]); + + const updateWorkspace = api.workspace.update.useMutation({ + onSuccess: () => { + void utils.workspace.byId.invalidate({ + workspacePublicId, + }); + }, + }); + + const handleToggle = () => { + const newValue = !isChecked; + setIsChecked(newValue); + updateWorkspace.mutate({ + workspacePublicId, + showEmailsToMembers: newValue, + }); + }; + + return ( +
+
+

+ {t`Allow workspace members to see each other's email addresses`} +

+
+ +
+ ); +} diff --git a/packages/api/src/routers/workspace.ts b/packages/api/src/routers/workspace.ts index f59c21d2..26060032 100644 --- a/packages/api/src/routers/workspace.ts +++ b/packages/api/src/routers/workspace.ts @@ -77,6 +77,49 @@ export const workspaceRouter = createTRPCRouter({ await assertUserInWorkspace(ctx.db, userId, result.id); + // Check if user is an admin + const userMember = result.members.find( + (member) => member.user?.id === userId, + ); + const isAdmin = userMember?.role === "admin"; + + // Show emails if user is admin OR workspace setting allows it + const shouldShowEmails = isAdmin || result.showEmailsToMembers === true; + + // If emails should be hidden, filter them out + if (!shouldShowEmails) { + const sanitizedMembers = result.members.map((member) => { + // If user doesn't have a display name, use anonymous identifier + const displayName = + member.user?.name?.trim() ?? `anonymous_${member.publicId}`; + + const { email: _memberEmail, ...memberWithoutEmail } = member; + const sanitizedUser = member.user + ? (() => { + const { email: _userEmail, ...userWithoutEmail } = member.user; + return { + ...userWithoutEmail, + name: displayName, + }; + })() + : { + id: null, + name: displayName, + image: null, + }; + + return { + ...memberWithoutEmail, + user: sanitizedUser, + }; + }); + + return { + ...result, + members: sanitizedMembers, + } as Awaited>; + } + return result; }), bySlug: publicProcedure @@ -230,6 +273,7 @@ export const workspaceRouter = createTRPCRouter({ .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .optional(), description: z.string().min(3).max(280).optional(), + showEmailsToMembers: z.boolean().optional(), }), ) .output(z.custom>>()) @@ -291,9 +335,16 @@ export const workspaceRouter = createTRPCRouter({ name: input.name, slug: input.slug, description: input.description, + showEmailsToMembers: input.showEmailsToMembers, }, ); + if (!result) + throw new TRPCError({ + message: `Unable to delete workspace`, + code: "INTERNAL_SERVER_ERROR", + }); + return result; }), delete: protectedProcedure @@ -336,12 +387,6 @@ export const workspaceRouter = createTRPCRouter({ input.workspacePublicId, ); - if (!result) - throw new TRPCError({ - message: `Unable to delete workspace`, - code: "INTERNAL_SERVER_ERROR", - }); - return result; }), checkSlugAvailability: publicProcedure diff --git a/packages/db/migrations/20260119164257_AddShowEmailsToMembersToWorkspace.sql b/packages/db/migrations/20260119164257_AddShowEmailsToMembersToWorkspace.sql new file mode 100644 index 00000000..ac2f56e5 --- /dev/null +++ b/packages/db/migrations/20260119164257_AddShowEmailsToMembersToWorkspace.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 79b06df3..87007a4a 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1767045713686, "tag": "20251229220153_UpdateCardTitleFromVarcharToText", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1768858977000, + "tag": "20260119164257_AddShowEmailsToMembersToWorkspace", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index 3afeef54..1ebd531e 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -82,6 +82,7 @@ export const update = async ( slug?: string; plan?: "free" | "pro" | "enterprise"; description?: string; + showEmailsToMembers?: boolean; }, ) => { const [result] = await db @@ -91,6 +92,7 @@ export const update = async ( slug: workspaceInput.slug, plan: workspaceInput.plan, description: workspaceInput.description, + showEmailsToMembers: workspaceInput.showEmailsToMembers, }) .where(eq(workspaces.publicId, workspacePublicId)) .returning({ @@ -100,6 +102,7 @@ export const update = async ( slug: workspaces.slug, description: workspaces.description, plan: workspaces.plan, + showEmailsToMembers: workspaces.showEmailsToMembers, }); return result; @@ -139,6 +142,7 @@ export const getByPublicIdWithMembers = ( columns: { id: true, publicId: true, + showEmailsToMembers: true, }, with: { members: { diff --git a/packages/db/src/schema/workspaces.ts b/packages/db/src/schema/workspaces.ts index ae86d141..4fccc8b1 100644 --- a/packages/db/src/schema/workspaces.ts +++ b/packages/db/src/schema/workspaces.ts @@ -43,6 +43,7 @@ export const workspaces = pgTable("workspace", { description: text("description"), slug: varchar("slug", { length: 255 }).notNull().unique(), plan: workspacePlanEnum("plan").notNull().default("free"), + showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true), createdBy: uuid("createdBy").references(() => users.id, { onDelete: "set null", }),