Add interface to block ip-addresses or clients due to abuse (#15484)

* Read IP blocks from database

* begin building general blocking solution

* add new frontend files

* Add UI for managing blockers

* correctly reset local data after creating blocker

* Tweak wording

* Add UI for managing blockers

* restructure admin pages

* improve test coverage

* Improve blocker UI

* add blocker to block emails from registration

* lint fix

* fix

* lint fixes

* fix import

* add new permission for managing blockers

* improve permission check

* fix managing permissions from admin

* improve navbar display for non fullAccess admin

* update block error strings

* lint fix

* add option to errorHandler to skip logging

* validate blocker value during input

* improve blocker form display

* chore(subproj): reconcile habitica-images

* fix(scripts): use same Mongo version for dev/test

* fix(whitespace): eof

* documentation improvements

* remove nconf import

* remove old test

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
This commit is contained in:
Phillip Thelen
2025-08-06 22:08:07 +02:00
committed by GitHub
parent ae4130b108
commit 12773d539e
51 changed files with 1454 additions and 428 deletions
@@ -1,7 +1,7 @@
<template>
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="admin-panel-content">
<h1>Admin Panel</h1>
<h1>{{ $t("adminPanel") }}</h1>
<form
class="form-inline"
@submit.prevent="searchUsers(userIdentifier)"
@@ -72,7 +72,7 @@ export default {
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: 'Admin Panel',
section: this.$t('adminPanel'),
});
},
methods: {
@@ -55,7 +55,7 @@
<script>
import VueRouter from 'vue-router';
import { mapState } from '@/libs/store';
import LoadingSpinner from '../ui/loadingSpinner';
import LoadingSpinner from '../../ui/loadingSpinner';
const { isNavigationFailure, NavigationFailureType } = VueRouter;
@@ -38,12 +38,17 @@
>
<div class="custom-control custom-checkbox">
<input
:id="permission.key"
v-model="hero.permissions[permission.key]"
:disabled="!hasPermission(user, permission.key)"
:disabled="!hasPermission(user, permission.key)
|| (hero.permissions.fullAccess && permission.key !== 'fullAccess')"
class="custom-control-input"
type="checkbox"
>
<label class="custom-control-label">
<label
class="custom-control-label"
:for="permission.key"
>
{{ permission.name }}<br>
<small class="text-secondary">{{ permission.description }}</small>
</label>
@@ -124,7 +129,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
@@ -147,7 +155,7 @@ import markdownDirective from '@/directives/markdown';
import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../mixins/userState';
import { userStateMixin } from '../../../../mixins/userState';
const permissionList = [
{
@@ -175,6 +183,11 @@ const permissionList = [
name: 'Challenge Admin',
description: 'Can create official habitica challenges and admin all challenges',
},
{
key: 'accessControl',
name: 'Access Control',
description: 'Can manage IP-Address, Client and E-Mail blockers',
},
{
key: 'coupons',
name: 'Coupon Creator',
@@ -126,7 +126,7 @@
@click="changeApiToken()"
>
Change API Token
</a>
</a>
<div
v-if="tokenModified"
>
@@ -46,7 +46,7 @@
:
<span :class="{ ownedItem: !item.neverOwned }">{{ item.text }}</span>
</span>
- {{ itemType }}.{{item.key}} - <i> {{ item.set }}</i>
- {{ itemType }}.{{ item.key }} - <i> {{ item.set }}</i>
<div
v-if="item.modified"
@@ -16,9 +16,9 @@
:hero="hero"
:reset-counter="resetCounter"
:has-unsaved-changes="hasUnsavedChanges([hero.flags, unModifiedHero.flags],
[hero.auth, unModifiedHero.auth],
[hero.balance, unModifiedHero.balance],
[hero.secret, unModifiedHero.secret])"
[hero.auth, unModifiedHero.auth],
[hero.balance, unModifiedHero.balance],
[hero.secret, unModifiedHero.secret])"
/>
<subscription-and-perks
@@ -88,7 +88,7 @@
<contributor-details
:hero="hero"
:hasUnsavedChanges="hasUnsavedChanges(
:has-unsaved-changes="hasUnsavedChanges(
[hero.contributor, unModifiedHero.contributor],
[hero.permissions, unModifiedHero.permissions],
[hero.secret, unModifiedHero.secret],
@@ -149,7 +149,7 @@ import Achievements from './achievements.vue';
import UserHistory from './userHistory.vue';
import Stats from './stats.vue';
import { userStateMixin } from '../../../mixins/userState';
import { userStateMixin } from '../../../../mixins/userState';
export default {
components: {
@@ -32,38 +32,43 @@
></p>
</div>
<div v-if="userHasParty">
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Party ID
</label>
<strong class="col-sm-9 col-form-label">
{{ groupPartyData._id }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Estimated Member Count
</label>
<strong class="col-sm-9 col-form-label">
{{ groupPartyData.memberCount }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Leader
</label>
<strong class="col-sm-9 col-form-label">
<span v-if="userIsPartyLeader">User is the party leader</span>
<span v-else>Party leader is
<router-link :to="{'name': 'userProfile', 'params': {'userId': groupPartyData.leader}}">
{{ groupPartyData.leader }}
</router-link>
</span>
</strong>
</div>
<div
class="btn btn-danger"
@click="removeFromParty()">Remove from Party</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Party ID
</label>
<strong class="col-sm-9 col-form-label">
{{ groupPartyData._id }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Estimated Member Count
</label>
<strong class="col-sm-9 col-form-label">
{{ groupPartyData.memberCount }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Leader
</label>
<strong class="col-sm-9 col-form-label">
<span v-if="userIsPartyLeader">User is the party leader</span>
<span v-else>Party leader is
<router-link
:to="{'name': 'userProfile', 'params': {'userId': groupPartyData.leader}}"
>
{{ groupPartyData.leader }}
</router-link>
</span>
</strong>
</div>
<div
class="btn btn-danger"
@click="removeFromParty()"
>
Remove from Party
</div>
</div>
<strong v-else>User is not in a party.</strong>
<div class="subsection-start">
@@ -1,11 +1,13 @@
<template>
<form @submit.prevent="saveHero({hero: {
_id: hero._id,
flags: hero.flags,
balance: hero.balance,
auth: hero.auth,
secret: hero.secret,
}, msg: 'Privileges or Gems or Moderation Notes'})">
<form
@submit.prevent="saveHero({hero: {
_id: hero._id,
flags: hero.flags,
balance: hero.balance,
auth: hero.auth,
secret: hero.secret,
}, msg: 'Privileges or Gems or Moderation Notes'})"
>
<div class="card mt-2">
<div class="card-header">
<h3
@@ -14,9 +16,12 @@
@click="expand = !expand"
>
Privileges, Gem Balance
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
Unsaved changes
</b>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -133,7 +138,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
@@ -8,9 +8,12 @@
@click="expand = !expand"
>
Stats
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
Unsaved changes
</b>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -18,47 +21,60 @@
class="card-body"
>
<stats-row
v-model="hero.stats.hp"
label="Health"
color="red-label"
:max="maxHealth"
v-model="hero.stats.hp" />
/>
<stats-row
v-model="hero.stats.exp"
label="Experience"
color="yellow-label"
min="0"
:max="maxFieldHardCap"
v-model="hero.stats.exp" />
/>
<stats-row
v-model="hero.stats.mp"
label="Mana"
color="blue-label"
min="0"
:max="maxFieldHardCap"
v-model="hero.stats.mp" />
/>
<stats-row
v-model="hero.stats.lvl"
label="Level"
step="1"
min="0"
:max="maxLevelHardCap"
v-model="hero.stats.lvl" />
/>
<stats-row
v-model="hero.stats.gp"
label="Gold"
min="0"
:max="maxFieldHardCap"
v-model="hero.stats.gp" />
/>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Selected Class</label>
<div class="col-sm-9">
<select
id="selectedClass"
v-model="hero.stats.class"
class="form-control"
:disabled="hero.stats.lvl < 10"
>
<option value="warrior">Warrior</option>
<option value="wizard">Mage</option>
<option value="healer">Healer</option>
<option value="rogue">Rogue</option>
</select>
id="selectedClass"
v-model="hero.stats.class"
class="form-control"
:disabled="hero.stats.lvl < 10"
>
<option value="warrior">
Warrior
</option>
<option value="wizard">
Mage
</option>
<option value="healer">
Healer
</option>
<option value="rogue">
Rogue
</option>
</select>
<small>
When changing class, players usually need stat points deallocated as well.
</small>
@@ -67,50 +83,59 @@
<h3>Stat Points</h3>
<stats-row
v-model="hero.stats.points"
label="Unallocated"
min="0"
step="1"
:max="maxStatPoints"
v-model="hero.stats.points" />
/>
<stats-row
v-model="hero.stats.str"
label="Strength"
color="red-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.str" />
/>
<stats-row
v-model="hero.stats.int"
label="Intelligence"
color="blue-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.int" />
/>
<stats-row
v-model="hero.stats.per"
label="Perception"
color="purple-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.per" />
/>
<stats-row
v-model="hero.stats.con"
label="Constitution"
color="yellow-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.con" />
/>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-warning btn-sm"
@click="deallocateStatPoints">
@click="deallocateStatPoints"
>
Deallocate all stat points
</button>
</div>
</div>
<div class="form-group row" v-if="statPointsIncorrect">
<div
v-if="statPointsIncorrect"
class="form-group row"
>
<div class="offset-sm-3 col-sm-9 text-danger">
Error: Sum of stat points should equal the users level
</div>
@@ -118,35 +143,40 @@
<h3>Buffs</h3>
<stats-row
v-model="hero.stats.buffs.str"
label="Strength"
color="red-label"
min="0"
step="1"
v-model="hero.stats.buffs.str" />
/>
<stats-row
v-model="hero.stats.buffs.int"
label="Intelligence"
color="blue-label"
min="0"
step="1"
v-model="hero.stats.buffs.int" />
/>
<stats-row
v-model="hero.stats.buffs.per"
label="Perception"
color="purple-label"
min="0"
step="1"
v-model="hero.stats.buffs.per" />
/>
<stats-row
v-model="hero.stats.buffs.con"
label="Constitution"
color="yellow-label"
min="0"
step="1"
v-model="hero.stats.buffs.con" />
/>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-warning btn-sm"
@click="resetBuffs">
@click="resetBuffs"
>
Reset Buffs
</button>
</div>
@@ -161,7 +191,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
@@ -189,7 +222,7 @@ import markdownDirective from '@/directives/markdown';
import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../mixins/userState';
import { userStateMixin } from '../../../../mixins/userState';
import StatsRow from './stats-row';
@@ -6,49 +6,71 @@
}, msg: 'Subscription Perks' })"
>
<div class="card mt-2">
<div class="card-header"
@click="expand = !expand">
<div
class="card-header"
@click="expand = !expand"
>
<h3
class="mb-0 mt-0"
:class="{ 'open': expand }"
>
Subscription, Monthly Perks
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
Unsaved changes
</b>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<div
<div
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Payment method:
</label>
<div class="col-sm-9">
<input v-model="hero.purchased.plan.paymentMethod"
<input
v-if="!isRegularPaymentMethod"
v-model="hero.purchased.plan.paymentMethod"
class="form-control"
type="text"
v-if="!isRegularPaymentMethod"
>
<select
<select
v-else
v-model="hero.purchased.plan.paymentMethod"
class="form-control"
type="text"
>
<option value="groupPlan">Group Plan</option>
<option value="Stripe">Stripe</option>
<option value="Apple">Apple</option>
<option value="Google">Google</option>
<option value="Amazon Payments">Amazon</option>
<option value="PayPal">PayPal</option>
<option value="Gift">Gift</option>
<option value="">Clear out</option>
</select>
v-model="hero.purchased.plan.paymentMethod"
class="form-control"
type="text"
>
<option value="groupPlan">
Group Plan
</option>
<option value="Stripe">
Stripe
</option>
<option value="Apple">
Apple
</option>
<option value="Google">
Google
</option>
<option value="Amazon Payments">
Amazon
</option>
<option value="PayPal">
PayPal
</option>
<option value="Gift">
Gift
</option>
<option value="">
Clear out
</option>
</select>
</div>
</div>
<div
@@ -58,25 +80,40 @@
Payment schedule:
</label>
<div class="col-sm-9">
<input v-model="hero.purchased.plan.planId"
<input
v-if="!isRegularPlanId"
v-model="hero.purchased.plan.planId"
class="form-control"
type="text"
v-if="!isRegularPlanId"
>
<select
<select
v-else
v-model="hero.purchased.plan.planId"
class="form-control"
type="text"
>
<option value="basic_earned">Monthly recurring</option>
<option value="basic_3mo">3 Months recurring</option>
<option value="basic_6mo">6 Months recurring</option>
<option value="basic_12mo">12 Months recurring</option>
<option value="group_monthly">Group Plan (legacy)</option>
<option value="group_plan_auto">Group Plan (auto)</option>
<option value="">Clear out</option>
</select>
v-model="hero.purchased.plan.planId"
class="form-control"
type="text"
>
<option value="basic_earned">
Monthly recurring
</option>
<option value="basic_3mo">
3 Months recurring
</option>
<option value="basic_6mo">
6 Months recurring
</option>
<option value="basic_12mo">
12 Months recurring
</option>
<option value="group_monthly">
Group Plan (legacy)
</option>
<option value="group_plan_auto">
Group Plan (auto)
</option>
<option value="">
Clear out
</option>
</select>
</div>
</div>
<div
@@ -86,43 +123,50 @@
Customer ID:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.customerId"
class="form-control"
type="text"
>
<input
v-model="hero.purchased.plan.customerId"
class="form-control"
type="text"
>
</div>
</div>
<div class="form-group row"
v-if="hero.purchased.plan.planId === 'group_plan_auto'">
<div
v-if="hero.purchased.plan.planId === 'group_plan_auto'"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Group Plan Memberships:
</label>
<div class="col-sm-9 col-form-label">
<loading-spinner
v-if="!groupPlans"
dark-color=true
/>
v-if="!groupPlans"
dark-color="true"
/>
<b
v-else-if="groupPlans.length === 0"
class="text-danger col-form-label"
v-else-if="groupPlans.length === 0"
class="text-danger col-form-label"
>User is not part of an active group plan!</b>
<div
v-else
v-for="group in groupPlans"
v-else
:key="group._id"
class="card mb-2">
class="card mb-2"
>
<div class="card-body">
<h6 class="card-title">{{ group.name }}
<h6 class="card-title">
{{ group.name }}
<small class="float-right">{{ group._id }}</small>
</h6>
<p class="card-text">
<strong>Leader: </strong>
<a
v-if="group.leader !== hero._id"
@click="switchUser(group.leader)"
>{{ group.leader }}</a>
<strong v-else class="text-success">This user</strong>
<a
v-if="group.leader !== hero._id"
@click="switchUser(group.leader)"
>{{ group.leader }}</a>
<strong
v-else
class="text-success"
>This user</strong>
</p>
<p class="card-text">
<strong>Members: </strong> {{ group.memberCount }}
@@ -190,16 +234,21 @@
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
</strong>
<a class="btn btn-danger"
href="#"
<a
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId"
v-b-modal.sub_termination_modal
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId">
class="btn btn-danger"
href="#"
>
Terminate
</a>
</a>
</div>
</div>
<small v-if="!hero.purchased.plan.dateTerminated
&& hero.purchased.plan.planId" class="text-success">
<small
v-if="!hero.purchased.plan.dateTerminated
&& hero.purchased.plan.planId"
class="text-success"
>
The subscription does not have a termination date and is active.
</small>
</div>
@@ -235,11 +284,13 @@
step="any"
>
<div class="input-group-append">
<a class="btn btn-warning"
<a
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0"
class="btn btn-warning"
@click="applyExtraMonths"
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0">
>
Apply Credit
</a>
</a>
</div>
</div>
<small class="text-secondary">
@@ -339,19 +390,24 @@
</span>
</div>
</div>
<div class="form-group row"
v-if="!isConvertingToGroupPlan && hero.purchased.plan.planId !== 'group_plan_auto'">
<div
v-if="!isConvertingToGroupPlan && hero.purchased.plan.planId !== 'group_plan_auto'"
class="form-group row"
>
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-secondary btn-sm"
@click="beginGroupPlanConvert">
@click="beginGroupPlanConvert"
>
Begin converting to group plan subscription
</button>
</div>
</div>
<div class="form-group row"
v-if="isConvertingToGroupPlan">
<div
v-if="isConvertingToGroupPlan"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Group Plan group ID:
</label>
@@ -374,25 +430,40 @@
class="btn btn-primary mt-1"
@click="saveClicked"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
</div>
<b-modal id="sub_termination_modal" title="Set Termination Date">
<b-modal
id="sub_termination_modal"
title="Set Termination Date"
>
<p>
You can set the sub benefit termination date to today or to the last
day of the current billing cycle. Any extra subscription credit will
then be processed and automatically added onto the selected date.
</p>
<template #modal-footer>
<div class="mt-3 btn btn-secondary" @click="$bvModal.hide('sub_termination_modal')">
<div
class="mt-3 btn btn-secondary"
@click="$bvModal.hide('sub_termination_modal')"
>
Close
</div>
<div class="mt-3 btn btn-danger" @click="terminateSubscription()">
<div
class="mt-3 btn btn-danger"
@click="terminateSubscription()"
>
Set to Today
</div>
<div class="mt-3 btn btn-danger" @click="terminateSubscription(todayWithRemainingCycle)">
<div
class="mt-3 btn btn-danger"
@click="terminateSubscription(todayWithRemainingCycle)"
>
Set to {{ todayWithRemainingCycle.utc().format('MM/DD/YYYY') }}
</div>
</template>
@@ -420,15 +491,15 @@
import isUUID from 'validator/es/lib/isUUID';
import moment from 'moment';
import { getPlanContext } from '@/../../common/script/cron';
import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks';
import saveHero from '../mixins/saveHero';
import subscriptionBlocks from '../../../../../common/script/content/subscriptionBlocks';
import LoadingSpinner from '@/components/ui/loadingSpinner';
export default {
mixins: [saveHero],
components: {
LoadingSpinner,
},
mixins: [saveHero],
props: {
hero: {
type: Object,
@@ -22,8 +22,8 @@
</template>
<script>
import PurchaseHistoryTable from '../../ui/purchaseHistoryTable.vue';
import { userStateMixin } from '../../../mixins/userState';
import PurchaseHistoryTable from '../../../ui/purchaseHistoryTable.vue';
import { userStateMixin } from '../../../../mixins/userState';
export default {
components: {
@@ -180,7 +180,7 @@
<script>
import moment from 'moment';
import { userStateMixin } from '../../../mixins/userState';
import { userStateMixin } from '../../../../mixins/userState';
export default {
filters: {
@@ -13,9 +13,12 @@
@click="expand = !expand"
>
User Profile
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
Unsaved changes
</b>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -66,7 +69,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
@@ -86,7 +92,7 @@ import markdownDirective from '@/directives/markdown';
import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../mixins/userState';
import { userStateMixin } from '../../../../mixins/userState';
function resetData (self) {
self.expand = false;
@@ -0,0 +1,133 @@
<template>
<div style="display: contents">
<td>
<select
v-model="blocker.type"
class="form-control"
@change="onTypeChanged"
>
<option value="ipaddress">
IP-Address
</option>
<option value="client">
Client Identifier
</option>
<option value="email">
E-Mail
</option>
</select>
</td>
<td>
<select
v-model="blocker.area"
class="form-control"
>
<option value="full">
Full
</option>
</select>
</td>
<td>
<input
v-model="blocker.value"
class="form-control"
autocorrect="off"
autocapitalize="off"
:class="{ 'is-invalid input-invalid': !isValid }"
@input="validateValue"
>
</td>
<td>
<input
v-model="blocker.reason"
class="form-control"
>
</td>
<td
colspan="3"
class="text-right"
>
<button
class="btn btn-primary mr-2"
:disabled="!isValid"
:class="{ disabled: !isValid }"
@click="$emit('save', blocker)"
>
<span>Save</span>
</button>
<button
class="btn btn-danger"
@click="$emit('cancel')"
>
<span>Cancel</span>
</button>
</td>
</div>
</template>
<style lang="scss" scoped>
.btn-primary.disabled {
background: #4F2A93;
color: white;
cursor: not-allowed;
opacity: 0.5;
}
</style>
<script>
import isIP from 'validator/es/lib/isIP';
export default {
name: 'BlockerForm',
props: {
isNew: {
type: Boolean,
default: false,
},
blocker: {
type: Object,
default: () => ({
type: '',
area: '',
value: '',
reason: '',
}),
},
},
data () {
return {
isValid: false,
};
},
mounted () {
this.validateValue();
},
methods: {
onTypeChanged () {
if (this.blocker.type === 'email') {
this.blocker.area = 'full';
}
this.validateValue();
},
validateValue () {
if (this.blocker.type === 'ipaddress') {
this.validateValueAsIpAddress();
} else if (this.blocker.type === 'client') {
this.validateValueAsClient();
} else if (this.blocker.type === 'email') {
this.validateValueAsEmail();
}
},
validateValueAsEmail () {
const emailRegex = /^([a-zA-Z0-9._%+-]*)@(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})?$/;
this.isValid = emailRegex.test(this.blocker.value) && this.blocker.value.length > 3;
},
validateValueAsIpAddress () {
this.isValid = isIP(this.blocker.value);
},
validateValueAsClient () {
this.isValid = this.blocker.value.length > 0;
},
},
};
</script>
@@ -0,0 +1,238 @@
<template>
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="blocker-content">
<h1>
Blockers
<button
class="btn btn-primary float-right"
@click="showCreateForm = true"
>
Create
</button>
</h1>
<table class="table table-bordered">
<thead>
<tr>
<th>
Type <span
id="type_tooltip"
class="info-icon"
>?</span>
<b-tooltip
target="type_tooltip"
>
<b>IP-Address</b> - Block access for a specific IP-Address
<br>
<br>
<b>Client</b> - Block access for a client based on the "x-client" header.
<br>
<br>
<b>E-Mail</b> - Blocks e-mails from being used for signup.
</b-tooltip>
</th>
<th>
Area <span
id="area_tooltip"
class="info-icon"
>?</span>
<b-tooltip
target="area_tooltip"
>
<b>Full</b> - Block access to the entire site.
<br>
<br>
<b>Payments</b> - Block access to any payment related functionality.
</b-tooltip>
</th>
<th>Value</th>
<th>Reason</th>
<th>Source</th>
<th>Created at</th>
<th class="btncol"></th>
</tr>
</thead>
<tbody>
<tr v-if="showCreateForm">
<BlockerForm
:is-new="true"
:blocker="newBlocker"
@save="createBlocker"
@cancel="showCreateForm = false"
/>
</tr>
<tr
v-for="blocker in blockers"
:key="blocker._id"
>
<BlockerForm
v-if="blocker._id === editedBlockerId"
:blocker="blocker"
@save="saveBlocker(blocker)"
@cancel="editedBlockerId = null"
/>
<template v-else>
<td>{{ getTypeName(blocker.type) }}</td>
<td>{{ getAreaName(blocker.area) }}</td>
<td>{{ blocker.value }}</td>
<td>{{ blocker.reason || "--" }}</td>
<td>{{ blocker.blockSource }}</td>
<td>{{ blocker.createdAt }}</td>
<td>
<button
class="btn btn-primary mr-2"
@click="editBlocker(blocker._id)"
>
<span
v-once
class="svg-icon icon-16"
v-html="icons.editIcon"
></span>
</button>
<button
class="btn btn-danger"
@click="deleteBlocker(blocker._id)"
>
<span
v-once
class="svg-icon icon-16"
v-html="icons.deleteIcon"
></span>
</button>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.blocker-content {
flex: 0 0 100%;
max-width: 1200px;
}
.btn {
padding: 0.4rem 0.75rem;
}
.btncol {
width: 123px;
}
td {
font-size: 1rem;
}
.info-icon {
font-size: 0.8rem;
color: $purple-400;
cursor: pointer;
margin-left: 0.5rem;
background-color: $gray-500;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.info-icon:hover {
background-color: $purple-400;
color: white;
}
</style>
<script>
import { mapState } from '@/libs/store';
import editIcon from '@/assets/svg/edit.svg?raw';
import deleteIcon from '@/assets/svg/delete.svg?raw';
import BlockerForm from './blocker_form.vue';
export default {
components: {
BlockerForm,
},
data () {
return {
showCreateForm: false,
newBlocker: {
type: '',
area: 'full',
value: '',
reason: '',
},
blockers: [],
editedBlockerId: null,
icons: Object.freeze({
editIcon,
deleteIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('siteBlockers'),
});
this.loadBlockers();
},
methods: {
async loadBlockers () {
this.blockers = await this.$store.dispatch('blockers:getBlockers');
},
editBlocker (id) {
this.editedBlockerId = id;
},
async saveBlocker (blocker) {
await this.$store.dispatch('blockers:updateBlocker', { blocker });
this.editedBlockerId = null;
this.loadBlockers();
},
async deleteBlocker (blockerId) {
if (!window.confirm('Are you sure you want to delete this blocker?')) {
return;
}
await this.$store.dispatch('blockers:deleteBlocker', { blockerId });
this.loadBlockers();
},
async createBlocker (blocker) {
await this.$store.dispatch('blockers:createBlocker', { blocker });
this.showCreateForm = false;
this.newBlocker = {
type: '',
area: 'full',
value: '',
reason: '',
};
this.loadBlockers();
},
getTypeName (type) {
switch (type) {
case 'ipaddress':
return 'IP-Address';
case 'email':
return 'E-Mail';
case 'client':
return 'Client Identifier';
default:
return type;
}
},
getAreaName (area) {
switch (area) {
case 'full':
return 'Full';
case 'payments':
return 'Payments';
default:
return area;
}
},
},
};
</script>
@@ -0,0 +1,40 @@
<template>
<div class="row">
<secondary-menu class="col-12">
<router-link
v-if="hasPermission(user, 'userSupport')"
class="nav-link"
:to="{name: 'adminPanel'}"
>
{{ $t('adminPanel') }}
</router-link>
<router-link
v-if="hasPermission(user, 'accessControl')"
class="nav-link"
:to="{name: 'blockers'}"
>
{{ $t('siteBlockers') }}
</router-link>
</secondary-menu><div class="col-12">
<router-view />
</div>
</div>
</template>
<script>
import { mapState } from '@/libs/store';
import SecondaryMenu from '@/components/secondaryMenu';
import { userStateMixin } from '../../mixins/userState';
export default {
components: {
SecondaryMenu,
},
mixins: [
userStateMixin,
],
computed: {
...mapState({ user: 'user.data' }),
},
};
</script>
+2 -2
View File
@@ -276,9 +276,9 @@
</div>
<div
class="time-travel"
v-if="TIME_TRAVEL_ENABLED && user?.permissions?.fullAccess"
:key="lastTimeJump"
class="time-travel"
>
<a
class="btn btn-secondary mr-1"
@@ -299,7 +299,7 @@
@click="resetTime()"
>
Reset
</a>
</a>
</div>
<a
class="btn btn-secondary mr-1"
@@ -227,7 +227,8 @@
<div class="quest-icon">
<Sprite
class="quest"
:image-name="`inventory_quest_scroll_${questData.key}`" />
:image-name="`inventory_quest_scroll_${questData.key}`"
/>
</div>
</div>
<div
@@ -286,7 +286,7 @@
:to="{ name: 'adminPanelUser',
params: { userIdentifier: hero._id } }"
>
admin panel
{{ $t("adminPanel") }}
</router-link>
</span>
</td>
+63 -9
View File
@@ -295,14 +295,6 @@
{{ $t('help') }}
</router-link>
<div class="topbar-dropdown">
<router-link
v-if="user.permissions.fullAccess ||
user.permissions.userSupport"
class="topbar-dropdown-item dropdown-item"
:to="{name: 'adminPanel'}"
>
Admin Panel
</router-link>
<router-link
class="topbar-dropdown-item dropdown-item"
:to="{name: 'faq'}"
@@ -336,6 +328,61 @@
>{{ $t('requestFeature') }}</a>
</div>
</li>
<li
v-if="hasElevatedPrivileges"
class="topbar-item droppable"
:class="{
'active': $route.path.startsWith('/admin')}"
>
<div
class="chevron rotate"
@click="dropdownMobile($event)"
>
<div
v-once
class="chevron-icon-down"
v-html="icons.chevronDown"
></div>
</div>
<router-link
v-if="hasPermission(user, 'userSupport')"
class="nav-link"
:to="{name: 'adminPanel'}"
>
{{ $t('admin') }}
</router-link>
<a
v-else
href="#"
class="nav-link"
>
{{ $t('admin') }}
</a>
<div class="topbar-dropdown">
<router-link
v-if="hasPermission(user, 'userSupport')"
class="topbar-dropdown-item dropdown-item"
:to="{name: 'adminPanel'}"
>
{{ $t("adminPanel") }}
</router-link>
<router-link
v-if="hasPermission(user, 'accessControl')"
class="topbar-dropdown-item dropdown-item"
:to="{name: 'blockers'}"
>
{{ $t("siteBlockers") }}
</router-link>
<a
v-if="hasPermission(user, 'news')"
class="topbar-dropdown-item dropdown-item"
target="_blank"
href="https://panel.habitica.com"
>
{{ $t('newsroom') }}
</a>
</div>
</li>
</b-navbar-nav>
<div class="currency-tray form-inline">
<div
@@ -757,6 +804,7 @@ import selectUserModal from '@/components/payments/selectUserModal';
import sync from '@/mixins/sync';
import userDropdown from './userDropdown';
import reportBug from '@/mixins/reportBug.js';
import { userStateMixin } from '../../mixins/userState';
export default {
components: {
@@ -769,7 +817,7 @@ export default {
selectUserModal,
userDropdown,
},
mixins: [sync, reportBug],
mixins: [sync, reportBug, userStateMixin],
data () {
return {
isUserDropdownOpen: false,
@@ -802,6 +850,12 @@ export default {
params: { groupId: this.groupPlans[0]._id },
};
},
hasElevatedPrivileges () {
return this.user.permissions.fullAccess
|| this.user.permissions.userSupport
|| this.user.permissions.accessControl
|| this.user.permissions.news;
},
},
async mounted () {
await this.getUserGroupPlans();
@@ -15,7 +15,8 @@
<Sprite
slot="icon"
class="mt-3"
:image-name="notification.data.icon" />
:image-name="notification.data.icon"
/>
</base-notification>
</template>
@@ -12,7 +12,8 @@
></div>
<Sprite
slot="icon"
:image-name="mysteryClass" />
:image-name="mysteryClass"
/>
</base-notification>
</template>
@@ -81,9 +81,10 @@ import moment from 'moment';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from '@/libs/store';
import seasonalNPC from '@/mixins/seasonalNPC';
import { userStateMixin } from '../../mixins/userState';
export default {
mixins: [seasonalNPC],
mixins: [seasonalNPC, userStateMixin],
data () {
return {
posts: [],
@@ -107,7 +108,7 @@ export default {
if (lastPublishedPost) this.posts.push(lastPublishedPost);
// If the user is authorized, show any draft
if (this.user && (this.user.permissions.news || this.user.permissions.fullAccess)) {
if (this.user && (this.hasPermission(this.user, 'news'))) {
this.posts.unshift(
...postsFromServer
.filter(p => !p.published || moment().isBefore(p.publishDate)),
@@ -7,10 +7,15 @@
</div>
<div class="row">
<div class="col-12 mb-5 mb-md-0">
<img :src="makeUrl('features_taskboard.png')" class="img-fluid">
<img
:src="makeUrl('features_taskboard.png')"
class="img-fluid"
>
<h2>{{ $t('marketing1Lead1Title') }}</h2>
<div class="row justify-content-md-center">
<p class="col col-lg-8 col-xl-6 margin-auto description">{{ $t('marketing1Lead1') }}</p>
<p class="col col-lg-8 col-xl-6 margin-auto description">
{{ $t('marketing1Lead1') }}
</p>
</div>
</div>
</div>
@@ -18,12 +23,16 @@
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_gear.png')">
<h2>{{ $t('marketing1Lead2Title') }}</h2>
<p class="description">{{ $t('marketing1Lead2') }}</p>
<p class="description">
{{ $t('marketing1Lead2') }}
</p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_items.png')">
<h2>{{ $t('marketing1Lead3Title') }}</h2>
<p class="description">{{ $t('marketing1Lead3') }}</p>
<p class="description">
{{ $t('marketing1Lead3') }}
</p>
</div>
</div>
<hr>
@@ -35,19 +44,26 @@
<div class="row mb-5">
<div class="col-12">
<h2>{{ $t('marketing2Lead1Title') }}</h2>
<p class="description">{{ $t('marketing2Lead1') }}</p>
<p class="description">
{{ $t('marketing2Lead1') }}
</p>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_monsters.png')">
<h2>{{ $t('marketing2Lead2Title') }}</h2>
<p class="description" v-markdown="$t('marketing2Lead2')"></p>
<p
v-markdown="$t('marketing2Lead2')"
class="description"
></p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_challenges.png')">
<h2>{{ $t('marketing2Lead3Title') }}</h2>
<p class="description">{{ $t('marketing2Lead3') }}</p>
<p class="description">
{{ $t('marketing2Lead3') }}
</p>
</div>
</div>
<hr>
@@ -60,12 +76,18 @@
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_mobile.png')">
<h2>{{ $t('marketing3Lead1Title') }}</h2>
<p class="description" v-markdown="$t('marketing3Lead1')"></p>
<p
v-markdown="$t('marketing3Lead1')"
class="description"
></p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_opensource.png')">
<h2>{{ $t('marketing3Lead2Title') }}</h2>
<p class="description" v-markdown="$t('marketing3Lead2')"></p>
<p
v-markdown="$t('marketing3Lead2')"
class="description"
></p>
</div>
</div>
<hr>
@@ -80,7 +102,9 @@
<img src="@/assets/images/marketing/education.png">
<div class="media-body">
<h2>{{ $t('marketing4Lead1Title') }}</h2>
<p class="description">{{ $t('marketing4Lead1') }}</p>
<p class="description">
{{ $t('marketing4Lead1') }}
</p>
</div>
</div>
</div>
@@ -89,7 +113,9 @@
<img src="@/assets/images/marketing/wellness.png">
<div class="media-body">
<h2>{{ $t('marketing4Lead2Title') }}</h2>
<p class="description">{{ $t('marketing4Lead2') }}</p>
<p class="description">
{{ $t('marketing4Lead2') }}
</p>
</div>
</div>
</div>