Add per-document control over who can see viewer insights (#5594)
This commit is contained in:
@@ -69,7 +69,7 @@ function InnerDocumentLink(
|
|||||||
if (isActiveDocument && hasChildDocuments) {
|
if (isActiveDocument && hasChildDocuments) {
|
||||||
void fetchChildDocuments(node.id);
|
void fetchChildDocuments(node.id);
|
||||||
}
|
}
|
||||||
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
|
}, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]);
|
||||||
|
|
||||||
const pathToNode = React.useMemo(
|
const pathToNode = React.useMemo(
|
||||||
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
||||||
|
|||||||
@@ -83,8 +83,11 @@ const Input = styled.label<{ width: number; height: number }>`
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: ${(props) => props.width}px;
|
width: ${(props) => props.width}px;
|
||||||
height: ${(props) => props.height}px;
|
height: ${(props) => props.height}px;
|
||||||
margin-right: 8px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Slider = styled.span<{ width: number; height: number }>`
|
const Slider = styled.span<{ width: number; height: number }>`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
const Text = styled.p<Props>`
|
const Text = styled.p<Props>`
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
text-align: ${(props) => (props.dir ? props.dir : "auto")};
|
text-align: ${(props) => (props.dir ? props.dir : "initial")};
|
||||||
color: ${(props) =>
|
color: ${(props) =>
|
||||||
props.type === "secondary"
|
props.type === "secondary"
|
||||||
? props.theme.textSecondary
|
? props.theme.textSecondary
|
||||||
|
|||||||
@@ -44,44 +44,77 @@ export default class Document extends ParanoidModel {
|
|||||||
|
|
||||||
store: DocumentsStore;
|
store: DocumentsStore;
|
||||||
|
|
||||||
@Field
|
|
||||||
@observable
|
|
||||||
collectionId?: string | null;
|
|
||||||
|
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id of the collection that this document belongs to, if any.
|
||||||
|
*/
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
collectionId?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text content of the document as Markdown.
|
||||||
|
*/
|
||||||
@observable
|
@observable
|
||||||
text: string;
|
text: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title of the document.
|
||||||
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is a template.
|
||||||
|
*/
|
||||||
@observable
|
@observable
|
||||||
template: boolean;
|
template: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the document layout is displayed full page width.
|
||||||
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
fullWidth: boolean;
|
fullWidth: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether team members can see who has viewed this document.
|
||||||
|
*/
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
insightsEnabled: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reference to the template that this document was created from.
|
||||||
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
templateId: string | undefined;
|
templateId: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id of the parent document that this is a child of, if any.
|
||||||
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
parentDocumentId: string | undefined;
|
parentDocumentId: string | undefined;
|
||||||
|
|
||||||
|
@observable
|
||||||
collaboratorIds: string[];
|
collaboratorIds: string[];
|
||||||
|
|
||||||
|
@observable
|
||||||
createdBy: User;
|
createdBy: User;
|
||||||
|
|
||||||
|
@observable
|
||||||
updatedBy: User;
|
updatedBy: User;
|
||||||
|
|
||||||
|
@observable
|
||||||
publishedAt: string | undefined;
|
publishedAt: string | undefined;
|
||||||
|
|
||||||
|
@observable
|
||||||
archivedAt: string;
|
archivedAt: string;
|
||||||
|
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import DocumentViews from "~/components/DocumentViews";
|
|||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import ListItem from "~/components/List/Item";
|
import ListItem from "~/components/List/Item";
|
||||||
import PaginatedList from "~/components/PaginatedList";
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
|
import Switch from "~/components/Switch";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import Time from "~/components/Time";
|
import Time from "~/components/Time";
|
||||||
import useKeyDown from "~/hooks/useKeyDown";
|
import useKeyDown from "~/hooks/useKeyDown";
|
||||||
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useTextSelection from "~/hooks/useTextSelection";
|
import useTextSelection from "~/hooks/useTextSelection";
|
||||||
import { documentPath } from "~/utils/routeHelpers";
|
import { documentPath } from "~/utils/routeHelpers";
|
||||||
@@ -30,6 +32,7 @@ function Insights() {
|
|||||||
const { editor } = useDocumentContext();
|
const { editor } = useDocumentContext();
|
||||||
const text = editor?.getPlainText();
|
const text = editor?.getPlainText();
|
||||||
const stats = useTextStats(text ?? "", selectedText);
|
const stats = useTextStats(text ?? "", selectedText);
|
||||||
|
const can = usePolicy(document);
|
||||||
const documentViews = document ? views.inDocument(document.id) : [];
|
const documentViews = document ? views.inDocument(document.id) : [];
|
||||||
|
|
||||||
const onCloseInsights = () => {
|
const onCloseInsights = () => {
|
||||||
@@ -83,57 +86,86 @@ function Insights() {
|
|||||||
</List>
|
</List>
|
||||||
</Text>
|
</Text>
|
||||||
</Content>
|
</Content>
|
||||||
<Content column>
|
{document.insightsEnabled && (
|
||||||
<Heading>{t("Contributors")}</Heading>
|
<>
|
||||||
<Text type="secondary" size="small">
|
<Content column>
|
||||||
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
|
<Heading>{t("Contributors")}</Heading>
|
||||||
<br />
|
<Text type="secondary" size="small">
|
||||||
{t(`Last updated`)}{" "}
|
{t(`Created`)}{" "}
|
||||||
<Time dateTime={document.updatedAt} addSuffix />.
|
<Time dateTime={document.createdAt} addSuffix />.
|
||||||
</Text>
|
<br />
|
||||||
<ListSpacing>
|
{t(`Last updated`)}{" "}
|
||||||
<PaginatedList
|
<Time dateTime={document.updatedAt} addSuffix />.
|
||||||
aria-label={t("Contributors")}
|
</Text>
|
||||||
items={document.collaborators}
|
<ListSpacing>
|
||||||
renderItem={(model: User) => (
|
<PaginatedList
|
||||||
<ListItem
|
aria-label={t("Contributors")}
|
||||||
key={model.id}
|
items={document.collaborators}
|
||||||
title={model.name}
|
renderItem={(model: User) => (
|
||||||
image={<Avatar model={model} size={32} />}
|
<ListItem
|
||||||
subtitle={
|
key={model.id}
|
||||||
model.id === document.createdBy.id
|
title={model.name}
|
||||||
? t("Creator")
|
image={<Avatar model={model} size={32} />}
|
||||||
: model.id === document.updatedBy.id
|
subtitle={
|
||||||
? t("Last edited")
|
model.id === document.createdBy.id
|
||||||
: t("Previously edited")
|
? t("Creator")
|
||||||
}
|
: model.id === document.updatedBy.id
|
||||||
border={false}
|
? t("Last edited")
|
||||||
small
|
: t("Previously edited")
|
||||||
|
}
|
||||||
|
border={false}
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
</ListSpacing>
|
||||||
|
</Content>
|
||||||
|
<Content column>
|
||||||
|
<Heading>{t("Views")}</Heading>
|
||||||
|
<Text type="secondary" size="small">
|
||||||
|
{documentViews.length <= 1
|
||||||
|
? t("No one else has viewed yet")
|
||||||
|
: t(
|
||||||
|
`Viewed {{ count }} times by {{ teamMembers }} people`,
|
||||||
|
{
|
||||||
|
count: documentViews.reduce(
|
||||||
|
(memo, view) => memo + view.count,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
teamMembers: documentViews.length,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
{documentViews.length > 1 && (
|
||||||
|
<ListSpacing>
|
||||||
|
<DocumentViews document={document} isOpen />
|
||||||
|
</ListSpacing>
|
||||||
)}
|
)}
|
||||||
|
</Content>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{can.updateInsights && (
|
||||||
|
<Manage>
|
||||||
|
<Flex column>
|
||||||
|
<Text size="small" weight="bold">
|
||||||
|
{t("Viewer insights")}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" size="small">
|
||||||
|
{t(
|
||||||
|
"As an admin you can manage if team members can see who has viewed this document"
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Switch
|
||||||
|
checked={document.insightsEnabled}
|
||||||
|
onChange={async (ev) => {
|
||||||
|
document.insightsEnabled = ev.currentTarget.checked;
|
||||||
|
await document.save();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ListSpacing>
|
</Manage>
|
||||||
</Content>
|
)}
|
||||||
<Content column>
|
|
||||||
<Heading>{t("Views")}</Heading>
|
|
||||||
<Text type="secondary" size="small">
|
|
||||||
{documentViews.length <= 1
|
|
||||||
? t("No one else has viewed yet")
|
|
||||||
: t(`Viewed {{ count }} times by {{ teamMembers }} people`, {
|
|
||||||
count: documentViews.reduce(
|
|
||||||
(memo, view) => memo + view.count,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
teamMembers: documentViews.length,
|
|
||||||
})}
|
|
||||||
.
|
|
||||||
</Text>
|
|
||||||
{documentViews.length > 1 && (
|
|
||||||
<ListSpacing>
|
|
||||||
<DocumentViews document={document} isOpen />
|
|
||||||
</ListSpacing>
|
|
||||||
)}
|
|
||||||
</Content>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
@@ -166,6 +198,17 @@ function countWords(text: string): number {
|
|||||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Manage = styled(Flex)`
|
||||||
|
border: 1px solid ${s("inputBorder")};
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 16px;
|
||||||
|
padding: 16px 16px 0;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
const ListSpacing = styled("div")`
|
const ListSpacing = styled("div")`
|
||||||
margin-top: -0.5em;
|
margin-top: -0.5em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type Props = {
|
|||||||
templateId?: string | null;
|
templateId?: string | null;
|
||||||
/** If the document should be displayed full-width on the screen */
|
/** If the document should be displayed full-width on the screen */
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
/** Whether insights should be visible on the document */
|
||||||
|
insightsEnabled?: boolean;
|
||||||
/** Whether the text be appended to the end instead of replace */
|
/** Whether the text be appended to the end instead of replace */
|
||||||
append?: boolean;
|
append?: boolean;
|
||||||
/** Whether the document should be published to the collection */
|
/** Whether the document should be published to the collection */
|
||||||
@@ -46,6 +48,7 @@ export default async function documentUpdater({
|
|||||||
editorVersion,
|
editorVersion,
|
||||||
templateId,
|
templateId,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
|
insightsEnabled,
|
||||||
append,
|
append,
|
||||||
publish,
|
publish,
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -68,6 +71,9 @@ export default async function documentUpdater({
|
|||||||
if (fullWidth !== undefined) {
|
if (fullWidth !== undefined) {
|
||||||
document.fullWidth = fullWidth;
|
document.fullWidth = fullWidth;
|
||||||
}
|
}
|
||||||
|
if (insightsEnabled !== undefined) {
|
||||||
|
document.insightsEnabled = insightsEnabled;
|
||||||
|
}
|
||||||
if (text !== undefined) {
|
if (text !== undefined) {
|
||||||
document = DocumentHelper.applyMarkdownToDocument(document, text, append);
|
document = DocumentHelper.applyMarkdownToDocument(document, text, append);
|
||||||
}
|
}
|
||||||
|
|||||||
15
server/migrations/20230720002422-add-insights-control.js
Normal file
15
server/migrations/20230720002422-add-insights-control.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn("documents", "insightsEnabled", {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeColumn("documents", "insightsEnabled");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -196,6 +196,9 @@ class Document extends ParanoidModel {
|
|||||||
@Column
|
@Column
|
||||||
fullWidth: boolean;
|
fullWidth: boolean;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
insightsEnabled: boolean;
|
||||||
|
|
||||||
@SimpleLength({
|
@SimpleLength({
|
||||||
max: 255,
|
max: 255,
|
||||||
msg: `editorVersion must be 255 characters or less`,
|
msg: `editorVersion must be 255 characters or less`,
|
||||||
|
|||||||
@@ -277,6 +277,20 @@ allow(User, "archive", Document, (user, document) => {
|
|||||||
return user.teamId === document.teamId;
|
return user.teamId === document.teamId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
allow(User, "updateInsights", Document, (user, document) => {
|
||||||
|
if (!document || !document.isActive || document.isDraft) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
invariant(
|
||||||
|
document.collection,
|
||||||
|
"collection is missing, did you forget to include in the query scope?"
|
||||||
|
);
|
||||||
|
if (cannot(user, "update", document.collection)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
allow(User, "unarchive", Document, (user, document) => {
|
allow(User, "unarchive", Document, (user, document) => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ async function presentDocument(
|
|||||||
templateId: document.templateId,
|
templateId: document.templateId,
|
||||||
collaboratorIds: [],
|
collaboratorIds: [],
|
||||||
revision: document.revisionCount,
|
revision: document.revisionCount,
|
||||||
|
insightsEnabled: document.insightsEnabled,
|
||||||
fullWidth: document.fullWidth,
|
fullWidth: document.fullWidth,
|
||||||
collectionId: undefined,
|
collectionId: undefined,
|
||||||
parentDocumentId: undefined,
|
parentDocumentId: undefined,
|
||||||
|
|||||||
@@ -892,18 +892,8 @@ router.post(
|
|||||||
auth(),
|
auth(),
|
||||||
validate(T.DocumentsUpdateSchema),
|
validate(T.DocumentsUpdateSchema),
|
||||||
async (ctx: APIContext<T.DocumentsUpdateReq>) => {
|
async (ctx: APIContext<T.DocumentsUpdateReq>) => {
|
||||||
const {
|
const { id, apiVersion, insightsEnabled, publish, collectionId, ...input } =
|
||||||
id,
|
ctx.input.body;
|
||||||
title,
|
|
||||||
text,
|
|
||||||
fullWidth,
|
|
||||||
publish,
|
|
||||||
templateId,
|
|
||||||
collectionId,
|
|
||||||
append,
|
|
||||||
apiVersion,
|
|
||||||
done,
|
|
||||||
} = ctx.input.body;
|
|
||||||
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
let collection: Collection | null | undefined;
|
let collection: Collection | null | undefined;
|
||||||
@@ -915,6 +905,10 @@ router.post(
|
|||||||
collection = document?.collection;
|
collection = document?.collection;
|
||||||
authorize(user, "update", document);
|
authorize(user, "update", document);
|
||||||
|
|
||||||
|
if (collection && insightsEnabled !== undefined) {
|
||||||
|
authorize(user, "updateInsights", document);
|
||||||
|
}
|
||||||
|
|
||||||
if (publish) {
|
if (publish) {
|
||||||
if (!document.collectionId) {
|
if (!document.collectionId) {
|
||||||
assertPresent(
|
assertPresent(
|
||||||
@@ -932,16 +926,12 @@ router.post(
|
|||||||
await documentUpdater({
|
await documentUpdater({
|
||||||
document,
|
document,
|
||||||
user,
|
user,
|
||||||
title,
|
...input,
|
||||||
text,
|
|
||||||
fullWidth,
|
|
||||||
publish,
|
publish,
|
||||||
collectionId,
|
collectionId,
|
||||||
append,
|
insightsEnabled,
|
||||||
templateId,
|
|
||||||
editorVersion,
|
editorVersion,
|
||||||
transaction,
|
transaction,
|
||||||
done,
|
|
||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
|
|||||||
/** Boolean to denote if the doc should occupy full width */
|
/** Boolean to denote if the doc should occupy full width */
|
||||||
fullWidth: z.boolean().optional(),
|
fullWidth: z.boolean().optional(),
|
||||||
|
|
||||||
|
/** Boolean to denote if insights should be visible on the doc */
|
||||||
|
insightsEnabled: z.boolean().optional(),
|
||||||
|
|
||||||
/** Boolean to denote if the doc should be published */
|
/** Boolean to denote if the doc should be published */
|
||||||
publish: z.boolean().optional(),
|
publish: z.boolean().optional(),
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import { ValidationError } from "@server/errors";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||||
import validate from "@server/middlewares/validate";
|
import validate from "@server/middlewares/validate";
|
||||||
@@ -23,6 +24,11 @@ router.post(
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
authorize(user, "read", document);
|
authorize(user, "read", document);
|
||||||
|
|
||||||
|
if (!document.insightsEnabled) {
|
||||||
|
throw ValidationError("Insights are not enabled for this document");
|
||||||
|
}
|
||||||
|
|
||||||
const views = await View.findByDocument(documentId, { includeSuspended });
|
const views = await View.findByDocument(documentId, { includeSuspended });
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
|||||||
@@ -511,6 +511,8 @@
|
|||||||
"No one else has viewed yet": "No one else has viewed yet",
|
"No one else has viewed yet": "No one else has viewed yet",
|
||||||
"Viewed {{ count }} times by {{ teamMembers }} people": "Viewed {{ count }} time by {{ teamMembers }} people",
|
"Viewed {{ count }} times by {{ teamMembers }} people": "Viewed {{ count }} time by {{ teamMembers }} people",
|
||||||
"Viewed {{ count }} times by {{ teamMembers }} people_plural": "Viewed {{ count }} times by {{ teamMembers }} people",
|
"Viewed {{ count }} times by {{ teamMembers }} people_plural": "Viewed {{ count }} times by {{ teamMembers }} people",
|
||||||
|
"Viewer insights": "Viewer insights",
|
||||||
|
"As an admin you can manage if team members can see who has viewed this document": "As an admin you can manage if team members can see who has viewed this document",
|
||||||
"Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document",
|
"Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document",
|
||||||
"Sorry, this document is too large - edits will no longer be persisted.": "Sorry, this document is too large - edits will no longer be persisted.",
|
"Sorry, this document is too large - edits will no longer be persisted.": "Sorry, this document is too large - edits will no longer be persisted.",
|
||||||
"Sorry, the last change could not be persisted – please reload the page": "Sorry, the last change could not be persisted – please reload the page",
|
"Sorry, the last change could not be persisted – please reload the page": "Sorry, the last change could not be persisted – please reload the page",
|
||||||
|
|||||||
Reference in New Issue
Block a user