🧑‍💼🎛️ Overhaul (#15270)

* Add option to search for users by email or username in admin panel

* Make Admin panel design more consistent

* fix test

* fix width of items

* escape regex for searching users

* load own user when pressing enter on empty field

* add styling for warning buttons

* improve sub styling

* fix checkbox alignment in admin panel

* Unify date preview display

* Fix bottom button display

* admin panel display improvements

* remove autocannon file

* search improvements

* time travel button display fix

* fix loading spinner

* fix sorting

* Split email search into multiple queries

* fix email search

* remove console

* fix line break
This commit is contained in:
Phillip Thelen
2024-08-29 16:15:45 +02:00
committed by GitHub
parent 23fad37205
commit d3b63abdd3
23 changed files with 1086 additions and 537 deletions

View File

@@ -42,23 +42,23 @@ describe('content index', () => {
expect(Object.keys(juneGear).length, '').to.equal(Object.keys(julyGear).length - 3);
});
it('Releases pets gear when appropriate without needing restarting', () => {
it('Releases pets when appropriate without needing restarting', () => {
clock = sinon.useFakeTimers(new Date('2024-06-20'));
const junePets = content.petInfo;
expect(junePets['Chameleon-Base']).to.not.exist;
clock.restore();
clock = sinon.useFakeTimers(new Date('2024-07-20'));
clock = sinon.useFakeTimers(new Date('2024-07-18'));
const julyPets = content.petInfo;
expect(julyPets['Chameleon-Base']).to.exist;
expect(Object.keys(junePets).length, '').to.equal(Object.keys(julyPets).length - 10);
});
it('Releases mounts gear when appropriate without needing restarting', () => {
it('Releases mounts when appropriate without needing restarting', () => {
clock = sinon.useFakeTimers(new Date('2024-06-20'));
const juneMounts = content.mountInfo;
expect(juneMounts['Chameleon-Base']).to.not.exist;
clock.restore();
clock = sinon.useFakeTimers(new Date('2024-07-20'));
clock = sinon.useFakeTimers(new Date('2024-07-18'));
const julyMounts = content.mountInfo;
expect(julyMounts['Chameleon-Base']).to.exist;
expect(Object.keys(juneMounts).length, '').to.equal(Object.keys(julyMounts).length - 10);

View File

@@ -174,6 +174,30 @@
}
}
.btn-warning {
background: $orange-10;
color: $white !important;
&:hover:not(:disabled):not(.disabled) {
background: $orange-100;
color: $white;
}
&:focus {
background: $orange-10;
border-color: $purple-400;
}
&:not(:disabled):not(.disabled):active:focus, &:not(:disabled):not(.disabled).active:focus {
box-shadow: none;
border-color: $purple-400;
}
&:not(:disabled):not(.disabled):active, &:not(:disabled):not(.disabled).active {
background: $orange-10;
}
}
.btn-success {
background: $green-50;
border: 1px solid transparent;

View File

@@ -1,30 +1,41 @@
<template>
<div class="row standard-page">
<div class="well col-12">
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="admin-panel-content">
<h1>Admin Panel</h1>
<div>
<form
class="form-inline"
@submit.prevent="loadHero(userIdentifier)"
@submit.prevent="searchUsers(userIdentifier)"
>
<div class="input-group col pl-0 pr-0">
<input
v-model="userIdentifier"
class="form-control uidField"
class="form-control"
type="text"
:placeholder="'User ID or Username; blank for your account'"
:placeholder="'UserID, username, email, or leave blank for your account'"
>
<input
type="submit"
value="Load User"
<div class="input-group-append">
<button
class="btn btn-primary"
type="button"
@click="loadUser(userIdentifier)"
>
Load User
</button>
<button
class="btn btn-secondary"
type="button"
@click="searchUsers(userIdentifier)"
>
Search
</button>
</div>
</div>
</form>
</div>
<div>
<router-view @changeUserIdentifier="changeUserIdentifier" />
</div>
<router-view
class="mt-3"
@changeUserIdentifier="changeUserIdentifier"
/>
</div>
</div>
</template>
@@ -33,6 +44,15 @@
.uidField {
min-width: 45ch;
}
.input-group-append {
width:auto;
}
.admin-panel-content {
flex: 0 0 800px;
max-width: 800px;
}
</style>
<script>
@@ -62,7 +82,24 @@ export default {
// (useful if we want to re-fetch the user after making changes).
this.userIdentifier = newId;
},
async loadHero (userIdentifier) {
async searchUsers (userIdentifier) {
if (!userIdentifier || userIdentifier === '') {
this.loadUser();
return;
}
this.$router.push({
name: 'adminPanelSearch',
params: { userIdentifier },
}).catch(failure => {
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
// the admin has requested that the same user be displayed again so reload the page
// (e.g., if they changed their mind about changes they were making)
this.$router.go();
}
});
},
async loadUser (userIdentifier) {
const id = userIdentifier || this.user._id;
this.$router.push({

View File

@@ -0,0 +1,155 @@
<template>
<div>
<div
v-if="noUsersFound"
class="alert alert-warning"
role="alert"
>
Could not find any matching users.
</div>
<loading-spinner class="mx-auto mb-2" dark-color="true" v-if="isSearching" />
<div
v-if="users.length > 0"
class="list-group"
>
<a
v-for="user in users"
:key="user._id"
href="#"
class="list-group-item list-group-item-action"
@click="loadUser(user._id)"
>
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ user.profile.name }}</h5>
<small>{{ user._id }}</small>
</div>
<p
class="mb-1"
:class="{'highlighted-value': matchValueToIdentifier(user.auth.local.username)}"
>
@{{ user.auth.local.username }}</p>
<p class="mb-0">
<span
v-for="email in userEmails(user)"
:key="email"
:class="{'highlighted-value': matchValueToIdentifier(email)}"
>
{{ email }}
</span>
</p>
</a>
</div>
</div>
</template>
<style lang="scss" scoped>
.highlighted-value {
font-weight: bold;
}
</style>
<script>
import VueRouter from 'vue-router';
import { mapState } from '@/libs/store';
import LoadingSpinner from '../ui/loadingSpinner';
const { isNavigationFailure, NavigationFailureType } = VueRouter;
export default {
components: {
LoadingSpinner,
},
data () {
return {
userIdentifier: '',
users: [],
noUsersFound: false,
isSearching: false,
};
},
computed: {
...mapState({ user: 'user.data' }),
},
beforeRouteUpdate (to, from, next) {
this.userIdentifier = to.params.userIdentifier;
next();
},
watch: {
userIdentifier () {
this.isSearching = true;
this.$store.dispatch('adminPanel:searchUsers', { userIdentifier: this.userIdentifier }).then(users => {
this.isSearching = false;
if (users.length === 1) {
this.loadUser(users[0]._id);
} else {
const matchIndex = users.findIndex(user => this.isExactMatch(user));
if (matchIndex !== -1) {
users.splice(0, 0, users.splice(matchIndex, 1)[0]);
}
this.users = users;
this.noUsersFound = users.length === 0;
}
});
this.$emit('changeUserIdentifier', this.userIdentifier); // change user identifier in Admin Panel's form
},
},
mounted () {
this.userIdentifier = this.$route.params.userIdentifier;
},
methods: {
matchValueToIdentifier (value) {
return value.toLowerCase().includes(this.userIdentifier.toLowerCase());
},
userEmails (user) {
const allEmails = [];
if (user.auth.local.email) allEmails.push(user.auth.local.email);
if (user.auth.google && user.auth.google.emails) {
const emails = user.auth.google.emails;
allEmails.push(...this.findSocialEmails(emails));
}
if (user.auth.apple && user.auth.apple.emails) {
const emails = user.auth.apple.emails;
allEmails.push(...this.findSocialEmails(emails));
}
if (user.auth.facebook && user.auth.facebook.emails) {
const emails = user.auth.facebook.emails;
allEmails.push(...this.findSocialEmails(emails));
}
return allEmails;
},
findSocialEmails (emails) {
if (typeof emails === 'string') return [emails];
if (Array.isArray(emails)) return emails.map(email => email.value);
if (typeof emails === 'object') return [emails.value];
return [];
},
async loadUser (userIdentifier) {
const id = userIdentifier || this.user._id;
this.$router.push({
name: 'adminPanelUser',
params: { userIdentifier: id },
}).catch(failure => {
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
// the admin has requested that the same user be displayed again so reload the page
// (e.g., if they changed their mind about changes they were making)
this.$router.go();
}
});
},
isExactMatch (user) {
return user._id === this.userIdentifier
|| user.auth.local.username === this.userIdentifier
|| (user.auth.google && user.auth.google.emails && user.auth.google.emails.findIndex(
email => email.value === this.userIdentifier,
) !== -1)
|| (user.auth.apple && user.auth.apple.emails && user.auth.apple.emails.findIndex(
email => email.value === this.userIdentifier,
) !== -1)
|| (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails.findIndex(
email => email.value === this.userIdentifier,
) !== -1);
},
},
};
</script>

View File

@@ -1,13 +1,18 @@
<template>
<div class="accordion-group">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
Achievements
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<ul>
<li
v-for="item in achievements"

View File

@@ -1,13 +1,18 @@
<template>
<div class="accordion-group">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
Current Avatar Appearance, Drop Count Today
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<div>Drops Today: {{ items.lastDrop.count }}</div>
<div>Most Recent Drop: {{ items.lastDrop.date | formatDate }}</div>
<div>Use Costume: {{ preferences.costume ? 'on' : 'off' }}</div>

View File

@@ -1,79 +1,45 @@
<template>
<div class="accordion-group">
<form @submit.prevent="saveHero({ hero, msg: 'Contributor details', clearData: true })">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{ 'open': expand }"
@click="expand = !expand"
>
Contributor Details
</h3>
<div v-if="expand">
<form @submit.prevent="saveHero({hero, msg: 'Contributor details', clearData: true})">
<div>
<label>Permissions</label>
<div class="checkbox">
<label>
<input
v-model="hero.permissions.fullAccess"
:disabled="!hasPermission(user, 'fullAccess')"
type="checkbox"
>
Full Admin Access (Allows access to everything. EVERYTHING)
</label>
</div>
<div class="checkbox">
<label>
<div
v-if="expand"
class="card-body"
>
<div class="mb-4">
<h3 class="mt-0">
Permissions
</h3>
<div
v-for="permission in permissionList"
:key="permission.key"
class="col-sm-9 offset-sm-3"
>
<div class="custom-control custom-checkbox">
<input
v-model="hero.permissions.userSupport"
:disabled="!hasPermission(user, 'fullAccess')"
v-model="hero.permissions[permission.key]"
:disabled="!hasPermission(user, permission.key)"
class="custom-control-input"
type="checkbox"
>
User Support (Access this form, access purchase history)
</label>
</div>
<div class="checkbox">
<label>
<input
v-model="hero.permissions.news"
:disabled="!hasPermission(user, 'fullAccess')"
type="checkbox"
>
News poster (Bailey CMS)
</label>
</div>
<div class="checkbox">
<label>
<input
v-model="hero.permissions.moderator"
:disabled="!hasPermission(user, 'fullAccess')"
type="checkbox"
>
Community Moderator (ban and mute users, access chat flags, manage social spaces)
</label>
</div>
<div class="checkbox">
<label>
<input
v-model="hero.permissions.challengeAdmin"
:disabled="!hasPermission(user, 'fullAccess')"
type="checkbox"
>
Challenge Admin (can create official habitica challenges and admin all challenges)
</label>
</div>
<div class="checkbox">
<label>
<input
v-model="hero.permissions.coupons"
:disabled="!hasPermission(user, 'fullAccess')"
type="checkbox"
>
Coupon Creator (can manage coupon codes)
<label class="custom-control-label">
{{ permission.name }}<br>
<small class="text-secondary">{{ permission.description }}</small>
</label>
</div>
</div>
<div class="form-group">
<label>Title</label>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Title</label>
<div class="col-sm-9">
<input
v-model="hero.contributor.text"
class="form-control textField"
@@ -89,8 +55,10 @@
Statistician, Tinker, Transcriber, Troubadour.
</small>
</div>
<div class="form-group form-inline">
<label>Tier</label>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Tier</label>
<div class="col-sm-9">
<input
v-model="hero.contributor.level"
class="form-control levelField"
@@ -99,34 +67,28 @@
<small>
1-7 for normal contributors, 8 for moderators, 9 for staff.
This determines which items, pets, mounts are available, and name-tag coloring.
Tiers 8 and 9 are automatically given admin status.
</small>
</div>
<div
v-if="hero.secret.text"
class="form-group"
>
<label>Moderation Notes</label>
<div
v-markdown="hero.secret.text"
class="markdownPreview"
></div>
</div>
<div class="form-group">
<label>Contributions</label>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Contributions</label>
<div class="col-sm-9">
<textarea
v-model="hero.contributor.contributions"
class="form-control"
cols="5"
rows="5"
></textarea>
>
</textarea>
<div
v-markdown="hero.contributor.contributions"
class="markdownPreview"
></div>
</div>
<div class="form-group">
<label>Edit Moderation Notes</label>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Moderation Notes</label>
<div class="col-sm-9">
<textarea
v-model="hero.secret.text"
class="form-control"
@@ -138,20 +100,27 @@
class="markdownPreview"
></div>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input
type="submit"
value="Save and Clear Data"
class="btn btn-primary"
value="Save"
class="btn btn-primary mt-1"
>
</div>
</div>
</form>
</div>
</div>
</template>
<style lang="scss" scoped>
.levelField {
min-width: 10ch;
}
.textField {
min-width: 50ch;
}
@@ -164,6 +133,39 @@ import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../mixins/userState';
const permissionList = [
{
key: 'fullAccess',
name: 'Full Admin Access',
description: 'Allows access to everything. EVERYTHING',
},
{
key: 'userSupport',
name: 'User Support',
description: 'Access this form, access purchase history',
},
{
key: 'news',
name: 'News Poster',
description: 'Bailey CMS',
},
{
key: 'moderator',
name: 'Community Moderator',
description: 'Ban and mute users, access chat flags, manage social spaces',
},
{
key: 'challengeAdmin',
name: 'Challenge Admin',
description: 'Can create official habitica challenges and admin all challenges',
},
{
key: 'coupons',
name: 'Coupon Creator',
description: 'Can manage coupon codes',
},
];
function resetData (self) {
self.expand = self.hero.contributor.level;
}
@@ -192,6 +194,7 @@ export default {
data () {
return {
expand: false,
permissionList,
};
},
watch: {

View File

@@ -1,7 +1,9 @@
<template>
<div class="accordion-group">
<form @submit.prevent="saveHero({ hero, msg: 'Authentication' })">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
@@ -10,7 +12,11 @@
v-if="errorsOrWarningsExist"
>- ERRORS / WARNINGS EXIST</span>
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<p
v-if="errorsOrWarningsExist"
class="errorMessage"
@@ -18,16 +24,22 @@
See error(s) below.
</p>
<div>
Account created:
<strong>{{ hero.auth.timestamps.created | formatDate }}</strong>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Account created:</label>
<strong class="col-sm-9 col-form-label">
{{ hero.auth.timestamps.created | formatDate }}</strong>
</div>
<div v-if="hero.flags.thirdPartyTools">
User has employed <strong>third party tools</strong>. Last known usage:
<strong>{{ hero.flags.thirdPartyTools | formatDate }}</strong>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Used third party tools:</label>
<div class="col-sm-9 col-form-label">
<strong v-if="hero.flags.thirdPartyTools">
Yes - {{ hero.flags.thirdPartyTools | formatDate }}</strong>
<strong v-else>No</strong>
</div>
<div v-if="cronError">
"lastCron" value:
</div>
<div v-if="cronError" class="form-group row">
<label class="col-sm-3 col-form-label">lastCron value:</label>
<strong>{{ hero.lastCron | formatDate }}</strong>
<br>
<span class="errorMessage">
@@ -35,26 +47,34 @@
("auth.timestamps.loggedin" and "lastCron" dates are different).
</span>
</div>
<div class="form-inline">
<div>
Most recent cron:
<strong>{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
("auth.timestamps.loggedin")
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Most recent cron:</label>
<div class="col-sm-9 col-form-label">
<strong>
{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
<button
class="btn btn-primary ml-2"
class="btn btn-warning btn-sm ml-4"
@click="resetCron()"
>
Reset Cron to Yesterday
</button>
</div>
<div class="subsection-start">
Time zone:
<strong>{{ hero.preferences.timezoneOffset | formatTimeZone }}</strong>
</div>
<div>
Custom Day Start time (CDS):
<strong>{{ hero.preferences.dayStart }}</strong>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Time zone:</label>
<strong class="col-sm-9 col-form-label">
{{ hero.preferences.timezoneOffset | formatTimeZone }}</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Custom Day Start time (CDS)</label>
<div class="col-sm-9">
<input
v-model="hero.preferences.dayStart"
class="form-control levelField"
type="number"
>
</div>
</div>
<div v-if="timezoneDiffError || timezoneMissingError">
Time zone at previous cron:
@@ -87,18 +107,18 @@
</div>
</div>
<div class="subsection-start form-inline">
API Token: &nbsp;
<form @submit.prevent="changeApiToken()">
<input
type="submit"
<div class="form-group row">
<label class="col-sm-3 col-form-label">API Token</label>
<div class="col-sm-9">
<button
value="Change API Token"
class="btn btn-primary"
class="btn btn-danger"
@click="changeApiToken()"
>
</form>
Change API Token
</button>
<div
v-if="tokenModified"
class="form-inline"
>
<strong>API Token has been changed. Tell the player something like this:</strong>
<br>
@@ -112,34 +132,56 @@
reboot your phone, then reinstall it.
</div>
</div>
<div class="subsection-start">
Local authentication:
<span v-if="hero.auth.local.email">Yes, &nbsp;
<strong>{{ hero.auth.local.email }}</strong></span>
<span v-else><strong>None</strong></span>
</div>
<div>
Google authentication:
<div class="form-group row">
<label class="col-sm-3 col-form-label">Local Authentication E-Mail</label>
<div class="col-sm-9">
<input
v-model="hero.auth.local.email"
class="form-control"
type="text"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Google authentication</label>
<div class="col-sm-9">
<pre v-if="authMethodExists('google')">{{ hero.auth.google }}</pre>
<span v-else><strong>None</strong></span>
</div>
<div>
Facebook authentication:
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Facebook authentication</label>
<div class="col-sm-9">
<pre v-if="authMethodExists('facebook')">{{ hero.auth.facebook }}</pre>
<span v-else><strong>None</strong></span>
</div>
<div>
Apple ID authentication:
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Apple ID authentication</label>
<div class="col-sm-9">
<pre v-if="authMethodExists('apple')">{{ hero.auth.apple }}</pre>
<span v-else><strong>None</strong></span>
</div>
</div>
<div class="subsection-start">
Full "auth" object for checking above is correct:
<pre>{{ hero.auth }}</pre>
</div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
>
</div>
</div>
</form>
</template>
<script>

View File

@@ -1,13 +1,18 @@
<template>
<div class="accordion-group">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
Customizations
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<div
v-for="itemType in itemTypes"
:key="itemType"

View File

@@ -1,13 +1,18 @@
<template>
<div class="accordion-group">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
Items
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<div>
The sections below display each item's key (bolded if the player has ever owned it),
followed by the item's English name.

View File

@@ -1,7 +1,8 @@
<template>
<div class="accordion-group">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
@@ -10,7 +11,11 @@
v-if="errorsOrWarningsExist"
>- ERRORS / WARNINGS EXIST</span>
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<div
v-if="errorsOrWarningsExist"
class="errorMessage"

View File

@@ -1,68 +1,107 @@
<template>
<div class="accordion-group">
<form @submit.prevent="saveHero({hero, msg: 'Privileges or Gems or Moderation Notes'})">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
Privileges, Gem Balance
Priviliges, Gem Balance
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<p
v-if="errorsOrWarningsExist"
class="errorMessage"
>
Player has had privileges removed or has moderation notes.
</p>
<form @submit.prevent="saveHero({hero, msg: 'Privileges or Gems or Moderation Notes'})">
<div class="checkbox">
<label>
<input
<div
v-if="hero.flags"
class="form-group row"
>
<div class="col-sm-9 offset-sm-3">
<div class="custom-control custom-checkbox">
<input
id="chatShadowMuted"
v-model="hero.flags.chatShadowMuted"
class="custom-control-input"
type="checkbox"
> Shadow Mute
>
<label
class="custom-control-label"
for="chatShadowMuted"
>
Shadow Mute
</label>
</div>
<div class="checkbox">
<label>
<input
</div>
</div>
<div
v-if="hero.flags"
v-model="hero.flags.chatRevoked"
type="checkbox"
> Mute (Revoke Chat Privileges)
</label>
</div>
<div class="checkbox">
<label>
class="form-group row"
>
<div class="col-sm-9 offset-sm-3">
<div class="custom-control custom-checkbox">
<input
v-model="hero.auth.blocked"
id="chatRevoked"
v-model="hero.flags.chatRevoked"
class="custom-control-input"
type="checkbox"
> Ban / Block
>
<label
class="custom-control-label"
for="chatRevoked"
>
Mute (Revoke Chat Privileges)
</label>
</div>
<div class="form-inline">
<label>
</div>
</div>
<div class="form-group row">
<div class="col-sm-9 offset-sm-3">
<div class="custom-control custom-checkbox">
<input
id="blocked"
v-model="hero.auth.blocked"
class="custom-control-input"
type="checkbox"
>
<label
class="custom-control-label"
for="blocked"
>
Ban / Block
</label>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Balance
</label>
<div class="col-sm-9">
<input
v-model="hero.balance"
class="form-control balanceField"
type="number"
step="0.25"
>
</label>
<span>
<small>
Balance is in USD, not in Gems.
E.g., if this number is 1, it means 4 Gems.
Arrows change Balance by 0.25 (i.e., 1 Gem per click).
Do not use when awarding tiers; tier gems are automatic.
</small>
</span>
</div>
<div class="form-group">
<label>Moderation Notes</label>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Moderation Notes</label>
<div class="col-sm-9">
<textarea
v-model="hero.secret.text"
class="form-control"
@@ -74,14 +113,20 @@
class="markdownPreview"
></div>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary"
class="btn btn-primary mt-1"
>
</div>
</div>
</form>
</div>
</div>
</template>
<style lang="scss" scoped>

View File

@@ -1,14 +1,19 @@
<template>
<div class="accordion-group">
<form @submit.prevent="saveHero({ hero, msg: 'Subscription Perks' })">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{ 'open': expand }"
@click="expand = !expand"
>
Subscription, Monthly Perks
</h3>
<div v-if="expand">
<form @submit.prevent="saveHero({ hero, msg: 'Subscription Perks' })">
</div>
<div
v-if="expand"
class="card-body"
>
<div v-if="hero.purchased.plan.paymentMethod">
Payment method:
<strong>{{ hero.purchased.plan.paymentMethod }}</strong>
@@ -23,46 +28,72 @@
</div>
<div
v-if="hero.purchased.plan.dateCreated"
class="form-inline"
class="form-group row"
>
<label>
<label class="col-sm-3 col-form-label">
Creation date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCreated"
class="form-control"
type="text"
> <strong class="ml-2">{{ dateFormat(hero.purchased.plan.dateCreated) }}</strong>
</label>
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCreated) }}
</strong>
</div>
</div>
</div>
</div>
<div
v-if="hero.purchased.plan.dateCurrentTypeCreated"
class="form-inline"
class="form-group row"
>
<label>
Start date for current subscription type:
<label class="col-sm-3 col-form-label">
Current sub start date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCurrentTypeCreated"
class="form-control"
type="text"
>
</label>
<strong class="ml-2">{{ dateFormat(hero.purchased.plan.dateCurrentTypeCreated) }}</strong>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCurrentTypeCreated) }}
</strong>
</div>
<div class="form-inline">
<label>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Termination date:
<div>
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateTerminated"
class="form-control"
type="text"
> <strong class="ml-2">{{ dateFormat(hero.purchased.plan.dateTerminated) }}</strong>
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
</strong>
</div>
</label>
</div>
<div class="form-inline">
<label>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Consecutive months:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.count"
class="form-control"
@@ -70,11 +101,13 @@
min="0"
step="1"
>
</label>
</div>
<div class="form-inline">
<label>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Perk offset months:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.offset"
class="form-control"
@@ -82,10 +115,13 @@
min="0"
step="1"
>
</label>
</div>
<div class="form-inline">
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Perk month count:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.perkMonthCount"
class="form-control"
@@ -95,13 +131,18 @@
step="1"
>
</div>
<div>
Next Mystic Hourglass:
<strong>{{ nextHourglassDate }}</strong>
</div>
<div class="form-inline">
<label>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Next Mystic Hourglass:
</label>
<strong class="col-sm-9 col-form-label">{{ nextHourglassDate }}</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystic Hourglasses:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.trinkets"
class="form-control"
@@ -109,11 +150,13 @@
min="0"
step="1"
>
</label>
</div>
<div class="form-inline">
<label>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gem cap increase:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.gemCapExtra"
class="form-control"
@@ -122,15 +165,21 @@
max="25"
step="5"
>
</label>
</div>
<div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Total Gem cap:
<strong>{{ Number(hero.purchased.plan.consecutive.gemCapExtra) + 25 }}</strong>
</label>
<strong class="col-sm-9 col-form-label">
{{ Number(hero.purchased.plan.consecutive.gemCapExtra) + 25 }}
</strong>
</div>
<div class="form-inline">
<label>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gems bought this month:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.gemsBought"
class="form-control"
@@ -139,19 +188,18 @@
:max="hero.purchased.plan.consecutive.gemCapExtra + 25"
step="1"
>
</label>
</div>
<div
v-if="hero.purchased.plan.extraMonths > 0"
>
</div>
<div v-if="hero.purchased.plan.extraMonths > 0">
Additional credit (applied upon cancellation):
<strong>{{ hero.purchased.plan.extraMonths }}</strong>
</div>
<div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystery Items:
<span
v-if="hero.purchased.plan.mysteryItems.length > 0"
>
</label>
<div class="col-sm-9 col-form-label">
<span v-if="hero.purchased.plan.mysteryItems.length > 0">
<span
v-for="(item, index) in hero.purchased.plan.mysteryItems"
:key="index"
@@ -166,16 +214,38 @@
<strong>None</strong>
</span>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
>
</div>
</div>
</form>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.input-group-append {
width: auto;
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
}
}
</style>
<script>
import moment from 'moment';
import { getPlanContext } from '@/../../common/script/cron';

View File

@@ -1,13 +1,18 @@
<template>
<div class="accordion-group">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="toggleTransactionsOpen"
>
Transactions
</h3>
<div v-if="expand">
</div>
<div
v-if="expand"
class="card-body"
>
<purchase-history-table
:gem-transactions="gemTransactions"
:hourglass-transactions="hourglassTransactions"

View File

@@ -1,52 +1,66 @@
<template>
<div class="accordion-group">
<form @submit.prevent="saveHero({hero, msg: 'Users Profile'})">
<div class="card mt-2">
<div class="card-header">
<h3
class="expand-toggle"
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
Users Profile
User Profile
</h3>
<div v-if="expand">
<form @submit.prevent="saveHero({hero, msg: 'Users Profile'})">
<div class="form-group">
<label>Display name</label>
</div>
<div
v-if="expand"
class="card-body"
>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Display name</label>
<div class="col-sm-9">
<input
v-model="hero.profile.name"
class="form-control textField"
class="form-control"
type="text"
>
</div>
<div class="form-group">
<label>Photo URL</label>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Photo URL</label>
<div class="col-sm-9">
<input
v-model="hero.profile.imageUrl"
class="form-control textField"
class="form-control"
type="text"
>
</div>
<div class="form-group">
<label>About</label>
<div class="row about-row">
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">About</label>
<div class="col-sm-9">
<textarea
v-model="hero.profile.blurb"
class="form-control col"
class="form-control"
rows="10"
></textarea>
<div
v-markdown="hero.profile.blurb"
class="markdownPreview col"
class="markdownPreview"
></div>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary"
class="btn btn-primary mt-1"
>
</div>
</div>
</form>
</div>
</div>
</template>
<style lang="scss" scoped>

View File

@@ -291,6 +291,7 @@
</div>
<div
class="time-travel"
v-if="TIME_TRAVEL_ENABLED && user.permissions && user.permissions.fullAccess"
:key="lastTimeJump"
>
@@ -309,9 +310,11 @@
<div class="my-2">
Time Traveling! It is {{ new Date().toLocaleDateString() }}
<a
class="btn btn-warning mr-1"
class="btn btn-warning btn-small"
@click="resetTime()"
>Reset</a>
>
Reset
</a>
</div>
<a
class="btn btn-secondary mr-1"
@@ -510,6 +513,8 @@ li {
grid-area: debug-pop;
}
.time-travel { grid-area: time-travel;}
footer {
background-color: $gray-500;
color: $gray-50;
@@ -530,7 +535,7 @@ footer {
"donate-text donate-text donate-text donate-button social"
"hr hr hr hr hr"
"copyright copyright melior privacy-terms privacy-terms"
"debug-toggle debug-toggle debug-toggle blank blank";
"time-travel time-travel debug-toggle debug-toggle debug-toggle";
grid-template-columns: repeat(5, 1fr);
grid-template-rows: auto;

View File

@@ -2,6 +2,7 @@
<div
v-once
class="loading-spinner"
:class="{'loading-spinner-purple': darkColor}"
role="text"
:aria-label="$t('loading')"
>
@@ -39,6 +40,10 @@
border-color: $white transparent transparent transparent;
}
.loading-spinner-purple div {
border-color: $purple-200 transparent transparent transparent;
}
.loading-spinner div:nth-child(1) {
animation-delay: -0.45s;
}
@@ -58,3 +63,16 @@
}
}
</style>
<script>
export default {
props: {
darkColor: {
type: Boolean,
default: false,
},
},
};
</script>

View File

@@ -318,13 +318,18 @@
color: $gray-50;
}
td span {
line-break: anywhere;
}
th, td {
padding-top: 0.35rem !important;
padding-bottom: 0.35rem !important;
}
.timestamp-column, .action-column {
width: 20%;
width: 27%;
}
.amount-column {
@@ -332,7 +337,7 @@
}
.note-column {
width: 50%;
width: 35%;
}
.entry-action {

View File

@@ -22,6 +22,7 @@ const HeroesPage = () => import(/* webpackChunkName: "hall" */'@/components/hall
// Admin Panel
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel');
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/user-support');
const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/search');
// Except for tasks that are always loaded all the other main level
// All the main level
@@ -193,9 +194,19 @@ const router = new VueRouter({
],
},
children: [
{
name: 'adminPanelSearch',
path: 'search/:userIdentifier',
component: AdminPanelSearchPage,
meta: {
privilegeNeeded: [
'userSupport',
],
},
},
{
name: 'adminPanelUser',
path: ':userIdentifier', // User ID or Username
path: ':userIdentifier',
component: AdminPanelUserPage,
meta: {
privilegeNeeded: [

View File

@@ -0,0 +1,7 @@
import axios from 'axios';
export async function searchUsers (store, payload) {
const url = `/api/v4/admin/search/${payload.userIdentifier}`;
const response = await axios.get(url);
return response.data.data;
}

View File

@@ -1,5 +1,6 @@
import { flattenAndNamespace } from '@/libs/store/helpers/internals';
import * as adminPanel from './adminPanel';
import * as common from './common';
import * as user from './user';
import * as tasks from './tasks';
@@ -24,6 +25,7 @@ import * as faq from './faq';
// Example: fetch in user.js -> 'user:fetch'
const actions = flattenAndNamespace({
adminPanel,
common,
user,
tasks,

View File

@@ -389,14 +389,19 @@ api.updateHero = {
hero.markModified('items');
}
if (updateData.auth && updateData.auth.blocked === true) {
if (updateData.auth) {
if (updateData.auth.blocked === true) {
hero.auth.blocked = updateData.auth.blocked;
hero.preferences.sleep = true; // when blocking, have them rest at an inn to prevent damage
}
if (updateData.auth && updateData.auth.blocked === false) {
} else if (updateData.auth.blocked === false) {
hero.auth.blocked = false;
}
if (updateData.auth.local && updateData.auth.local.email) {
hero.auth.local.email = updateData.auth.local.email;
}
}
if (updateData.flags && _.isBoolean(updateData.flags.chatRevoked)) {
hero.flags.chatRevoked = updateData.flags.chatRevoked;
}

View File

@@ -0,0 +1,76 @@
import validator from 'validator';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { model as User } from '../../models/user';
const api = {};
/**
* @api {get} /api/v4/admin/search/:userIdentifier Search for users by username or email
* @apiParam (Path) {String} userIdentifier The username or email of the user to search for
* @apiName SearchUsers
* @apiGroup Admin
* @apiPermission Admin
*
* @apiDescription Returns a list of users that match the search criteria
*
* @apiSuccess {Object} data The User list
*
* @apiUse NoAuthHeaders
* @apiUse NoAccount
* @apiUse NoUser
* @apiUse NotAdmin
*/
api.getHero = {
method: 'GET',
url: '/admin/search/:userIdentifier',
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
async handler (req, res) {
req.checkParams('userIdentifier', res.t('userIdentifierRequired')).notEmpty();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { userIdentifier } = req.params;
const re = new RegExp(String.raw`^${userIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
let query;
let users = [];
if (validator.isUUID(userIdentifier)) {
query = { _id: userIdentifier };
} else if (validator.isEmail(userIdentifier)) {
const emailFields = [
'auth.local.email',
'auth.google.emails.value',
'auth.apple.emails.value',
'auth.facebook.emails.value',
];
for (const field of emailFields) {
const emailQuery = { [field]: userIdentifier };
// eslint-disable-next-line no-await-in-loop
const found = await User.findOne(emailQuery)
.select('contributor backer profile auth')
.lean()
.exec();
if (found) {
users.push(found);
}
}
} else {
query = { 'auth.local.lowerCaseUsername': { $regex: re, $options: 'i' } };
}
if (query) {
users = await User
.find(query)
.select('contributor backer profile auth')
.limit(30)
.lean()
.exec();
}
res.respond(200, users);
},
};
export default api;