This commit is contained in:
Shumit Taher 2025-11-08 21:26:26 +00:00 committed by GitHub
commit 49d4ab5257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 302 additions and 57 deletions

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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