diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index 98a1f4dfd..a5fae45e7 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -24,7 +24,8 @@ const siteIdRequiredPaths = [ '/heatmaps', '/custom_metrics', '/dashboards', - '/metrics' + '/metrics', + '/trails', // '/custom_metrics/sessions', ]; diff --git a/frontend/app/components/Client/Audit/AuditDetailModal/AuditDetailModal.tsx b/frontend/app/components/Client/Audit/AuditDetailModal/AuditDetailModal.tsx new file mode 100644 index 000000000..934604dbb --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditDetailModal/AuditDetailModal.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { JSONTree } from 'UI'; +import { checkForRecent } from 'App/date'; + +interface Props { + audit: any; +} +function AuditDetailModal(props: Props) { + const { audit } = props; + // const jsonResponse = typeof audit.payload === 'string' ? JSON.parse(audit.payload) : audit.payload; + // console.log('jsonResponse', jsonResponse) + + return ( +
+

Audit Details

+
+
{ 'URL'}
+
{ audit.endPoint }
+ +
+
+
Username
+
{audit.username}
+
+
+
Created At
+
{audit.createdAt && checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}
+
+
+ +
+
+
Action
+
{audit.action}
+
+
+
Method
+
{audit.method}
+
+
+ + { audit.payload && ( +
+
Payload
+ +
+ )} +
+
+ ); +} + +export default AuditDetailModal; \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditDetailModal/index.ts b/frontend/app/components/Client/Audit/AuditDetailModal/index.ts new file mode 100644 index 000000000..fcf6bb2b4 --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditDetailModal/index.ts @@ -0,0 +1 @@ +export { default } from './AuditDetailModal'; \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditList/AuditList.tsx b/frontend/app/components/Client/Audit/AuditList/AuditList.tsx new file mode 100644 index 000000000..5ab4c0d3a --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditList/AuditList.tsx @@ -0,0 +1,67 @@ +import { useModal } from 'App/components/Modal'; +import { useStore } from 'App/mstore'; +import { useObserver } from 'mobx-react-lite'; +import React, { useEffect } from 'react'; +import { Loader, Pagination, NoContent } from 'UI'; +import AuditDetailModal from '../AuditDetailModal'; +import AuditListItem from '../AuditListItem'; + +interface Props { + +} +function AuditList(props: Props) { + const { auditStore } = useStore(); + const loading = useObserver(() => auditStore.isLoading); + const list = useObserver(() => auditStore.list); + const searchQuery = useObserver(() => auditStore.searchQuery); + const page = useObserver(() => auditStore.page); + const order = useObserver(() => auditStore.order); + const period = useObserver(() => auditStore.period); + const { showModal } = useModal(); + console.log('AuditList', period.toTimestamps()); + + useEffect(() => { + const { startTimestamp, endTimestamp } = period.toTimestamps(); + auditStore.fetchAudits({ + page: auditStore.page, + limit: auditStore.pageSize, + query: auditStore.searchQuery, + order: auditStore.order, + startDate: startTimestamp, + endDate: endTimestamp, + }); + }, [page, searchQuery, order, period]); + + return useObserver(() => ( + + +
+
Name
+
Status
+
Time
+
+ + {list.map((item, index) => ( +
+ showModal(, { right: true })} + /> +
+ ))} + +
+ auditStore.updateKey('page', page)} + limit={auditStore.pageSize} + debounceRequest={200} + /> +
+
+
+ )); +} + +export default AuditList; \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditList/index.ts b/frontend/app/components/Client/Audit/AuditList/index.ts new file mode 100644 index 000000000..2e6bc3739 --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditList/index.ts @@ -0,0 +1 @@ +export { default } from './AuditList' \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditListItem/AuditListItem.tsx b/frontend/app/components/Client/Audit/AuditListItem/AuditListItem.tsx new file mode 100644 index 000000000..7d584bb06 --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditListItem/AuditListItem.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { checkForRecent } from 'App/date'; + +interface Props { + audit: any; + onShowDetails: () => void; +} +function AuditListItem(props: Props) { + const { audit, onShowDetails } = props; + return ( +
+
{audit.username}
+
{audit.action}
+
{audit.createdAt && checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}
+
+ ); +} + +export default AuditListItem; \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditListItem/index.ts b/frontend/app/components/Client/Audit/AuditListItem/index.ts new file mode 100644 index 000000000..821ee9639 --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditListItem/index.ts @@ -0,0 +1 @@ +export { default } from './AuditListItem'; \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditSearchField/AuditSearchField.tsx b/frontend/app/components/Client/Audit/AuditSearchField/AuditSearchField.tsx new file mode 100644 index 000000000..5574da847 --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditSearchField/AuditSearchField.tsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; +import { Icon } from 'UI'; +import { debounce } from 'App/utils'; + +let debounceUpdate: any = () => {} +interface Props { + onChange: (value: string) => void; +} +function AuditSearchField(props: Props) { + const { onChange } = props; + + useEffect(() => { + debounceUpdate = debounce((value) => onChange(value), 500); + }, []) + + const write = ({ target: { name, value } }) => { + debounceUpdate(value); + } + + return ( +
+ + +
+ ); +} + +export default AuditSearchField; \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditSearchField/index.ts b/frontend/app/components/Client/Audit/AuditSearchField/index.ts new file mode 100644 index 000000000..646947095 --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditSearchField/index.ts @@ -0,0 +1 @@ +export { default } from './AuditSearchField'; \ No newline at end of file diff --git a/frontend/app/components/Client/Audit/AuditView/AuditView.tsx b/frontend/app/components/Client/Audit/AuditView/AuditView.tsx new file mode 100644 index 000000000..41d506b11 --- /dev/null +++ b/frontend/app/components/Client/Audit/AuditView/AuditView.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { PageTitle, Icon } from 'UI'; +import AuditList from '../AuditList'; +import AuditSearchField from '../AuditSearchField'; +import { useStore } from 'App/mstore'; +import { useObserver } from 'mobx-react-lite'; +import Select from 'Shared/Select'; +import SelectDateRange from 'Shared/SelectDateRange'; + +function AuditView(props) { + const { auditStore } = useStore(); + const order = useObserver(() => auditStore.order); + const total = useObserver(() => auditStore.total); + + const exportToCsv = () => { + auditStore.exportToCsv(); + } + + const onChange = (data) => { + auditStore.setDateRange(data); + } + + return useObserver(() => ( +
+
+ + Audit Trail + {total} +
+ } /> +
+
+ +
+
+ onChange(value)} + components={{ SingleValue: ({ children, ...props} : any) => { + return ( + + {period.rangeName === CUSTOM_RANGE ? period.rangeFormatted() : children} + + ) + } }} + period={period} + /> + { + isCustom && + setIsCustom(false)} + > +
+ setIsCustom(false) } + selectedDateRange={ period.range } + /> +
+
+ } +
+ ); +} + +export default SelectDateRange; + + \ No newline at end of file diff --git a/frontend/app/components/shared/SelectDateRange/index.ts b/frontend/app/components/shared/SelectDateRange/index.ts new file mode 100644 index 000000000..9b59aa99b --- /dev/null +++ b/frontend/app/components/shared/SelectDateRange/index.ts @@ -0,0 +1 @@ +export { default } from './SelectDateRange'; \ No newline at end of file diff --git a/frontend/app/dateRange.js b/frontend/app/dateRange.js index b50ec2fb9..4a2ea923d 100644 --- a/frontend/app/dateRange.js +++ b/frontend/app/dateRange.js @@ -23,6 +23,13 @@ Object.keys(DATE_RANGE_LABELS).forEach((key) => { DATE_RANGE_VALUES[ key ] = key export { DATE_RANGE_VALUES }; export const dateRangeValues = Object.keys(DATE_RANGE_VALUES); +export const DATE_RANGE_OPTIONS = Object.keys(DATE_RANGE_LABELS).map((key) => { + return { + label: DATE_RANGE_LABELS[ key ], + value: key, + }; +}); + export function getDateRangeFromTs(start, end) { return moment.range( moment(start), diff --git a/frontend/app/mstore/auditStore.ts b/frontend/app/mstore/auditStore.ts new file mode 100644 index 000000000..efc34d65d --- /dev/null +++ b/frontend/app/mstore/auditStore.ts @@ -0,0 +1,86 @@ +import { makeAutoObservable, runInAction, observable, action, reaction } from "mobx" +import { auditService } from "App/services" +import Audit from './types/audit' +import Period, { LAST_7_DAYS } from 'Types/app/period'; +import { toast } from 'react-toastify'; +import { exportCSVFile } from 'App/utils'; +import { formatDateTimeDefault } from 'App/date'; +import { DateTime, Duration } from 'luxon'; // TODO + +export default class AuditStore { + list: any[] = []; + total: number = 0; + page: number = 1; + pageSize: number = 20; + searchQuery: string = ''; + isLoading: boolean = false; + order: string = 'desc'; + period: Period|null = Period({ rangeName: LAST_7_DAYS }) + + constructor() { + makeAutoObservable(this, { + searchQuery: observable, + period: observable, + updateKey: action, + fetchAudits: action, + setDateRange: action, + }) + } + + setDateRange(data: any) { + this['period'] = data; + } + + updateKey(key: string, value: any) { + this[key] = value; + } + + fetchAudits = (data: any): Promise => { + this.isLoading = true; + return new Promise((resolve, reject) => { + auditService.all(data).then(response => { + runInAction(() => { + this.list = response.sessions.map(item => Audit.fromJson(item)) + this.total = response.count + }) + resolve() + }).catch(error => { + reject(error) + }).finally(() => { + this.isLoading = false; + }) + }) + } + + fetchAllAudits = async (data: any): Promise => { + return new Promise((resolve, reject) => { + auditService.all(data).then((data) => { + const headers = [ + { label: 'User', key: 'username' }, + { label: 'Email', key: 'email' }, + { label: 'UserID', key: 'userId' }, + { label: 'Method', key: 'method' }, + { label: 'Action', key: 'action' }, + { label: 'Endpoint', key: 'endpoint' }, + { label: 'Created At', key: 'createdAt' }, + ] + data = data.sessions.map(item => ({ + ...item, + createdAt: DateTime.fromMillis(item.createdAt).toFormat('LLL dd yyyy hh:mm a') + })) + exportCSVFile(headers, data, `audit-${new Date().toLocaleDateString()}`); + resolve(data) + }).catch(error => { + reject(error) + }) + }) + } + + exportToCsv = async (): Promise => { + const promise = this.fetchAllAudits({ limit: this.total }) + toast.promise(promise, { + pending: 'Exporting...', + success: 'Export successful', + }) + } +} \ No newline at end of file diff --git a/frontend/app/mstore/index.tsx b/frontend/app/mstore/index.tsx index f29b5cedf..11d8f35b6 100644 --- a/frontend/app/mstore/index.tsx +++ b/frontend/app/mstore/index.tsx @@ -5,8 +5,9 @@ import UserStore from './userStore'; import RoleStore from './roleStore'; import APIClient from 'App/api_client'; import FunnelStore from './funnelStore'; -import { dashboardService, metricService, funnelService, sessionService, userService } from 'App/services'; +import { dashboardService, metricService, funnelService, sessionService, userService, auditService } from 'App/services'; import SettingsStore from './settingsStore'; +import AuditStore from './auditStore'; export class RootStore { dashboardStore: IDashboardSotre; @@ -15,6 +16,7 @@ export class RootStore { settingsStore: SettingsStore; userStore: UserStore; roleStore: RoleStore; + auditStore: AuditStore; constructor() { this.dashboardStore = new DashboardStore(); @@ -23,6 +25,7 @@ export class RootStore { this.settingsStore = new SettingsStore(); this.userStore = new UserStore(); this.roleStore = new RoleStore(); + this.auditStore = new AuditStore(); } initClient() { @@ -32,6 +35,7 @@ export class RootStore { funnelService.initClient(client) sessionService.initClient(client) userService.initClient(client) + auditService.initClient(client) } } diff --git a/frontend/app/mstore/types/audit.ts b/frontend/app/mstore/types/audit.ts new file mode 100644 index 000000000..dd8f7c095 --- /dev/null +++ b/frontend/app/mstore/types/audit.ts @@ -0,0 +1,40 @@ +import { DateTime } from 'luxon'; +import { unserscoreToSpaceAndCapitalize } from 'App/utils'; + +export default class Audit { + id: string = ''; + username: string = ''; + email: string = ''; + action: string = ''; + createdAt: any = null; + endPoint: string = ''; + parameters: any = {}; + method: string = ''; + status: string = ''; + payload: any = {} + + constructor() { + } + + static fromJson(json: any): Audit { + const audit = new Audit(); + audit.id = json.rn; + audit.username = json.username; + audit.action = unserscoreToSpaceAndCapitalize(json.action); + audit.createdAt = json.createdAt && DateTime.fromMillis(json.createdAt || 0); + audit.endPoint = json.endpoint; + audit.parameters = json.parameters; + audit.method = json.method; + audit.status = json.status + audit.email = json.email + audit.payload = typeof json.payload === 'string' ? JSON.parse(json.payload) : json.payload + return audit; + } + + toJson(): any { + return { + id: this.id, + username: this.username + }; + } +} \ No newline at end of file diff --git a/frontend/app/mstore/types/user.ts b/frontend/app/mstore/types/user.ts index c69b31067..53ae5dbf0 100644 --- a/frontend/app/mstore/types/user.ts +++ b/frontend/app/mstore/types/user.ts @@ -60,7 +60,7 @@ export default class User implements IUser { this.userId = json.userId || json.id; // TODO api returning id this.name = json.name; this.email = json.email; - this.createdAt = json.createdAt && DateTime.fromISO(json.createdAt || 0) + this.createdAt = json.createdAt && DateTime.fromMillis(json.createdAt || 0) this.isAdmin = json.admin this.isSuperAdmin = json.superAdmin this.isJoined = json.joined diff --git a/frontend/app/routes.js b/frontend/app/routes.js index fd8859935..91ccc8578 100644 --- a/frontend/app/routes.js +++ b/frontend/app/routes.js @@ -61,10 +61,11 @@ export const CLIENT_TABS = { PROFILE: 'account', MANAGE_USERS: 'team', MANAGE_ROLES: 'roles', - SITES: 'projects', + SITES: 'projects', CUSTOM_FIELDS: 'metadata', WEBHOOKS: 'webhooks', NOTIFICATIONS: 'notifications', + AUDIT: 'audit', }; export const CLIENT_DEFAULT_TAB = CLIENT_TABS.PROFILE; const routerClientTabString = `:activeTab(${ Object.values(CLIENT_TABS).join('|') })`; diff --git a/frontend/app/services/AuditService.ts b/frontend/app/services/AuditService.ts new file mode 100644 index 000000000..39b7521ed --- /dev/null +++ b/frontend/app/services/AuditService.ts @@ -0,0 +1,25 @@ +import APIClient from 'App/api_client'; + +export default class AuditService { + private client: APIClient; + + constructor(client?: APIClient) { + this.client = client ? client : new APIClient(); + } + + initClient(client?: APIClient) { + this.client = client || new APIClient(); + } + + all(data: any): Promise { + return this.client.post('/trails', data) + .then(response => response.json()) + .then(response => response.data || []); + } + + one(id: string): Promise { + return this.client.get('/trails/' + id) + .then(response => response.json()) + .then(response => response.data || {}); + } +} \ No newline at end of file diff --git a/frontend/app/services/index.ts b/frontend/app/services/index.ts index 7ddcac0ff..ace22e0fb 100644 --- a/frontend/app/services/index.ts +++ b/frontend/app/services/index.ts @@ -3,9 +3,11 @@ import MetricService, { IMetricService } from "./MetricService"; import FunnelService, { IFunnelService } from "./FunnelService"; import SessionSerivce from "./SessionService"; import UserService from "./UserService"; +import AuditService from './AuditService'; export const dashboardService: IDashboardService = new DashboardService(); export const metricService: IMetricService = new MetricService(); export const sessionService: SessionSerivce = new SessionSerivce(); export const userService: UserService = new UserService(); export const funnelService: IFunnelService = new FunnelService(); +export const auditService: AuditService = new AuditService(); diff --git a/frontend/app/svg/icons/grid-3x3.svg b/frontend/app/svg/icons/grid-3x3.svg new file mode 100644 index 000000000..c40d98c96 --- /dev/null +++ b/frontend/app/svg/icons/grid-3x3.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/list-ul.svg b/frontend/app/svg/icons/list-ul.svg new file mode 100644 index 000000000..a07021b20 --- /dev/null +++ b/frontend/app/svg/icons/list-ul.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/text-paragraph.svg b/frontend/app/svg/icons/text-paragraph.svg index 9779beabf..f06f83174 100644 --- a/frontend/app/svg/icons/text-paragraph.svg +++ b/frontend/app/svg/icons/text-paragraph.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/frontend/app/types/app/period.js b/frontend/app/types/app/period.js index deebf9cad..b4f8798c5 100644 --- a/frontend/app/types/app/period.js +++ b/frontend/app/types/app/period.js @@ -103,6 +103,10 @@ export default Record({ endTimestamp: this.end, }; }, + rangeFormatted(format = 'MMM Do YY, hh:mm A') { + console.log('period', this) + return this.range.start.format(format) + ' - ' + this.range.end.format(format); + }, toTimestampstwo() { return { startTimestamp: this.start / 1000, diff --git a/frontend/app/utils.js b/frontend/app/utils.js index bdece4058..029e24599 100644 --- a/frontend/app/utils.js +++ b/frontend/app/utils.js @@ -264,4 +264,55 @@ export const convertElementToImage = async (el) => { }, }); return image; +} + +export const unserscoreToSpaceAndCapitalize = (str) => { + return str.replace(/_/g, ' ').replace(/\w\S*/g, (txt) => { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); + }); +} + +export const convertToCSV = (headers, objArray) => { + var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray; + var str = ''; + const headersMap = headers.reduce((acc, curr) => { + acc[curr.key] = curr; + return acc; + }, {}); + + str += headers.map(h => h.label).join(',') + '\r\n'; + + for (var i = 0; i < array.length; i++) { + var line = ''; + for (var index in headersMap) { + if (line !== '') line += ','; + line += array[i][index]; + } + str += line + '\r\n'; + } + + + return str; +} + +export const exportCSVFile = (headers, items, fileTitle) => { + var jsonObject = JSON.stringify(items); + var csv = convertToCSV(headers, jsonObject); + var exportedFilenmae = fileTitle + '.csv' || 'export.csv'; + + var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + if (navigator.msSaveBlob) { // IE 10+ + navigator.msSaveBlob(blob, exportedFilenmae); + } else { + var link = document.createElement("a"); + if (link.download !== undefined) { + var url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", exportedFilenmae); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } } \ No newline at end of file