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,
LinkIcon,
TeamIcon,
BulletedListIcon,
} from 'outline-icons';
import ZapierIcon from './icons/Zapier';
import SlackIcon from './icons/Slack';
@@ -94,6 +95,13 @@ class SettingsSidebar extends React.Component<Props> {
icon={<LinkIcon />}
label="Share Links"
/>
{user.isAdmin && (
<SidebarLink
to="/settings/events"
icon={<BulletedListIcon />}
label="Audit Log"
/>
)}
{user.isAdmin && (
<SidebarLink
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 Tokens from 'scenes/Settings/Tokens';
import Export from 'scenes/Settings/Export';
import Events from 'scenes/Settings/Events';
import Error404 from 'scenes/Error404';
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/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route exact path="/settings/events" component={Events} />
<Route
exact
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);
this.isLoaded = true;
});
return res.data;
} finally {
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 CollectionsStore from './CollectionsStore';
import DocumentsStore from './DocumentsStore';
import EventsStore from './EventsStore';
import IntegrationsStore from './IntegrationsStore';
import NotificationSettingsStore from './NotificationSettingsStore';
import RevisionsStore from './RevisionsStore';
@@ -16,6 +17,7 @@ export default class RootStore {
auth: AuthStore;
collections: CollectionsStore;
documents: DocumentsStore;
events: EventsStore;
integrations: IntegrationsStore;
notificationSettings: NotificationSettingsStore;
revisions: RevisionsStore;
@@ -29,6 +31,7 @@ export default class RootStore {
this.auth = new AuthStore(this);
this.collections = new CollectionsStore(this);
this.documents = new DocumentsStore(this);
this.events = new EventsStore(this);
this.integrations = new IntegrationsStore(this);
this.notificationSettings = new NotificationSettingsStore(this);
this.revisions = new RevisionsStore(this);
@@ -42,6 +45,7 @@ export default class RootStore {
this.apiKeys.clear();
this.collections.clear();
this.documents.clear();
this.events.clear();
this.integrations.clear();
this.notificationSettings.clear();
this.revisions.clear();