mirror of
https://github.com/Lissy93/dashy.git
synced 2025-12-06 08:34:14 +01:00
Merge 751bf1747a into 996de036e8
This commit is contained in:
commit
49d4ab5257
9 changed files with 302 additions and 57 deletions
|
|
@ -243,6 +243,7 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
|
|||
**`items`** | `array` | _Optional_ | An array of items to be displayed within the section. See [`item`](#sectionitem). Sections must include either 1 or more items, or 1 or more widgets.
|
||||
**`widgets`** | `array` | _Optional_ | An array of widgets to be displayed within the section. See [`widget`](#sectionwidget-optional)
|
||||
**`displayData`** | `object` | _Optional_ | Meta-data to optionally override display settings for a given section. See [`displayData`](#sectiondisplaydata-optional)
|
||||
**`pin`** | `string` | _Optional_ | The PIN code for unlocking this section if `secret` under `displayData` is true. Provide at the section root (e.g., pin: 2749). Validated client-side and remembered only for the current browser tab/session. Not intended for protecting highly sensitive data. Only for Child-proofing. if not entered but section is locked then default pin will be 0000. optionally user can input SHA256 hash of their pin here.
|
||||
|
||||
**[⬆️ Back to Top](#configuring)**
|
||||
|
||||
|
|
@ -317,6 +318,7 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
|
|||
**`hideForGuests`** | `boolean` | _Optional_ | Current section will be visible for logged in users, but not for guests (see `appConfig.enableGuestAccess`). Defaults to `false`
|
||||
**`hideForKeycloakUsers`** | `object` | _Optional_ | Current section will be visible to all keycloak users, except for those configured via these groups and roles. See `hideForKeycloakUsers`
|
||||
**`showForKeycloakUsers`** | `object` | _Optional_ | Current section will be hidden from all keycloak users, except for those configured via these groups and roles. See `showForKeycloakUsers`
|
||||
**`secret`** | `boolean` | _Optional_ | When true, the section is hidden behind a PIN gate. The PIN must be provided at the section root via `pin`. While locked, the section shows a PIN input instead of its items. On successful entry, the section unlocks for the current browser tab/session (not persisted across tab closes). In Edit Mode the gate is bypassed so you can configure/unhide the section.
|
||||
|
||||
**[⬆️ Back to Top](#configuring)**
|
||||
|
||||
|
|
|
|||
|
|
@ -251,6 +251,13 @@
|
|||
"remove-section": "Remove"
|
||||
}
|
||||
},
|
||||
"pin": {
|
||||
"unlock": "Unlock",
|
||||
"lock": "Lock",
|
||||
"lockedSection": "Locked section",
|
||||
"enter-pin": "Enter PIN",
|
||||
"incorrect-pin": "Incorrect PIN"
|
||||
},
|
||||
"footer": {
|
||||
"dev-by": "Developed by",
|
||||
"licensed-under": "Licensed under",
|
||||
|
|
|
|||
77
src/components/InteractiveEditor/PinInput.vue
Normal file
77
src/components/InteractiveEditor/PinInput.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="pin-gate">
|
||||
<div class="pin-head">
|
||||
<i class="far fa-lock" aria-hidden="true"></i>
|
||||
<span>{{ $t('pin.lockedSection') }}</span>
|
||||
</div>
|
||||
|
||||
<FormSchema
|
||||
:schema="schema"
|
||||
v-model="form"
|
||||
name="pinInputForm"
|
||||
class="pin-form"
|
||||
/>
|
||||
|
||||
<div class="pin-actions">
|
||||
<div v-if="errorMessage" class="pin-error">{{ errorMessage }}</div>
|
||||
<button class="pin-btn" @click="submit" :aria-label="$t('pin.unlock')">
|
||||
<i class="fas fa-unlock-alt btn-icon" aria-hidden="true"></i>
|
||||
{{ $t('pin.unlock') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormSchema from '@formschema/native';
|
||||
|
||||
export default {
|
||||
name: 'PinInput',
|
||||
components: { FormSchema },
|
||||
props: {
|
||||
id: String,
|
||||
errorMessage: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: { pin: '' },
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pin: {
|
||||
title: this.$t('pin.enter-pin'),
|
||||
type: 'string',
|
||||
attrs: {
|
||||
type: 'password',
|
||||
inputmode: 'numeric',
|
||||
autocomplete: 'one-time-code',
|
||||
placeholder: '••••',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['pin'],
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
const tried = String(this.form.pin || '');
|
||||
this.$emit('unlock_attempt', {
|
||||
pin: tried,
|
||||
id: this.id,
|
||||
});
|
||||
// clear local field
|
||||
this.form.pin = '';
|
||||
},
|
||||
lockAgain() {
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@import '@/styles/pin-input.scss';
|
||||
|
||||
</style>
|
||||
|
|
@ -14,63 +14,77 @@
|
|||
:id="sectionRef"
|
||||
:ref="sectionRef"
|
||||
>
|
||||
<!-- If no items, show message -->
|
||||
<div v-if="isEmpty" class="no-items">
|
||||
{{ $t('home.no-items-section') }}
|
||||
</div>
|
||||
<!-- Item Container -->
|
||||
<div v-if="hasItems"
|
||||
:class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`"
|
||||
:style="gridStyle" :id="`section-${groupId}`"
|
||||
> <!-- Show for each item -->
|
||||
<template v-for="(item) in sortedItems">
|
||||
<SubItemGroup
|
||||
v-if="item.subItems"
|
||||
:key="item.id"
|
||||
:itemId="item.id"
|
||||
:title="item.title"
|
||||
:subItems="item.subItems"
|
||||
@triggerModal="triggerModal"
|
||||
/>
|
||||
<Item
|
||||
v-else
|
||||
:item="item"
|
||||
:key="item.id"
|
||||
:itemSize="itemSize"
|
||||
<PinInput
|
||||
v-if="showPinRequired"
|
||||
:id = "sectionRef"
|
||||
:errorMessage="pinError"
|
||||
@unlock_attempt="saveUnlockPins"
|
||||
/>
|
||||
<div v-if="!showPinRequired">
|
||||
<!-- If no items, show message -->
|
||||
<div v-if="isEmpty" class="no-items">
|
||||
{{ $t('home.no-items-section') }}
|
||||
</div>
|
||||
<!-- Item Container -->
|
||||
<div v-if="hasItems"
|
||||
:class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`"
|
||||
:style="gridStyle" :id="`section-${groupId}`"
|
||||
> <!-- Show for each item -->
|
||||
<template v-for="(item) in sortedItems">
|
||||
<SubItemGroup
|
||||
v-if="item.subItems"
|
||||
:key="item.id"
|
||||
:itemId="item.id"
|
||||
:title="item.title"
|
||||
:subItems="item.subItems"
|
||||
@triggerModal="triggerModal"
|
||||
/>
|
||||
<Item
|
||||
v-else
|
||||
:item="item"
|
||||
:key="item.id"
|
||||
:itemSize="itemSize"
|
||||
:parentSectionTitle="title"
|
||||
@itemClicked="$emit('itemClicked')"
|
||||
@triggerModal="triggerModal"
|
||||
:isAddNew="false"
|
||||
:sectionWidth="sectionWidth"
|
||||
:sectionDisplayData="displayData"
|
||||
/>
|
||||
</template>
|
||||
<!-- When in edit mode, show additional item, for Add New item -->
|
||||
<Item v-if="isEditMode"
|
||||
:item="{
|
||||
icon: ':heavy_plus_sign:',
|
||||
title: 'Add New Item',
|
||||
description: 'Click to add new item',
|
||||
id: 'add-new',
|
||||
}"
|
||||
:isAddNew="true"
|
||||
:parentSectionTitle="title"
|
||||
@itemClicked="$emit('itemClicked')"
|
||||
@triggerModal="triggerModal"
|
||||
:isAddNew="false"
|
||||
key="add-new"
|
||||
class="add-new-item"
|
||||
:sectionWidth="sectionWidth"
|
||||
:sectionDisplayData="displayData"
|
||||
:itemSize="itemSize"
|
||||
/>
|
||||
</template>
|
||||
<!-- When in edit mode, show additional item, for Add New item -->
|
||||
<Item v-if="isEditMode"
|
||||
:item="{
|
||||
icon: ':heavy_plus_sign:',
|
||||
title: 'Add New Item',
|
||||
description: 'Click to add new item',
|
||||
id: 'add-new',
|
||||
}"
|
||||
:isAddNew="true"
|
||||
:parentSectionTitle="title"
|
||||
key="add-new"
|
||||
class="add-new-item"
|
||||
:sectionWidth="sectionWidth"
|
||||
:itemSize="itemSize"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasWidgets"
|
||||
:class="`widget-list ${isWide? 'wide' : ''}`">
|
||||
<WidgetBase
|
||||
v-for="(widget, widgetIndx) in widgets"
|
||||
:key="widgetIndx"
|
||||
:widget="widget"
|
||||
:index="index"
|
||||
@navigateToSection="navigateToSection"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasWidgets"
|
||||
:class="`widget-list ${isWide? 'wide' : ''}`">
|
||||
<WidgetBase
|
||||
v-for="(widget, widgetIndx) in widgets"
|
||||
:key="widgetIndx"
|
||||
:widget="widget"
|
||||
:index="index"
|
||||
@navigateToSection="navigateToSection"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="unLockedWithPin" class="pin-unlocked-bar">
|
||||
<button class="pin-reset" @click="lockAgain" type="button">
|
||||
<i class="fas fa-lock btn-icon" aria-hidden="true"></i>
|
||||
{{ $t('pin.lock') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal for opening in modal view -->
|
||||
<IframeModal
|
||||
|
|
@ -109,20 +123,26 @@ import Collapsable from '@/components/LinkItems/Collapsable.vue';
|
|||
import IframeModal from '@/components/LinkItems/IframeModal.vue';
|
||||
import EditSection from '@/components/InteractiveEditor/EditSection.vue';
|
||||
import ContextMenu from '@/components/LinkItems/SectionContextMenu.vue';
|
||||
import PinInput from '@/components/InteractiveEditor/PinInput.vue';
|
||||
import ErrorHandler from '@/utils/ErrorHandler';
|
||||
import StoreKeys from '@/utils/StoreMutations';
|
||||
import { pinHash } from '@/utils/SectionHelpers';
|
||||
import {
|
||||
sortOrder as defaultSortOrder,
|
||||
localStorageKeys,
|
||||
modalNames,
|
||||
} from '@/utils/defaults';
|
||||
|
||||
const SECRET_UNLOCKED_KEY = 'dashy.secret.unlocked';
|
||||
const SECRET_PINS_KEY = 'dashy.secret.expectedPins';
|
||||
|
||||
export default {
|
||||
name: 'Section',
|
||||
props: {
|
||||
groupId: String,
|
||||
title: String,
|
||||
icon: String,
|
||||
pin: [String, Number],
|
||||
displayData: Object,
|
||||
items: Array,
|
||||
widgets: Array,
|
||||
|
|
@ -137,6 +157,7 @@ export default {
|
|||
WidgetBase,
|
||||
IframeModal,
|
||||
EditSection,
|
||||
PinInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -148,9 +169,12 @@ export default {
|
|||
},
|
||||
sectionWidth: 0,
|
||||
resizeObserver: null,
|
||||
isUnlocked: true,
|
||||
pinError: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
||||
appConfig() {
|
||||
return this.$store.getters.appConfig;
|
||||
},
|
||||
|
|
@ -209,6 +233,14 @@ export default {
|
|||
}
|
||||
return styles;
|
||||
},
|
||||
showPinRequired() {
|
||||
if (this.isEditMode) return false;
|
||||
return this.displayData.secret === true && !this.isUnlocked;
|
||||
},
|
||||
unLockedWithPin() {
|
||||
if (this.isEditMode) return false;
|
||||
return this.displayData.secret === true && this.isUnlocked;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/* Opens the iframe modal */
|
||||
|
|
@ -300,6 +332,32 @@ export default {
|
|||
const secElem = this.$refs[this.sectionRef];
|
||||
if (secElem && secElem.$el.clientWidth) this.sectionWidth = secElem.$el.clientWidth;
|
||||
},
|
||||
saveUnlockPins({ pin, id }) {
|
||||
const map = JSON.parse(sessionStorage.getItem(SECRET_UNLOCKED_KEY) || '{}');
|
||||
map[id] = pin;
|
||||
sessionStorage.setItem(SECRET_UNLOCKED_KEY, JSON.stringify(map));
|
||||
const unlockPins = JSON.parse(sessionStorage.getItem(SECRET_PINS_KEY) || '{}');
|
||||
const sectionKey = this.sectionRef;
|
||||
const savedPin = unlockPins[sectionKey];
|
||||
|
||||
if (savedPin === pinHash(pin) || savedPin === pin.toString()) {
|
||||
this.pinError = '';
|
||||
this.isUnlocked = true;
|
||||
} else {
|
||||
this.pinError = this.$t('pin.incorrect-pin');
|
||||
this.isUnlocked = false;
|
||||
}
|
||||
},
|
||||
lockAgain() {
|
||||
const unlockPins = JSON.parse(sessionStorage.getItem(SECRET_UNLOCKED_KEY) || '{}');
|
||||
const sectionKey = this.sectionRef;
|
||||
if (unlockPins[sectionKey]) {
|
||||
delete unlockPins[sectionKey];
|
||||
sessionStorage.setItem(SECRET_UNLOCKED_KEY, JSON.stringify(unlockPins));
|
||||
}
|
||||
this.pinError = '';
|
||||
this.isUnlocked = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Set the section width, and recalculate when section resized
|
||||
|
|
@ -307,6 +365,21 @@ export default {
|
|||
this.resizeObserver = new ResizeObserver(this.calculateSectionWidth)
|
||||
.observe(this.$refs[this.sectionRef].$el);
|
||||
}
|
||||
if (this.displayData?.collapsed) {
|
||||
this.isCollapsed = this.displayData.collapsed;
|
||||
}
|
||||
if (this.displayData?.secret) {
|
||||
if (this.displayData.secret) this.isUnlocked = false;
|
||||
|
||||
const secretPin = String(this.pin || '0000');
|
||||
const sectionKey = this.sectionRef;
|
||||
console.log('Section Key', sectionKey, secretPin);
|
||||
const pins = JSON.parse(sessionStorage.getItem(SECRET_PINS_KEY) || '{}');
|
||||
if (pins[sectionKey] !== secretPin) {
|
||||
pins[sectionKey] = secretPin;
|
||||
sessionStorage.setItem(SECRET_PINS_KEY, JSON.stringify(pins));
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
// If resize observer set, and element still present, then de-register
|
||||
|
|
@ -320,6 +393,7 @@ export default {
|
|||
<style scoped lang="scss">
|
||||
@import '@/styles/media-queries.scss';
|
||||
@import '@/styles/style-helpers.scss';
|
||||
@import '@/styles/pin-input.scss';
|
||||
|
||||
.no-items {
|
||||
width: 100px;
|
||||
|
|
|
|||
69
src/styles/pin-input.scss
Normal file
69
src/styles/pin-input.scss
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
.btn-icon {
|
||||
margin-right: .4rem;
|
||||
font-size: .95em;
|
||||
line-height: 0;
|
||||
opacity: .9;
|
||||
}
|
||||
|
||||
.pin-gate {
|
||||
padding: .9rem;
|
||||
border-radius: var(--curve-factor);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--item-background);
|
||||
box-shadow: var(--item-shadow);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pin-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
margin-bottom: .6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pin-form {
|
||||
margin-bottom: .6rem;
|
||||
}
|
||||
.pin-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: right;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
padding: .5rem .9rem;
|
||||
border-radius: .6rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--button-bg);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
transition: transform .06s ease;
|
||||
|
||||
&:active { transform: translateY(1px); }
|
||||
}
|
||||
|
||||
.pin-reset {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: .3rem .2rem;
|
||||
color: var(--primary);
|
||||
opacity: .8;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
.pin-unlocked-bar{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pin-error {
|
||||
color: var(--error, #d9534f);
|
||||
}
|
||||
|
|
@ -756,6 +756,11 @@
|
|||
"type": "string",
|
||||
"description": "Icon will be displayed next to title"
|
||||
},
|
||||
"pin":{
|
||||
"title": "Section Pin",
|
||||
"type": ["number", "string"],
|
||||
"description": "PIN to unlock this section"
|
||||
},
|
||||
"displayData": {
|
||||
"title": "Display Data",
|
||||
"type": "object",
|
||||
|
|
@ -776,6 +781,12 @@
|
|||
"default": "default",
|
||||
"description": "How to sort items within the section. By default items are displayed in the order in which they are listed in within the config"
|
||||
},
|
||||
"secret": {
|
||||
"title": "Pin Locked",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, section will be hidden by default, and can be revealed by inputing pin"
|
||||
},
|
||||
"collapsed": {
|
||||
"title": "Is Collapsed?",
|
||||
"type": "boolean",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* Helper functions for Sections and Items */
|
||||
|
||||
import sha256 from 'crypto-js/sha256';
|
||||
import { hideFurnitureOn } from '@/utils/defaults';
|
||||
|
||||
/* Returns false if page furniture should be hidden on said route */
|
||||
|
|
@ -39,3 +39,8 @@ export const applyItemId = (inputSections) => {
|
|||
});
|
||||
return sections;
|
||||
};
|
||||
|
||||
export const pinHash = (pin) => {
|
||||
if (!pin) return null;
|
||||
return sha256(pin).toString().toUpperCase();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
:index="index"
|
||||
:title="section.name"
|
||||
:icon="section.icon || undefined"
|
||||
:pin="section.pin || undefined"
|
||||
:displayData="getDisplayData(section)"
|
||||
:groupId="makeSectionId(section)"
|
||||
:items="section.filteredItems"
|
||||
|
|
|
|||
|
|
@ -43,5 +43,4 @@ sections:
|
|||
- title: Support
|
||||
description: Get help with Dashy, raise a bug, or get in contact
|
||||
url: https://github.com/Lissy93/dashy/blob/master/.github/SUPPORT.md
|
||||
icon: far fa-hands-helping
|
||||
|
||||
icon: far fa-hands-helping
|
||||
Loading…
Reference in a new issue