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

View File

@@ -0,0 +1,276 @@
<template>
<div v-if="hasPermission(user, 'userSupport')">
<div
v-if="hero && hero.profile"
class="row"
>
<div class="form col-12">
<basic-details
:user-id="hero._id"
:auth="hero.auth"
:preferences="hero.preferences"
:profile="hero.profile"
/>
<privileges-and-gems
: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])"
/>
<subscription-and-perks
:hero="hero"
:group-plans="groupPlans"
:has-unsaved-changes="hasUnsavedChanges([hero.purchased.plan,
unModifiedHero.purchased.plan])"
/>
<cron-and-auth
:hero="hero"
:reset-counter="resetCounter"
/>
<user-profile
:hero="hero"
:reset-counter="resetCounter"
:has-unsaved-changes="hasUnsavedChanges([hero.profile, unModifiedHero.profile])"
/>
<party-and-quest
v-if="adminHasPrivForParty"
:user-id="hero._id"
:username="hero.auth.local.username"
:user-has-party="hasParty"
:party-not-exist-error="partyNotExistError"
:user-party-data="hero.party"
:group-party-data="party"
:reset-counter="resetCounter"
/>
<avatar-and-drops
:items="hero.items"
:preferences="hero.preferences"
/>
<stats
:hero="hero"
:has-unsaved-changes="hasUnsavedChanges([hero.stats, unModifiedHero.stats])"
:reset-counter="resetCounter"
/>
<items-owned
:hero="hero"
:reset-counter="resetCounter"
/>
<customizations-owned
:hero="hero"
:reset-counter="resetCounter"
/>
<achievements
:hero="hero"
:reset-counter="resetCounter"
/>
<transactions
:hero="hero"
:reset-counter="resetCounter"
/>
<user-history
:hero="hero"
:reset-counter="resetCounter"
/>
<contributor-details
:hero="hero"
:has-unsaved-changes="hasUnsavedChanges(
[hero.contributor, unModifiedHero.contributor],
[hero.permissions, unModifiedHero.permissions],
[hero.secret, unModifiedHero.secret],
)"
:reset-counter="resetCounter"
@clear-data="clearData"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
::v-deep .accordion-group .accordion-group {
margin-left: 1em;
}
::v-deep h3 {
margin-top: 2em;
}
::v-deep h4 {
margin-top: 1em;
}
::v-deep .expand-toggle::after {
margin-left: 5px;
}
::v-deep .subsection-start {
margin-top: 1em;
}
::v-deep .form-inline {
margin-bottom: 1em;
input, span {
margin-left: 5px;
}
}
::v-deep .errorMessage {
font-weight: bold;
}
::v-deep .markdownPreview {
margin-left: 3em;
margin-top: 1em;
}
</style>
<script>
import isEqualWith from 'lodash/isEqualWith';
import BasicDetails from './basicDetails';
import ItemsOwned from './itemsOwned';
import CronAndAuth from './cronAndAuth';
import UserProfile from './userProfile';
import PartyAndQuest from './partyAndQuest';
import AvatarAndDrops from './avatarAndDrops';
import PrivilegesAndGems from './privilegesAndGems';
import ContributorDetails from './contributorDetails';
import Transactions from './transactions';
import SubscriptionAndPerks from './subscriptionAndPerks';
import CustomizationsOwned from './customizationsOwned.vue';
import Achievements from './achievements.vue';
import UserHistory from './userHistory.vue';
import Stats from './stats.vue';
import { userStateMixin } from '../../../../mixins/userState';
export default {
components: {
BasicDetails,
ItemsOwned,
CustomizationsOwned,
CronAndAuth,
PartyAndQuest,
AvatarAndDrops,
PrivilegesAndGems,
ContributorDetails,
Transactions,
UserHistory,
Stats,
SubscriptionAndPerks,
UserProfile,
Achievements,
},
mixins: [userStateMixin],
beforeRouteUpdate (to, from, next) {
this.userIdentifier = to.params.userIdentifier;
next();
},
data () {
return {
userIdentifier: '',
resetCounter: 0,
unModifiedHero: {},
hero: {},
party: {},
groupPlans: [],
hasParty: false,
partyNotExistError: false,
adminHasPrivForParty: true,
};
},
watch: {
userIdentifier () {
// close modal if the page is opened in an existing tab from the modal
this.$root.$emit('bv::hide::modal', 'profile');
this.loadHero(this.userIdentifier);
},
},
mounted () {
this.userIdentifier = this.$route.params.userIdentifier;
},
methods: {
clearData () {
this.unModifiedHero = {};
this.hero = {};
},
async loadHero (userIdentifier) {
const id = userIdentifier.replace(/@/, ''); // allow "@name" to be entered
this.$emit('changeUserIdentifier', id); // change user identifier in Admin Panel's form
this.hero = await this.$store.dispatch('hall:getHero', { uuid: id });
this.unModifiedHero = JSON.parse(JSON.stringify(this.hero));
if (!this.hero.flags) {
this.hero.flags = {
chatRevoked: false,
chatShadowMuted: false,
};
}
if (!this.hero.permissions) {
this.hero.permissions = {};
}
this.hasParty = false;
this.partyNotExistError = false;
this.adminHasPrivForParty = true;
if (this.hero.party && this.hero.party._id) {
try {
this.party = await this.$store.dispatch('hall:getHeroParty', { groupId: this.hero.party._id });
this.hasParty = true;
} catch (e) {
if (e.message.includes('status code 401')) {
// @TODO is there a better way to recognise NotAuthorized error?
this.adminHasPrivForParty = false;
} else {
// the API's error message isn't worth reporting ("Request failed with status code 404")
this.partyNotExistError = true;
}
}
}
if (this.hero.purchased.plan.planId === 'group_plan_auto') {
try {
this.groupPlans = await this.$store.dispatch('hall:getHeroGroupPlans', { heroId: this.hero._id });
} catch (e) {
this.groupPlans = [];
}
}
this.resetCounter += 1; // tell child components to reinstantiate from scratch
},
hasUnsavedChanges (...comparisons) {
for (const index in comparisons) {
if (index && comparisons[index]) {
const objs = comparisons[index];
const obj1 = objs[0];
const obj2 = objs[1];
if (!isEqualWith(obj1, obj2, (x, y) => {
if (typeof x === 'object' && typeof y === 'object') {
return undefined;
}
if (x === false && y === undefined) {
// Special case for checkboxes
return true;
}
return x == y; // eslint-disable-line eqeqeq
})) {
return true;
}
}
}
return false;
},
},
};
</script>