🔀 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:
Alicia Sykes
2024-04-28 22:58:57 +01:00
committed by GitHub
41 changed files with 779 additions and 289 deletions

View File

@@ -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 : '';

View File

@@ -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...",

View File

@@ -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';
},

View File

@@ -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;

View 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>

View File

@@ -37,8 +37,10 @@ export default {
input: '',
};
},
props: {
iconSize: String,
computed: {
iconSize() {
return this.$store.getters.iconSize;
},
},
components: {
IconSmall,

View File

@@ -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);

View File

@@ -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" />

View File

@@ -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;
},

View File

@@ -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();
}
});

View File

@@ -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];

View File

@@ -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;

View File

@@ -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: {},

View File

@@ -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*

View File

@@ -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'] {

View File

@@ -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…" }

View File

@@ -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;
};

View File

@@ -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",

View File

@@ -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();
}
}

View File

@@ -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 = () => {

View File

@@ -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

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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"