From f6e25b0d3200ef34db2d74504628967387bad377 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:15:36 -0500 Subject: [PATCH] Add "Email display" preference to control email address visibility (#11103) * Initial plan * Add emailDisplay TeamPreference to control email address visibility Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * Add tests for emailDisplay TeamPreference policy logic Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * tweaks * Add Everyone setting, tests * PR feedback --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Co-authored-by: Tom Moor --- app/scenes/Settings/Security.tsx | 51 ++++- server/policies/user.test.ts | 215 +++++++++++++++++++++ server/policies/user.ts | 30 ++- server/routes/api/teams/schema.ts | 3 +- shared/constants.ts | 3 +- shared/i18n/locales/en_US/translation.json | 4 + shared/types.ts | 9 + 7 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 server/policies/user.test.ts diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 98bf015d32..cad2abd510 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { toast } from "sonner"; -import { TeamPreference } from "@shared/types"; +import { TeamPreference, EmailDisplay } from "@shared/types"; import ConfirmationDialog from "~/components/ConfirmationDialog"; import Heading from "~/components/Heading"; import type { Option } from "~/components/InputSelect"; @@ -50,6 +50,28 @@ function Security() { [t] ); + const emailDisplayOptions: Option[] = React.useMemo( + () => + [ + { + type: "item", + label: t("Members"), + value: EmailDisplay.Members, + }, + { + type: "item", + label: t("Members and guests"), + value: EmailDisplay.Everyone, + }, + { + type: "item", + label: t("No one"), + value: EmailDisplay.None, + }, + ] satisfies Option[], + [t] + ); + const showSuccessMessage = React.useMemo( () => debounce(() => { @@ -146,6 +168,17 @@ function Security() { [saveData, team.preferences] ); + const handleEmailDisplayChange = React.useCallback( + async (emailDisplay: string) => { + const preferences = { + ...team.preferences, + [TeamPreference.EmailDisplay]: emailDisplay, + }; + await saveData({ preferences }); + }, + [saveData, team.preferences] + ); + const handleInviteRequiredChange = React.useCallback( async (checked: boolean) => { const inviteRequired = checked; @@ -307,6 +340,22 @@ function Security() { onChange={handleDocumentEmbedsChange} /> + + + { + describe("readEmail", () => { + it("should allow user to read their own email", async () => { + const team = await buildTeam(); + const user = await buildUser({ + teamId: team.id, + }); + const abilities = serialize(user, user); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should allow admin to read other users' emails", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ + teamId: team.id, + }); + const user = await buildUser({ + teamId: team.id, + }); + const abilities = serialize(admin, user); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should allow members to read other members' emails when emailDisplay is 'members' (default)", async () => { + const team = await buildTeam(); + const user1 = await buildUser({ + teamId: team.id, + }); + const user2 = await buildUser({ + teamId: team.id, + }); + const abilities = serialize(user1, user2); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should allow members to read other members' emails when emailDisplay is explicitly 'members'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.Members); + await team.save(); + + const user1 = await buildUser({ + teamId: team.id, + }); + const user2 = await buildUser({ + teamId: team.id, + }); + + // Reload to get fresh team with preferences + await user1.reload({ include: [{ association: "team" }] }); + + const abilities = serialize(user1, user2); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should NOT allow members to read other members' emails when emailDisplay is 'none'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None); + await team.save(); + + const user1 = await buildUser({ + teamId: team.id, + }); + const user2 = await buildUser({ + teamId: team.id, + }); + + // Reload to get fresh team with preferences + await user1.reload({ include: [{ association: "team" }] }); + + const abilities = serialize(user1, user2); + expect(abilities.readEmail).toBeFalsy(); + }); + + it("should allow user to read their own email even when emailDisplay is 'none'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None); + await team.save(); + + const user = await buildUser({ + teamId: team.id, + }); + + // Reload to get fresh team with preferences + await user.reload({ include: [{ association: "team" }] }); + + const abilities = serialize(user, user); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should allow admin to read other users' emails even when emailDisplay is 'none'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None); + await team.save(); + + const admin = await buildAdmin({ + teamId: team.id, + }); + const user = await buildUser({ + teamId: team.id, + }); + + // Reload to get fresh team with preferences + await admin.reload({ include: [{ association: "team" }] }); + + const abilities = serialize(admin, user); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should allow members to read other members' emails when emailDisplay is 'everyone'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.Everyone); + await team.save(); + + const user1 = await buildUser({ + teamId: team.id, + }); + const user2 = await buildUser({ + teamId: team.id, + }); + + // Reload to get fresh team with preferences + await user1.reload({ include: [{ association: "team" }] }); + + const abilities = serialize(user1, user2); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should NOT allow guest users to read other users' emails when emailDisplay is 'none'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None); + await team.save(); + + const guest = await buildUser({ + teamId: team.id, + role: UserRole.Guest, + }); + const user = await buildUser({ + teamId: team.id, + }); + + const abilities = serialize(guest, user); + expect(abilities.readEmail).toBeFalsy(); + }); + + it("should allow guest users to read their own email", async () => { + const team = await buildTeam(); + const guest = await buildUser({ + teamId: team.id, + role: UserRole.Guest, + }); + + const abilities = serialize(guest, guest); + expect(abilities.readEmail).toBeTruthy(); + }); + + it("should NOT allow viewer users to read other users' emails when emailDisplay is 'members'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.Members); + await team.save(); + + const viewer = await buildViewer({ + teamId: team.id, + }); + const user = await buildUser({ + teamId: team.id, + }); + + const abilities = serialize(viewer, user); + expect(abilities.readEmail).toBeFalsy(); + }); + + it("should NOT allow viewer users to read other users' emails when emailDisplay is 'none'", async () => { + const team = await buildTeam(); + team.setPreference(TeamPreference.EmailDisplay, EmailDisplay.None); + await team.save(); + + const viewer = await buildViewer({ + teamId: team.id, + }); + const user = await buildUser({ + teamId: team.id, + }); + + const abilities = serialize(viewer, user); + expect(abilities.readEmail).toBeFalsy(); + }); + + it("should NOT allow users from different teams to read each other's emails", async () => { + const team1 = await buildTeam(); + const team2 = await buildTeam(); + + const user1 = await buildUser({ + teamId: team1.id, + }); + const user2 = await buildUser({ + teamId: team2.id, + }); + + // Reload to ensure team is loaded + await user1.reload({ include: [{ association: "team" }] }); + + const abilities = serialize(user1, user2); + expect(abilities.readEmail).toBeFalsy(); + }); + }); +}); diff --git a/server/policies/user.ts b/server/policies/user.ts index 9ca2e2c1f8..1705079455 100644 --- a/server/policies/user.ts +++ b/server/policies/user.ts @@ -1,4 +1,4 @@ -import { TeamPreference } from "@shared/types"; +import { TeamPreference, EmailDisplay } from "@shared/types"; import { User, Team } from "@server/models"; import { allow } from "./cancan"; import { @@ -38,14 +38,30 @@ allow(User, ["update", "readDetails", "listApiKeys"], User, (actor, user) => ) ); -allow(User, "readEmail", User, (actor, user) => - or( +allow(User, "readEmail", User, (actor, user) => { + const emailDisplay = + actor.team?.getPreference(TeamPreference.EmailDisplay) ?? + EmailDisplay.Members; + + if (emailDisplay === EmailDisplay.None) { + return or(isTeamAdmin(actor, user), actor.id === user?.id); + } + + if (emailDisplay === EmailDisplay.Members) { + return or( + isTeamAdmin(actor, user), + isTeamMember(actor, user), + actor.id === user?.id + ); + } + + // EmailDisplay.Everyone + return or( // - isTeamAdmin(actor, user), - isTeamMember(actor, user), + isTeamModel(actor, user), actor.id === user?.id - ) -); + ); +}); allow(User, "delete", User, (actor, user) => or( diff --git a/server/routes/api/teams/schema.ts b/server/routes/api/teams/schema.ts index 63e6803b37..3e7e58996b 100644 --- a/server/routes/api/teams/schema.ts +++ b/server/routes/api/teams/schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { TOCPosition, UserRole } from "@shared/types"; +import { EmailDisplay, TOCPosition, UserRole } from "@shared/types"; import { BaseSchema } from "@server/routes/api/schema"; export const TeamsUpdateSchema = BaseSchema.extend({ @@ -60,6 +60,7 @@ export const TeamsUpdateSchema = BaseSchema.extend({ .optional(), /** Side to display the document's table of contents in relation to the main content. */ tocPosition: z.nativeEnum(TOCPosition).optional(), + emailDisplay: z.nativeEnum(EmailDisplay).optional(), /** Whether to prevent shared documents from being embedded in iframes on external websites. */ preventDocumentEmbedding: z.boolean().optional(), }) diff --git a/shared/constants.ts b/shared/constants.ts index 9ff928d324..96bbbb8977 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -1,5 +1,5 @@ import type { TeamPreferences, UserPreferences } from "./types"; -import { TOCPosition, TeamPreference, UserPreference } from "./types"; +import { TOCPosition, TeamPreference, UserPreference, EmailDisplay } from "./types"; export const MAX_AVATAR_DISPLAY = 6; @@ -28,6 +28,7 @@ export const TeamPreferenceDefaults: TeamPreferences = { [TeamPreference.CustomTheme]: undefined, [TeamPreference.TocPosition]: TOCPosition.Left, [TeamPreference.PreventDocumentEmbedding]: false, + [TeamPreference.EmailDisplay]: EmailDisplay.Members, }; export const UserPreferenceDefaults: UserPreferences = { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 9bb9df9dcf..e51a1d19b1 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -1232,6 +1232,8 @@ "Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.", "This could be your real name, or a nickname — however you’d like people to refer to you.": "This could be your real name, or a nickname — however you’d like people to refer to you.", "Email address": "Email address", + "Members and guests": "Members and guests", + "No one": "No one", "Are you sure you want to require invites?": "Are you sure you want to require invites?", "New users will first need to be invited to create an account. Default role and Allowed domains will no longer apply.": "New users will first need to be invited to create an account. Default role and Allowed domains will no longer apply.", "Settings that impact the access, security, and content of your workspace.": "Settings that impact the access, security, and content of your workspace.", @@ -1250,6 +1252,8 @@ "When enabled, users can delete their own account from the workspace": "When enabled, users can delete their own account from the workspace", "Rich service embeds": "Rich service embeds", "Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents", + "Email address visibility": "Email address visibility", + "Controls who can see user email addresses in the workspace": "Controls who can see user email addresses in the workspace", "Collection creation": "Collection creation", "Allow editors to create new collections within the workspace": "Allow editors to create new collections within the workspace", "Workspace creation": "Workspace creation", diff --git a/shared/types.ts b/shared/types.ts index 0cdb7fb005..b9cfbf28cb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -287,6 +287,12 @@ export enum TOCPosition { Right = "right", } +export enum EmailDisplay { + None = "none", + Members = "members", + Everyone = "everyone", +} + export enum TeamPreference { /** Whether documents have a separate edit mode instead of always editing. */ SeamlessEdit = "seamlessEdit", @@ -310,6 +316,8 @@ export enum TeamPreference { TocPosition = "tocPosition", /** Whether to prevent shared documents from being embedded in iframes on external websites. */ PreventDocumentEmbedding = "preventDocumentEmbedding", + /** Who can see user email addresses. */ + EmailDisplay = "emailDisplay", } export type TeamPreferences = { @@ -324,6 +332,7 @@ export type TeamPreferences = { [TeamPreference.CustomTheme]?: Partial; [TeamPreference.TocPosition]?: TOCPosition; [TeamPreference.PreventDocumentEmbedding]?: boolean; + [TeamPreference.EmailDisplay]?: EmailDisplay; }; export enum NavigationNodeType {