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:
@@ -54,6 +54,7 @@ export default function MembersPage() {
|
|||||||
memberStatus,
|
memberStatus,
|
||||||
isLastRow,
|
isLastRow,
|
||||||
showSkeleton,
|
showSkeleton,
|
||||||
|
showPendingIcon,
|
||||||
}: {
|
}: {
|
||||||
memberPublicId?: string;
|
memberPublicId?: string;
|
||||||
memberId?: string | null | undefined;
|
memberId?: string | null | undefined;
|
||||||
@@ -64,6 +65,7 @@ export default function MembersPage() {
|
|||||||
memberStatus?: string;
|
memberStatus?: string;
|
||||||
isLastRow?: boolean;
|
isLastRow?: boolean;
|
||||||
showSkeleton?: boolean;
|
showSkeleton?: boolean;
|
||||||
|
showPendingIcon?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<tr className="rounded-b-lg">
|
<tr className="rounded-b-lg">
|
||||||
@@ -82,6 +84,7 @@ export default function MembersPage() {
|
|||||||
name={memberName ?? ""}
|
name={memberName ?? ""}
|
||||||
email={memberEmail ?? ""}
|
email={memberEmail ?? ""}
|
||||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||||
|
icon={showPendingIcon ? "?" : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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",
|
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||||
showSkeleton &&
|
showSkeleton &&
|
||||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
"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}
|
{memberName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
{((workspace.role === "admin" ||
|
||||||
className={twMerge(
|
data?.showEmailsToMembers === true) ||
|
||||||
"truncate text-xs text-dark-900 sm:text-sm",
|
showSkeleton) && (
|
||||||
showSkeleton &&
|
<p
|
||||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
className={twMerge(
|
||||||
)}
|
"truncate text-xs text-dark-900 sm:text-sm",
|
||||||
>
|
showSkeleton &&
|
||||||
{memberEmail}
|
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||||
</p>
|
)}
|
||||||
|
>
|
||||||
|
{memberEmail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,19 +255,24 @@ export default function MembersPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
data?.members.map((member, index) => (
|
data?.members.map((member, index) => {
|
||||||
<TableRow
|
const isPendingInvite = member.status === "invited";
|
||||||
key={member.publicId}
|
|
||||||
memberPublicId={member.publicId}
|
return (
|
||||||
memberId={member.user?.id}
|
<TableRow
|
||||||
memberName={member.user?.name}
|
key={member.publicId}
|
||||||
memberEmail={member.user?.email ?? member.email}
|
memberPublicId={member.publicId}
|
||||||
memberImage={member.user?.image}
|
memberId={member.user?.id}
|
||||||
memberRole={member.role}
|
memberName={member.user?.name}
|
||||||
memberStatus={member.status}
|
memberEmail={member.user?.email ?? member.email}
|
||||||
isLastRow={index === data.members.length - 1}
|
memberImage={member.user?.image}
|
||||||
/>
|
memberRole={member.role}
|
||||||
))}
|
memberStatus={member.status}
|
||||||
|
isLastRow={index === data.members.length - 1}
|
||||||
|
showPendingIcon={isPendingInvite}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useWorkspace } from "~/providers/workspace";
|
|||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||||
|
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||||
@@ -79,6 +80,14 @@ export default function WorkspaceSettings() {
|
|||||||
workspaceDescription={workspace.description ?? ""}
|
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" &&
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
!hasActiveSubscription(subscriptions, "pro") &&
|
!hasActiveSubscription(subscriptions, "pro") &&
|
||||||
!hasActiveSubscription(subscriptions, "team") && (
|
!hasActiveSubscription(subscriptions, "team") && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,6 +77,49 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, result.id);
|
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;
|
return result;
|
||||||
}),
|
}),
|
||||||
bySlug: publicProcedure
|
bySlug: publicProcedure
|
||||||
@@ -230,6 +273,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
description: z.string().min(3).max(280).optional(),
|
description: z.string().min(3).max(280).optional(),
|
||||||
|
showEmailsToMembers: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
||||||
@@ -291,9 +335,16 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
showEmailsToMembers: input.showEmailsToMembers,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `Unable to delete workspace`,
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
@@ -336,12 +387,6 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
input.workspacePublicId,
|
input.workspacePublicId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result)
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `Unable to delete workspace`,
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
checkSlugAvailability: publicProcedure
|
checkSlugAvailability: publicProcedure
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
||||||
@@ -155,6 +155,13 @@
|
|||||||
"when": 1767045713686,
|
"when": 1767045713686,
|
||||||
"tag": "20251229220153_UpdateCardTitleFromVarcharToText",
|
"tag": "20251229220153_UpdateCardTitleFromVarcharToText",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1768858977000,
|
||||||
|
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@ export const update = async (
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
plan?: "free" | "pro" | "enterprise";
|
plan?: "free" | "pro" | "enterprise";
|
||||||
description?: string;
|
description?: string;
|
||||||
|
showEmailsToMembers?: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
@@ -91,6 +92,7 @@ export const update = async (
|
|||||||
slug: workspaceInput.slug,
|
slug: workspaceInput.slug,
|
||||||
plan: workspaceInput.plan,
|
plan: workspaceInput.plan,
|
||||||
description: workspaceInput.description,
|
description: workspaceInput.description,
|
||||||
|
showEmailsToMembers: workspaceInput.showEmailsToMembers,
|
||||||
})
|
})
|
||||||
.where(eq(workspaces.publicId, workspacePublicId))
|
.where(eq(workspaces.publicId, workspacePublicId))
|
||||||
.returning({
|
.returning({
|
||||||
@@ -100,6 +102,7 @@ export const update = async (
|
|||||||
slug: workspaces.slug,
|
slug: workspaces.slug,
|
||||||
description: workspaces.description,
|
description: workspaces.description,
|
||||||
plan: workspaces.plan,
|
plan: workspaces.plan,
|
||||||
|
showEmailsToMembers: workspaces.showEmailsToMembers,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -139,6 +142,7 @@ export const getByPublicIdWithMembers = (
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
publicId: true,
|
publicId: true,
|
||||||
|
showEmailsToMembers: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
members: {
|
members: {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const workspaces = pgTable("workspace", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||||
|
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
|
||||||
createdBy: uuid("createdBy").references(() => users.id, {
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user