Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

Expand Down Expand Up @@ -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)**

Expand Down
7 changes: 7 additions & 0 deletions src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions src/components/InteractiveEditor/PinInput.vue
Original file line number Diff line number Diff line change
@@ -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>
182 changes: 128 additions & 54 deletions src/components/LinkItems/Section.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -137,6 +157,7 @@ export default {
WidgetBase,
IframeModal,
EditSection,
PinInput,
},
data() {
return {
Expand All @@ -148,9 +169,12 @@ export default {
},
sectionWidth: 0,
resizeObserver: null,
isUnlocked: true,
pinError: '',
};
},
computed: {

appConfig() {
return this.$store.getters.appConfig;
},
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -300,13 +332,54 @@ 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
if (this.$refs[this.sectionRef]) {
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
Expand All @@ -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;
Expand Down
Loading