feat: Record share link last accessed time (#2047)

* chore: Migrations

* chore: Add recording of share link views

* feat: Add display of share link accessed date in admin

* translations

* test

* translations, admin pagination
This commit is contained in:
Tom Moor
2021-04-18 09:38:13 -07:00
committed by GitHub
parent e9f083feb8
commit 7f9cba9819
11 changed files with 80 additions and 36 deletions

View File

@@ -9,6 +9,7 @@ class Share extends BaseModel {
documentId: string;
documentTitle: string;
documentUrl: string;
lastAccessedAt: ?string;
createdBy: User;
createdAt: string;
updatedAt: string;

View File

@@ -1,12 +1,13 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import HelpText from "components/HelpText";
import List from "components/List";
import PageTitle from "components/PageTitle";
import PaginatedList from "components/PaginatedList";
import Subheading from "components/Subheading";
import ShareListItem from "./components/ShareListItem";
import useCurrentTeam from "hooks/useCurrentTeam";
@@ -14,43 +15,40 @@ import useStores from "hooks/useStores";
function Shares() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { shares, auth, policies } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const hasSharedDocuments = shares.orderedData.length > 0;
const can = policies.abilities(team.id);
React.useEffect(() => {
shares.fetchPage({ limit: 100 });
}, [shares]);
return (
<CenteredContent>
<PageTitle title="Share Links" />
<h1>Share Links</h1>
<PageTitle title={t("Share Links")} />
<h1>{t("Share Links")}</h1>
<HelpText>
Documents that have been shared are listed below. Anyone that has the
public link can access a read-only version of the document until the
link has been revoked.
<Trans>
Documents that have been shared are listed below. Anyone that has the
public link can access a read-only version of the document until the
link has been revoked.
</Trans>
</HelpText>
{can.manage && (
<HelpText>
{!canShareDocuments && (
<strong>Sharing is currently disabled.</strong>
<strong>{t("Sharing is currently disabled.")}</strong>
)}{" "}
You can turn {canShareDocuments ? "off" : "on"} public document
sharing in <Link to="/settings/security">security settings</Link>.
<Trans
defaults="You can globally enable and disable public document sharing in the <em>security settings</em>."
components={{ em: <Link to="/settings/security" /> }}
/>
</HelpText>
)}
<Subheading>Shared Documents</Subheading>
{hasSharedDocuments ? (
<List>
{shares.published.map((share) => (
<ShareListItem key={share.id} share={share} />
))}
</List>
) : (
<Empty>No share links, yet.</Empty>
)}
<Subheading>{t("Shared documents")}</Subheading>
<PaginatedList
items={shares.published}
empty={<Empty>{t("No share links, yet.")}</Empty>}
fetch={shares.fetchPage}
renderItem={(item) => <ShareListItem key={item.id} share={item} />}
/>
</CenteredContent>
);
}

View File

@@ -1,23 +1,33 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import Share from "models/Share";
import ListItem from "components/List/Item";
import Time from "components/Time";
import ShareMenu from "menus/ShareMenu";
type Props = {
type Props = {|
share: Share,
};
|};
const ShareListItem = ({ share }: Props) => {
const { t } = useTranslation();
const { lastAccessedAt } = share;
return (
<ListItem
key={share.id}
title={share.documentTitle}
subtitle={
<>
Shared <Time dateTime={share.createdAt} /> ago by{" "}
{share.createdBy.name}
{t("Shared")} <Time dateTime={share.createdAt} addSuffix />{" "}
{t("by {{ name }}", { name: share.createdBy.name })}{" "}
{lastAccessedAt && (
<>
{" "}
&middot; {t("Last accessed")}{" "}
<Time dateTime={lastAccessedAt} addSuffix />
</>
)}
</>
}
actions={<ShareMenu share={share} />}

View File

@@ -518,6 +518,8 @@ async function loadDocument({ id, shareId, user }) {
if (!team.sharing) {
throw new AuthorizationError();
}
await share.update({ lastAccessedAt: new Date() });
} else {
document = await Document.findByPk(id, {
userId: user ? user.id : undefined,

View File

@@ -93,6 +93,9 @@ describe("#documents.info", () => {
expect(body.data.id).toEqual(document.id);
expect(body.data.createdBy).toEqual(undefined);
expect(body.data.updatedBy).toEqual(undefined);
await share.reload();
expect(share.lastAccessedAt).toBeTruthy();
});
it("should not return document from shareId if sharing is disabled for team", async () => {

View File

@@ -36,7 +36,7 @@ router.post("shares.info", auth(), async (ctx) => {
authorize(user, "read", share);
ctx.body = {
data: presentShare(share),
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
};
});
@@ -89,7 +89,7 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
ctx.body = {
pagination: ctx.state.pagination,
data: shares.map(presentShare),
data: shares.map((share) => presentShare(share, user.isAdmin)),
policies: presentPolicies(user, shares),
};
});
@@ -117,7 +117,7 @@ router.post("shares.update", auth(), async (ctx) => {
});
ctx.body = {
data: presentShare(share),
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
};
});

View File

@@ -0,0 +1,14 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("shares", "lastAccessedAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("shares", "lastAccessedAt");
}
};

View File

@@ -12,6 +12,7 @@ const Share = sequelize.define(
published: DataTypes.BOOLEAN,
revokedAt: DataTypes.DATE,
revokedById: DataTypes.UUID,
lastAccessedAt: DataTypes.DATE,
},
{
getterMethods: {

View File

@@ -2,7 +2,7 @@
import { Event } from "../models";
import presentUser from "./user";
export default function present(event: Event, auditLog: boolean = false) {
export default function present(event: Event, isAdmin: boolean = false) {
let data = {
id: event.id,
name: event.name,
@@ -16,7 +16,7 @@ export default function present(event: Event, auditLog: boolean = false) {
actor: presentUser(event.actor),
};
if (!auditLog) {
if (!isAdmin) {
delete data.actorIpAddress;
}

View File

@@ -2,8 +2,8 @@
import { Share } from "../models";
import { presentUser } from ".";
export default function present(share: Share) {
return {
export default function present(share: Share, isAdmin: boolean = false) {
let data = {
id: share.id,
documentId: share.documentId,
documentTitle: share.document.title,
@@ -11,7 +11,14 @@ export default function present(share: Share) {
published: share.published,
url: `${share.team.url}/share/${share.id}`,
createdBy: presentUser(share.user),
lastAccessedAt: share.lastAccessedAt,
createdAt: share.createdAt,
updatedAt: share.updatedAt,
};
if (!isAdmin) {
delete data.lastAccessedAt;
}
return data;
}

View File

@@ -345,6 +345,9 @@
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
"Create a new document?": "Create a new document?",
"Clear filters": "Clear filters",
"Shared": "Shared",
"by {{ name }}": "by {{ name }}",
"Last accessed": "Last accessed",
"Import started": "Import started",
"Export in progress…": "Export in progress…",
"It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.": "It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.",
@@ -376,6 +379,11 @@
"Delete Account": "Delete Account",
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
"Delete account": "Delete account",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
"Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
"Shared documents": "Shared documents",
"No share links, yet.": "No share links, yet.",
"Youve not starred any documents yet.": "Youve not starred any documents yet.",
"There are no templates just yet.": "There are no templates just yet.",
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",