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:
@@ -9,6 +9,7 @@ class Share extends BaseModel {
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
documentUrl: string;
|
||||
lastAccessedAt: ?string;
|
||||
createdBy: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
{" "}
|
||||
· {t("Last accessed")}{" "}
|
||||
<Time dateTime={lastAccessedAt} addSuffix />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={<ShareMenu share={share} />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
});
|
||||
|
||||
14
server/migrations/20210418053152-share-last-viewed.js
Normal file
14
server/migrations/20210418053152-share-last-viewed.js
Normal 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");
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,7 @@ const Share = sequelize.define(
|
||||
published: DataTypes.BOOLEAN,
|
||||
revokedAt: DataTypes.DATE,
|
||||
revokedById: DataTypes.UUID,
|
||||
lastAccessedAt: DataTypes.DATE,
|
||||
},
|
||||
{
|
||||
getterMethods: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"You’ve not starred any documents yet.": "You’ve 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.",
|
||||
|
||||
Reference in New Issue
Block a user