mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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 <tom@getoutline.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Email address visibility")}
|
||||
name={TeamPreference.EmailDisplay}
|
||||
description={t(
|
||||
"Controls who can see user email addresses in the workspace"
|
||||
)}
|
||||
>
|
||||
<InputSelect
|
||||
value={team.getPreference(TeamPreference.EmailDisplay) as string}
|
||||
options={emailDisplayOptions}
|
||||
onChange={handleEmailDisplayChange}
|
||||
label={t("Email address visibility")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Collection creation")}
|
||||
name="memberCollectionCreate"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { TeamPreference, EmailDisplay, UserRole } from "@shared/types";
|
||||
import {
|
||||
buildUser,
|
||||
buildTeam,
|
||||
buildAdmin,
|
||||
buildViewer,
|
||||
} from "@server/test/factories";
|
||||
import { serialize } from "./index";
|
||||
|
||||
describe("policies/user", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
-7
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
+2
-1
@@ -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 = {
|
||||
|
||||
@@ -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. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> 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",
|
||||
|
||||
@@ -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<CustomTheme>;
|
||||
[TeamPreference.TocPosition]?: TOCPosition;
|
||||
[TeamPreference.PreventDocumentEmbedding]?: boolean;
|
||||
[TeamPreference.EmailDisplay]?: EmailDisplay;
|
||||
};
|
||||
|
||||
export enum NavigationNodeType {
|
||||
|
||||
Reference in New Issue
Block a user