🔀 Merge pull request #1542 from Lissy93/FEAT/3.0.1-improvements
[FEAT] Clearer error messaging and documented user-data dir (3.0.1)
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<LoadingScreen :isLoading="isLoading" v-if="shouldShowSplash" />
|
||||
<Header :pageInfo="pageInfo" />
|
||||
<router-view v-if="!isFetching" />
|
||||
<CriticalError v-if="hasCriticalError" />
|
||||
<Footer :text="footerText" v-if="visibleComponents.footer && !isFetching" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +13,7 @@
|
||||
import Header from '@/components/PageStrcture/Header.vue';
|
||||
import Footer from '@/components/PageStrcture/Footer.vue';
|
||||
import EditModeTopBanner from '@/components/InteractiveEditor/EditModeTopBanner.vue';
|
||||
import CriticalError from '@/components/PageStrcture/CriticalError.vue';
|
||||
import LoadingScreen from '@/components/PageStrcture/LoadingScreen.vue';
|
||||
import { welcomeMsg } from '@/utils/CoolConsole';
|
||||
import ErrorHandler from '@/utils/ErrorHandler';
|
||||
@@ -29,6 +31,7 @@ export default {
|
||||
Footer,
|
||||
LoadingScreen,
|
||||
EditModeTopBanner,
|
||||
CriticalError,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -72,6 +75,9 @@ export default {
|
||||
isEditMode() {
|
||||
return this.$store.state.editMode;
|
||||
},
|
||||
hasCriticalError() {
|
||||
return this.$store.state.criticalError;
|
||||
},
|
||||
subPageClassName() {
|
||||
const currentSubPage = this.$store.state.currentConfigInfo;
|
||||
return (currentSubPage && currentSubPage.pageId) ? currentSubPage.pageId : '';
|
||||
|
||||
@@ -312,6 +312,14 @@
|
||||
"view-title": "View Config"
|
||||
}
|
||||
},
|
||||
"critical-error": {
|
||||
"title": "Configuration Load Error",
|
||||
"subtitle": "Dashy has failed to load correctly due to a configuration error.",
|
||||
"sub-ensure-that": "Ensure that",
|
||||
"sub-error-details": "Error Details",
|
||||
"sub-next-steps": "Next Steps",
|
||||
"ignore-button": "Ignore Critical Errors"
|
||||
},
|
||||
"widgets": {
|
||||
"general": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -116,7 +116,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
const jsonData = { ...this.config };
|
||||
jsonData.sections = jsonData.sections.map(({ filteredItems, ...section }) => section);
|
||||
jsonData.sections = (jsonData.sections || []).map(({ filteredItems, ...section }) => section);
|
||||
if (!jsonData.pageInfo) jsonData.pageInfo = { title: 'Dashy' };
|
||||
this.jsonData = jsonData;
|
||||
if (!this.allowWriteToDisk) this.saveMode = 'local';
|
||||
},
|
||||
|
||||
@@ -64,7 +64,6 @@ export default {
|
||||
return this.$store.state.editMode;
|
||||
},
|
||||
sectionKey() {
|
||||
if (this.isEditMode) return undefined;
|
||||
return `collapsible-${this.uniqueKey}`;
|
||||
},
|
||||
collapseClass() {
|
||||
@@ -104,12 +103,23 @@ export default {
|
||||
watch: {
|
||||
checkboxState(newState) {
|
||||
this.isExpanded = newState;
|
||||
this.updateLocalStorage(); // Save every change immediately
|
||||
},
|
||||
uniqueKey() {
|
||||
this.checkboxState = this.isExpanded;
|
||||
uniqueKey(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.refreshCollapseState(); // Refresh state when key changes
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
refreshCollapseState() {
|
||||
this.checkboxState = this.isExpanded;
|
||||
},
|
||||
updateLocalStorage() {
|
||||
const collapseState = this.locallyStoredCollapseStates();
|
||||
collapseState[this.uniqueKey] = this.checkboxState;
|
||||
localStorage.setItem(localStorageKeys.COLLAPSE_STATE, JSON.stringify(collapseState));
|
||||
},
|
||||
/* Either expand or collapse section, based on it's current state */
|
||||
toggle() {
|
||||
this.checkboxState = !this.checkboxState;
|
||||
|
||||
153
src/components/PageStrcture/CriticalError.vue
Normal file
153
src/components/PageStrcture/CriticalError.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="critical-error-wrap" v-if="shouldShow">
|
||||
<button class="close" title="Close Warning" @click="close">🗙</button>
|
||||
<h3>{{ $t('critical-error.title') }}</h3>
|
||||
<p>{{ $t('critical-error.subtitle') }}</p>
|
||||
<h4>{{ $t('critical-error.sub-ensure-that') }}</h4>
|
||||
<ul>
|
||||
<li>The configuration file can be found at the specified location</li>
|
||||
<li>There are no CORS rules preventing client-side access</li>
|
||||
<li>The YAML is valid, parsable and matches the schema</li>
|
||||
</ul>
|
||||
<h4>{{ $t('critical-error.sub-error-details') }}</h4>
|
||||
<pre>{{ this.$store.state.criticalError }}</pre>
|
||||
<h4>{{ $t('critical-error.sub-next-steps') }}</h4>
|
||||
<ul>
|
||||
<li>Check the browser console for more details
|
||||
(<a href="https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md#how-to-open-browser-console">see how</a>)
|
||||
</li>
|
||||
<li>View the
|
||||
<a href="https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md">Troubleshooting Guide</a>
|
||||
and <a href="https://dashy.to/docs/">Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
If you've verified the config is present, accessible and valid, and cannot find the solution
|
||||
in the troubleshooting, docs or GitHub issues,
|
||||
then <a href="https://github.com/Lissy93/dashy/issues/new/choose">open a ticket on GitHub</a>
|
||||
</li>
|
||||
<li>Click 'Ignore Critical Errors' below to not show this warning again</li>
|
||||
</ul>
|
||||
<button class="user-doesnt-care" @click="ignoreWarning">
|
||||
{{ $t('critical-error.ignore-button') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { localStorageKeys } from '@/utils/defaults';
|
||||
import Keys from '@/utils/StoreMutations';
|
||||
|
||||
export default {
|
||||
name: 'CriticalError',
|
||||
computed: {
|
||||
/* Determines if we should show this component.
|
||||
* If error present AND user hasn't disabled */
|
||||
shouldShow() {
|
||||
return this.$store.state.criticalError
|
||||
&& !localStorage[localStorageKeys.DISABLE_CRITICAL_WARNING];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/* Ignore all future errors, by putting a key in local storage */
|
||||
ignoreWarning() {
|
||||
localStorage.setItem(localStorageKeys.DISABLE_CRITICAL_WARNING, true);
|
||||
this.close();
|
||||
},
|
||||
/* Close this dialog, by removing this error from the local store */
|
||||
close() {
|
||||
this.$store.commit(Keys.CRITICAL_ERROR_MSG, null);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/media-queries.scss';
|
||||
.critical-error-wrap {
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
max-width: 50rem;
|
||||
background: var(--background-darker);
|
||||
padding: 1rem;
|
||||
border-radius: var(--curve-factor);
|
||||
color: var(--danger);
|
||||
border: 2px solid var(--danger);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
@include tablet-down {
|
||||
top: 50%;
|
||||
width: 85vw;
|
||||
}
|
||||
p, ul, h4, a {
|
||||
margin: 0;
|
||||
color: var(--white);
|
||||
}
|
||||
pre {
|
||||
color: var(--warning);
|
||||
font-size: 0.8rem;
|
||||
overflow: auto;
|
||||
background: var(--transparent-white-10);
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--curve-factor);
|
||||
}
|
||||
h4 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 2.2rem;
|
||||
text-align: center;
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
ul {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.user-doesnt-care {
|
||||
background: var(--background-darker);
|
||||
color: var(--white);
|
||||
border-radius: var(--curve-factor);
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
background: var(--danger);
|
||||
color: var(--background-darker);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
background: var(--background);
|
||||
color: var(--primary);
|
||||
border: none;
|
||||
border-radius: var(--curve-factor);
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
color: var(--background);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -37,8 +37,10 @@ export default {
|
||||
input: '',
|
||||
};
|
||||
},
|
||||
props: {
|
||||
iconSize: String,
|
||||
computed: {
|
||||
iconSize() {
|
||||
return this.$store.getters.iconSize;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
IconSmall,
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
<IconDeafault
|
||||
@click="updateDisplayLayout('auto')"
|
||||
v-tooltip="tooltip($t('settings.layout-auto'))"
|
||||
:class="`layout-icon ${displayLayout === 'auto' ? 'selected' : ''}`"
|
||||
:class="`layout-icon ${layout === 'auto' ? 'selected' : ''}`"
|
||||
tabindex="-2"
|
||||
/>
|
||||
<IconHorizontal
|
||||
@click="updateDisplayLayout('horizontal')"
|
||||
v-tooltip="tooltip($t('settings.layout-horizontal'))"
|
||||
:class="`layout-icon ${displayLayout === 'horizontal' ? 'selected' : ''}`"
|
||||
:class="`layout-icon ${layout === 'horizontal' ? 'selected' : ''}`"
|
||||
tabindex="-2"
|
||||
/>
|
||||
<IconVertical
|
||||
@click="updateDisplayLayout('vertical')"
|
||||
v-tooltip="tooltip($t('settings.layout-vertical'))"
|
||||
:class="`layout-icon ${displayLayout === 'vertical' ? 'selected' : ''}`"
|
||||
:class="`layout-icon ${layout === 'vertical' ? 'selected' : ''}`"
|
||||
tabindex="-2"
|
||||
/>
|
||||
</div>
|
||||
@@ -40,6 +40,11 @@ export default {
|
||||
IconHorizontal,
|
||||
IconVertical,
|
||||
},
|
||||
computed: {
|
||||
layout() {
|
||||
return this.$store.getters.layout;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateDisplayLayout(layout) {
|
||||
this.$store.commit(StoreKeys.SET_ITEM_LAYOUT, layout);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="options-outer">
|
||||
<div :class="`options-container ${!settingsVisible ? 'hide' : ''}`">
|
||||
<ThemeSelector />
|
||||
<LayoutSelector :displayLayout="displayLayout" />
|
||||
<LayoutSelector :displayLayout="$store.getters.layout" />
|
||||
<ItemSizeSelector :iconSize="iconSize" />
|
||||
<ConfigLauncher />
|
||||
<AuthButtons v-if="userState !== 0" :userType="userState" />
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="readme-stats">
|
||||
<img class="stats-card" v-if="!hideProfileCard" :src="profileCard" alt="Profile Card" />
|
||||
<img class="stats-card" v-if="!hideLanguagesCard" :src="topLanguagesCard" alt="Languages" />
|
||||
<a v-if="!hideProfileCard" :href="profileCardLink" target="_blank">
|
||||
<img class="stats-card" :src="profileCard" alt="Profile Card" />
|
||||
</a>
|
||||
<a v-if="!hideLanguagesCard" :href="profileCardLink" target="_blank">
|
||||
<img class="stats-card" :src="topLanguagesCard" alt="Languages" />
|
||||
</a>
|
||||
<template v-if="repos">
|
||||
<img class="stats-card" v-for="(repo, i) in repoCards" :key="i" :src="repo" :alt="repo" />
|
||||
<a v-for="(repo, i) in repoCards" :key="i" :href="repo.cardHref" target="_blank">
|
||||
<img class="stats-card" :src="repo.cardSrc" :alt="repo" />
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,6 +67,9 @@ export default {
|
||||
profileCard() {
|
||||
return `${widgetApiEndpoints.readMeStats}?username=${this.username}${this.cardConfig}`;
|
||||
},
|
||||
profileCardLink() {
|
||||
return `https://github.com/${this.username}`;
|
||||
},
|
||||
topLanguagesCard() {
|
||||
return `${widgetApiEndpoints.readMeStats}/top-langs/?username=${this.username}`
|
||||
+ `${this.cardConfig}&langs_count=12`;
|
||||
@@ -70,8 +79,11 @@ export default {
|
||||
this.repos.forEach((repo) => {
|
||||
const username = repo.split('/')[0];
|
||||
const repoName = repo.split('/')[1];
|
||||
cards.push(`${widgetApiEndpoints.readMeStats}/pin/?username=${username}&repo=${repoName}`
|
||||
+ `${this.cardConfig}&show_owner=true`);
|
||||
cards.push({
|
||||
cardSrc: `${widgetApiEndpoints.readMeStats}/pin/?username=${username}`
|
||||
+ `&repo=${repoName}${this.cardConfig}&show_owner=true`,
|
||||
cardHref: `https://github.com/${username}/${repoName}`,
|
||||
});
|
||||
});
|
||||
return cards;
|
||||
},
|
||||
|
||||
16
src/main.js
16
src/main.js
@@ -23,6 +23,7 @@ import { toastedOptions, tooltipOptions, language as defaultLanguage } from '@/u
|
||||
import { initKeycloakAuth, isKeycloakEnabled } from '@/utils/KeycloakAuth';
|
||||
import { initHeaderAuth, isHeaderAuthEnabled } from '@/utils/HeaderAuth';
|
||||
import Keys from '@/utils/StoreMutations';
|
||||
import ErrorHandler from '@/utils/ErrorHandler';
|
||||
|
||||
// Initialize global Vue components
|
||||
Vue.use(VueI18n);
|
||||
@@ -61,16 +62,19 @@ const mount = () => new Vue({
|
||||
}).$mount('#app');
|
||||
|
||||
store.dispatch(Keys.INITIALIZE_CONFIG).then(() => {
|
||||
// Keycloak is enabled, redirect to KC login page
|
||||
if (isKeycloakEnabled()) {
|
||||
if (isKeycloakEnabled()) { // If Keycloak is enabled, initialize auth
|
||||
initKeycloakAuth()
|
||||
.then(() => mount())
|
||||
.catch(() => window.location.reload());
|
||||
} else if (isHeaderAuthEnabled()) {
|
||||
.catch((e) => {
|
||||
ErrorHandler('Failed to authenticate with Keycloak', e);
|
||||
});
|
||||
} else if (isHeaderAuthEnabled()) { // If header auth is enabled, initialize auth
|
||||
initHeaderAuth()
|
||||
.then(() => mount())
|
||||
.catch(() => window.location.reload());
|
||||
} else { // If Keycloak not enabled, then proceed straight to the app
|
||||
.catch((e) => {
|
||||
ErrorHandler('Failed to authenticate with server', e);
|
||||
});
|
||||
} else { // If no third-party auth, just mount the app as normal
|
||||
mount();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,19 +28,24 @@ const HomeMixin = {
|
||||
return this.$store.state.modalOpen;
|
||||
},
|
||||
pageId() {
|
||||
return (this.subPageInfo && this.subPageInfo.pageId) ? this.subPageInfo.pageId : 'home';
|
||||
return this.$store.state.currentConfigInfo?.confId || 'home';
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
searchValue: '',
|
||||
}),
|
||||
async mounted() {
|
||||
// await this.getConfigForRoute();
|
||||
},
|
||||
watch: {
|
||||
async $route() {
|
||||
this.loadUpConfig();
|
||||
},
|
||||
pageInfo: {
|
||||
handler(newPageInfo) {
|
||||
if (newPageInfo && newPageInfo.title) {
|
||||
document.title = newPageInfo.title;
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
this.loadUpConfig();
|
||||
@@ -79,6 +84,14 @@ const HomeMixin = {
|
||||
searching(searchValue) {
|
||||
this.searchValue = searchValue || '';
|
||||
},
|
||||
/* Returns a unique ID based on the page and section name */
|
||||
makeSectionId(section) {
|
||||
const normalize = (str) => (
|
||||
str ? str.trim().toLowerCase().replace(/[^a-zA-Z0-9]/g, '-')
|
||||
: `unnamed-${(`000${Math.floor(Math.random() * 1000)}`).slice(-3)}`
|
||||
);
|
||||
return `${this.pageId || 'unknown-page'}-${normalize(section.name)}`;
|
||||
},
|
||||
/* Returns true if there is one or more sections in the config */
|
||||
checkTheresData(sections) {
|
||||
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
|
||||
|
||||
@@ -105,10 +105,18 @@ const WidgetMixin = {
|
||||
const method = protocol || 'GET';
|
||||
const url = this.useProxy ? this.proxyReqEndpoint : endpoint;
|
||||
const data = JSON.stringify(body || {});
|
||||
|
||||
const CustomHeaders = options || {};
|
||||
const headers = new Headers(this.useProxy
|
||||
? ({ ...CustomHeaders, 'Target-URL': endpoint })
|
||||
: CustomHeaders);
|
||||
const headers = new Headers();
|
||||
|
||||
// If using a proxy, set the 'Target-URL' header
|
||||
if (this.useProxy) {
|
||||
headers.append('Target-URL', endpoint);
|
||||
}
|
||||
// Apply widget-specific custom headers
|
||||
Object.entries(CustomHeaders).forEach(([key, value]) => {
|
||||
headers.append(key, value);
|
||||
});
|
||||
|
||||
// If the request is a GET, delete the body
|
||||
const bodyContent = method.toUpperCase() === 'GET' ? undefined : data;
|
||||
|
||||
102
src/store.js
102
src/store.js
@@ -8,7 +8,7 @@ import { makePageName, formatConfigPath, componentVisibility } from '@/utils/Con
|
||||
import { applyItemId } from '@/utils/SectionHelpers';
|
||||
import filterUserSections from '@/utils/CheckSectionVisibility';
|
||||
import ErrorHandler, { InfoHandler, InfoKeys } from '@/utils/ErrorHandler';
|
||||
import { isUserAdmin } from '@/utils/Auth';
|
||||
import { isUserAdmin, makeBasicAuthHeaders, isLoggedInAsGuest } from '@/utils/Auth';
|
||||
import { localStorageKeys, theme as defaultTheme } from './utils/defaults';
|
||||
|
||||
Vue.use(Vuex);
|
||||
@@ -41,8 +41,15 @@ const {
|
||||
INSERT_ITEM,
|
||||
UPDATE_CUSTOM_CSS,
|
||||
CONF_MENU_INDEX,
|
||||
CRITICAL_ERROR_MSG,
|
||||
} = Keys;
|
||||
|
||||
const emptyConfig = {
|
||||
appConfig: {},
|
||||
pageInfo: { title: 'Dashy' },
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
config: {}, // The current config being used, and rendered to the UI
|
||||
@@ -51,6 +58,7 @@ const store = new Vuex.Store({
|
||||
modalOpen: false, // KB shortcut functionality will be disabled when modal is open
|
||||
currentConfigInfo: {}, // For multi-page support, will store info about config file
|
||||
isUsingLocalConfig: false, // If true, will use local config instead of fetched
|
||||
criticalError: null, // Will store a message, if a critical error occurs
|
||||
navigateConfToTab: undefined, // Used to switch active tab in config modal
|
||||
},
|
||||
getters: {
|
||||
@@ -106,7 +114,8 @@ const store = new Vuex.Store({
|
||||
}
|
||||
// Disable everything
|
||||
if (appConfig.disableConfiguration
|
||||
|| (appConfig.disableConfigurationForNonAdmin && !isUserAdmin())) {
|
||||
|| (appConfig.disableConfigurationForNonAdmin && !isUserAdmin())
|
||||
|| isLoggedInAsGuest()) {
|
||||
perms.allowWriteToDisk = false;
|
||||
perms.allowSaveLocally = false;
|
||||
perms.allowViewConfig = false;
|
||||
@@ -137,10 +146,18 @@ const store = new Vuex.Store({
|
||||
return foundSection;
|
||||
},
|
||||
layout(state) {
|
||||
return state.config.appConfig.layout || 'auto';
|
||||
const pageId = state.currentConfigInfo.confId;
|
||||
const layoutStoreKey = pageId
|
||||
? `${localStorageKeys.LAYOUT_ORIENTATION}-${pageId}` : localStorageKeys.LAYOUT_ORIENTATION;
|
||||
const appConfigLayout = state.config.appConfig.layout;
|
||||
return localStorage.getItem(layoutStoreKey) || appConfigLayout || 'auto';
|
||||
},
|
||||
iconSize(state) {
|
||||
return state.config.appConfig.iconSize || 'medium';
|
||||
const pageId = state.currentConfigInfo.confId;
|
||||
const sizeStoreKey = pageId
|
||||
? `${localStorageKeys.ICON_SIZE}-${pageId}` : localStorageKeys.ICON_SIZE;
|
||||
const appConfigSize = state.config.appConfig.iconSize;
|
||||
return localStorage.getItem(sizeStoreKey) || appConfigSize || 'medium';
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
@@ -174,6 +191,10 @@ const store = new Vuex.Store({
|
||||
state.editMode = editMode;
|
||||
}
|
||||
},
|
||||
[CRITICAL_ERROR_MSG](state, message) {
|
||||
if (message) ErrorHandler(message);
|
||||
state.criticalError = message;
|
||||
},
|
||||
[UPDATE_ITEM](state, payload) {
|
||||
const { itemId, newItem } = payload;
|
||||
const newConfig = { ...state.config };
|
||||
@@ -298,11 +319,23 @@ const store = new Vuex.Store({
|
||||
InfoHandler('Color palette updated', InfoKeys.VISUAL);
|
||||
},
|
||||
[SET_ITEM_LAYOUT](state, layout) {
|
||||
state.config.appConfig.layout = layout;
|
||||
const newConfig = { ...state.config };
|
||||
newConfig.appConfig.layout = layout;
|
||||
state.config = newConfig;
|
||||
const pageId = state.currentConfigInfo.confId;
|
||||
const layoutStoreKey = pageId
|
||||
? `${localStorageKeys.LAYOUT_ORIENTATION}-${pageId}` : localStorageKeys.LAYOUT_ORIENTATION;
|
||||
localStorage.setItem(layoutStoreKey, layout);
|
||||
InfoHandler('Layout updated', InfoKeys.VISUAL);
|
||||
},
|
||||
[SET_ITEM_SIZE](state, iconSize) {
|
||||
state.config.appConfig.iconSize = iconSize;
|
||||
const newConfig = { ...state.config };
|
||||
newConfig.appConfig.iconSize = iconSize;
|
||||
state.config = newConfig;
|
||||
const pageId = state.currentConfigInfo.confId;
|
||||
const sizeStoreKey = pageId
|
||||
? `${localStorageKeys.ICON_SIZE}-${pageId}` : localStorageKeys.ICON_SIZE;
|
||||
localStorage.setItem(sizeStoreKey, iconSize);
|
||||
InfoHandler('Item size updated', InfoKeys.VISUAL);
|
||||
},
|
||||
[UPDATE_CUSTOM_CSS](state, customCss) {
|
||||
@@ -320,16 +353,39 @@ const store = new Vuex.Store({
|
||||
actions: {
|
||||
/* Fetches the root config file, only ever called by INITIALIZE_CONFIG */
|
||||
async [INITIALIZE_ROOT_CONFIG]({ commit }) {
|
||||
// Load and parse config from root config file
|
||||
const configFilePath = process.env.VUE_APP_CONFIG_PATH || '/conf.yml';
|
||||
const data = await yaml.load((await axios.get(configFilePath)).data);
|
||||
// Replace missing root properties with empty objects
|
||||
if (!data.appConfig) data.appConfig = {};
|
||||
if (!data.pageInfo) data.pageInfo = {};
|
||||
if (!data.sections) data.sections = [];
|
||||
// Set the state, and return data
|
||||
commit(SET_ROOT_CONFIG, data);
|
||||
return data;
|
||||
try {
|
||||
// Attempt to fetch the YAML file
|
||||
const response = await axios.get(configFilePath, makeBasicAuthHeaders());
|
||||
let data;
|
||||
try {
|
||||
data = yaml.load(response.data);
|
||||
} catch (parseError) {
|
||||
commit(CRITICAL_ERROR_MSG, `Failed to parse YAML: ${parseError.message}`);
|
||||
return { ...emptyConfig };
|
||||
}
|
||||
// Replace missing root properties with empty objects
|
||||
if (!data.appConfig) data.appConfig = {};
|
||||
if (!data.pageInfo) data.pageInfo = {};
|
||||
if (!data.sections) data.sections = [];
|
||||
// Set the state, and return data
|
||||
commit(SET_ROOT_CONFIG, data);
|
||||
commit(CRITICAL_ERROR_MSG, null);
|
||||
return data;
|
||||
} catch (fetchError) {
|
||||
if (fetchError.response) {
|
||||
commit(
|
||||
CRITICAL_ERROR_MSG,
|
||||
'Failed to fetch configuration: Server responded with status '
|
||||
+ `${fetchError.response?.status || 'mystery status'}`,
|
||||
);
|
||||
} else if (fetchError.request) {
|
||||
commit(CRITICAL_ERROR_MSG, 'Failed to fetch configuration: No response from server');
|
||||
} else {
|
||||
commit(CRITICAL_ERROR_MSG, `Failed to fetch configuration: ${fetchError.message}`);
|
||||
}
|
||||
return { ...emptyConfig };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Fetches config and updates state
|
||||
@@ -339,6 +395,7 @@ const store = new Vuex.Store({
|
||||
*/
|
||||
async [INITIALIZE_CONFIG]({ commit, state }, subConfigId) {
|
||||
const rootConfig = state.rootConfig || await this.dispatch(Keys.INITIALIZE_ROOT_CONFIG);
|
||||
|
||||
commit(SET_IS_USING_LOCAL_CONFIG, false);
|
||||
if (!subConfigId) { // Use root config as config
|
||||
commit(SET_CONFIG, rootConfig);
|
||||
@@ -351,7 +408,7 @@ const store = new Vuex.Store({
|
||||
const json = JSON.parse(localSectionsRaw);
|
||||
if (json.length >= 1) localSections = json;
|
||||
} catch (e) {
|
||||
ErrorHandler('Malformed section data in local storage');
|
||||
commit(CRITICAL_ERROR_MSG, 'Malformed section data in local storage');
|
||||
}
|
||||
}
|
||||
if (localSections.length > 0) {
|
||||
@@ -366,11 +423,10 @@ const store = new Vuex.Store({
|
||||
)?.path);
|
||||
|
||||
if (!subConfigPath) {
|
||||
ErrorHandler(`Unable to find config for '${subConfigId}'`);
|
||||
return null;
|
||||
commit(CRITICAL_ERROR_MSG, `Unable to find config for '${subConfigId}'`);
|
||||
return { ...emptyConfig };
|
||||
}
|
||||
|
||||
axios.get(subConfigPath).then((response) => {
|
||||
axios.get(subConfigPath, makeBasicAuthHeaders()).then((response) => {
|
||||
// Parse the YAML
|
||||
const configContent = yaml.load(response.data) || {};
|
||||
// Certain values must be inherited from root config
|
||||
@@ -389,17 +445,17 @@ const store = new Vuex.Store({
|
||||
commit(SET_IS_USING_LOCAL_CONFIG, true);
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorHandler('Malformed section data in local storage for sub-config');
|
||||
commit(CRITICAL_ERROR_MSG, 'Malformed section data in local storage for sub-config');
|
||||
}
|
||||
}
|
||||
// Set the config
|
||||
commit(SET_CONFIG, configContent);
|
||||
commit(SET_CURRENT_CONFIG_INFO, { confPath: subConfigPath, confId: subConfigId });
|
||||
}).catch((err) => {
|
||||
ErrorHandler(`Unable to load config from '${subConfigPath}'`, err);
|
||||
commit(CRITICAL_ERROR_MSG, `Unable to load config from '${subConfigPath}'`, err);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
return { ...emptyConfig };
|
||||
},
|
||||
},
|
||||
modules: {},
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
--transparent-white-70: #ffffffb3;
|
||||
--transparent-white-50: #ffffff80;
|
||||
--transparent-white-30: #ffffff4d;
|
||||
--transparent-white-10: #ffffff0f;
|
||||
|
||||
/* Color variables for specific components
|
||||
* all variables are optional, since they inherit initial values from above*
|
||||
|
||||
@@ -1717,6 +1717,7 @@ html[data-theme='neomorphic'] {
|
||||
.config-buttons > svg,
|
||||
.display-options svg,
|
||||
form.minimal input,
|
||||
.critical-error-wrap button.user-doesnt-care,
|
||||
a.config-button, button.config-button {
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: var(--glass-button-shadow);
|
||||
@@ -1724,6 +1725,7 @@ html[data-theme='neomorphic'] {
|
||||
border: 1px solid rgba(255, 255, 255, 0.19);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.2s ease-in-out;
|
||||
text-decoration: none;
|
||||
&:hover, &.selected {
|
||||
box-shadow: var(--glass-button-hover-shadow);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25) !important;
|
||||
@@ -1791,6 +1793,11 @@ html[data-theme='neomorphic'] {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(50px);
|
||||
}
|
||||
|
||||
.critical-error-wrap {
|
||||
backdrop-filter: blur(15px);
|
||||
background: #0f0528c4;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='glass'] {
|
||||
|
||||
@@ -22,6 +22,11 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
#dashy {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Hide text, and show 'Loading...' while Vue is initializing tags */
|
||||
[v-cloak] > * { display:none }
|
||||
[v-cloak]::before { content: "loading…" }
|
||||
|
||||
@@ -11,8 +11,6 @@ const getAppConfig = () => {
|
||||
return config.appConfig || {};
|
||||
};
|
||||
|
||||
// const appConfig = $store.getters.appConfig || {};
|
||||
|
||||
/**
|
||||
* Called when the user is still using array for users, prints warning
|
||||
* This was a breaking change, implemented in V 1.6.5
|
||||
@@ -41,37 +39,52 @@ const getUsers = () => {
|
||||
* @returns {String} The hashed token
|
||||
*/
|
||||
const generateUserToken = (user) => {
|
||||
if (!user.user || !user.hash) {
|
||||
ErrorHandler('Invalid user object. Must have `user` and `hash` parameters');
|
||||
if (!user.user || (!user.hash && !user.password)) {
|
||||
ErrorHandler('Invalid user object. Must have `user` and either a `hash` or `password` param');
|
||||
return undefined;
|
||||
}
|
||||
const passHash = user.hash || sha256(process.env[user.password]).toString().toUpperCase();
|
||||
const strAndUpper = (input) => input.toString().toUpperCase();
|
||||
const sha = sha256(strAndUpper(user.user) + strAndUpper(user.hash));
|
||||
const sha = sha256(strAndUpper(user.user) + strAndUpper(passHash));
|
||||
return strAndUpper(sha);
|
||||
};
|
||||
|
||||
export const getCookieToken = () => {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${cookieKeys.AUTH_TOKEN}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
};
|
||||
|
||||
export const makeBasicAuthHeaders = () => {
|
||||
const token = getCookieToken();
|
||||
const bearerAuth = (token && token.length > 5) ? `Bearer ${token}` : null;
|
||||
|
||||
const username = process.env.VUE_APP_BASIC_AUTH_USERNAME
|
||||
|| localStorage[localStorageKeys.USERNAME]
|
||||
|| 'user';
|
||||
const password = process.env.VUE_APP_BASIC_AUTH_PASSWORD || bearerAuth;
|
||||
const basicAuth = `Basic ${btoa(`${username}:${password}`)}`;
|
||||
|
||||
const headers = password
|
||||
? { headers: { Authorization: basicAuth, 'WWW-Authenticate': 'true' } }
|
||||
: {};
|
||||
return headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the user is currently authenticated
|
||||
* @returns {Boolean} Will return true if the user is logged in, else false
|
||||
*/
|
||||
export const isLoggedIn = () => {
|
||||
const users = getUsers();
|
||||
let userAuthenticated = document.cookie.split(';').some((cookie) => {
|
||||
if (cookie && cookie.split('=').length > 1) {
|
||||
const cookieKey = cookie.split('=')[0].trim();
|
||||
const cookieValue = cookie.split('=')[1].trim();
|
||||
if (cookieKey === cookieKeys.AUTH_TOKEN) {
|
||||
userAuthenticated = users.some((user) => {
|
||||
if (generateUserToken(user) === cookieValue) {
|
||||
localStorage.setItem(localStorageKeys.USERNAME, user.user);
|
||||
return true;
|
||||
} else return false;
|
||||
});
|
||||
return userAuthenticated;
|
||||
} else return false;
|
||||
const cookieToken = getCookieToken();
|
||||
return users.some((user) => {
|
||||
if (generateUserToken(user) === cookieToken) {
|
||||
localStorage.setItem(localStorageKeys.USERNAME, user.user);
|
||||
return true;
|
||||
} else return false;
|
||||
});
|
||||
return userAuthenticated;
|
||||
};
|
||||
|
||||
/* Returns true if authentication is enabled */
|
||||
@@ -108,7 +121,18 @@ export const checkCredentials = (username, pass, users, messages) => {
|
||||
} else {
|
||||
users.forEach((user) => {
|
||||
if (user.user.toLowerCase() === username.toLowerCase()) { // User found
|
||||
if (user.hash.toLowerCase() === sha256(pass).toString().toLowerCase()) {
|
||||
if (user.password) {
|
||||
if (!user.password.startsWith('VUE_APP_')) {
|
||||
ErrorHandler('Invalid password format. Please use VUE_APP_ prefix');
|
||||
response = { correct: false, msg: messages.incorrectPassword };
|
||||
} else if (!process.env[user.password]) {
|
||||
ErrorHandler(`Missing environmental variable for ${user.password}`);
|
||||
} else if (process.env[user.password] === pass) {
|
||||
response = { correct: true, msg: messages.successMsg };
|
||||
} else {
|
||||
response = { correct: false, msg: messages.incorrectPassword };
|
||||
}
|
||||
} else if (user.hash && user.hash.toLowerCase() === sha256(pass).toString().toLowerCase()) {
|
||||
response = { correct: true, msg: messages.successMsg }; // Password is correct
|
||||
} else { // User found, but password is not a match
|
||||
response = { correct: false, msg: messages.incorrectPassword };
|
||||
@@ -163,9 +187,9 @@ export const getCurrentUser = () => {
|
||||
* Checks if the user is viewing the dashboard as a guest
|
||||
* Returns true if guest mode enabled, and user not logged in
|
||||
* */
|
||||
export const isLoggedInAsGuest = (currentUser) => {
|
||||
export const isLoggedInAsGuest = () => {
|
||||
const guestEnabled = isGuestAccessEnabled();
|
||||
const loggedIn = isLoggedIn() && currentUser;
|
||||
const loggedIn = isLoggedIn();
|
||||
return guestEnabled && !loggedIn;
|
||||
};
|
||||
|
||||
|
||||
@@ -500,14 +500,9 @@
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"type": "array",
|
||||
"description": "Usernames and hashed credentials for frontend authentication",
|
||||
"description": "Usernames and hashed credentials for frontend authentication. Needs to be set at build-time.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"user",
|
||||
"hash"
|
||||
],
|
||||
"properties": {
|
||||
"user": {
|
||||
"title": "Username",
|
||||
@@ -521,6 +516,12 @@
|
||||
"minLength": 64,
|
||||
"maxLength": 64
|
||||
},
|
||||
"password": {
|
||||
"title": "Password",
|
||||
"type": "string",
|
||||
"description": "The environmental variable pointing to a plaintext password for that user. Must start with VUE_APP_",
|
||||
"pattern": "^VUE_APP_.*"
|
||||
},
|
||||
"type": {
|
||||
"title": "Privileges",
|
||||
"type": "string",
|
||||
@@ -531,9 +532,15 @@
|
||||
"description": "User type, denoting privilege level, either admin or normal",
|
||||
"default": "normal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["user"],
|
||||
"oneOf": [
|
||||
{ "required": ["hash"] },
|
||||
{ "required": ["password"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
"enableHeaderAuth": {
|
||||
"title": "Enable HeaderAuth?",
|
||||
"type": "boolean",
|
||||
|
||||
@@ -3,7 +3,7 @@ import sha256 from 'crypto-js/sha256';
|
||||
import ConfigAccumulator from '@/utils/ConfigAccumalator';
|
||||
import { cookieKeys, localStorageKeys, serviceEndpoints } from '@/utils/defaults';
|
||||
import { InfoHandler, ErrorHandler, InfoKeys } from '@/utils/ErrorHandler';
|
||||
import { logout } from '@/utils/Auth';
|
||||
import { logout as authLogout } from '@/utils/Auth';
|
||||
|
||||
const getAppConfig = () => {
|
||||
const Accumulator = new ConfigAccumulator();
|
||||
@@ -22,7 +22,6 @@ class HeaderAuth {
|
||||
this.users = auth.users;
|
||||
}
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
login() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
|
||||
@@ -44,6 +43,7 @@ class HeaderAuth {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
ErrorHandler('Error while trying to login using header authentication', e);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,9 @@ class HeaderAuth {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
logout() {
|
||||
logout();
|
||||
authLogout();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const determineIntersection = (source = [], target = []) => {
|
||||
/* Returns false if the displayData of a section/item
|
||||
should not be rendered for the current user/ guest */
|
||||
export const isVisibleToUser = (displayData, currentUser) => {
|
||||
const isGuest = isLoggedInAsGuest(currentUser); // Check if current user is a guest
|
||||
const isGuest = isLoggedInAsGuest(); // Check if current user is a guest
|
||||
|
||||
// Checks if user explicitly has access to a certain section
|
||||
const checkVisibility = () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ const KEY_NAMES = [
|
||||
'INSERT_ITEM',
|
||||
'UPDATE_CUSTOM_CSS',
|
||||
'CONF_MENU_INDEX',
|
||||
'CRITICAL_ERROR_MSG',
|
||||
];
|
||||
|
||||
// Convert array of key names into an object, and export
|
||||
|
||||
@@ -135,6 +135,7 @@ module.exports = {
|
||||
MOST_USED: 'mostUsed',
|
||||
LAST_USED: 'lastUsed',
|
||||
KEYCLOAK_INFO: 'keycloakInfo',
|
||||
DISABLE_CRITICAL_WARNING: 'disableCriticalWarning',
|
||||
},
|
||||
/* Key names for cookie identifiers */
|
||||
cookieKeys: {
|
||||
|
||||
@@ -19,14 +19,7 @@
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- Main content, section for each group of items -->
|
||||
<div v-if="checkTheresData(sections) || isEditMode"
|
||||
:class="`item-group-container `
|
||||
+ `orientation-${layout} `
|
||||
+ `item-size-${itemSizeBound} `
|
||||
+ (isEditMode ? 'edit-mode ' : '')
|
||||
+ (singleSectionView ? 'single-section-view ' : '')
|
||||
+ (this.colCount ? `col-count-${this.colCount} ` : '')"
|
||||
>
|
||||
<div v-if="checkTheresData(sections) || isEditMode" :class="computedClass">
|
||||
<template v-for="(section, index) in filteredSections">
|
||||
<Section
|
||||
:key="index"
|
||||
@@ -34,7 +27,7 @@
|
||||
:title="section.name"
|
||||
:icon="section.icon || undefined"
|
||||
:displayData="getDisplayData(section)"
|
||||
:groupId="`${pageId}-section-${index}`"
|
||||
:groupId="makeSectionId(section)"
|
||||
:items="section.filteredItems"
|
||||
:widgets="section.widgets"
|
||||
:searchTerm="searchValue"
|
||||
@@ -70,7 +63,7 @@ import ExportConfigMenu from '@/components/InteractiveEditor/ExportConfigMenu.vu
|
||||
import AddNewSection from '@/components/InteractiveEditor/AddNewSectionLauncher.vue';
|
||||
import NotificationThing from '@/components/Settings/LocalConfigWarning.vue';
|
||||
import StoreKeys from '@/utils/StoreMutations';
|
||||
import { localStorageKeys, modalNames } from '@/utils/defaults';
|
||||
import { modalNames } from '@/utils/defaults';
|
||||
import ErrorHandler from '@/utils/ErrorHandler';
|
||||
import BackIcon from '@/assets/interface-icons/back-arrow.svg';
|
||||
|
||||
@@ -120,19 +113,13 @@ export default {
|
||||
iconSize() {
|
||||
return this.$store.getters.iconSize;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
layoutOrientation(layout) {
|
||||
if (layout) {
|
||||
localStorage.setItem(localStorageKeys.LAYOUT_ORIENTATION, layout);
|
||||
this.layout = layout;
|
||||
}
|
||||
},
|
||||
iconSize(size) {
|
||||
if (size) {
|
||||
localStorage.setItem(localStorageKeys.ICON_SIZE, size);
|
||||
this.itemSizeBound = size;
|
||||
}
|
||||
computedClass() {
|
||||
let classes = 'item-group-container '
|
||||
+ ` orientation-${this.$store.getters.layout} item-size-${this.itemSizeBound}`;
|
||||
if (this.isEditMode) classes += ' edit-mode';
|
||||
if (this.singleSectionView) classes += ' single-section-view';
|
||||
if (this.colCount) classes += ` col-count-${this.colCount}`;
|
||||
return classes;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
:index="index"
|
||||
:title="section.name"
|
||||
:icon="section.icon || undefined"
|
||||
:groupId="`section-${index}`"
|
||||
:groupId="makeSectionId(section)"
|
||||
:items="filterTiles(section.items)"
|
||||
:widgets="section.widgets"
|
||||
:selected="selectedSection === index"
|
||||
|
||||
Reference in New Issue
Block a user