;
25 |
26 | export const inject =
27 | (...stores: I[]) =>
28 | // eslint-disable-next-line @typescript-eslint/ban-types
29 | (
30 | node: React.ComponentType
31 | ): React.ComponentType>> =>
32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
33 | mobxInject(...stores)(node) as any;
34 |
35 | export const InjectProvider: React.FC<{stores: StoreMapping}> = ({children, stores}) => (
36 | {children}
37 | );
38 |
--------------------------------------------------------------------------------
/ui/src/message/WebSocketStore.ts:
--------------------------------------------------------------------------------
1 | import {SnackReporter} from '../snack/SnackManager';
2 | import {CurrentUser} from '../CurrentUser';
3 | import * as config from '../config';
4 | import {AxiosError} from 'axios';
5 | import {IMessage} from '../types';
6 |
7 | export class WebSocketStore {
8 | private wsActive = false;
9 | private ws: WebSocket | null = null;
10 |
11 | public constructor(
12 | private readonly snack: SnackReporter,
13 | private readonly currentUser: CurrentUser
14 | ) {}
15 |
16 | public listen = (callback: (msg: IMessage) => void) => {
17 | if (!this.currentUser.token() || this.wsActive) {
18 | return;
19 | }
20 | this.wsActive = true;
21 |
22 | const wsUrl = config.get('url').replace('http', 'ws').replace('https', 'wss');
23 | const ws = new WebSocket(wsUrl + 'stream?token=' + this.currentUser.token());
24 |
25 | ws.onerror = (e) => {
26 | this.wsActive = false;
27 | console.log('WebSocket connection errored', e);
28 | };
29 |
30 | ws.onmessage = (data) => callback(JSON.parse(data.data));
31 |
32 | ws.onclose = () => {
33 | this.wsActive = false;
34 | this.currentUser
35 | .tryAuthenticate()
36 | .then(() => {
37 | this.snack('WebSocket connection closed, trying again in 30 seconds.');
38 | setTimeout(() => this.listen(callback), 30000);
39 | })
40 | .catch((error: AxiosError) => {
41 | if (error?.response?.status === 401) {
42 | this.snack('Could not authenticate with client token, logging out.');
43 | }
44 | });
45 | };
46 |
47 | this.ws = ws;
48 | };
49 |
50 | public close = () => this.ws?.close(1000, 'WebSocketStore#close');
51 | }
52 |
--------------------------------------------------------------------------------
/ui/src/message/extras.ts:
--------------------------------------------------------------------------------
1 | import {IMessageExtras} from '../types';
2 |
3 | export enum RenderMode {
4 | Markdown = 'text/markdown',
5 | Plain = 'text/plain',
6 | }
7 |
8 | export const contentType = (extras?: IMessageExtras): RenderMode => {
9 | const type = extract(extras, 'client::display', 'contentType');
10 | const valid = Object.keys(RenderMode)
11 | .map((k) => RenderMode[k])
12 | .some((mode) => mode === type);
13 | return valid ? type : RenderMode.Plain;
14 | };
15 |
16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
17 | const extract = (extras: IMessageExtras | undefined, key: string, path: string): any => {
18 | if (!extras) {
19 | return null;
20 | }
21 |
22 | if (!extras[key]) {
23 | return null;
24 | }
25 |
26 | if (!extras[key][path]) {
27 | return null;
28 | }
29 |
30 | return extras[key][path];
31 | };
32 |
--------------------------------------------------------------------------------
/ui/src/plugin/PluginStore.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import {action} from 'mobx';
3 | import {BaseStore} from '../common/BaseStore';
4 | import * as config from '../config';
5 | import {SnackReporter} from '../snack/SnackManager';
6 | import {IPlugin} from '../types';
7 |
8 | export class PluginStore extends BaseStore {
9 | public onDelete: () => void = () => {};
10 |
11 | public constructor(private readonly snack: SnackReporter) {
12 | super();
13 | }
14 |
15 | public requestConfig = (id: number): Promise =>
16 | axios.get(`${config.get('url')}plugin/${id}/config`).then((response) => response.data);
17 |
18 | public requestDisplay = (id: number): Promise =>
19 | axios.get(`${config.get('url')}plugin/${id}/display`).then((response) => response.data);
20 |
21 | protected requestItems = (): Promise =>
22 | axios.get(`${config.get('url')}plugin`).then((response) => response.data);
23 |
24 | protected requestDelete = (): Promise => {
25 | this.snack('Cannot delete plugin');
26 | throw new Error('Cannot delete plugin');
27 | };
28 |
29 | public getName = (id: number): string => {
30 | const plugin = this.getByIDOrUndefined(id);
31 | return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown';
32 | };
33 |
34 | @action
35 | public changeConfig = async (id: number, newConfig: string): Promise => {
36 | await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, {
37 | headers: {'content-type': 'application/x-yaml'},
38 | });
39 | this.snack(`Plugin config updated`);
40 | await this.refresh();
41 | };
42 |
43 | @action
44 | public changeEnabledState = async (id: number, enabled: boolean): Promise => {
45 | await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`);
46 | this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`);
47 | await this.refresh();
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/ui/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/ui/src/reactions.ts:
--------------------------------------------------------------------------------
1 | import {StoreMapping} from './inject';
2 | import {reaction} from 'mobx';
3 | import * as Notifications from './snack/browserNotification';
4 |
5 | export const registerReactions = (stores: StoreMapping) => {
6 | const clearAll = () => {
7 | stores.messagesStore.clearAll();
8 | stores.appStore.clear();
9 | stores.clientStore.clear();
10 | stores.userStore.clear();
11 | stores.wsStore.close();
12 | };
13 | const loadAll = () => {
14 | stores.wsStore.listen((message) => {
15 | stores.messagesStore.publishSingleMessage(message);
16 | Notifications.notifyNewMessage(message);
17 | if (message.priority >= 4) {
18 | const src = 'static/notification.ogg';
19 | const audio = new Audio(src);
20 | audio.play();
21 | }
22 | });
23 | stores.appStore.refresh();
24 | };
25 |
26 | reaction(
27 | () => stores.currentUser.loggedIn,
28 | (loggedIn) => {
29 | if (loggedIn) {
30 | loadAll();
31 | } else {
32 | clearAll();
33 | }
34 | }
35 | );
36 |
37 | reaction(
38 | () => stores.currentUser.connectionErrorMessage,
39 | (connectionErrorMessage) => {
40 | if (!connectionErrorMessage) {
41 | clearAll();
42 | loadAll();
43 | }
44 | }
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/ui/src/registerServiceWorker.ts:
--------------------------------------------------------------------------------
1 | export function unregister() {
2 | if ('serviceWorker' in navigator) {
3 | navigator.serviceWorker.ready.then((registration) => {
4 | registration.unregister();
5 | });
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/ui/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | jest.setTimeout(process.env.CI === 'true' ? 50000 : 20000);
2 |
--------------------------------------------------------------------------------
/ui/src/snack/SnackBarHandler.tsx:
--------------------------------------------------------------------------------
1 | import IconButton from '@material-ui/core/IconButton';
2 | import Snackbar from '@material-ui/core/Snackbar';
3 | import Close from '@material-ui/icons/Close';
4 | import React, {Component} from 'react';
5 | import {observable, reaction} from 'mobx';
6 | import {observer} from 'mobx-react';
7 | import {inject, Stores} from '../inject';
8 |
9 | @observer
10 | class SnackBarHandler extends Component> {
11 | private static MAX_VISIBLE_SNACK_TIME_IN_MS = 6000;
12 | private static MIN_VISIBLE_SNACK_TIME_IN_MS = 1000;
13 |
14 | @observable
15 | private open = false;
16 | @observable
17 | private openWhen = 0;
18 |
19 | private dispose: () => void = () => {};
20 |
21 | public componentDidMount = () =>
22 | (this.dispose = reaction(() => this.props.snackManager.counter, this.onNewSnack));
23 |
24 | public componentWillUnmount = () => this.dispose();
25 |
26 | public render() {
27 | const {message: current, hasNext} = this.props.snackManager;
28 | const duration = hasNext()
29 | ? SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS
30 | : SnackBarHandler.MAX_VISIBLE_SNACK_TIME_IN_MS;
31 |
32 | return (
33 | {current}}
40 | action={
41 |
46 |
47 |
48 | }
49 | />
50 | );
51 | }
52 |
53 | private onNewSnack = () => {
54 | const {open, openWhen} = this;
55 |
56 | if (!open) {
57 | this.openNextSnack();
58 | return;
59 | }
60 |
61 | const snackOpenSince = Date.now() - openWhen;
62 | if (snackOpenSince > SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS) {
63 | this.closeCurrentSnack();
64 | } else {
65 | setTimeout(
66 | this.closeCurrentSnack,
67 | SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS - snackOpenSince
68 | );
69 | }
70 | };
71 |
72 | private openNextSnack = () => {
73 | if (this.props.snackManager.hasNext()) {
74 | this.open = true;
75 | this.openWhen = Date.now();
76 | this.props.snackManager.next();
77 | }
78 | };
79 |
80 | private closeCurrentSnack = () => (this.open = false);
81 | }
82 |
83 | export default inject('snackManager')(SnackBarHandler);
84 |
--------------------------------------------------------------------------------
/ui/src/snack/SnackManager.ts:
--------------------------------------------------------------------------------
1 | import {action, observable} from 'mobx';
2 |
3 | export interface SnackReporter {
4 | (message: string): void;
5 | }
6 |
7 | export class SnackManager {
8 | @observable
9 | private messages: string[] = [];
10 | @observable
11 | public message: string | null = null;
12 | @observable
13 | public counter = 0;
14 |
15 | @action
16 | public next = (): void => {
17 | if (!this.hasNext()) {
18 | throw new Error('There is nothing here :(');
19 | }
20 | this.message = this.messages.shift() as string;
21 | };
22 |
23 | public hasNext = () => this.messages.length > 0;
24 |
25 | @action
26 | public snack: SnackReporter = (message: string): void => {
27 | this.messages.push(message);
28 | this.counter++;
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/ui/src/snack/browserNotification.ts:
--------------------------------------------------------------------------------
1 | import Notify from 'notifyjs';
2 | import removeMarkdown from 'remove-markdown';
3 | import {IMessage} from '../types';
4 |
5 | export function mayAllowPermission(): boolean {
6 | return Notify.needsPermission && Notify.isSupported() && Notification.permission !== 'denied';
7 | }
8 |
9 | export function requestPermission() {
10 | if (Notify.needsPermission && Notify.isSupported()) {
11 | Notify.requestPermission(
12 | () => console.log('granted notification permissions'),
13 | () => console.log('notification permission denied')
14 | );
15 | }
16 | }
17 |
18 | export function notifyNewMessage(msg: IMessage) {
19 | const notify = new Notify(msg.title, {
20 | body: removeMarkdown(msg.message),
21 | icon: msg.image,
22 | silent: true,
23 | notifyClick: closeAndFocus,
24 | notifyShow: closeAfterTimeout,
25 | });
26 | notify.show();
27 | }
28 |
29 | function closeAndFocus(event: Event) {
30 | if (window.parent) {
31 | window.parent.focus();
32 | }
33 | window.focus();
34 | window.location.href = '/';
35 | const target = event.target as Notification;
36 | target.close();
37 | }
38 |
39 | function closeAfterTimeout(event: Event) {
40 | setTimeout(() => {
41 | const target = event.target as Notification;
42 | target.close();
43 | }, 5000);
44 | }
45 |
--------------------------------------------------------------------------------
/ui/src/tests/authentication.ts:
--------------------------------------------------------------------------------
1 | import {Page} from 'puppeteer';
2 | import {waitForExists} from './utils';
3 | import * as selector from './selector';
4 |
5 | const $loginForm = selector.form('#login-form');
6 |
7 | export const login = async (page: Page, user = 'admin', pass = 'admin'): Promise => {
8 | await waitForExists(page, selector.heading(), 'Login');
9 | expect(page.url()).toContain('/login');
10 | await page.type($loginForm.input('.name'), user);
11 | await page.type($loginForm.input('.password'), pass);
12 | await page.click($loginForm.button('.login'));
13 | await waitForExists(page, selector.heading(), 'All Messages');
14 | await waitForExists(page, 'button', 'logout');
15 | };
16 |
17 | export const logout = async (page: Page): Promise => {
18 | await page.click('#logout');
19 | await waitForExists(page, selector.heading(), 'Login');
20 | expect(page.url()).toContain('/login');
21 | };
22 |
--------------------------------------------------------------------------------
/ui/src/tests/selector.ts:
--------------------------------------------------------------------------------
1 | export const heading = () => `main h4`;
2 |
3 | export const table = (tableSelector: string) => ({
4 | selector: () => tableSelector,
5 | rows: () => `${tableSelector} tbody tr`,
6 | row: (index: number) => `${tableSelector} tbody tr:nth-child(${index})`,
7 | cell: (index: number, col: number, suffix = '') =>
8 | `${tableSelector} tbody tr:nth-child(${index}) td:nth-child(${col}) ${suffix}`,
9 | });
10 |
11 | export const form = (dialogSelector: string) => ({
12 | selector: () => dialogSelector,
13 | input: (selector: string) => `${dialogSelector} ${selector} input`,
14 | textarea: (selector: string) => `${dialogSelector} ${selector} textarea`,
15 | button: (selector: string) => `${dialogSelector} button${selector}`,
16 | });
17 |
18 | export const $confirmDialog = form('.confirm-dialog');
19 |
--------------------------------------------------------------------------------
/ui/src/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import {ElementHandle, JSHandle, Page} from 'puppeteer';
2 |
3 | export const innerText = async (page: ElementHandle | Page, selector: string): Promise => {
4 | const element = await page.$(selector);
5 | const handle = await element!.getProperty('innerText');
6 | const value = await handle.jsonValue();
7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
8 | return (value as any).toString().trim();
9 | };
10 |
11 | export const clickByText = async (page: Page, selector: string, text: string): Promise => {
12 | await waitForExists(page, selector, text);
13 | text = text.toLowerCase();
14 | await page.evaluate(
15 | (_selector, _text) => {
16 | (
17 | Array.from(document.querySelectorAll(_selector)).filter(
18 | (element) => element.textContent?.toLowerCase().trim() === _text
19 | )[0] as HTMLButtonElement
20 | ).click();
21 | },
22 | selector,
23 | text
24 | );
25 | };
26 |
27 | export const count = async (page: Page, selector: string): Promise =>
28 | page.$$(selector).then((elements) => elements.length);
29 |
30 | export const waitToDisappear = async (page: Page, selector: string): Promise =>
31 | page.waitForFunction((_selector: string) => !document.querySelector(_selector), {}, selector);
32 |
33 | export const waitForCount = async (
34 | page: Page,
35 | selector: string,
36 | amount: number
37 | ): Promise =>
38 | page.waitForFunction(
39 | (_selector: string, _amount: number) =>
40 | document.querySelectorAll(_selector).length === _amount,
41 | {},
42 | selector,
43 | amount
44 | );
45 |
46 | export const waitForExists = async (page: Page, selector: string, text: string): Promise => {
47 | text = text.toLowerCase();
48 | await page.waitForFunction(
49 | (_selector: string, _text: string) =>
50 | Array.from(document.querySelectorAll(_selector)).filter(
51 | (element) => element.textContent!.toLowerCase().trim() === _text
52 | ).length > 0,
53 | {},
54 | selector,
55 | text
56 | );
57 | };
58 |
59 | export const clearField = async (element: ElementHandle | Page, selector: string) => {
60 | const elementHandle = await element.$(selector);
61 | if (!elementHandle) {
62 | fail();
63 | }
64 | await elementHandle.click();
65 | await elementHandle.focus();
66 | // click three times to select all
67 | await elementHandle.click({clickCount: 3});
68 | await elementHandle.press('Backspace');
69 | };
70 |
--------------------------------------------------------------------------------
/ui/src/typedef/notifyjs.d.ts:
--------------------------------------------------------------------------------
1 | import Notify = require('notifyjs');
2 | export as namespace notifyjs;
3 | export = Notify;
4 |
--------------------------------------------------------------------------------
/ui/src/typedef/react-timeago.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-timeago' {
2 | import React from 'react';
3 |
4 | export interface ITimeAgoProps {
5 | date: string;
6 | }
7 |
8 | export default class TimeAgo extends React.Component {}
9 | }
10 |
--------------------------------------------------------------------------------
/ui/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface IApplication {
2 | id: number;
3 | token: string;
4 | name: string;
5 | description: string;
6 | image: string;
7 | internal: boolean;
8 | defaultPriority: number;
9 | lastUsed: string | null;
10 | }
11 |
12 | export interface IClient {
13 | id: number;
14 | token: string;
15 | name: string;
16 | lastUsed: string | null;
17 | }
18 |
19 | export interface IPlugin {
20 | id: number;
21 | token: string;
22 | name: string;
23 | modulePath: string;
24 | enabled: boolean;
25 | author?: string;
26 | website?: string;
27 | license?: string;
28 | capabilities: Array<'webhooker' | 'displayer' | 'configurer' | 'messenger' | 'storager'>;
29 | }
30 |
31 | export interface IMessage {
32 | id: number;
33 | appid: number;
34 | message: string;
35 | title: string;
36 | priority: number;
37 | date: string;
38 | image?: string;
39 | extras?: IMessageExtras;
40 | }
41 |
42 | export interface IMessageExtras {
43 | [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
44 | }
45 |
46 | export interface IPagedMessages {
47 | paging: IPaging;
48 | messages: IMessage[];
49 | }
50 |
51 | export interface IPaging {
52 | next?: string;
53 | since?: number;
54 | size: number;
55 | limit: number;
56 | }
57 |
58 | export interface IUser {
59 | id: number;
60 | name: string;
61 | admin: boolean;
62 | }
63 |
64 | export interface IVersion {
65 | version: string;
66 | commit: string;
67 | buildDate: string;
68 | }
69 |
--------------------------------------------------------------------------------
/ui/src/user/UserStore.ts:
--------------------------------------------------------------------------------
1 | import {BaseStore} from '../common/BaseStore';
2 | import axios from 'axios';
3 | import * as config from '../config';
4 | import {action} from 'mobx';
5 | import {SnackReporter} from '../snack/SnackManager';
6 | import {IUser} from '../types';
7 |
8 | export class UserStore extends BaseStore {
9 | constructor(private readonly snack: SnackReporter) {
10 | super();
11 | }
12 |
13 | protected requestItems = (): Promise =>
14 | axios.get(`${config.get('url')}user`).then((response) => response.data);
15 |
16 | protected requestDelete(id: number): Promise {
17 | return axios
18 | .delete(`${config.get('url')}user/${id}`)
19 | .then(() => this.snack('User deleted'));
20 | }
21 |
22 | @action
23 | public create = async (name: string, pass: string, admin: boolean) => {
24 | await axios.post(`${config.get('url')}user`, {name, pass, admin});
25 | await this.refresh();
26 | this.snack('User created');
27 | };
28 |
29 | @action
30 | public update = async (id: number, name: string, pass: string | null, admin: boolean) => {
31 | await axios.post(config.get('url') + 'user/' + id, {name, pass, admin});
32 | await this.refresh();
33 | this.snack('User updated');
34 | };
35 | }
36 |
--------------------------------------------------------------------------------
/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src",
4 | "outDir": "build/dist",
5 | "target": "es5",
6 | "lib": [
7 | "es6",
8 | "dom"
9 | ],
10 | "sourceMap": true,
11 | "allowJs": true,
12 | "jsx": "react",
13 | "moduleResolution": "node",
14 | "rootDir": "src",
15 | "forceConsistentCasingInFileNames": true,
16 | "noImplicitReturns": true,
17 | "noImplicitThis": true,
18 | "noImplicitAny": true,
19 | "strictNullChecks": true,
20 | "suppressImplicitAnyIndexErrors": true,
21 | "noUnusedLocals": true,
22 | "allowSyntheticDefaultImports": true,
23 | "experimentalDecorators": true,
24 | "skipLibCheck": true,
25 | "esModuleInterop": true,
26 | "strict": true,
27 | "isolatedModules": true,
28 | "noEmit": true,
29 | "module": "esnext",
30 | "resolveJsonModule": true,
31 | "keyofStringsOnly": true,
32 | "noFallthroughCasesInSwitch": true
33 | },
34 | "exclude": [
35 | "node_modules",
36 | "build",
37 | "scripts",
38 | "acceptance-tests",
39 | "webpack",
40 | "jest",
41 | "src/setupTests.ts"
42 | ],
43 | "include": [
44 | "src"
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/ui/tsconfig.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json"
3 | }
--------------------------------------------------------------------------------
/ui/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs"
5 | }
6 | }
--------------------------------------------------------------------------------