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) {
|
||||
void fetchChildDocuments(node.id);
|
||||
}
|
||||
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
|
||||
}, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]);
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
||||
|
||||
@@ -83,8 +83,11 @@ const Input = styled.label<{ width: number; height: number }>`
|
||||
display: inline-block;
|
||||
width: ${(props) => props.width}px;
|
||||
height: ${(props) => props.height}px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Slider = styled.span<{ width: number; height: number }>`
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
*/
|
||||
const Text = styled.p<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "auto")};
|
||||
text-align: ${(props) => (props.dir ? props.dir : "initial")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
|
||||
@@ -44,44 +44,77 @@ export default class Document extends ParanoidModel {
|
||||
|
||||
store: DocumentsStore;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
collectionId?: string | null;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
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
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* The title of the document.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Whether this is a template.
|
||||
*/
|
||||
@observable
|
||||
template: boolean;
|
||||
|
||||
/**
|
||||
* Whether the document layout is displayed full page width.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
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
|
||||
@observable
|
||||
templateId: string | undefined;
|
||||
|
||||
/**
|
||||
* The id of the parent document that this is a child of, if any.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
parentDocumentId: string | undefined;
|
||||
|
||||
@observable
|
||||
collaboratorIds: string[];
|
||||
|
||||
@observable
|
||||
createdBy: User;
|
||||
|
||||
@observable
|
||||
updatedBy: User;
|
||||
|
||||
@observable
|
||||
publishedAt: string | undefined;
|
||||
|
||||
@observable
|
||||
archivedAt: string;
|
||||
|
||||
url: string;
|
||||
|
||||
@@ -12,9 +12,11 @@ import DocumentViews from "~/components/DocumentViews";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -30,6 +32,7 @@ function Insights() {
|
||||
const { editor } = useDocumentContext();
|
||||
const text = editor?.getPlainText();
|
||||
const stats = useTextStats(text ?? "", selectedText);
|
||||
const can = usePolicy(document);
|
||||
const documentViews = document ? views.inDocument(document.id) : [];
|
||||
|
||||
const onCloseInsights = () => {
|
||||
@@ -83,57 +86,86 @@ function Insights() {
|
||||
</List>
|
||||
</Text>
|
||||
</Content>
|
||||
<Content column>
|
||||
<Heading>{t("Contributors")}</Heading>
|
||||
<Text type="secondary" size="small">
|
||||
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
|
||||
<br />
|
||||
{t(`Last updated`)}{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix />.
|
||||
</Text>
|
||||
<ListSpacing>
|
||||
<PaginatedList
|
||||
aria-label={t("Contributors")}
|
||||
items={document.collaborators}
|
||||
renderItem={(model: User) => (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
image={<Avatar model={model} size={32} />}
|
||||
subtitle={
|
||||
model.id === document.createdBy.id
|
||||
? t("Creator")
|
||||
: model.id === document.updatedBy.id
|
||||
? t("Last edited")
|
||||
: t("Previously edited")
|
||||
}
|
||||
border={false}
|
||||
small
|
||||
{document.insightsEnabled && (
|
||||
<>
|
||||
<Content column>
|
||||
<Heading>{t("Contributors")}</Heading>
|
||||
<Text type="secondary" size="small">
|
||||
{t(`Created`)}{" "}
|
||||
<Time dateTime={document.createdAt} addSuffix />.
|
||||
<br />
|
||||
{t(`Last updated`)}{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix />.
|
||||
</Text>
|
||||
<ListSpacing>
|
||||
<PaginatedList
|
||||
aria-label={t("Contributors")}
|
||||
items={document.collaborators}
|
||||
renderItem={(model: User) => (
|
||||
<ListItem
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
image={<Avatar model={model} size={32} />}
|
||||
subtitle={
|
||||
model.id === document.createdBy.id
|
||||
? t("Creator")
|
||||
: model.id === document.updatedBy.id
|
||||
? t("Last edited")
|
||||
: 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>
|
||||
</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>
|
||||
</Manage>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
@@ -166,6 +198,17 @@ function countWords(text: string): number {
|
||||
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")`
|
||||
margin-top: -0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -19,6 +19,8 @@ type Props = {
|
||||
templateId?: string | null;
|
||||
/** If the document should be displayed full-width on the screen */
|
||||
fullWidth?: boolean;
|
||||
/** Whether insights should be visible on the document */
|
||||
insightsEnabled?: boolean;
|
||||
/** Whether the text be appended to the end instead of replace */
|
||||
append?: boolean;
|
||||
/** Whether the document should be published to the collection */
|
||||
@@ -46,6 +48,7 @@ export default async function documentUpdater({
|
||||
editorVersion,
|
||||
templateId,
|
||||
fullWidth,
|
||||
insightsEnabled,
|
||||
append,
|
||||
publish,
|
||||
collectionId,
|
||||
@@ -68,6 +71,9 @@ export default async function documentUpdater({
|
||||
if (fullWidth !== undefined) {
|
||||
document.fullWidth = fullWidth;
|
||||
}
|
||||
if (insightsEnabled !== undefined) {
|
||||
document.insightsEnabled = insightsEnabled;
|
||||
}
|
||||
if (text !== undefined) {
|
||||
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
|
||||
fullWidth: boolean;
|
||||
|
||||
@Column
|
||||
insightsEnabled: boolean;
|
||||
|
||||
@SimpleLength({
|
||||
max: 255,
|
||||
msg: `editorVersion must be 255 characters or less`,
|
||||
|
||||
@@ -277,6 +277,20 @@ allow(User, "archive", Document, (user, document) => {
|
||||
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) => {
|
||||
if (!document) {
|
||||
return false;
|
||||
|
||||
@@ -41,6 +41,7 @@ async function presentDocument(
|
||||
templateId: document.templateId,
|
||||
collaboratorIds: [],
|
||||
revision: document.revisionCount,
|
||||
insightsEnabled: document.insightsEnabled,
|
||||
fullWidth: document.fullWidth,
|
||||
collectionId: undefined,
|
||||
parentDocumentId: undefined,
|
||||
|
||||
@@ -892,18 +892,8 @@ router.post(
|
||||
auth(),
|
||||
validate(T.DocumentsUpdateSchema),
|
||||
async (ctx: APIContext<T.DocumentsUpdateReq>) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
text,
|
||||
fullWidth,
|
||||
publish,
|
||||
templateId,
|
||||
collectionId,
|
||||
append,
|
||||
apiVersion,
|
||||
done,
|
||||
} = ctx.input.body;
|
||||
const { id, apiVersion, insightsEnabled, publish, collectionId, ...input } =
|
||||
ctx.input.body;
|
||||
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
|
||||
const { user } = ctx.state.auth;
|
||||
let collection: Collection | null | undefined;
|
||||
@@ -915,6 +905,10 @@ router.post(
|
||||
collection = document?.collection;
|
||||
authorize(user, "update", document);
|
||||
|
||||
if (collection && insightsEnabled !== undefined) {
|
||||
authorize(user, "updateInsights", document);
|
||||
}
|
||||
|
||||
if (publish) {
|
||||
if (!document.collectionId) {
|
||||
assertPresent(
|
||||
@@ -932,16 +926,12 @@ router.post(
|
||||
await documentUpdater({
|
||||
document,
|
||||
user,
|
||||
title,
|
||||
text,
|
||||
fullWidth,
|
||||
...input,
|
||||
publish,
|
||||
collectionId,
|
||||
append,
|
||||
templateId,
|
||||
insightsEnabled,
|
||||
editorVersion,
|
||||
transaction,
|
||||
done,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
|
||||
@@ -189,6 +189,9 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
|
||||
/** Boolean to denote if the doc should occupy full width */
|
||||
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 */
|
||||
publish: z.boolean().optional(),
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Router from "koa-router";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import validate from "@server/middlewares/validate";
|
||||
@@ -23,6 +24,11 @@ router.post(
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
if (!document.insightsEnabled) {
|
||||
throw ValidationError("Insights are not enabled for this document");
|
||||
}
|
||||
|
||||
const views = await View.findByDocument(documentId, { includeSuspended });
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -511,6 +511,8 @@
|
||||
"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_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, 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",
|
||||
|
||||
Reference in New Issue
Block a user