1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2025-04-05 00:27:25 +00:00

Merge branch 'reporting' into postgrey-whitelist

This commit is contained in:
downtownallday 2021-04-10 16:50:30 -04:00
commit 9c87884837
27 changed files with 256 additions and 110 deletions

View File

@ -14,7 +14,7 @@ from functools import wraps
from reporting.capture.db.SqliteConnFactory import SqliteConnFactory
import reporting.uidata as uidata
from mailconfig import get_mail_users
from mailconfig import ( get_mail_users, validate_email )
def add_reports(app, env, authorized_personnel_only):
@ -178,7 +178,7 @@ def add_reports(app, env, authorized_personnel_only):
r = subprocess.run(["systemctl", "reload", "miabldap-capture"])
if r.returncode != 0:
log.warning('systemctl reload faild for miabldap-capture: code=%s', r.returncode)
log.warning('systemctl reload failed for miabldap-capture: code=%s', r.returncode)
else:
# wait a sec for daemon to pick up new config
# TODO: monitor runtime config for mtime change
@ -214,3 +214,41 @@ def add_reports(app, env, authorized_personnel_only):
return jsonify(uidata.capture_db_stats(conn))
finally:
db_conn_factory.close(conn)
@app.route('/reports/uidata/message-headers', methods=['POST'])
@authorized_personnel_only
@json_payload
def get_message_headers(payload):
try:
user_id = payload['user_id']
lmtp_id = payload['lmtp_id']
except KeyError:
return ('invalid request', 400)
if not validate_email(user_id, mode="user"):
return ('invalid email address', 400)
r = subprocess.run(
[
"/usr/bin/doveadm",
"fetch",
"-u",user_id,
"hdr",
"HEADER","received","LMTP id " + lmtp_id
],
encoding="utf8",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
if r.returncode != 0:
log.error('retrieving message headers failed, code=%s, lmtp_id=%s, user_id=%s, stderr=%s', r.returncode, lmtp_id, user_id, r.stderr)
return Response(r.stderr, status=400, mimetype='text/plain')
else:
out = r.stdout.strip()
if out.startswith('hdr:\n'):
out = out[5:]
return Response(out, status=200, mimetype='text/plain')

View File

@ -1,9 +1,14 @@
import { BvTable, ConnectionDisposition, DateFormatter } from "./charting.js";
import { spinner } from "../../ui-common/page-header.js";
Vue.component('capture-db-stats', {
export default Vue.component('capture-db-stats', {
props: {
},
components: {
spinner,
},
template:'<div>'+
'<template v-if="stats">'+
'<caption class="text-nowrap">Database date range</caption><div class="ml-2">First: {{stats.db_stats.connect_time.min_str}}</div><div class="ml-2">Last: {{stats.db_stats.connect_time.max_str}}</div>'+

View File

@ -1,4 +1,6 @@
Vue.component('chart-multi-line-timeseries', {
import { ChartPrefs, NumberFormatter, ChartVue } from "./charting.js";
export default Vue.component('chart-multi-line-timeseries', {
props: {
chart_data: { type:Object, required:false }, /* TimeseriesData */
width: { type:Number, default: ChartPrefs.default_width },
@ -175,7 +177,7 @@ Vue.component('chart-multi-line-timeseries', {
const yvalue = this.yscale.invert(pointer[1]); // number
//const i = d3.bisectCenter(this.tsdata.dates, xvalue); // index
var i = d3.bisect(this.tsdata.dates, xvalue); // index
if (i > this.tsdata.dates.length) return;
if (i<0 || i > this.tsdata.dates.length) return;
i = Math.min(this.tsdata.dates.length-1, i);
// closest series
@ -190,7 +192,7 @@ Vue.component('chart-multi-line-timeseries', {
}
}
const s = this.tsdata.series[closest.sidx];
if (i>= s.values.length) {
if (i<0 || i>= s.values.length) {
dot.attr("display", "none");
return;
}

View File

@ -1,4 +1,7 @@
Vue.component('chart-pie', {
import { ChartPrefs, NumberFormatter, ChartVue } from "./charting.js";
export default Vue.component('chart-pie', {
/*
* chart_data: [
* { name: 'name', value: value },
@ -127,7 +130,7 @@ Vue.component('chart-pie', {
radius *= 0.7;
else
radius *= 0.8;
arcLabel = d3.arc().innerRadius(radius).outerRadius(radius);
var arcLabel = d3.arc().innerRadius(radius).outerRadius(radius);
svg.append("g")
.attr("stroke", "white")

View File

@ -2,7 +2,10 @@
stacked bar chart
*/
Vue.component('chart-stacked-bar-timeseries', {
import { ChartPrefs, NumberFormatter, ChartVue } from "./charting.js";
export default Vue.component('chart-stacked-bar-timeseries', {
props: {
chart_data: { type:Object, required:false }, /* TimeseriesData */
width: { type:Number, default: ChartPrefs.default_width },

View File

@ -1,4 +1,4 @@
Vue.component('chart-table', {
export default Vue.component('chart-table', {
props: {
items: Array,
fields: Array,
@ -38,7 +38,7 @@ Vue.component('chart-table', {
if (this.items.length == 0) {
return [{
key: 'no data',
thClass: 'text-nowrap'
thClass: 'text-nowrap align-top'
}];
}
return this.fields;

View File

@ -1,5 +1,5 @@
class ChartPrefs {
export class ChartPrefs {
static get colors() {
// see: https://github.com/d3/d3-scale-chromatic
return d3.schemeSet2;
@ -40,7 +40,7 @@ class ChartPrefs {
};
class DateFormatter {
export class DateFormatter {
/*
* date and time
*/
@ -162,7 +162,7 @@ class DateFormatter {
};
class DateRange {
export class DateRange {
/*
* ranges
*/
@ -241,7 +241,7 @@ class DateRange {
};
class NumberFormatter {
export class NumberFormatter {
static format(v) {
return isNaN(v) || v===null ? "N/A" : v.toLocaleString(ChartPrefs.locales);
}
@ -319,7 +319,7 @@ class NumberFormatter {
});
class BvTable {
export class BvTable {
constructor(data, opt) {
opt = opt || {};
Object.assign(this, data);
@ -446,7 +446,7 @@ class BvTable {
};
class BvTableField {
export class BvTableField {
constructor(field, field_type) {
// this:
// key - required
@ -628,7 +628,7 @@ class BvTableField {
};
class MailBvTable extends BvTable {
export class MailBvTable extends BvTable {
flag(key, fn) {
var field = this.get_field(key, true);
if (!field) return;
@ -736,7 +736,7 @@ class MailBvTable extends BvTable {
}
class ChartVue {
export class ChartVue {
static svg_attrs(viewBox) {
var attrs = {
@ -818,7 +818,7 @@ class ChartVue {
* }
*/
class TimeseriesData {
export class TimeseriesData {
constructor(data) {
Object.assign(this, data);
this.convert_dates();
@ -984,7 +984,7 @@ class TimeseriesData {
};
class ConnectionDisposition {
export class ConnectionDisposition {
constructor(disposition) {
const data = {
'failed_login_attempt': {

View File

@ -1,4 +1,7 @@
Vue.component('date-range-picker', {
import { DateRange, DateFormatter } from "./charting.js";
export default Vue.component('date-range-picker', {
props: {
start_range: [ String, Array ], // "ytd", "mtd", "wtd", or [start, end] where start and end are strings in format YYYY-MM-DD in localti
recall_id: String, // save / recall from localStorage

View File

@ -22,34 +22,10 @@
<script src="https://d3js.org/d3.v6.min.js"></script>
<script>axios.defaults.baseURL="/admin"</script>
<!-- our code -->
<script src="ui-common/exceptions.js"></script>
<script src="ui-common/authentication.js"></script>
<script src="ui-common/page-layout.js"></script>
<script src="ui-common/page-header.js"></script>
<script src="reports/ui/settings.js"></script>
<script src="reports/ui/capture-db-stats.js"></script>
<script src="reports/ui/charting.js"></script>
<script src="reports/ui/wbr-text.js"></script>
<script src="reports/ui/date-range-picker.js"></script>
<script src="reports/ui/chart-pie.js"></script>
<script src="reports/ui/chart-table.js"></script>
<script src="reports/ui/chart-stacked-bar-timeseries.js"></script>
<script src="reports/ui/chart-multi-line-timeseries.js"></script>
<script src="reports/ui/panel-messages-sent.js"></script>
<script src="reports/ui/panel-messages-received.js"></script>
<script src="reports/ui/panel-flagged-connections.js"></script>
<script src="reports/ui/panel-user-activity.js"></script>
<script src="reports/ui/panel-remote-sender-activity.js"></script>
<script src="reports/ui/reports-page-header.js"></script>
<script src="reports/ui/page-settings.js"></script>
<script src="reports/ui/page-reports-main.js"></script>
<script src="reports/ui/index.js"></script>
<script src="reports/ui/index.js" type="module"></script>
</head>
<body onload="init_app()">
<body>
<router-view id="app"></router-view>

View File

@ -2,14 +2,19 @@
* reports index page
*/
import page_settings from "./page-settings.js";
import page_reports_main from "./page-reports-main.js";
import { Me, init_authentication_interceptors } from "../../ui-common/authentication.js";
import { AuthenticationError } from "../../ui-common/exceptions.js";
import UserSettings from "./settings.js";
const app = {
router: new VueRouter({
routes: [
{ path: '/', component: Vue.component('page-reports-main') },
{ path: '/settings', component: Vue.component('page-settings') },
{ path: '/:panel', component: Vue.component('page-reports-main') },
{ path: '/', component: page_reports_main },
{ path: '/settings', component: page_settings },
{ path: '/:panel', component: page_reports_main },
],
scrollBehavior: function(to, from, savedPosition) {
if (savedPosition) {
@ -19,8 +24,8 @@ const app = {
}),
components: {
'page-settings': Vue.component('page-settings'),
'page-reports-main': Vue.component('page-reports-main'),
'page-settings': page_settings,
'page-reports-main': page_reports_main,
},
data: {
@ -64,12 +69,12 @@ const app = {
function init_app() {
init_axios_interceptors();
init_authentication_interceptors();
UserSettings.load().then(settings => {
new Vue(app).$mount('#app');
}).catch(error => {
alert('' + error);
});
}
UserSettings.load().then(settings => {
new Vue(app).$mount('#app');
}).catch(error => {
alert('' + error);
});

View File

@ -1,16 +1,31 @@
Vue.component('page-reports-main', function(resolve, reject) {
import page_layout from '../../ui-common/page-layout.js';
import reports_page_header from './reports-page-header.js';
import date_range_picker from './date-range-picker.js';
import capture_db_stats from './capture-db-stats.js';
import panel_messages_sent from './panel-messages-sent.js';
import panel_messages_received from './panel-messages-received.js';
import panel_flagged_connections from './panel-flagged-connections.js';
import panel_user_activity from './panel-user-activity.js';
import panel_remote_sender_activity from './panel-remote-sender-activity.js';
import { TimeseriesData } from './charting.js';
export default Vue.component('page-reports-main', function(resolve, reject) {
axios.get('reports/ui/page-reports-main.html').then((response) => { resolve({
template: response.data,
components: {
'page-layout': Vue.component('page-layout'),
'reports-page-header': Vue.component('reports-page-header'),
'date-range-picker': Vue.component('date-range-picker'),
'panel-messages-sent': Vue.component('panel-messages-sent'),
'panel-messages-received': Vue.component('panel-messages-received'),
'panel-flagged-connections': Vue.component('panel-flagged-connections'),
'panel-user-activity': Vue.component('panel-user-activity'),
'page-layout': page_layout,
'reports-page-header': reports_page_header,
'date-range-picker': date_range_picker,
'capture-db-stats': capture_db_stats,
'panel-messages-sent': panel_messages_sent,
'panel-messages-received': panel_messages_received,
'panel-flagged-connections': panel_flagged_connections,
'panel-user-activity': panel_user_activity,
'panel-remote-sender-activity': panel_remote_sender_activity,
},
data: function() {

View File

@ -1,11 +1,18 @@
Vue.component('page-settings', function(resolve, reject) {
import page_layout from '../../ui-common/page-layout.js';
import reports_page_header from './reports-page-header.js';
import UserSettings from "./settings.js";
import { CaptureConfig } from "./settings.js";
export default Vue.component('page-settings', function(resolve, reject) {
axios.get('reports/ui/page-settings.html').then((response) => { resolve({
template: response.data,
components: {
'page-layout': Vue.component('page-layout'),
'reports-page-header': Vue.component('reports-page-header'),
'page-layout': page_layout,
'reports-page-header': reports_page_header,
},
data: function() {

View File

@ -1,4 +1,13 @@
Vue.component('panel-flagged-connections', function(resolve, reject) {
import chart_multi_line_timeseries from "./chart-multi-line-timeseries.js";
import chart_stacked_bar_timeseries from "./chart-stacked-bar-timeseries.js";
import chart_pie from "./chart-pie.js";
import chart_table from "./chart-table.js";
import { ChartPrefs, TimeseriesData, BvTable, ConnectionDisposition } from "./charting.js";
export default Vue.component('panel-flagged-connections', function(resolve, reject) {
axios.get('reports/ui/panel-flagged-connections.html').then((response) => { resolve({
template: response.data,
@ -14,10 +23,10 @@ Vue.component('panel-flagged-connections', function(resolve, reject) {
},
components: {
'chart-multi-line-timeseries': Vue.component('chart-multi-line-timeseries'),
'chart-stacked-bar-timeseries': Vue.component('chart-stacked-bar-timeseries'),
'chart-pie': Vue.component('chart-pie'),
'chart-table': Vue.component('chart-table'),
'chart-multi-line-timeseries': chart_multi_line_timeseries,
'chart-stacked-bar-timeseries': chart_stacked_bar_timeseries,
'chart-pie': chart_pie,
'chart-table': chart_table,
},
computed: {

View File

@ -3,7 +3,13 @@
*/
Vue.component('panel-messages-received', function(resolve, reject) {
import chart_multi_line_timeseries from "./chart-multi-line-timeseries.js";
import chart_table from "./chart-table.js";
import { ChartPrefs, TimeseriesData, BvTable } from "./charting.js";
export default Vue.component('panel-messages-received', function(resolve, reject) {
axios.get('reports/ui/panel-messages-received.html').then((response) => { resolve({
template: response.data,
@ -19,10 +25,8 @@ Vue.component('panel-messages-received', function(resolve, reject) {
},
components: {
'chart-multi-line-timeseries': Vue.component('chart-multi-line-timeseries'),
// 'chart-stacked-bar-timeseries': Vue.component('chart-stacked-bar-timeseries'),
// 'chart-pie': Vue.component('chart-pie'),
'chart-table': Vue.component('chart-table'),
'chart-multi-line-timeseries': chart_multi_line_timeseries,
'chart-table': chart_table,
},
data: function() {

View File

@ -9,7 +9,15 @@
'loading' event=number
*/
Vue.component('panel-messages-sent', function(resolve, reject) {
import chart_multi_line_timeseries from "./chart-multi-line-timeseries.js";
import chart_stacked_bar_timeseries from "./chart-stacked-bar-timeseries.js";
import chart_pie from "./chart-pie.js";
import chart_table from "./chart-table.js";
import { ChartPrefs, TimeseriesData, BvTable } from "./charting.js";
export default Vue.component('panel-messages-sent', function(resolve, reject) {
axios.get('reports/ui/panel-messages-sent.html').then((response) => { resolve({
template: response.data,
@ -27,10 +35,10 @@ Vue.component('panel-messages-sent', function(resolve, reject) {
},
components: {
'chart-multi-line-timeseries': Vue.component('chart-multi-line-timeseries'),
'chart-stacked-bar-timeseries': Vue.component('chart-stacked-bar-timeseries'),
'chart-pie': Vue.component('chart-pie'),
'chart-table': Vue.component('chart-table'),
'chart-multi-line-timeseries': chart_multi_line_timeseries,
'chart-stacked-bar-timeseries': chart_stacked_bar_timeseries,
'chart-pie': chart_pie,
'chart-table': chart_table,
},
data: function() {

View File

@ -2,7 +2,11 @@
details on the activity of a remote sender (envelope from)
*/
Vue.component('panel-remote-sender-activity', function(resolve, reject) {
import UserSettings from "./settings.js";
import { MailBvTable, ConnectionDisposition } from "./charting.js";
export default Vue.component('panel-remote-sender-activity', function(resolve, reject) {
axios.get('reports/ui/panel-remote-sender-activity.html').then((response) => { resolve({
template: response.data,

View File

@ -1,5 +1,9 @@
<div>
<b-modal ref="message_headers_modal" hide-header no-fade ok-only no-close-on-backdrop size="lg" scrollable>
<message-headers-view :user_id="data_user_id" :lmtp_id="lmtp_id"></message-headers-view>
</b-modal>
<datalist id="panel-ua-users">
<option v-for="user in all_users">{{ user }}</option>
</datalist>
@ -61,7 +65,7 @@
</template>
<template #row-details="row">
<b-card>
<div><strong>Remote host</strong>: {{ row.item.remote_host }}[{{ row.item.remote_ip }}]</div>
<div><strong>Remote sender</strong>: {{ row.item.remote_host }}[{{ row.item.remote_ip }}]</div>
<div><strong>Connection disposition</strong>: {{ disposition_formatter(row.item.disposition) }}</div>
<div v-if="row.item.orig_to"><strong>Sent to alias</strong>: {{ row.item.orig_to }}</div>
<div v-if="row.item.dkim_reason"><strong>Dkim reason</strong>: {{row.item.dkim_reason}}</div>
@ -69,7 +73,9 @@
<div v-if="row.item.postgrey_reason"><strong>Postgrey reason</strong>: {{row.item.postgrey_reason}}</div>
<div v-if="row.item.postgrey_delay"><strong>Postgrey delay</strong>: {{received_mail.x_fields.postgrey_delay.formatter(row.item.postgrey_delay)}}</div>
<div v-if="row.item.spam_result"><strong>Spam score</strong>: {{received_mail.x_fields.spam_score.formatter(row.item.spam_score)}}</div>
<div v-if="row.item.message_id"><strong>Message-ID</strong>: {{ row.item.message_id }}</div>
<div v-if="row.item.failure_info"><strong>Failure info</strong>: {{row.item.failure_info}}</div>
<div v-if="row.item.lmtp_id"><a href="#" @click.prevent.stop="show_message_headers(row.item.lmtp_id)">Message headers</a></div>
</b-card>
</template>
</b-table>

View File

@ -2,7 +2,13 @@
details on the activity of a user
*/
Vue.component('panel-user-activity', function(resolve, reject) {
import wbr_text from "./wbr-text.js";
import message_headers_view from "./message_headers_view.js";
import UserSettings from "./settings.js";
import { MailBvTable, ConnectionDisposition } from "./charting.js";
export default Vue.component('panel-user-activity', function(resolve, reject) {
axios.get('reports/ui/panel-user-activity.html').then((response) => { resolve({
template: response.data,
@ -12,7 +18,8 @@ Vue.component('panel-user-activity', function(resolve, reject) {
},
components: {
'wbr-text': Vue.component('wbr-text'),
'wbr-text': wbr_text,
'message-headers-view': message_headers_view
},
data: function() {
@ -30,6 +37,7 @@ Vue.component('panel-user-activity', function(resolve, reject) {
sent_mail: null,
received_mail: null,
imap_details: null,
lmtp_id: null, /* for message headers modal */
all_users: [],
disposition_formatter: ConnectionDisposition.formatter,
};
@ -145,7 +153,9 @@ Vue.component('panel-user-activity', function(resolve, reject) {
'postgrey_reason',
'postgrey_delay',
'spam_score',
'orig_to'
'orig_to',
'message_id',
'lmtp_id',
]);
// combine fields 'envelope_from' and 'sasl_username'
var f = this.received_mail.combine_fields(
@ -286,6 +296,15 @@ Vue.component('panel-user-activity', function(resolve, reject) {
row_clicked: function(item, index, event) {
item._showDetails = ! item._showDetails;
},
show_message_headers: function(lmtp_id) {
// set the lmtp_id that component message-headers-view
// searches for
this.lmtp_id = lmtp_id;
// show the modal dialog
this.$refs.message_headers_modal.show();
}
}

View File

@ -1,10 +1,12 @@
Vue.component('reports-page-header', {
import page_header from '../../ui-common/page-header.js';
export default Vue.component('reports-page-header', {
props: {
loading_counter: { type:Number, required:true },
},
components: {
'page-header': Vue.component('page-header'),
'page-header': page_header,
},
template:

View File

@ -1,6 +1,9 @@
import { ValueError } from '../../ui-common/exceptions.js';
window.miabldap = window.miabldap || {};
class CaptureConfig {
export class CaptureConfig {
static get() {
return axios.get('/reports/capture/config').then(response => {
var cc = new CaptureConfig();
@ -11,7 +14,7 @@ class CaptureConfig {
};
class UserSettings {
export default class UserSettings {
static load() {
if (window.miabldap.user_settings) {
return Promise.resolve(window.miabldap.user_settings);

View File

@ -11,7 +11,7 @@
* browser to wrap at any character of the text.
*/
Vue.component('wbr-text', {
export default Vue.component('wbr-text', {
props: {
text: { type:String, required: true },
break_chars: { type:String, default:'@_.,:+=' },

View File

@ -7,9 +7,9 @@ connect_time, mta_connection.service AS service, sasl_username, disposition,
remote_host, remote_ip,
-- mta_accept
envelope_from, spf_result, dkim_result, dkim_reason, dmarc_result, dmarc_reason,
failure_info,
message_id, failure_info,
-- mta_delivery
postgrey_result, postgrey_reason, postgrey_delay, spam_score, spam_result, message_size, orig_to
postgrey_result, postgrey_reason, postgrey_delay, spam_score, spam_result, message_size, orig_to, delivery_info
FROM mta_accept
JOIN mta_connection ON mta_accept.mta_conn_id = mta_connection.mta_conn_id
JOIN mta_delivery ON mta_accept.mta_accept_id = mta_delivery.mta_accept_id

View File

@ -124,6 +124,7 @@ def user_activity(conn, args):
'dkim_reason',
'dmarc_result',
'dmarc_reason',
'message_id',
'failure_info',
# mta_delivery
@ -134,6 +135,7 @@ def user_activity(conn, args):
'spam_score',
'spam_result',
'message_size',
'lmtp_id',
],
'field_types': [
{ 'type':'datetime', 'format': '%Y-%m-%d %H:%M:%S' },# connect_time
@ -148,6 +150,7 @@ def user_activity(conn, args):
'text/plain', # dkim_result
'text/plain', # dmarc_result
'text/plain', # dmarc_reason
'text/plain', # message_id
'text/plain', # failure_info
'text/email', # orig_to
'text/plain', # postgrey_result
@ -156,6 +159,7 @@ def user_activity(conn, args):
{ 'type':'decimal', 'places':2 }, # spam_score
'text/plain', # spam_result
'number/size', # message_size
'text/plain', # lmtp_id
],
'items': []
}
@ -167,7 +171,31 @@ def user_activity(conn, args):
}):
v = []
for key in received_mail['fields']:
v.append(row[key])
if key == 'lmtp_id':
# Extract the LMTP ID from delivery info, which looks
# like:
#
# "250 2.0.0 <user@domain.tld> oPHmBDvTaWA7UwAAlWWVsw
# Saved"
#
# When we know the LMTP ID, we can get the message
# headers using doveadm, like this:
#
# "/usr/bin/doveadm fetch -u "user@domain.tld" hdr
# HEADER received "LMTP id oPHmBDvTaWA7UwAAlWWVsw"
#
delivery_info = row['delivery_info']
valid = False
if delivery_info:
parts = delivery_info.split(' ')
if parts[0]=='250' and parts[1]=='2.0.0':
v.append(parts[-2])
valid = True
if not valid:
v.append(None)
else:
v.append(row[key])
received_mail['items'].append(v)

View File

@ -1,4 +1,7 @@
class Me {
import { AuthenticationError } from './exceptions.js';
export class Me {
/* construct with return value from GET /me */
constructor(me) {
Object.assign(this, me);
@ -18,7 +21,7 @@ class Me {
* axios interceptors for authentication
*/
function init_axios_interceptors() {
export function init_authentication_interceptors() {
// requests: attach non-session based auth (admin panel)
axios.interceptors.request.use(request => {
@ -38,7 +41,8 @@ function init_axios_interceptors() {
});
// reponses: redirect on authorization failure
// reponses: handle authorization failures by throwing exceptions
// users should catch AuthenticationError exceptions
axios.interceptors.response.use(
response => {
if (response.data &&

View File

@ -1,13 +1,13 @@
class ValueError extends Error {
export class ValueError extends Error {
constructor(msg) {
super(msg);
}
};
class AssertionError extends Error {
export class AssertionError extends Error {
}
class AuthenticationError extends Error {
export class AuthenticationError extends Error {
constructor(caused_by_error, msg, response) {
super(msg);
this.caused_by = caused_by_error;

View File

@ -1,8 +1,8 @@
Vue.component('spinner', {
var spinner = Vue.component('spinner', {
template: '<span class="spinner-border spinner-border-sm"></span>'
});
Vue.component('page-header', function(resolve, reject) {
var header = Vue.component('page-header', function(resolve, reject) {
axios.get('ui-common/page-header.html').then((response) => { resolve({
props: {
@ -17,3 +17,5 @@ Vue.component('page-header', function(resolve, reject) {
});
});
export { spinner, header as default };

View File

@ -1,4 +1,4 @@
Vue.component('page-layout', function(resolve, reject) {
export default Vue.component('page-layout', function(resolve, reject) {
axios.get('ui-common/page-layout.html').then((response) => { resolve({
template: response.data,