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:
@ -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
33
app/models/Event.js
Normal 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;
|
@ -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"
|
||||||
|
99
app/scenes/Settings/Events.js
Normal file
99
app/scenes/Settings/Events.js
Normal 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 we’re 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);
|
125
app/scenes/Settings/components/EventListItem.js
Normal file
125
app/scenes/Settings/components/EventListItem.js
Normal 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 ·{' '}
|
||||||
|
{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;
|
@ -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
19
app/stores/EventsStore.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
10
server/api/__snapshots__/events.test.js.snap
Normal file
10
server/api/__snapshots__/events.test.js.snap
Normal 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,
|
||||||
|
}
|
||||||
|
`;
|
@ -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 = {
|
||||||
|
@ -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
58
server/api/events.js
Normal 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
49
server/api/events.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
@ -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());
|
||||||
|
@ -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 = {
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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 });
|
||||||
|
@ -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 });
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
35
server/migrations/20190606035733-events.js
Normal file
35
server/migrations/20190606035733-events.js
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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 />
|
||||||
|
@ -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;
|
||||||
|
24
server/presenters/event.js
Normal file
24
server/presenters/event.js
Normal 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;
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -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++;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user