Hide member emails from non-admins (#327)

* feat(workspace): sanitize member emails for non-admin users in workspace router

* feat(workspace): add email visibility setting and update member display logic

* fix(workspace): update default value for showEmailsToMembers column to true

* - Removed unnecessary type assertions
- Added anonymous name handling (anonymous_[publicId]) in the API
- Removed placeholder logic
- Reverted null check
- Avoided as unknown cast
This commit is contained in:
Charity
2026-01-24 16:41:59 -05:00
committed by GitHub
parent ff7944a4a2
commit 03bd2d771b
8 changed files with 162 additions and 28 deletions

View File

@@ -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 (
<tr className="rounded-b-lg">
@@ -82,6 +84,7 @@ export default function MembersPage() {
name={memberName ?? ""}
email={memberEmail ?? ""}
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
icon={showPendingIcon ? "?" : undefined}
/>
)}
</div>
@@ -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}
</p>
</div>
<p
className={twMerge(
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberEmail}
</p>
{((workspace.role === "admin" ||
data?.showEmailsToMembers === true) ||
showSkeleton) && (
<p
className={twMerge(
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberEmail}
</p>
)}
</div>
</div>
</div>
@@ -246,19 +255,24 @@ export default function MembersPage() {
</thead>
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading &&
data?.members.map((member, index) => (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}
memberRole={member.role}
memberStatus={member.status}
isLastRow={index === data.members.length - 1}
/>
))}
data?.members.map((member, index) => {
const isPendingInvite = member.status === "invited";
return (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}
memberRole={member.role}
memberStatus={member.status}
isLastRow={index === data.members.length - 1}
showPendingIcon={isPendingInvite}
/>
);
})}
{isLoading && (
<>

View File

@@ -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 ?? ""}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Email visibility`}
</h2>
<UpdateWorkspaceEmailVisibilityForm
workspacePublicId={workspace.publicId}
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
/>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasActiveSubscription(subscriptions, "team") && (

View File

@@ -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 (
<div className="mb-8 flex items-center justify-between">
<div className="flex-1">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`Allow workspace members to see each other's email addresses`}
</p>
</div>
<Toggle
isChecked={isChecked}
onChange={handleToggle}
label=""
disabled={updateWorkspace.isPending}
/>
</div>
);
}

View File

@@ -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<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
}
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<Awaited<ReturnType<typeof workspaceRepo.update>>>())
@@ -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

View File

@@ -0,0 +1 @@
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;

View File

@@ -155,6 +155,13 @@
"when": 1767045713686,
"tag": "20251229220153_UpdateCardTitleFromVarcharToText",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1768858977000,
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
"breakpoints": true
}
]
}

View File

@@ -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: {

View File

@@ -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",
}),