@oxygen-cms/ui 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.babelrc +1 -0
- package/.eslintrc.js +22 -0
- package/.github/workflows/node.js.yml +29 -0
- package/.idea/modules.xml +8 -0
- package/.idea/ui.iml +10 -0
- package/.jshintrc +3 -0
- package/README.md +7 -0
- package/assets/oxygen-icon.png +0 -0
- package/jest.init.js +1 -0
- package/package.json +72 -0
- package/src/AuthApi.js +116 -0
- package/src/CrudApi.js +112 -0
- package/src/EventsApi.js +16 -0
- package/src/GroupsApi.js +9 -0
- package/src/Internationalize.js +31 -0
- package/src/MediaApi.js +52 -0
- package/src/MediaDirectoryApi.js +62 -0
- package/src/PreferencesApi.js +47 -0
- package/src/UserPermissions.js +66 -0
- package/src/UserPreferences.js +69 -0
- package/src/UserPreferences.test.js +23 -0
- package/src/UsersApi.js +41 -0
- package/src/api.js +209 -0
- package/src/components/App.vue +61 -0
- package/src/components/AuthenticatedLayout.vue +254 -0
- package/src/components/AuthenticationLog.vue +196 -0
- package/src/components/CodeEditor.vue +90 -0
- package/src/components/EditButtonOnRowHover.vue +21 -0
- package/src/components/Error404.vue +25 -0
- package/src/components/EventsChooser.vue +88 -0
- package/src/components/EventsTable.vue +82 -0
- package/src/components/GenericEditableField.vue +74 -0
- package/src/components/GroupsChooser.vue +58 -0
- package/src/components/GroupsList.vue +129 -0
- package/src/components/ImportExport.vue +45 -0
- package/src/components/LegacyPage.vue +256 -0
- package/src/components/UserJoined.vue +35 -0
- package/src/components/UserManagement.vue +168 -0
- package/src/components/UserProfileForm.vue +214 -0
- package/src/components/ViewProfile.vue +32 -0
- package/src/components/auth/Auth404.vue +16 -0
- package/src/components/auth/Login.vue +135 -0
- package/src/components/auth/LoginLogo.vue +30 -0
- package/src/components/auth/Logout.vue +26 -0
- package/src/components/auth/PasswordRemind.vue +71 -0
- package/src/components/auth/PasswordReset.vue +97 -0
- package/src/components/auth/TwoFactorSetup.vue +115 -0
- package/src/components/auth/VerifyEmail.vue +71 -0
- package/src/components/auth/WelcomeFloat.vue +87 -0
- package/src/components/auth/login.scss +17 -0
- package/src/components/media/MediaChooseDirectory.vue +129 -0
- package/src/components/media/MediaDirectory.vue +109 -0
- package/src/components/media/MediaInsertModal.vue +88 -0
- package/src/components/media/MediaItem.vue +282 -0
- package/src/components/media/MediaItemPreview.vue +45 -0
- package/src/components/media/MediaList.vue +305 -0
- package/src/components/media/MediaPage.vue +44 -0
- package/src/components/media/MediaResponsiveImages.vue +51 -0
- package/src/components/media/MediaUpload.vue +133 -0
- package/src/components/media/media.scss +51 -0
- package/src/components/preferences/PreferencesAdminAppearance.vue +22 -0
- package/src/components/preferences/PreferencesAuthentication.vue +27 -0
- package/src/components/preferences/PreferencesEventTemplates.vue +22 -0
- package/src/components/preferences/PreferencesField.vue +215 -0
- package/src/components/preferences/PreferencesList.vue +50 -0
- package/src/components/preferences/PreferencesPageTemplates.vue +23 -0
- package/src/components/preferences/PreferencesSiteAppearance.vue +22 -0
- package/src/components/preferences/PreferencesThemeChooser.vue +73 -0
- package/src/components/preferences/ShowIfPermitted.vue +37 -0
- package/src/components/preferences/UserPreferences.vue +30 -0
- package/src/components/users/CreateUserModal.vue +73 -0
- package/src/components/util.css +47 -0
- package/src/icons.js +90 -0
- package/src/main.js +112 -0
- package/src/modules/LegacyPages.js +18 -0
- package/src/modules/Media.js +45 -0
- package/src/modules/UserManagement.js +24 -0
- package/src/routes/index.js +92 -0
- package/src/store/index.js +70 -0
- package/src/styles/_variables.scss +23 -0
- package/src/styles/app.scss +76 -0
- package/src/unsavedChanges.js +16 -0
- package/src/util.js +65 -0
- package/src/util.test.js +39 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="box">
|
|
3
|
+
<div class="level">
|
|
4
|
+
<div class="level-left">
|
|
5
|
+
<h1 class="title">Groups</h1>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="level-right">
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<b-table :data="paginatedItems.items === null ? [] : paginatedItems.items" :loading="paginatedItems.loading">
|
|
12
|
+
<b-table-column v-slot="props" label="">
|
|
13
|
+
<GenericEditableField :api="groupsApi" :data="props.row" field-name="icon" @update:data="updateGroup">
|
|
14
|
+
<template #display="{ value, edit }">
|
|
15
|
+
<b-icon :icon="value" size="is-small"></b-icon>
|
|
16
|
+
<EditButtonOnRowHover :edit="edit" />
|
|
17
|
+
</template>
|
|
18
|
+
</GenericEditableField>
|
|
19
|
+
</b-table-column>
|
|
20
|
+
<b-table-column v-slot="props" label="Name">
|
|
21
|
+
<GenericEditableField :api="groupsApi" :data="props.row" field-name="name" @update:data="updateGroup" />
|
|
22
|
+
</b-table-column>
|
|
23
|
+
<b-table-column v-slot="props" label="Description">
|
|
24
|
+
<GenericEditableField :api="groupsApi" :data="props.row" field-name="description" @update:data="updateGroup" />
|
|
25
|
+
</b-table-column>
|
|
26
|
+
<b-table-column v-slot="props" label="Nickname">
|
|
27
|
+
<GenericEditableField :api="groupsApi" :data="props.row" field-name="nickname" @update:data="updateGroup">
|
|
28
|
+
<template #display="{ value, edit }">
|
|
29
|
+
<code>{{ value }}</code>
|
|
30
|
+
<EditButtonOnRowHover :edit="edit" />
|
|
31
|
+
</template>
|
|
32
|
+
</GenericEditableField>
|
|
33
|
+
</b-table-column>
|
|
34
|
+
<b-table-column v-slot="props" label="Created">
|
|
35
|
+
<UserJoined :user="props.row"></UserJoined>
|
|
36
|
+
</b-table-column>
|
|
37
|
+
<b-table-column v-slot="props" label="" width="25em">
|
|
38
|
+
<div class="buttons">
|
|
39
|
+
<b-button v-if="props.row.deletedAt" rounded outlined icon-left="recycle" size="is-small" @click="restoreItem(props.row.id)">Restore</b-button>
|
|
40
|
+
<b-button v-if="props.row.deletedAt" rounded type="is-danger" outlined icon-left="trash" size="is-small" @click="forceDeleteItem(props.row.id)">Delete Forever</b-button>
|
|
41
|
+
<b-button v-if="!props.row.deletedAt" rounded icon-left="trash" size="is-small" @click="deleteItem(props.row.id)">Delete</b-button>
|
|
42
|
+
</div>
|
|
43
|
+
</b-table-column>
|
|
44
|
+
</b-table>
|
|
45
|
+
|
|
46
|
+
<hr />
|
|
47
|
+
|
|
48
|
+
<div class="content">
|
|
49
|
+
<h3>Permissions</h3>
|
|
50
|
+
<p>To edit group permissions, you will need access to the <code>artisan</code> console command which comes installed with this application.</p>
|
|
51
|
+
<p>Here are some getting-started tips:</p>
|
|
52
|
+
<pre><code>
|
|
53
|
+
# list all permissions recognised the application
|
|
54
|
+
artisan permissions
|
|
55
|
+
|
|
56
|
+
# list permissions explictly set for the "admin" group
|
|
57
|
+
artisan permissions admin
|
|
58
|
+
|
|
59
|
+
# list all permissions for the "admin" group
|
|
60
|
+
artisan permissions admin -a
|
|
61
|
+
|
|
62
|
+
# grant a permission to group "admin"
|
|
63
|
+
artisan permissions admin --grant pages.postCreate
|
|
64
|
+
|
|
65
|
+
# unset a permission for group "admin"
|
|
66
|
+
artisan permissions admin --unset pages.postCreate
|
|
67
|
+
|
|
68
|
+
# explicitly deny a permission for group "admin"
|
|
69
|
+
artisan permissions admin --deny pages.postCreate
|
|
70
|
+
|
|
71
|
+
# sets that "pages" should inherit their permissions from "_content"
|
|
72
|
+
artisan permissions admin --inherit pages:_content</code></pre>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<script>
|
|
79
|
+
import GroupsApi from "../GroupsApi";
|
|
80
|
+
import UserJoined from "./UserJoined.vue";
|
|
81
|
+
import GenericEditableField from "./GenericEditableField.vue";
|
|
82
|
+
import EditButtonOnRowHover from "./EditButtonOnRowHover.vue";
|
|
83
|
+
|
|
84
|
+
export default {
|
|
85
|
+
name: "GroupsList",
|
|
86
|
+
components: { UserJoined, GenericEditableField, EditButtonOnRowHover },
|
|
87
|
+
data() {
|
|
88
|
+
return {
|
|
89
|
+
groupsApi: new GroupsApi(this.$buefy),
|
|
90
|
+
paginatedItems: {items: null, totalItems: null, itemsPerPage: null, loading: false, currentPage: 1},
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
async mounted() {
|
|
94
|
+
this.fetchData()
|
|
95
|
+
},
|
|
96
|
+
methods: {
|
|
97
|
+
async fetchData() {
|
|
98
|
+
this.paginatedItems.loading = true;
|
|
99
|
+
let data = await this.groupsApi.list(false, this.paginatedItems.currentPage, null);
|
|
100
|
+
this.paginatedItems.items = data.items;
|
|
101
|
+
this.paginatedItems.totalItems = data.totalItems;
|
|
102
|
+
this.paginatedItems.itemsPerPage = data.itemsPerPage;
|
|
103
|
+
this.paginatedItems.loading = false;
|
|
104
|
+
},
|
|
105
|
+
updateGroup(group) {
|
|
106
|
+
this.paginatedItems.items = this.paginatedItems.items.map(g => {
|
|
107
|
+
return g.id === group.id ? group : g;
|
|
108
|
+
});
|
|
109
|
+
this.$emit('updated');
|
|
110
|
+
},
|
|
111
|
+
async deleteItem(id) {
|
|
112
|
+
await this.groupsApi.deleteAndNotify(id);
|
|
113
|
+
await this.fetchData();
|
|
114
|
+
},
|
|
115
|
+
async forceDeleteItem(id) {
|
|
116
|
+
await this.groupsApi.confirmForceDelete(id);
|
|
117
|
+
await this.fetchData();
|
|
118
|
+
},
|
|
119
|
+
async restoreItem(id) {
|
|
120
|
+
await this.groupsApi.restoreAndNotify(id);
|
|
121
|
+
await this.fetchData();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<style scoped>
|
|
128
|
+
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="content">
|
|
3
|
+
<h3>Export Data</h3>
|
|
4
|
+
<p>
|
|
5
|
+
Create a backup of all the content and settings of the website.<br>
|
|
6
|
+
It is recommended to make regular backups to ensure the safety of your content.
|
|
7
|
+
</p>
|
|
8
|
+
<b-button type="is-primary" size="is-medium" :loading="exporting" @click="processDownload">Download data as `.zip`</b-button>
|
|
9
|
+
|
|
10
|
+
<h3>Import Data</h3>
|
|
11
|
+
<p>To restore the contents and settings of this site from a backup, use the Artisan Console.</p>
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
import {FetchBuilder} from "../api";
|
|
17
|
+
import {API_ROOT} from "../CrudApi";
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
name: "ImportExport",
|
|
21
|
+
data() {
|
|
22
|
+
return {
|
|
23
|
+
exporting: false
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
methods: {
|
|
27
|
+
async processDownload() {
|
|
28
|
+
this.exporting = true;
|
|
29
|
+
let builder = (new FetchBuilder(this.$buefy, 'post'))
|
|
30
|
+
.cookies();
|
|
31
|
+
await builder.setXsrfTokenHeader();
|
|
32
|
+
let response = await window.fetch(API_ROOT + "import-export/export", { ... builder });
|
|
33
|
+
this.$buefy.notification.open({ message: 'Export successful', type: 'is-success', queue: false });
|
|
34
|
+
let blob = await response.blob();
|
|
35
|
+
let file = window.URL.createObjectURL(blob);
|
|
36
|
+
this.exporting = false;
|
|
37
|
+
window.location.assign(file);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<style scoped>
|
|
44
|
+
|
|
45
|
+
</style>
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="full-height full-height-container legacy-container">
|
|
3
|
+
<transition name="fade">
|
|
4
|
+
<iframe v-show="!loading" ref="iframe" class="iframe" />
|
|
5
|
+
</transition>
|
|
6
|
+
|
|
7
|
+
<MediaInsertModal :active.sync="isInsertMediaItemModalActive" @close="closeInsertMediaItemModal" @select="onFilesSelected" />
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script>
|
|
12
|
+
import MediaInsertModal from "./media/MediaInsertModal.vue";
|
|
13
|
+
|
|
14
|
+
import { morphToNotification } from "../api";
|
|
15
|
+
import MediaApi from "../MediaApi";
|
|
16
|
+
|
|
17
|
+
// from https://gist.github.com/hdodov/a87c097216718655ead6cf2969b0dcfa
|
|
18
|
+
|
|
19
|
+
const iframeURLChange = (iframe, callback, legacyPage) => {
|
|
20
|
+
var unloadHandler = function() {
|
|
21
|
+
console.log('[LegacyPage] Starting load');
|
|
22
|
+
legacyPage.loadingPath = 'unknown';
|
|
23
|
+
// Timeout needed because the URL changes immediately after
|
|
24
|
+
// the `unload` event is dispatched.
|
|
25
|
+
// TODO: this is rather brittle because I believe it relies upon timing
|
|
26
|
+
// setTimeout(function() {
|
|
27
|
+
// console.log(iframe.contentWindow);
|
|
28
|
+
// if(iframe.contentWindow) {
|
|
29
|
+
// callback(iframe.contentWindow.location.href);
|
|
30
|
+
// }
|
|
31
|
+
// }, 0);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function attachUnload() {
|
|
35
|
+
// Remove the unloadHandler in case it was already attached.
|
|
36
|
+
// Otherwise, the change will be dispatched twice.
|
|
37
|
+
iframe.contentWindow.removeEventListener("unload", unloadHandler);
|
|
38
|
+
iframe.contentWindow.addEventListener("unload", unloadHandler);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
iframe.addEventListener("load", attachUnload);
|
|
42
|
+
attachUnload();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// This component manages the tricky/hacky integration of two incompatible GUI systems.
|
|
46
|
+
// Legacy pages are rendered inside an iframe, and legacy pages can transition between each other using SmoothState.js
|
|
47
|
+
// When the iframe location is updated, so is the url of the parent page using this.$router.push()
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
name: "LegacyPage",
|
|
51
|
+
components: { MediaInsertModal },
|
|
52
|
+
beforeRouteLeave(to, from, next) {
|
|
53
|
+
window.document.body.style.overflowY = 'auto';
|
|
54
|
+
window.document.documentElement.style.overflowY = 'auto';
|
|
55
|
+
this.$parent.$data.requestedCollapsed = false;
|
|
56
|
+
next();
|
|
57
|
+
},
|
|
58
|
+
props: {
|
|
59
|
+
fullPath: { type: String, required: true },
|
|
60
|
+
legacyPrefix: { type: String, required: true },
|
|
61
|
+
adminPrefix: { type: String, required: true }
|
|
62
|
+
},
|
|
63
|
+
'watch': {
|
|
64
|
+
'fullPath': 'onFullPathChanged'
|
|
65
|
+
},
|
|
66
|
+
data() {
|
|
67
|
+
return {
|
|
68
|
+
loadingPath: null,
|
|
69
|
+
currentPath: null,
|
|
70
|
+
isInsertMediaItemModalActive: false,
|
|
71
|
+
resolveInsertMediaItems: null,
|
|
72
|
+
rejectInsertMediaItems: null
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
computed: {
|
|
76
|
+
loading() { return this.loadingPath !== null; },
|
|
77
|
+
userPreferences() { return this.$store.getters.userPreferences; }
|
|
78
|
+
},
|
|
79
|
+
async mounted() {
|
|
80
|
+
this.loadingPath = 'prefs';
|
|
81
|
+
|
|
82
|
+
window.document.body.style.overflowY = 'hidden';
|
|
83
|
+
window.document.documentElement.style.overflowY = 'hidden';
|
|
84
|
+
let iframe = this.$refs.iframe;
|
|
85
|
+
|
|
86
|
+
iframeURLChange(iframe, this.onNavigated.bind(this), this);
|
|
87
|
+
|
|
88
|
+
this.loadPath(this.$route.fullPath);
|
|
89
|
+
|
|
90
|
+
iframe.addEventListener('load', this.onLoaded.bind(this));
|
|
91
|
+
if(iframe.contentDocument.readyState === "complete") {
|
|
92
|
+
console.warn('[LegacyPage] mounted: page was already loaded - perhaps this page was cached?');
|
|
93
|
+
this.onLoaded();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
unmounted() {
|
|
97
|
+
this.$parent.$data.requestedCollapsed = false;
|
|
98
|
+
},
|
|
99
|
+
methods: {
|
|
100
|
+
onFullPathChanged(newFullPath) {
|
|
101
|
+
console.log('Route changed', );
|
|
102
|
+
this.loadPath(newFullPath);
|
|
103
|
+
},
|
|
104
|
+
setupIframeIntegrations() {
|
|
105
|
+
console.log('[LegacyPage] Setting up iframe integrations for', this.$refs.iframe.contentWindow.location.href);
|
|
106
|
+
let elem = this.$refs.iframe;
|
|
107
|
+
elem.contentWindow.Oxygen = elem.contentWindow.Oxygen || {};
|
|
108
|
+
elem.contentWindow.Oxygen.user = this.userPreferences.preferences;
|
|
109
|
+
elem.contentWindow.Oxygen.onNavigationBegin = this.onNavigated.bind(this);
|
|
110
|
+
elem.contentWindow.Oxygen.onNavigationEnd = this.onLoaded.bind(this);
|
|
111
|
+
elem.contentWindow.Oxygen.notify = this.showInnerNotification.bind(this);
|
|
112
|
+
elem.contentWindow.Oxygen.openAlertDialog = this.openAlertDialog.bind(this);
|
|
113
|
+
elem.contentWindow.Oxygen.hardRedirect = this.hardRedirect.bind(this);
|
|
114
|
+
elem.contentWindow.Oxygen.insertMediaItem = this.openInsertMediaItemModal.bind(this);
|
|
115
|
+
elem.contentWindow.Oxygen.openConfirmDialog = this.openConfirmDialog.bind(this);
|
|
116
|
+
elem.contentWindow.Oxygen.popState = this.popState.bind(this);
|
|
117
|
+
elem.contentWindow.Oxygen.onToggleFullscreen = this.onToggleFullscreen.bind(this);
|
|
118
|
+
|
|
119
|
+
if(elem.contentWindow.Oxygen.onLoadedInsideIFrame) {
|
|
120
|
+
elem.contentWindow.Oxygen.onLoadedInsideIFrame();
|
|
121
|
+
} else {
|
|
122
|
+
console.warn('[LegacyPage] no onLoadedInsideIFrame callback set');
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
fullURLToVuePath(url) {
|
|
126
|
+
let urlObj = new URL(url);
|
|
127
|
+
let urlString = urlObj.toString();
|
|
128
|
+
if(urlObj.pathname.startsWith(this.legacyPrefix)) {
|
|
129
|
+
return { loadInside: 'iframe', location: this.adminPrefix + urlString.split(this.legacyPrefix)[1] };
|
|
130
|
+
} else if(urlObj.pathname.startsWith(this.adminPrefix)) {
|
|
131
|
+
return { loadInside: 'vue', location: urlString.split(this.adminPrefix)[1] };
|
|
132
|
+
} else {
|
|
133
|
+
return { loadInside: false, location: urlString };
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
vuePathToURL(path) {
|
|
137
|
+
return this.legacyPrefix + path;
|
|
138
|
+
},
|
|
139
|
+
// We detect when the iframe url changes, and update our window accordingly...
|
|
140
|
+
onNavigated(newURL) {
|
|
141
|
+
console.log('[LegacyPage] Navigated to ' + newURL);
|
|
142
|
+
},
|
|
143
|
+
showInnerNotification(data) {
|
|
144
|
+
this.$buefy.notification.open(morphToNotification(data));
|
|
145
|
+
},
|
|
146
|
+
openAlertDialog(message) {
|
|
147
|
+
this.$buefy.dialog.alert({
|
|
148
|
+
message: message,
|
|
149
|
+
size: 'is-small'
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
openConfirmDialog(options) {
|
|
153
|
+
this.$buefy.dialog.confirm(options);
|
|
154
|
+
},
|
|
155
|
+
popState() {
|
|
156
|
+
this.$router.back();
|
|
157
|
+
},
|
|
158
|
+
onLoaded() {
|
|
159
|
+
let path = this.$refs.iframe.contentWindow.location.href;
|
|
160
|
+
if(path === 'about:blank') { return; }
|
|
161
|
+
console.log('[LegacyPage] Loaded', path);
|
|
162
|
+
|
|
163
|
+
if(path !== this.currentPath) {
|
|
164
|
+
let { loadInside, location } = this.fullURLToVuePath(path);
|
|
165
|
+
console.log('[LegacyPage] ', loadInside, location);
|
|
166
|
+
if(loadInside === 'iframe') {
|
|
167
|
+
window.history.pushState({}, "", location);
|
|
168
|
+
} else if(loadInside === 'vue') {
|
|
169
|
+
this.$router.push(location);
|
|
170
|
+
} else {
|
|
171
|
+
// load outside of iframe
|
|
172
|
+
window.location = location;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.currentPath = path;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
document.title = this.$refs.iframe.contentDocument.title;
|
|
179
|
+
this.setupIframeIntegrations();
|
|
180
|
+
|
|
181
|
+
this.loadingPath = null;
|
|
182
|
+
},
|
|
183
|
+
loadPath(routePath) {
|
|
184
|
+
let path = this.vuePathToURL(routePath);
|
|
185
|
+
if(path === '/oxygen/view/auth/login') {
|
|
186
|
+
console.log('[LegacyPage] Need to login again, redirecting...');
|
|
187
|
+
window.location.replace('/oxygen/view/auth/login');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log('[LegacyPage] Loading', path);
|
|
191
|
+
|
|
192
|
+
this.loadingPath = path;
|
|
193
|
+
|
|
194
|
+
let elem = this.$refs.iframe;
|
|
195
|
+
if(elem.src !== path) {
|
|
196
|
+
// load the page from scratch
|
|
197
|
+
elem.src = path;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
hardRedirect(loc) {
|
|
201
|
+
window.location.replace(loc);
|
|
202
|
+
},
|
|
203
|
+
openInsertMediaItemModal() {
|
|
204
|
+
this.isInsertMediaItemModalActive = true;
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
this.resolveInsertMediaItems = resolve;
|
|
207
|
+
this.rejectInsertMediaItems = reject;
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
closeInsertMediaItemModal() {
|
|
211
|
+
this.rejectInsertMediaItems({ message: 'modal closed' });
|
|
212
|
+
this.isInsertMediaItemModalActive = false;
|
|
213
|
+
},
|
|
214
|
+
onFilesSelected(files) {
|
|
215
|
+
let include = files.map(item => MediaApi.generateIncludeStatement(item)).join("\n") + "\n";
|
|
216
|
+
let filenames = files.map(item => item.fullPath).join(",");
|
|
217
|
+
this.resolveInsertMediaItems(include);
|
|
218
|
+
this.isInsertMediaItemModalActive = false;
|
|
219
|
+
this.$buefy.toast.open({
|
|
220
|
+
message: 'Inserted ' + filenames,
|
|
221
|
+
type: 'is-info',
|
|
222
|
+
queue: false
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
onToggleFullscreen(mode) {
|
|
226
|
+
this.$parent.$data.requestedCollapsed = mode;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
</script>
|
|
231
|
+
|
|
232
|
+
<style scoped lang="scss">
|
|
233
|
+
@import './util.css';
|
|
234
|
+
@import '../styles/_variables.scss';
|
|
235
|
+
|
|
236
|
+
.iframe {
|
|
237
|
+
flex: 1;
|
|
238
|
+
width: 100%;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.hidden {
|
|
242
|
+
transition: opacity 1s ease, visibility 1s ease;
|
|
243
|
+
opacity: 0;
|
|
244
|
+
visibility: hidden;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.visible {
|
|
248
|
+
opacity: 1;
|
|
249
|
+
visibility: visible;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.legacy-container {
|
|
253
|
+
position: relative;
|
|
254
|
+
background-color: $grey-lightest;
|
|
255
|
+
}
|
|
256
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span>{{ joined }}, on {{ joinedAbs }}</span>
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script>
|
|
6
|
+
import Internationalize from "../Internationalize";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
name: "UserJoined",
|
|
10
|
+
props: {
|
|
11
|
+
user: {
|
|
12
|
+
type: Object,
|
|
13
|
+
required: true
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
computed: {
|
|
17
|
+
joined() {
|
|
18
|
+
const rtf1 = new Intl.RelativeTimeFormat('en', { style: 'narrow' });
|
|
19
|
+
let daysSinceJoined = (new Date(this.user.createdAt) - new Date()) / (1000 * 24 * 60 * 60);
|
|
20
|
+
let shouldUseYears = daysSinceJoined < -400;
|
|
21
|
+
return rtf1.format(
|
|
22
|
+
Math.round(shouldUseYears ? daysSinceJoined / 365 : daysSinceJoined),
|
|
23
|
+
shouldUseYears ? 'year' : 'day'
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
joinedAbs() {
|
|
27
|
+
return Internationalize.formatDate(this.user.createdAt);
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<style scoped>
|
|
34
|
+
|
|
35
|
+
</style>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="full-height scroll-container pad">
|
|
3
|
+
<div class="box">
|
|
4
|
+
<div class="level">
|
|
5
|
+
<div class="level-left">
|
|
6
|
+
<h1 class="title">Users</h1>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="level-right">
|
|
9
|
+
<b-input v-model="searchQuery" icon-left="search" rounded placeholder="Search for users..." class="mr-4"></b-input>
|
|
10
|
+
<b-button type="is-success" icon-left="plus" @click="isCreateUserModalActive = true">Create Account</b-button>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<b-table :data="paginatedItems.items === null ? [] : paginatedItems.items" :loading="paginatedItems.loading">
|
|
15
|
+
<b-table-column v-slot="props" label="Full Name">
|
|
16
|
+
<GenericEditableField :api="usersApi" :data="props.row" field-name="fullName" @update:data="updateUser">
|
|
17
|
+
<template #display="{ value, edit }">
|
|
18
|
+
<p>
|
|
19
|
+
{{ value }}
|
|
20
|
+
<EditButtonOnRowHover :edit="edit" />
|
|
21
|
+
</p>
|
|
22
|
+
</template>
|
|
23
|
+
</GenericEditableField>
|
|
24
|
+
</b-table-column>
|
|
25
|
+
<b-table-column v-slot="props" label="Email">
|
|
26
|
+
<GenericEditableField :api="usersApi" :data="props.row" field-name="email" type="email" @update:data="updateUser">
|
|
27
|
+
<template #display="{ value, edit }">
|
|
28
|
+
<p>
|
|
29
|
+
<a :href="'mailto:' + value" target="_blank" class="is-size-7">{{ value }}</a>
|
|
30
|
+
<EditButtonOnRowHover :edit="edit" />
|
|
31
|
+
</p>
|
|
32
|
+
</template>
|
|
33
|
+
</GenericEditableField>
|
|
34
|
+
</b-table-column>
|
|
35
|
+
<b-table-column v-slot="props" label="Group">
|
|
36
|
+
<GenericEditableField :api="usersApi" :data="props.row" field-name="group" @update:data="updateUser">
|
|
37
|
+
<template #display="{ value, edit }">
|
|
38
|
+
<p>
|
|
39
|
+
{{ value.name }}
|
|
40
|
+
<EditButtonOnRowHover :edit="edit" />
|
|
41
|
+
</p>
|
|
42
|
+
</template>
|
|
43
|
+
<template #edit="{ initialValue, submit, updating }">
|
|
44
|
+
<GroupsChooser :value="initialValue" :updating="updating" @select="submit" />
|
|
45
|
+
</template>
|
|
46
|
+
</GenericEditableField>
|
|
47
|
+
</b-table-column>
|
|
48
|
+
<b-table-column v-slot="props" label="Username">
|
|
49
|
+
<GenericEditableField :api="usersApi" :data="props.row" field-name="username" @update:data="updateUser">
|
|
50
|
+
<template #display="{ value, edit }">
|
|
51
|
+
<p>
|
|
52
|
+
{{ value }}
|
|
53
|
+
<EditButtonOnRowHover :edit="edit" />
|
|
54
|
+
</p>
|
|
55
|
+
</template>
|
|
56
|
+
</GenericEditableField>
|
|
57
|
+
</b-table-column>
|
|
58
|
+
<b-table-column v-slot="props" label="Email Verified">
|
|
59
|
+
{{ props.row.emailVerified ? 'Yes' : 'No' }}
|
|
60
|
+
</b-table-column>
|
|
61
|
+
<b-table-column v-slot="props" label="Two-Factor Auth">
|
|
62
|
+
{{ props.row.twoFactorAuthEnabled ? 'Yes' : 'No' }}
|
|
63
|
+
</b-table-column>
|
|
64
|
+
<b-table-column v-slot="props" label="Joined">
|
|
65
|
+
<UserJoined :user="props.row"></UserJoined>
|
|
66
|
+
</b-table-column>
|
|
67
|
+
<b-table-column v-slot="props" label="" width="25em">
|
|
68
|
+
<div class="buttons">
|
|
69
|
+
<b-button rounded icon-left="sign-in-alt" size="is-small" type="is-info" @click="impersonate(props.row.id)">Login as this user</b-button>
|
|
70
|
+
<b-button v-if="props.row.deletedAt" rounded icon-left="trash" size="is-small" type="is-danger" @click="forceDelete(props.row.id)">Delete Forever</b-button>
|
|
71
|
+
<b-button v-if="!props.row.deletedAt" rounded icon-left="minus-circle" size="is-small" @click="deactivate(props.row.id)">Deactivate</b-button>
|
|
72
|
+
<b-button v-else rounded icon-left="plus" size="is-small" type="is-success" @click="activate(props.row.id)">Activate</b-button>
|
|
73
|
+
</div>
|
|
74
|
+
</b-table-column>
|
|
75
|
+
</b-table>
|
|
76
|
+
|
|
77
|
+
<CreateUserModal :active.sync="isCreateUserModalActive" @update:users="fetchData" />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<GroupsList @updated="fetchData" />
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<script>
|
|
85
|
+
import UsersApi from "../UsersApi";
|
|
86
|
+
import UserJoined from "./UserJoined.vue";
|
|
87
|
+
import {morphToNotification} from "../api";
|
|
88
|
+
import {isNavigationFailure, NavigationFailureType} from "vue-router/src/util/errors";
|
|
89
|
+
import GenericEditableField from "./GenericEditableField.vue";
|
|
90
|
+
import GroupsChooser from "./GroupsChooser.vue";
|
|
91
|
+
import GroupsList from "./GroupsList.vue";
|
|
92
|
+
import EditButtonOnRowHover from "./EditButtonOnRowHover.vue";
|
|
93
|
+
import CreateUserModal from "./users/CreateUserModal.vue";
|
|
94
|
+
|
|
95
|
+
export default {
|
|
96
|
+
name: "UserManagement",
|
|
97
|
+
components: {
|
|
98
|
+
CreateUserModal,
|
|
99
|
+
EditButtonOnRowHover, GroupsChooser, GenericEditableField, UserJoined, GroupsList},
|
|
100
|
+
data() {
|
|
101
|
+
return {
|
|
102
|
+
usersApi: new UsersApi(this.$buefy),
|
|
103
|
+
selectedUser: null,
|
|
104
|
+
searchQuery: null,
|
|
105
|
+
isCreateUserModalActive: false,
|
|
106
|
+
paginatedItems: {items: null, totalItems: null, itemsPerPage: null, loading: false, currentPage: 1},
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
watch: {
|
|
110
|
+
'searchQuery': 'fetchData'
|
|
111
|
+
},
|
|
112
|
+
created() {
|
|
113
|
+
this.fetchData()
|
|
114
|
+
},
|
|
115
|
+
methods: {
|
|
116
|
+
async fetchData() {
|
|
117
|
+
this.paginatedItems.loading = true;
|
|
118
|
+
let data = await this.usersApi.list(false, this.paginatedItems.currentPage, this.searchQuery);
|
|
119
|
+
|
|
120
|
+
this.paginatedItems.items = data.items;
|
|
121
|
+
this.paginatedItems.totalItems = data.totalItems;
|
|
122
|
+
this.paginatedItems.itemsPerPage = data.itemsPerPage;
|
|
123
|
+
this.paginatedItems.loading = false;
|
|
124
|
+
},
|
|
125
|
+
async impersonate(id) {
|
|
126
|
+
const promise = new Promise((resolve) => {
|
|
127
|
+
this.$buefy.dialog.confirm({
|
|
128
|
+
message: 'This super-admin functionality allows you to impersonate another user. Are you sure you want to continue?',
|
|
129
|
+
onConfirm: resolve
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await promise;
|
|
134
|
+
let response = await this.usersApi.impersonate(id);
|
|
135
|
+
console.log(response);
|
|
136
|
+
this.$buefy.notification.open(morphToNotification(response));
|
|
137
|
+
this.$store.commit('setImpersonating', response.user);
|
|
138
|
+
// ignore duplicated navigation failure
|
|
139
|
+
await this.$router.push({ path: '/' }).catch(failure => {
|
|
140
|
+
if(!isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
|
141
|
+
throw failure;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
async deactivate(id) {
|
|
146
|
+
await this.usersApi.deleteAndNotify(id);
|
|
147
|
+
await this.fetchData();
|
|
148
|
+
},
|
|
149
|
+
async activate(id) {
|
|
150
|
+
await this.usersApi.restoreAndNotify(id);
|
|
151
|
+
await this.fetchData();
|
|
152
|
+
},
|
|
153
|
+
async forceDelete(id) {
|
|
154
|
+
await this.usersApi.confirmForceDelete(id);
|
|
155
|
+
await this.fetchData();
|
|
156
|
+
},
|
|
157
|
+
updateUser(user) {
|
|
158
|
+
this.paginatedItems.items = this.paginatedItems.items.map(u => {
|
|
159
|
+
return u.id === user.id ? user : u;
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<style scoped>
|
|
167
|
+
@import './util.css';
|
|
168
|
+
</style>
|