feat: Events / audit log (#1008)

* feat: Record events in DB

* feat: events API

* First pass, hacky activity feed

* WIP

* Reset dashboard

* feat: audit log UI
feat: store ip address

* chore: Document events.list api

* fix: command specs

* await event create

* fix: backlinks service

* tidy

* fix: Hide audit log menu item if not admin
This commit is contained in:
Tom Moor
2019-08-05 20:38:31 -07:00
committed by GitHub
parent 75b03fdba2
commit fb4f6822a4
37 changed files with 911 additions and 148 deletions

View File

@ -10,6 +10,7 @@ import {
UserIcon, UserIcon,
LinkIcon, LinkIcon,
TeamIcon, TeamIcon,
BulletedListIcon,
} from 'outline-icons'; } from 'outline-icons';
import ZapierIcon from './icons/Zapier'; import ZapierIcon from './icons/Zapier';
import SlackIcon from './icons/Slack'; import SlackIcon from './icons/Slack';
@ -94,6 +95,13 @@ class SettingsSidebar extends React.Component<Props> {
icon={<LinkIcon />} icon={<LinkIcon />}
label="Share Links" label="Share Links"
/> />
{user.isAdmin && (
<SidebarLink
to="/settings/events"
icon={<BulletedListIcon />}
label="Audit Log"
/>
)}
{user.isAdmin && ( {user.isAdmin && (
<SidebarLink <SidebarLink
to="/settings/export" to="/settings/export"

33
app/models/Event.js Normal file
View File

@ -0,0 +1,33 @@
// @flow
import BaseModel from './BaseModel';
import User from './User';
class Event extends BaseModel {
id: string;
name: string;
modelId: ?string;
actorId: string;
actorIpAddress: ?string;
documentId: string;
collectionId: ?string;
userId: string;
createdAt: string;
actor: User;
data: { name: string, email: string };
get model() {
return this.name.split('.')[0];
}
get verb() {
return this.name.split('.')[1];
}
get verbPastTense() {
const v = this.verb;
if (v.endsWith('e')) return `${v}d`;
return `${v}ed`;
}
}
export default Event;

View File

@ -20,6 +20,7 @@ import Zapier from 'scenes/Settings/Zapier';
import Shares from 'scenes/Settings/Shares'; import Shares from 'scenes/Settings/Shares';
import Tokens from 'scenes/Settings/Tokens'; import Tokens from 'scenes/Settings/Tokens';
import Export from 'scenes/Settings/Export'; import Export from 'scenes/Settings/Export';
import Events from 'scenes/Settings/Events';
import Error404 from 'scenes/Error404'; import Error404 from 'scenes/Error404';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
@ -56,6 +57,7 @@ export default function Routes() {
<Route exact path="/settings/people/:filter" component={People} /> <Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/shares" component={Shares} /> <Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} /> <Route exact path="/settings/tokens" component={Tokens} />
<Route exact path="/settings/events" component={Events} />
<Route <Route
exact exact
path="/settings/notifications" path="/settings/notifications"

View File

@ -0,0 +1,99 @@
// @flow
import * as React from 'react';
import { observable, action } from 'mobx';
import { observer, inject } from 'mobx-react';
import Waypoint from 'react-waypoint';
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
import EventsStore from 'stores/EventsStore';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
import List from 'components/List';
import Tabs from 'components/Tabs';
import Tab from 'components/Tab';
import { ListPlaceholder } from 'components/LoadingPlaceholder';
import EventListItem from './components/EventListItem';
type Props = {
events: EventsStore,
match: Object,
};
@observer
class Events extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
componentDidMount() {
this.fetchResults();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.events.fetchPage({
limit,
offset: this.offset,
auditLog: true,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
render() {
const { events } = this.props;
const showLoading = events.isFetching && !events.orderedData.length;
return (
<CenteredContent>
<PageTitle title="Audit Log" />
<h1>Audit Log</h1>
<HelpText>
The audit log details the history of security related and other events
across your knowledgebase.
</HelpText>
<Tabs>
<Tab to="/settings/events" exact>
Events
</Tab>
</Tabs>
<List>
{showLoading ? (
<ListPlaceholder count={5} />
) : (
<React.Fragment>
{events.orderedData.map(event => <EventListItem event={event} />)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</React.Fragment>
)}
</List>
</CenteredContent>
);
}
}
export default inject('events')(Events);

View File

@ -0,0 +1,125 @@
// @flow
import * as React from 'react';
import { Link } from 'react-router-dom';
import { capitalize } from 'lodash';
import styled from 'styled-components';
import Time from 'shared/components/Time';
import ListItem from 'components/List/Item';
import Avatar from 'components/Avatar';
import Event from 'models/Event';
type Props = {
event: Event,
};
const description = event => {
switch (event.name) {
case 'teams.create':
return 'Created the team';
case 'shares.create':
case 'shares.revoke':
return (
<React.Fragment>
{capitalize(event.verbPastTense)} a{' '}
<Link to={`/share/${event.modelId || ''}`}>public link</Link> to a{' '}
<Link to={`/doc/${event.documentId}`}>document</Link>
</React.Fragment>
);
case 'users.create':
return (
<React.Fragment>{event.data.name} created an account</React.Fragment>
);
case 'users.invite':
return (
<React.Fragment>
{capitalize(event.verbPastTense)} {event.data.name} (<a
href={`mailto:${event.data.email || ''}`}
>
{event.data.email || ''}
</a>)
</React.Fragment>
);
case 'collections.add_user':
return (
<React.Fragment>
Added {event.data.name} to a private{' '}
<Link to={`/collections/${event.collectionId || ''}`}>
collection
</Link>
</React.Fragment>
);
case 'collections.remove_user':
return (
<React.Fragment>
Remove {event.data.name} from a private{' '}
<Link to={`/collections/${event.collectionId || ''}`}>
collection
</Link>
</React.Fragment>
);
default:
}
if (event.documentId) {
return (
<React.Fragment>
{capitalize(event.verbPastTense)} a{' '}
<Link to={`/doc/${event.documentId}`}>document</Link>
</React.Fragment>
);
}
if (event.collectionId) {
return (
<React.Fragment>
{capitalize(event.verbPastTense)} a{' '}
<Link to={`/collections/${event.collectionId || ''}`}>collection</Link>
</React.Fragment>
);
}
if (event.userId) {
return (
<React.Fragment>
{capitalize(event.verbPastTense)} the user {event.data.name}
</React.Fragment>
);
}
return '';
};
const EventListItem = ({ event }: Props) => {
return (
<ListItem
key={event.id}
title={event.actor.name}
image={<Avatar src={event.actor.avatarUrl} size={32} />}
subtitle={
<React.Fragment>
{description(event)} <Time dateTime={event.createdAt} /> ago &middot;{' '}
{event.name}
</React.Fragment>
}
actions={
event.actorIpAddress ? (
<IP>
<a
href={`http://geoiplookup.net/ip/${event.actorIpAddress}`}
target="_blank"
rel="noreferrer noopener"
>
{event.actorIpAddress}
</a>
</IP>
) : (
undefined
)
}
/>
);
};
const IP = styled('span')`
color: ${props => props.theme.textTertiary};
font-size: 12px;
`;
export default EventListItem;

View File

@ -153,6 +153,7 @@ export default class BaseStore<T: BaseModel> {
res.data.forEach(this.add); res.data.forEach(this.add);
this.isLoaded = true; this.isLoaded = true;
}); });
return res.data;
} finally { } finally {
this.isFetching = false; this.isFetching = false;
} }

19
app/stores/EventsStore.js Normal file
View File

@ -0,0 +1,19 @@
// @flow
import { sortBy } from 'lodash';
import { computed } from 'mobx';
import BaseStore from './BaseStore';
import RootStore from './RootStore';
import Event from 'models/Event';
export default class EventsStore extends BaseStore<Event> {
actions = ['list'];
constructor(rootStore: RootStore) {
super(rootStore, Event);
}
@computed
get orderedData(): Event[] {
return sortBy(Array.from(this.data.values()), 'createdAt').reverse();
}
}

View File

@ -3,6 +3,7 @@ import ApiKeysStore from './ApiKeysStore';
import AuthStore from './AuthStore'; import AuthStore from './AuthStore';
import CollectionsStore from './CollectionsStore'; import CollectionsStore from './CollectionsStore';
import DocumentsStore from './DocumentsStore'; import DocumentsStore from './DocumentsStore';
import EventsStore from './EventsStore';
import IntegrationsStore from './IntegrationsStore'; import IntegrationsStore from './IntegrationsStore';
import NotificationSettingsStore from './NotificationSettingsStore'; import NotificationSettingsStore from './NotificationSettingsStore';
import RevisionsStore from './RevisionsStore'; import RevisionsStore from './RevisionsStore';
@ -16,6 +17,7 @@ export default class RootStore {
auth: AuthStore; auth: AuthStore;
collections: CollectionsStore; collections: CollectionsStore;
documents: DocumentsStore; documents: DocumentsStore;
events: EventsStore;
integrations: IntegrationsStore; integrations: IntegrationsStore;
notificationSettings: NotificationSettingsStore; notificationSettings: NotificationSettingsStore;
revisions: RevisionsStore; revisions: RevisionsStore;
@ -29,6 +31,7 @@ export default class RootStore {
this.auth = new AuthStore(this); this.auth = new AuthStore(this);
this.collections = new CollectionsStore(this); this.collections = new CollectionsStore(this);
this.documents = new DocumentsStore(this); this.documents = new DocumentsStore(this);
this.events = new EventsStore(this);
this.integrations = new IntegrationsStore(this); this.integrations = new IntegrationsStore(this);
this.notificationSettings = new NotificationSettingsStore(this); this.notificationSettings = new NotificationSettingsStore(this);
this.revisions = new RevisionsStore(this); this.revisions = new RevisionsStore(this);
@ -42,6 +45,7 @@ export default class RootStore {
this.apiKeys.clear(); this.apiKeys.clear();
this.collections.clear(); this.collections.clear();
this.documents.clear(); this.documents.clear();
this.events.clear();
this.integrations.clear(); this.integrations.clear();
this.notificationSettings.clear(); this.notificationSettings.clear();
this.revisions.clear(); this.revisions.clear();

View File

@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#events.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@ -4,12 +4,11 @@ import Router from 'koa-router';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination'; import pagination from './middlewares/pagination';
import { presentCollection, presentUser } from '../presenters'; import { presentCollection, presentUser } from '../presenters';
import { Collection, CollectionUser, Team, User } from '../models'; import { Collection, CollectionUser, Team, Event, User } from '../models';
import { ValidationError, InvalidRequestError } from '../errors'; import { ValidationError, InvalidRequestError } from '../errors';
import { exportCollections } from '../logistics'; import { exportCollections } from '../logistics';
import { archiveCollection } from '../utils/zip'; import { archiveCollection } from '../utils/zip';
import policy from '../policies'; import policy from '../policies';
import events from '../events';
const { authorize } = policy; const { authorize } = policy;
const router = new Router(); const router = new Router();
@ -35,11 +34,13 @@ router.post('collections.create', auth(), async ctx => {
private: isPrivate, private: isPrivate,
}); });
events.add({ await Event.create({
name: 'collections.create', name: 'collections.create',
modelId: collection.id, collectionId: collection.id,
teamId: collection.teamId, teamId: collection.teamId,
actorId: user.id, actorId: user.id,
data: { name },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -81,12 +82,14 @@ router.post('collections.add_user', auth(), async ctx => {
createdById: ctx.state.user.id, createdById: ctx.state.user.id,
}); });
events.add({ await Event.create({
name: 'collections.add_user', name: 'collections.add_user',
modelId: userId, userId,
collectionId: collection.id, collectionId: collection.id,
teamId: collection.teamId, teamId: collection.teamId,
actorId: ctx.state.user.id, actorId: ctx.state.user.id,
data: { name: user.name },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -111,12 +114,14 @@ router.post('collections.remove_user', auth(), async ctx => {
await collection.removeUser(user); await collection.removeUser(user);
events.add({ await Event.create({
name: 'collections.remove_user', name: 'collections.remove_user',
modelId: userId, userId,
collectionId: collection.id, collectionId: collection.id,
teamId: collection.teamId, teamId: collection.teamId,
actorId: ctx.state.user.id, actorId: ctx.state.user.id,
data: { name: user.name },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -148,6 +153,15 @@ router.post('collections.export', auth(), async ctx => {
const filePath = await archiveCollection(collection); const filePath = await archiveCollection(collection);
await Event.create({
name: 'collections.export',
collectionId: collection.id,
teamId: user.teamId,
actorId: user.id,
data: { title: collection.title },
ip: ctx.request.ip,
});
ctx.attachment(`${collection.name}.zip`); ctx.attachment(`${collection.name}.zip`);
ctx.set('Content-Type', 'application/force-download'); ctx.set('Content-Type', 'application/force-download');
ctx.body = fs.createReadStream(filePath); ctx.body = fs.createReadStream(filePath);
@ -161,6 +175,13 @@ router.post('collections.exportAll', auth(), async ctx => {
// async operation to create zip archive and email user // async operation to create zip archive and email user
exportCollections(user.teamId, user.email); exportCollections(user.teamId, user.email);
await Event.create({
name: 'collections.export',
teamId: user.teamId,
actorId: user.id,
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
success: true, success: true,
}; };
@ -197,11 +218,13 @@ router.post('collections.update', auth(), async ctx => {
collection.private = isPrivate; collection.private = isPrivate;
await collection.save(); await collection.save();
events.add({ await Event.create({
name: 'collections.update', name: 'collections.update',
modelId: collection.id, collectionId: collection.id,
teamId: collection.teamId, teamId: collection.teamId,
actorId: user.id, actorId: user.id,
data: { name },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -246,11 +269,13 @@ router.post('collections.delete', auth(), async ctx => {
await collection.destroy(); await collection.destroy();
events.add({ await Event.create({
name: 'collections.delete', name: 'collections.delete',
modelId: collection.id, collectionId: collection.id,
teamId: collection.teamId, teamId: collection.teamId,
actorId: user.id, actorId: user.id,
data: { name: collection.name },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {

View File

@ -10,8 +10,9 @@ import {
presentRevision, presentRevision,
} from '../presenters'; } from '../presenters';
import { import {
Document,
Collection, Collection,
Document,
Event,
Share, Share,
Star, Star,
View, View,
@ -20,7 +21,6 @@ import {
User, User,
} from '../models'; } from '../models';
import { InvalidRequestError } from '../errors'; import { InvalidRequestError } from '../errors';
import events from '../events';
import policy from '../policies'; import policy from '../policies';
import { sequelize } from '../sequelize'; import { sequelize } from '../sequelize';
@ -369,12 +369,14 @@ router.post('documents.restore', auth(), async ctx => {
// restore a previously archived document // restore a previously archived document
await document.unarchive(user.id); await document.unarchive(user.id);
events.add({ await Event.create({
name: 'documents.unarchive', name: 'documents.unarchive',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
} else if (revisionId) { } else if (revisionId) {
// restore a document to a specific revision // restore a document to a specific revision
@ -387,12 +389,14 @@ router.post('documents.restore', auth(), async ctx => {
document.title = revision.title; document.title = revision.title;
await document.save(); await document.save();
events.add({ await Event.create({
name: 'documents.restore', name: 'documents.restore',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
} else { } else {
ctx.assertPresent(revisionId, 'revisionId is required'); ctx.assertPresent(revisionId, 'revisionId is required');
@ -463,12 +467,14 @@ router.post('documents.pin', auth(), async ctx => {
document.pinnedById = user.id; document.pinnedById = user.id;
await document.save(); await document.save();
events.add({ await Event.create({
name: 'documents.pin', name: 'documents.pin',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -487,12 +493,14 @@ router.post('documents.unpin', auth(), async ctx => {
document.pinnedById = null; document.pinnedById = null;
await document.save(); await document.save();
events.add({ await Event.create({
name: 'documents.unpin', name: 'documents.unpin',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -512,12 +520,14 @@ router.post('documents.star', auth(), async ctx => {
where: { documentId: document.id, userId: user.id }, where: { documentId: document.id, userId: user.id },
}); });
events.add({ await Event.create({
name: 'documents.star', name: 'documents.star',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
}); });
@ -533,12 +543,14 @@ router.post('documents.unstar', auth(), async ctx => {
where: { documentId: document.id, userId: user.id }, where: { documentId: document.id, userId: user.id },
}); });
events.add({ await Event.create({
name: 'documents.unstar', name: 'documents.unstar',
modelId: document.id, modelId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
}); });
@ -592,23 +604,27 @@ router.post('documents.create', auth(), async ctx => {
text, text,
}); });
events.add({ await Event.create({
name: 'documents.create', name: 'documents.create',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
if (publish) { if (publish) {
await document.publish(); await document.publish();
events.add({ await Event.create({
name: 'documents.publish', name: 'documents.publish',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
} }
@ -664,29 +680,10 @@ router.post('documents.update', auth(), async ctx => {
if (publish) { if (publish) {
await document.publish({ transaction }); await document.publish({ transaction });
await transaction.commit();
events.add({
name: 'documents.publish',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
} else { } else {
await document.save({ autosave, transaction }); await document.save({ autosave, transaction });
await transaction.commit();
events.add({
name: 'documents.update',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
autosave,
done,
});
} }
await transaction.commit();
} catch (err) { } catch (err) {
if (transaction) { if (transaction) {
await transaction.rollback(); await transaction.rollback();
@ -694,6 +691,32 @@ router.post('documents.update', auth(), async ctx => {
throw err; throw err;
} }
if (publish) {
await Event.create({
name: 'documents.publish',
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
} else {
await Event.create({
name: 'documents.update',
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
autosave,
done,
title: document.title,
},
ip: ctx.request.ip,
});
}
ctx.body = { ctx.body = {
data: await presentDocument(document), data: await presentDocument(document),
}; };
@ -735,10 +758,12 @@ router.post('documents.move', auth(), async ctx => {
} }
const { documents, collections } = await documentMover({ const { documents, collections } = await documentMover({
user,
document, document,
collectionId, collectionId,
parentDocumentId, parentDocumentId,
index, index,
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -763,12 +788,14 @@ router.post('documents.archive', auth(), async ctx => {
await document.archive(user.id); await document.archive(user.id);
events.add({ await Event.create({
name: 'documents.archive', name: 'documents.archive',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -786,12 +813,14 @@ router.post('documents.delete', auth(), async ctx => {
await document.delete(); await document.delete();
events.add({ await Event.create({
name: 'documents.delete', name: 'documents.delete',
modelId: document.id, documentId: document.id,
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {

58
server/api/events.js Normal file
View File

@ -0,0 +1,58 @@
// @flow
import Sequelize from 'sequelize';
import Router from 'koa-router';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentEvent } from '../presenters';
import { Event, Team, User } from '../models';
import policy from '../policies';
const Op = Sequelize.Op;
const { authorize } = policy;
const router = new Router();
router.post('events.list', auth(), pagination(), async ctx => {
let { sort = 'updatedAt', direction, auditLog = false } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
let where = {
name: Event.ACTIVITY_EVENTS,
teamId: user.teamId,
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: {
[Op.eq]: null,
},
},
],
};
if (auditLog) {
authorize(user, 'auditLog', Team);
where.name = Event.AUDIT_EVENTS;
}
const events = await Event.findAll({
where,
order: [[sort, direction]],
include: [
{
model: User,
as: 'actor',
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: events.map(event => presentEvent(event, auditLog)),
};
});
export default router;

49
server/api/events.test.js Normal file
View File

@ -0,0 +1,49 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '../app';
import { flushdb, seed } from '../test/support';
import { buildEvent } from '../test/factories';
const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe('#events.list', async () => {
it('should only return activity events', async () => {
const { user, admin, document, collection } = await seed();
// private event
await buildEvent({
name: 'users.promote',
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: 'documents.publish',
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post('/api/events.list', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it('should require authentication', async () => {
const res = await server.post('/api/events.list');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
});

View File

@ -4,6 +4,7 @@ import Koa from 'koa';
import Router from 'koa-router'; import Router from 'koa-router';
import auth from './auth'; import auth from './auth';
import events from './events';
import users from './users'; import users from './users';
import collections from './collections'; import collections from './collections';
import documents from './documents'; import documents from './documents';
@ -35,6 +36,7 @@ api.use(apiWrapper());
// routes // routes
router.use('/', auth.routes()); router.use('/', auth.routes());
router.use('/', events.routes());
router.use('/', users.routes()); router.use('/', users.routes());
router.use('/', collections.routes()); router.use('/', collections.routes());
router.use('/', documents.routes()); router.use('/', documents.routes());

View File

@ -3,9 +3,9 @@ import Router from 'koa-router';
import Integration from '../models/Integration'; import Integration from '../models/Integration';
import pagination from './middlewares/pagination'; import pagination from './middlewares/pagination';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import { Event } from '../models';
import { presentIntegration } from '../presenters'; import { presentIntegration } from '../presenters';
import policy from '../policies'; import policy from '../policies';
import events from '../events';
const { authorize } = policy; const { authorize } = policy;
const router = new Router(); const router = new Router();
@ -38,11 +38,12 @@ router.post('integrations.delete', auth(), async ctx => {
await integration.destroy(); await integration.destroy();
events.add({ await Event.create({
name: 'integrations.delete', name: 'integrations.delete',
modelId: integration.id, modelId: integration.id,
teamId: integration.teamId, teamId: integration.teamId,
actorId: user.id, actorId: user.id,
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {

View File

@ -4,7 +4,7 @@ import Sequelize from 'sequelize';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination'; import pagination from './middlewares/pagination';
import { presentShare } from '../presenters'; import { presentShare } from '../presenters';
import { Document, User, Share, Team } from '../models'; import { Document, User, Event, Share, Team } from '../models';
import policy from '../policies'; import policy from '../policies';
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -50,6 +50,7 @@ router.post('shares.list', auth(), pagination(), async ctx => {
}); });
ctx.body = { ctx.body = {
pagination: ctx.state.pagination,
data: shares.map(presentShare), data: shares.map(presentShare),
}; };
}); });
@ -73,6 +74,17 @@ router.post('shares.create', auth(), async ctx => {
}, },
}); });
await Event.create({
name: 'shares.create',
documentId,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { name: document.title },
ip: ctx.request.ip,
});
share.user = user; share.user = user;
share.document = document; share.document = document;
@ -91,6 +103,19 @@ router.post('shares.revoke', auth(), async ctx => {
await share.revoke(user.id); await share.revoke(user.id);
const document = await Document.findByPk(share.documentId);
await Event.create({
name: 'shares.revoke',
documentId: document.id,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: { name: document.title },
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
success: true, success: true,
}; };

View File

@ -86,6 +86,7 @@ router.post('users.s3Upload', auth(), async ctx => {
}, },
teamId: ctx.state.user.teamId, teamId: ctx.state.user.teamId,
userId: ctx.state.user.id, userId: ctx.state.user.id,
ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
@ -126,6 +127,15 @@ router.post('users.promote', auth(), async ctx => {
const team = await Team.findByPk(teamId); const team = await Team.findByPk(teamId);
await team.addAdmin(user); await team.addAdmin(user);
await Event.create({
name: 'users.promote',
actorId: ctx.state.user.id,
userId,
teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
data: presentUser(user, { includeDetails: true }), data: presentUser(user, { includeDetails: true }),
}; };
@ -146,6 +156,15 @@ router.post('users.demote', auth(), async ctx => {
throw new ValidationError(err.message); throw new ValidationError(err.message);
} }
await Event.create({
name: 'users.demote',
actorId: ctx.state.user.id,
userId,
teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
data: presentUser(user, { includeDetails: true }), data: presentUser(user, { includeDetails: true }),
}; };
@ -167,6 +186,15 @@ router.post('users.suspend', auth(), async ctx => {
throw new ValidationError(err.message); throw new ValidationError(err.message);
} }
await Event.create({
name: 'users.suspend',
actorId: ctx.state.user.id,
userId,
teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
data: presentUser(user, { includeDetails: true }), data: presentUser(user, { includeDetails: true }),
}; };
@ -184,6 +212,15 @@ router.post('users.activate', auth(), async ctx => {
const team = await Team.findByPk(teamId); const team = await Team.findByPk(teamId);
await team.activateUser(user, admin); await team.activateUser(user, admin);
await Event.create({
name: 'users.activate',
actorId: ctx.state.user.id,
userId,
teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
data: presentUser(user, { includeDetails: true }), data: presentUser(user, { includeDetails: true }),
}; };
@ -196,7 +233,7 @@ router.post('users.invite', auth(), async ctx => {
const user = ctx.state.user; const user = ctx.state.user;
authorize(user, 'invite', User); authorize(user, 'invite', User);
const invitesSent = await userInviter({ user, invites }); const invitesSent = await userInviter({ user, invites, ip: ctx.request.ip });
ctx.body = { ctx.body = {
data: invitesSent, data: invitesSent,
@ -216,6 +253,15 @@ router.post('users.delete', auth(), async ctx => {
throw new ValidationError(err.message); throw new ValidationError(err.message);
} }
await Event.create({
name: 'users.delete',
actorId: user.id,
userId: user.id,
teamId: user.teamId,
data: { name: user.name },
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
success: true, success: true,
}; };

View File

@ -2,7 +2,7 @@
import Router from 'koa-router'; import Router from 'koa-router';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import { presentView } from '../presenters'; import { presentView } from '../presenters';
import { View, Document, User } from '../models'; import { View, Document, Event, User } from '../models';
import policy from '../policies'; import policy from '../policies';
const { authorize } = policy; const { authorize } = policy;
@ -42,6 +42,16 @@ router.post('views.create', auth(), async ctx => {
await View.increment({ documentId, userId: user.id }); await View.increment({ documentId, userId: user.id });
await Event.create({
name: 'views.create',
actorId: user.id,
documentId: document.id,
collectionId: document.collectionId,
teamId: user.teamId,
data: { title: document.title },
ip: ctx.request.ip,
});
ctx.body = { ctx.body = {
success: true, success: true,
}; };

View File

@ -3,7 +3,7 @@ import crypto from 'crypto';
import Router from 'koa-router'; import Router from 'koa-router';
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
import { OAuth2Client } from 'google-auth-library'; import { OAuth2Client } from 'google-auth-library';
import { User, Team } from '../models'; import { User, Team, Event } from '../models';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
const router = new Router(); const router = new Router();
@ -91,6 +91,20 @@ router.get('google.callback', auth({ required: false }), async ctx => {
}, },
}); });
if (isFirstSignin) {
await Event.create({
name: 'users.create',
actorId: user.id,
userId: user.id,
teamId: team.id,
data: {
name: user.name,
service: 'google',
},
ip: ctx.request.ip,
});
}
// update email address if it's changed in Google // update email address if it's changed in Google
if (!isFirstSignin && profile.data.email !== user.email) { if (!isFirstSignin && profile.data.email !== user.email) {
await user.update({ email: profile.data.email }); await user.update({ email: profile.data.email });

View File

@ -4,7 +4,14 @@ import auth from '../middlewares/authentication';
import addHours from 'date-fns/add_hours'; import addHours from 'date-fns/add_hours';
import { stripSubdomain } from '../../shared/utils/domains'; import { stripSubdomain } from '../../shared/utils/domains';
import { slackAuth } from '../../shared/utils/routeHelpers'; import { slackAuth } from '../../shared/utils/routeHelpers';
import { Authentication, Collection, Integration, User, Team } from '../models'; import {
Authentication,
Collection,
Integration,
User,
Event,
Team,
} from '../models';
import * as Slack from '../slack'; import * as Slack from '../slack';
const router = new Router(); const router = new Router();
@ -69,6 +76,20 @@ router.get('slack.callback', auth({ required: false }), async ctx => {
await team.provisionSubdomain(data.team.domain); await team.provisionSubdomain(data.team.domain);
} }
if (isFirstSignin) {
await Event.create({
name: 'users.create',
actorId: user.id,
userId: user.id,
teamId: team.id,
data: {
name: user.name,
service: 'slack',
},
ip: ctx.request.ip,
});
}
// update email address if it's changed in Slack // update email address if it's changed in Slack
if (!isFirstSignin && data.user.email !== user.email) { if (!isFirstSignin && data.user.email !== user.email) {
await user.update({ email: data.user.email }); await user.update({ email: data.user.email });

View File

@ -1,18 +1,22 @@
// @flow // @flow
import { Document, Collection } from '../models'; import { Document, Collection, Event } from '../models';
import { sequelize } from '../sequelize'; import { sequelize } from '../sequelize';
import events from '../events'; import { type Context } from 'koa';
export default async function documentMover({ export default async function documentMover({
user,
document, document,
collectionId, collectionId,
parentDocumentId, parentDocumentId,
index, index,
ip,
}: { }: {
user: Context,
document: Document, document: Document,
collectionId: string, collectionId: string,
parentDocumentId: string, parentDocumentId: string,
index?: number, index?: number,
ip: string,
}) { }) {
let transaction; let transaction;
const result = { collections: [], documents: [] }; const result = { collections: [], documents: [] };
@ -72,12 +76,18 @@ export default async function documentMover({
await transaction.commit(); await transaction.commit();
events.add({ await Event.create({
name: 'documents.move', name: 'documents.move',
modelId: document.id, actorId: user.id,
collectionIds: result.collections.map(c => c.id), documentId: document.id,
documentIds: result.documents.map(d => d.id), collectionId,
teamId: document.teamId, teamId: document.teamId,
data: {
title: document.title,
collectionIds: result.collections.map(c => c.id),
documentIds: result.documents.map(d => d.id),
},
ip,
}); });
} catch (err) { } catch (err) {
if (transaction) { if (transaction) {

View File

@ -6,12 +6,16 @@ import { buildDocument, buildCollection } from '../test/factories';
beforeEach(flushdb); beforeEach(flushdb);
describe('documentMover', async () => { describe('documentMover', async () => {
const ip = '127.0.0.1';
it('should move within a collection', async () => { it('should move within a collection', async () => {
const { document, collection } = await seed(); const { document, user, collection } = await seed();
const response = await documentMover({ const response = await documentMover({
user,
document, document,
collectionId: collection.id, collectionId: collection.id,
ip,
}); });
expect(response.collections.length).toEqual(1); expect(response.collections.length).toEqual(1);
@ -19,7 +23,7 @@ describe('documentMover', async () => {
}); });
it('should move with children', async () => { it('should move with children', async () => {
const { document, collection } = await seed(); const { document, user, collection } = await seed();
const newDocument = await buildDocument({ const newDocument = await buildDocument({
parentDocumentId: document.id, parentDocumentId: document.id,
collectionId: collection.id, collectionId: collection.id,
@ -31,10 +35,12 @@ describe('documentMover', async () => {
await collection.addDocumentToStructure(newDocument); await collection.addDocumentToStructure(newDocument);
const response = await documentMover({ const response = await documentMover({
user,
document, document,
collectionId: collection.id, collectionId: collection.id,
parentDocumentId: undefined, parentDocumentId: undefined,
index: 0, index: 0,
ip,
}); });
expect(response.collections[0].documentStructure[0].children[0].id).toBe( expect(response.collections[0].documentStructure[0].children[0].id).toBe(
@ -45,7 +51,7 @@ describe('documentMover', async () => {
}); });
it('should move with children to another collection', async () => { it('should move with children to another collection', async () => {
const { document, collection } = await seed(); const { document, user, collection } = await seed();
const newCollection = await buildCollection({ const newCollection = await buildCollection({
teamId: collection.teamId, teamId: collection.teamId,
}); });
@ -60,10 +66,12 @@ describe('documentMover', async () => {
await collection.addDocumentToStructure(newDocument); await collection.addDocumentToStructure(newDocument);
const response = await documentMover({ const response = await documentMover({
user,
document, document,
collectionId: newCollection.id, collectionId: newCollection.id,
parentDocumentId: undefined, parentDocumentId: undefined,
index: 0, index: 0,
ip,
}); });
// check document ids where updated // check document ids where updated

View File

@ -1,7 +1,6 @@
// @flow // @flow
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { User, Team } from '../models'; import { User, Event, Team } from '../models';
import events from '../events';
import mailer from '../mailer'; import mailer from '../mailer';
type Invite = { name: string, email: string }; type Invite = { name: string, email: string };
@ -9,9 +8,11 @@ type Invite = { name: string, email: string };
export default async function userInviter({ export default async function userInviter({
user, user,
invites, invites,
ip,
}: { }: {
user: User, user: User,
invites: Invite[], invites: Invite[],
ip: string,
}): Promise<{ sent: Invite[] }> { }): Promise<{ sent: Invite[] }> {
const team = await Team.findByPk(user.teamId); const team = await Team.findByPk(user.teamId);
@ -35,23 +36,28 @@ export default async function userInviter({
); );
// send and record invites // send and record invites
filteredInvites.forEach(async invite => { await Promise.all(
await mailer.invite({ filteredInvites.map(async invite => {
to: invite.email, await Event.create({
name: invite.name, name: 'users.invite',
actorName: user.name, actorId: user.id,
actorEmail: user.email, teamId: user.teamId,
teamName: team.name, data: {
teamUrl: team.url, email: invite.email,
}); name: invite.name,
},
events.add({ ip,
name: 'users.invite', });
actorId: user.id, await mailer.invite({
teamId: user.teamId, to: invite.email,
email: invite.email, name: invite.name,
}); actorName: user.name,
}); actorEmail: user.email,
teamName: team.name,
teamUrl: team.url,
});
})
);
return { sent: filteredInvites }; return { sent: filteredInvites };
} }

View File

@ -6,11 +6,14 @@ import { buildUser } from '../test/factories';
beforeEach(flushdb); beforeEach(flushdb);
describe('userInviter', async () => { describe('userInviter', async () => {
const ip = '127.0.0.1';
it('should return sent invites', async () => { it('should return sent invites', async () => {
const user = await buildUser(); const user = await buildUser();
const response = await userInviter({ const response = await userInviter({
invites: [{ email: 'test@example.com', name: 'Test' }], invites: [{ email: 'test@example.com', name: 'Test' }],
user, user,
ip,
}); });
expect(response.sent.length).toEqual(1); expect(response.sent.length).toEqual(1);
}); });
@ -20,6 +23,7 @@ describe('userInviter', async () => {
const response = await userInviter({ const response = await userInviter({
invites: [{ email: ' ', name: 'Test' }], invites: [{ email: ' ', name: 'Test' }],
user, user,
ip,
}); });
expect(response.sent.length).toEqual(0); expect(response.sent.length).toEqual(0);
}); });
@ -29,6 +33,7 @@ describe('userInviter', async () => {
const response = await userInviter({ const response = await userInviter({
invites: [{ email: 'notanemail', name: 'Test' }], invites: [{ email: 'notanemail', name: 'Test' }],
user, user,
ip,
}); });
expect(response.sent.length).toEqual(0); expect(response.sent.length).toEqual(0);
}); });
@ -41,6 +46,7 @@ describe('userInviter', async () => {
{ email: 'the@same.com', name: 'Test' }, { email: 'the@same.com', name: 'Test' },
], ],
user, user,
ip,
}); });
expect(response.sent.length).toEqual(1); expect(response.sent.length).toEqual(1);
}); });
@ -50,6 +56,7 @@ describe('userInviter', async () => {
const response = await userInviter({ const response = await userInviter({
invites: [{ email: user.email, name: user.name }], invites: [{ email: user.email, name: user.name }],
user, user,
ip,
}); });
expect(response.sent.length).toEqual(0); expect(response.sent.length).toEqual(0);
}); });

View File

@ -9,7 +9,7 @@ export type UserEvent =
| 'users.suspend' | 'users.suspend'
| 'users.activate' | 'users.activate'
| 'users.delete', | 'users.delete',
modelId: string, userId: string,
teamId: string, teamId: string,
actorId: string, actorId: string,
} }
@ -17,7 +17,10 @@ export type UserEvent =
name: 'users.invite', name: 'users.invite',
teamId: string, teamId: string,
actorId: string, actorId: string,
email: string, data: {
email: string,
name: string,
},
}; };
export type DocumentEvent = export type DocumentEvent =
@ -32,27 +35,32 @@ export type DocumentEvent =
| 'documents.restore' | 'documents.restore'
| 'documents.star' | 'documents.star'
| 'documents.unstar', | 'documents.unstar',
modelId: string, documentId: string,
collectionId: string, collectionId: string,
teamId: string, teamId: string,
actorId: string, actorId: string,
} }
| { | {
name: 'documents.move', name: 'documents.move',
modelId: string, documentId: string,
collectionIds: string[],
documentIds: string[],
teamId: string,
actorId: string,
}
| {
name: 'documents.update',
modelId: string,
collectionId: string, collectionId: string,
teamId: string, teamId: string,
actorId: string, actorId: string,
autosave: boolean, data: {
done: boolean, collectionIds: string[],
documentIds: string[],
},
}
| {
name: 'documents.update',
documentId: string,
collectionId: string,
teamId: string,
actorId: string,
data: {
autosave: boolean,
done: boolean,
},
}; };
export type CollectionEvent = export type CollectionEvent =
@ -60,20 +68,20 @@ export type CollectionEvent =
name: | 'collections.create' // eslint-disable-line name: | 'collections.create' // eslint-disable-line
| 'collections.update' | 'collections.update'
| 'collections.delete', | 'collections.delete',
modelId: string, collectionId: string,
teamId: string, teamId: string,
actorId: string, actorId: string,
} }
| { | {
name: 'collections.add_user' | 'collections.remove_user', name: 'collections.add_user' | 'collections.remove_user',
modelId: string, userId: string,
collectionId: string, collectionId: string,
teamId: string, teamId: string,
actorId: string, actorId: string,
}; };
export type IntegrationEvent = { export type IntegrationEvent = {
name: 'integrations.create' | 'integrations.update' | 'collections.delete', name: 'integrations.create' | 'integrations.update',
modelId: string, modelId: string,
teamId: string, teamId: string,
actorId: string, actorId: string,

View File

@ -0,0 +1,35 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('events', 'data', {
type: Sequelize.JSONB,
allowNull: true,
});
await queryInterface.addColumn('events', 'actorId', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
},
});
await queryInterface.addColumn('events', 'modelId', {
type: Sequelize.UUID,
allowNull: true
});
await queryInterface.addColumn('events', 'ip', {
type: Sequelize.STRING,
allowNull: true
});
await queryInterface.addIndex('events', ['name']);
await queryInterface.addIndex('events', ['actorId']);
await queryInterface.addIndex('events', ['teamId', 'collectionId']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('events', 'actorId');
await queryInterface.removeColumn('events', 'modelId');
await queryInterface.removeColumn('events', 'ip');
await queryInterface.removeIndex('events', ['name']);
await queryInterface.removeIndex('events', ['actorId']);
await queryInterface.removeIndex('events', ['teamId', 'collectionId']);
}
}

View File

@ -1,5 +1,6 @@
// @flow // @flow
import { DataTypes, sequelize } from '../sequelize'; import { DataTypes, sequelize } from '../sequelize';
import events from '../events';
const Event = sequelize.define('event', { const Event = sequelize.define('event', {
id: { id: {
@ -8,6 +9,7 @@ const Event = sequelize.define('event', {
primaryKey: true, primaryKey: true,
}, },
name: DataTypes.STRING, name: DataTypes.STRING,
ip: DataTypes.STRING,
data: DataTypes.JSONB, data: DataTypes.JSONB,
}); });
@ -16,6 +18,10 @@ Event.associate = models => {
as: 'user', as: 'user',
foreignKey: 'userId', foreignKey: 'userId',
}); });
Event.belongsTo(models.User, {
as: 'actor',
foreignKey: 'actorId',
});
Event.belongsTo(models.Collection, { Event.belongsTo(models.Collection, {
as: 'collection', as: 'collection',
foreignKey: 'collectionId', foreignKey: 'collectionId',
@ -30,4 +36,52 @@ Event.associate = models => {
}); });
}; };
Event.beforeCreate(event => {
if (event.ip) {
// cleanup IPV6 representations of IPV4 addresses
event.ip = event.ip.replace(/^::ffff:/, '');
}
});
Event.afterCreate(event => {
events.add(event);
});
Event.ACTIVITY_EVENTS = [
'users.create',
'documents.publish',
'documents.archive',
'documents.unarchive',
'documents.pin',
'documents.unpin',
'documents.delete',
'collections.create',
'collections.delete',
];
Event.AUDIT_EVENTS = [
'users.create',
'users.promote',
'users.demote',
'users.invite',
'users.suspend',
'users.activate',
'users.delete',
'documents.publish',
'documents.update',
'documents.archive',
'documents.unarchive',
'documents.pin',
'documents.unpin',
'documents.move',
'documents.delete',
'shares.create',
'shares.revoke',
'collections.create',
'collections.update',
'collections.add_user',
'collections.remove_user',
'collections.delete',
];
export default Event; export default Event;

View File

@ -172,22 +172,24 @@ User.afterCreate(async user => {
// By default when a user signs up we subscribe them to email notifications // By default when a user signs up we subscribe them to email notifications
// when documents they created are edited by other team members and onboarding // when documents they created are edited by other team members and onboarding
User.afterCreate(async (user, options) => { User.afterCreate(async (user, options) => {
await NotificationSetting.findOrCreate({ await Promise.all([
where: { NotificationSetting.findOrCreate({
userId: user.id, where: {
teamId: user.teamId, userId: user.id,
event: 'documents.update', teamId: user.teamId,
}, event: 'documents.update',
transaction: options.transaction, },
}); transaction: options.transaction,
await NotificationSetting.findOrCreate({ }),
where: { NotificationSetting.findOrCreate({
userId: user.id, where: {
teamId: user.teamId, userId: user.id,
event: 'emails.onboarding', teamId: user.teamId,
}, event: 'emails.onboarding',
transaction: options.transaction, },
}); transaction: options.transaction,
}),
]);
}); });
export default User; export default User;

View File

@ -24,6 +24,16 @@ export default function Api() {
<Arguments /> <Arguments />
</Method> </Method>
<Method method="events.list" label="List team's events">
<Description>List all of the events in the team.</Description>
<Arguments pagination>
<Argument
id="auditLog"
description="Boolean. If user token has access, return auditing events"
/>
</Arguments>
</Method>
<Method method="users.list" label="List team's users"> <Method method="users.list" label="List team's users">
<Description>List all of the users in the team.</Description> <Description>List all of the users in the team.</Description>
<Arguments pagination /> <Arguments pagination />

View File

@ -12,6 +12,11 @@ allow(User, 'share', Team, (user, team) => {
return team.sharing; return team.sharing;
}); });
allow(User, 'auditLog', Team, user => {
if (user.isAdmin) return true;
return false;
});
allow(User, ['update', 'export'], Team, (user, team) => { allow(User, ['update', 'export'], Team, (user, team) => {
if (!team || user.teamId !== team.id) return false; if (!team || user.teamId !== team.id) return false;
if (user.isAdmin) return true; if (user.isAdmin) return true;

View File

@ -0,0 +1,24 @@
// @flow
import { Event } from '../models';
import presentUser from './user';
export default function present(event: Event, auditLog: boolean = false) {
let data = {
id: event.id,
name: event.name,
modelId: event.modelId,
actorId: event.actorId,
actorIpAddress: event.ip,
collectionId: event.collectionId,
documentId: event.documentId,
createdAt: event.createdAt,
data: event.data,
actor: presentUser(event.actor),
};
if (!auditLog) {
delete data.actorIpAddress;
}
return data;
}

View File

@ -2,6 +2,7 @@
import presentUser from './user'; import presentUser from './user';
import presentView from './view'; import presentView from './view';
import presentDocument from './document'; import presentDocument from './document';
import presentEvent from './event';
import presentRevision from './revision'; import presentRevision from './revision';
import presentCollection from './collection'; import presentCollection from './collection';
import presentApiKey from './apiKey'; import presentApiKey from './apiKey';
@ -15,6 +16,7 @@ export {
presentUser, presentUser,
presentView, presentView,
presentDocument, presentDocument,
presentEvent,
presentRevision, presentRevision,
presentCollection, presentCollection,
presentApiKey, presentApiKey,

View File

@ -8,18 +8,18 @@ export default class Backlinks {
async on(event: DocumentEvent) { async on(event: DocumentEvent) {
switch (event.name) { switch (event.name) {
case 'documents.publish': { case 'documents.publish': {
const document = await Document.findByPk(event.modelId); const document = await Document.findByPk(event.documentId);
const linkIds = parseDocumentIds(document.text); const linkIds = parseDocumentIds(document.text);
await Promise.all( await Promise.all(
linkIds.map(async linkId => { linkIds.map(async linkId => {
const linkedDocument = await Document.findByPk(linkId); const linkedDocument = await Document.findByPk(linkId);
if (linkedDocument.id === event.modelId) return; if (linkedDocument.id === event.documentId) return;
await Backlink.findOrCreate({ await Backlink.findOrCreate({
where: { where: {
documentId: linkedDocument.id, documentId: linkedDocument.id,
reverseDocumentId: event.modelId, reverseDocumentId: event.documentId,
}, },
defaults: { defaults: {
userId: document.lastModifiedById, userId: document.lastModifiedById,
@ -32,14 +32,14 @@ export default class Backlinks {
} }
case 'documents.update': { case 'documents.update': {
// no-op for now // no-op for now
if (event.autosave) return; if (event.data.autosave) return;
// no-op for drafts // no-op for drafts
const document = await Document.findByPk(event.modelId); const document = await Document.findByPk(event.documentId);
if (!document.publishedAt) return; if (!document.publishedAt) return;
const [currentRevision, previsionRevision] = await Revision.findAll({ const [currentRevision, previsionRevision] = await Revision.findAll({
where: { documentId: event.modelId }, where: { documentId: event.documentId },
order: [['createdAt', 'desc']], order: [['createdAt', 'desc']],
limit: 2, limit: 2,
}); });
@ -51,12 +51,12 @@ export default class Backlinks {
await Promise.all( await Promise.all(
addedLinkIds.map(async linkId => { addedLinkIds.map(async linkId => {
const linkedDocument = await Document.findByPk(linkId); const linkedDocument = await Document.findByPk(linkId);
if (linkedDocument.id === event.modelId) return; if (linkedDocument.id === event.documentId) return;
await Backlink.findOrCreate({ await Backlink.findOrCreate({
where: { where: {
documentId: linkedDocument.id, documentId: linkedDocument.id,
reverseDocumentId: event.modelId, reverseDocumentId: event.documentId,
}, },
defaults: { defaults: {
userId: currentRevision.userId, userId: currentRevision.userId,
@ -71,7 +71,7 @@ export default class Backlinks {
await Backlink.destroy({ await Backlink.destroy({
where: { where: {
documentId: document.id, documentId: document.id,
reverseDocumentId: event.modelId, reverseDocumentId: event.documentId,
}, },
}); });
}) })
@ -81,12 +81,12 @@ export default class Backlinks {
case 'documents.delete': { case 'documents.delete': {
await Backlink.destroy({ await Backlink.destroy({
where: { where: {
reverseDocumentId: event.modelId, reverseDocumentId: event.documentId,
}, },
}); });
await Backlink.destroy({ await Backlink.destroy({
where: { where: {
documentId: event.modelId, documentId: event.documentId,
}, },
}); });
break; break;

View File

@ -18,12 +18,12 @@ export default class Notifications {
async documentUpdated(event: DocumentEvent) { async documentUpdated(event: DocumentEvent) {
// lets not send a notification on every autosave update // lets not send a notification on every autosave update
if (event.autosave) return; if (event.data && event.data.autosave) return;
// wait until the user has finished editing // wait until the user has finished editing
if (!event.done) return; if (event.data && !event.data.done) return;
const document = await Document.findByPk(event.modelId); const document = await Document.findByPk(event.documentId);
if (!document) return; if (!document) return;
const { collection } = document; const { collection } = document;
@ -72,7 +72,7 @@ export default class Notifications {
} }
async collectionCreated(event: CollectionEvent) { async collectionCreated(event: CollectionEvent) {
const collection = await Collection.findByPk(event.modelId, { const collection = await Collection.findByPk(event.collectionId, {
include: [ include: [
{ {
model: User, model: User,

View File

@ -58,9 +58,12 @@ export default class Slack {
async documentUpdated(event: DocumentEvent) { async documentUpdated(event: DocumentEvent) {
// lets not send a notification on every autosave update // lets not send a notification on every autosave update
if (event.autosave) return; if (event.data && event.data.autosave) return;
const document = await Document.findByPk(event.modelId); // lets not send a notification on every CMD+S update
if (event.data && !event.data.done) return;
const document = await Document.findByPk(event.documentId);
if (!document) return; if (!document) return;
// never send information on draft documents // never send information on draft documents

View File

@ -17,7 +17,7 @@ export default class Websockets {
case 'documents.unpin': case 'documents.unpin':
case 'documents.update': case 'documents.update':
case 'documents.delete': { case 'documents.delete': {
const document = await Document.findByPk(event.modelId, { const document = await Document.findByPk(event.documentId, {
paranoid: false, paranoid: false,
}); });
const documents = [await presentDocument(document)]; const documents = [await presentDocument(document)];
@ -32,7 +32,7 @@ export default class Websockets {
}); });
} }
case 'documents.create': { case 'documents.create': {
const document = await Document.findByPk(event.modelId); const document = await Document.findByPk(event.documentId);
const documents = [await presentDocument(document)]; const documents = [await presentDocument(document)];
const collections = [await presentCollection(document.collection)]; const collections = [await presentCollection(document.collection)];
@ -45,19 +45,19 @@ export default class Websockets {
case 'documents.star': case 'documents.star':
case 'documents.unstar': { case 'documents.unstar': {
return socketio.to(`user-${event.actorId}`).emit(event.name, { return socketio.to(`user-${event.actorId}`).emit(event.name, {
documentId: event.modelId, documentId: event.documentId,
}); });
} }
case 'documents.move': { case 'documents.move': {
const documents = await Document.findAll({ const documents = await Document.findAll({
where: { where: {
id: event.documentIds, id: event.data.documentIds,
}, },
paranoid: false, paranoid: false,
}); });
const collections = await Collection.findAll({ const collections = await Collection.findAll({
where: { where: {
id: event.collectionIds, id: event.data.collectionIds,
}, },
paranoid: false, paranoid: false,
}); });
@ -78,7 +78,7 @@ export default class Websockets {
return; return;
} }
case 'collections.create': { case 'collections.create': {
const collection = await Collection.findByPk(event.modelId, { const collection = await Collection.findByPk(event.collectionId, {
paranoid: false, paranoid: false,
}); });
const collections = [await presentCollection(collection)]; const collections = [await presentCollection(collection)];
@ -106,7 +106,7 @@ export default class Websockets {
} }
case 'collections.update': case 'collections.update':
case 'collections.delete': { case 'collections.delete': {
const collection = await Collection.findByPk(event.modelId, { const collection = await Collection.findByPk(event.collectionId, {
paranoid: false, paranoid: false,
}); });
const collections = [await presentCollection(collection)]; const collections = [await presentCollection(collection)];
@ -117,12 +117,12 @@ export default class Websockets {
}); });
} }
case 'collections.add_user': case 'collections.add_user':
return socketio.to(`user-${event.modelId}`).emit('join', { return socketio.to(`user-${event.userId}`).emit('join', {
event: event.name, event: event.name,
roomId: event.collectionId, roomId: event.collectionId,
}); });
case 'collections.remove_user': case 'collections.remove_user':
return socketio.to(`user-${event.modelId}`).emit('leave', { return socketio.to(`user-${event.userId}`).emit('leave', {
event: event.name, event: event.name,
roomId: event.collectionId, roomId: event.collectionId,
}); });

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { Share, Team, User, Document, Collection } from '../models'; import { Share, Team, User, Event, Document, Collection } from '../models';
import uuid from 'uuid'; import uuid from 'uuid';
let count = 0; let count = 0;
@ -27,6 +27,14 @@ export function buildTeam(overrides: Object = {}) {
}); });
} }
export function buildEvent(overrides: Object = {}) {
return Event.create({
name: 'documents.publish',
ip: '127.0.0.1',
...overrides,
});
}
export async function buildUser(overrides: Object = {}) { export async function buildUser(overrides: Object = {}) {
count++; count++;