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,
|
||||
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
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 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"
|
||||
|
||||
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);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
return res.data;
|
||||
} finally {
|
||||
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 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();
|
||||
|
||||
Reference in New Issue
Block a user