🧑‍💼🎛️ 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); 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')); clock = sinon.useFakeTimers(new Date('2024-06-20'));
const junePets = content.petInfo; const junePets = content.petInfo;
expect(junePets['Chameleon-Base']).to.not.exist; expect(junePets['Chameleon-Base']).to.not.exist;
clock.restore(); clock.restore();
clock = sinon.useFakeTimers(new Date('2024-07-20')); clock = sinon.useFakeTimers(new Date('2024-07-18'));
const julyPets = content.petInfo; const julyPets = content.petInfo;
expect(julyPets['Chameleon-Base']).to.exist; expect(julyPets['Chameleon-Base']).to.exist;
expect(Object.keys(junePets).length, '').to.equal(Object.keys(julyPets).length - 10); 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')); clock = sinon.useFakeTimers(new Date('2024-06-20'));
const juneMounts = content.mountInfo; const juneMounts = content.mountInfo;
expect(juneMounts['Chameleon-Base']).to.not.exist; expect(juneMounts['Chameleon-Base']).to.not.exist;
clock.restore(); clock.restore();
clock = sinon.useFakeTimers(new Date('2024-07-20')); clock = sinon.useFakeTimers(new Date('2024-07-18'));
const julyMounts = content.mountInfo; const julyMounts = content.mountInfo;
expect(julyMounts['Chameleon-Base']).to.exist; expect(julyMounts['Chameleon-Base']).to.exist;
expect(Object.keys(juneMounts).length, '').to.equal(Object.keys(julyMounts).length - 10); 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 { .btn-success {
background: $green-50; background: $green-50;
border: 1px solid transparent; border: 1px solid transparent;

View File

@@ -1,30 +1,41 @@
<template> <template>
<div class="row standard-page"> <div class="row standard-page col-12 d-flex justify-content-center">
<div class="well col-12"> <div class="admin-panel-content">
<h1>Admin Panel</h1> <h1>Admin Panel</h1>
<form
<div> class="form-inline"
<form @submit.prevent="searchUsers(userIdentifier)"
class="form-inline" >
@submit.prevent="loadHero(userIdentifier)" <div class="input-group col pl-0 pr-0">
>
<input <input
v-model="userIdentifier" v-model="userIdentifier"
class="form-control uidField" class="form-control"
type="text" type="text"
:placeholder="'User ID or Username; blank for your account'" :placeholder="'UserID, username, email, or leave blank for your account'"
> >
<input <div class="input-group-append">
type="submit" <button
value="Load User" class="btn btn-primary"
class="btn btn-secondary" type="button"
> @click="loadUser(userIdentifier)"
</form> >
</div> Load User
</button>
<button
class="btn btn-secondary"
type="button"
@click="searchUsers(userIdentifier)"
>
Search
</button>
</div>
</div>
</form>
<div> <router-view
<router-view @changeUserIdentifier="changeUserIdentifier" /> class="mt-3"
</div> @changeUserIdentifier="changeUserIdentifier"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -33,6 +44,15 @@
.uidField { .uidField {
min-width: 45ch; min-width: 45ch;
} }
.input-group-append {
width:auto;
}
.admin-panel-content {
flex: 0 0 800px;
max-width: 800px;
}
</style> </style>
<script> <script>
@@ -62,7 +82,24 @@ export default {
// (useful if we want to re-fetch the user after making changes). // (useful if we want to re-fetch the user after making changes).
this.userIdentifier = newId; 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; const id = userIdentifier || this.user._id;
this.$router.push({ 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> <template>
<div class="accordion-group"> <div class="card mt-2">
<h3 <div class="card-header">
class="expand-toggle" <h3
:class="{'open': expand}" class="mb-0 mt-0"
@click="expand = !expand" :class="{'open': expand}"
@click="expand = !expand"
>
Achievements
</h3>
</div>
<div
v-if="expand"
class="card-body"
> >
Achievements
</h3>
<div v-if="expand">
<ul> <ul>
<li <li
v-for="item in achievements" v-for="item in achievements"

View File

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

View File

@@ -1,160 +1,129 @@
<template> <template>
<div class="accordion-group"> <form @submit.prevent="saveHero({ hero, msg: 'Contributor details', clearData: true })">
<h3 <div class="card mt-2">
class="expand-toggle" <div class="card-header">
:class="{'open': expand}" <h3
@click="expand = !expand" class="mb-0 mt-0"
> :class="{ 'open': expand }"
Contributor Details @click="expand = !expand"
</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>
<input
v-model="hero.permissions.userSupport"
:disabled="!hasPermission(user, 'fullAccess')"
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>
</div>
</div>
<div class="form-group">
<label>Title</label>
<input
v-model="hero.contributor.text"
class="form-control textField"
type="text"
>
<small>
Common titles:
<strong>Ambassador, Artisan, Bard, Blacksmith, Challenger, Comrade, Fletcher,
Linguist, Linguistic Scribe, Scribe, Socialite, Storyteller</strong>.
<br>
Rare titles:
Advisor, Chamberlain, Designer, Mathematician, Shirtster, Spokesperson,
Statistician, Tinker, Transcriber, Troubadour.
</small>
</div>
<div class="form-group form-inline">
<label>Tier</label>
<input
v-model="hero.contributor.level"
class="form-control levelField"
type="number"
>
<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> Contributor Details
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<div class="mb-4">
<h3 class="mt-0">
Permissions
</h3>
<div <div
v-markdown="hero.secret.text" v-for="permission in permissionList"
class="markdownPreview" :key="permission.key"
></div> class="col-sm-9 offset-sm-3"
>
<div class="custom-control custom-checkbox">
<input
v-model="hero.permissions[permission.key]"
:disabled="!hasPermission(user, permission.key)"
class="custom-control-input"
type="checkbox"
>
<label class="custom-control-label">
{{ permission.name }}<br>
<small class="text-secondary">{{ permission.description }}</small>
</label>
</div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group row">
<label>Contributions</label> <label class="col-sm-3 col-form-label">Title</label>
<textarea <div class="col-sm-9">
v-model="hero.contributor.contributions" <input
class="form-control" v-model="hero.contributor.text"
cols="5" class="form-control textField"
rows="5" type="text"
></textarea> >
<div <small>
v-markdown="hero.contributor.contributions" Common titles:
class="markdownPreview" <strong>Ambassador, Artisan, Bard, Blacksmith, Challenger, Comrade, Fletcher,
></div> Linguist, Linguistic Scribe, Scribe, Socialite, Storyteller</strong>.
<br>
Rare titles:
Advisor, Chamberlain, Designer, Mathematician, Shirtster, Spokesperson,
Statistician, Tinker, Transcriber, Troubadour.
</small>
</div>
</div> </div>
<div class="form-group"> <div class="form-group row">
<label>Edit Moderation Notes</label> <label class="col-sm-3 col-form-label">Tier</label>
<textarea <div class="col-sm-9">
v-model="hero.secret.text" <input
class="form-control" v-model="hero.contributor.level"
cols="5" class="form-control levelField"
rows="3" type="number"
></textarea> >
<div <small>
v-markdown="hero.secret.text" 1-7 for normal contributors, 8 for moderators, 9 for staff.
class="markdownPreview" This determines which items, pets, mounts are available, and name-tag coloring.
></div> </small>
</div>
</div> </div>
<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>
<div
v-markdown="hero.contributor.contributions"
class="markdownPreview"
></div>
</div>
</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"
cols="5"
rows="3"
></textarea>
<div
v-markdown="hero.secret.text"
class="markdownPreview"
></div>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input <input
type="submit" type="submit"
value="Save and Clear Data" value="Save"
class="btn btn-primary" class="btn btn-primary mt-1"
> >
</form> </div>
</div> </div>
</div> </form>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.levelField { .levelField {
min-width: 10ch; min-width: 10ch;
} }
.textField {
min-width: 50ch; .textField {
} min-width: 50ch;
}
</style> </style>
<script> <script>
@@ -164,6 +133,39 @@ import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../mixins/userState'; 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) { function resetData (self) {
self.expand = self.hero.contributor.level; self.expand = self.hero.contributor.level;
} }
@@ -192,6 +194,7 @@ export default {
data () { data () {
return { return {
expand: false, expand: false,
permissionList,
}; };
}, },
watch: { watch: {

View File

@@ -1,145 +1,187 @@
<template> <template>
<div class="accordion-group"> <form @submit.prevent="saveHero({ hero, msg: 'Authentication' })">
<h3 <div class="card mt-2">
class="expand-toggle" <div class="card-header">
:class="{'open': expand}" <h3
@click="expand = !expand" class="mb-0 mt-0"
> :class="{'open': expand}"
Timestamps, Time Zone, Authentication, Email Address @click="expand = !expand"
<span >
v-if="errorsOrWarningsExist" Timestamps, Time Zone, Authentication, Email Address
>- ERRORS / WARNINGS EXIST</span> <span
</h3> v-if="errorsOrWarningsExist"
<div v-if="expand"> >- ERRORS / WARNINGS EXIST</span>
<p </h3>
v-if="errorsOrWarningsExist" </div>
class="errorMessage" <div
v-if="expand"
class="card-body"
> >
See error(s) below. <p
</p> v-if="errorsOrWarningsExist"
class="errorMessage"
<div>
Account created:
<strong>{{ 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>
<div v-if="cronError">
"lastCron" value:
<strong>{{ hero.lastCron | formatDate }}</strong>
<br>
<span class="errorMessage">
ERROR: cron probably crashed before finishing
("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>
<button
class="btn btn-primary ml-2"
@click="resetCron()"
> >
Reset Cron to Yesterday See error(s) below.
</button> </p>
</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>
<div v-if="timezoneDiffError || timezoneMissingError">
Time zone at previous cron:
<strong>{{ hero.preferences.timezoneOffsetAtLastCron | formatTimeZone }}</strong>
<div class="errorMessage"> <div class="form-group row">
<div v-if="timezoneDiffError"> <label class="col-sm-3 col-form-label">Account created:</label>
ERROR: the player's current time zone is different than their time zone when <strong class="col-sm-9 col-form-label">
their previous cron ran. This can be because: {{ hero.auth.timestamps.created | formatDate }}</strong>
<ul> </div>
<li>daylight savings started or stopped <sup>*</sup></li> <div class="form-group row">
<li>the player changed zones due to travel <sup>*</sup></li> <label class="col-sm-3 col-form-label">Used third party tools:</label>
<li>the player has devices set to different zones <sup>**</sup></li>
<li>the player uses a VPN with varying zones <sup>**</sup></li>
<li>something similarly unpleasant is happening. <sup>**</sup></li>
</ul>
<p>
<em>* The problem should fix itself in about a day.</em><br>
<em>** One of these causes is probably happening if the time zones stay
different for more than a day.</em>
</p>
</div>
<div v-if="timezoneMissingError"> <div class="col-sm-9 col-form-label">
ERROR: One of the player's time zones is missing. <strong v-if="hero.flags.thirdPartyTools">
This is expected and okay if it's the "Time zone at previous cron" Yes - {{ hero.flags.thirdPartyTools | formatDate }}</strong>
AND if it's their first day in Habitica. <strong v-else>No</strong>
Otherwise an error has occurred.
</div> </div>
</div> </div>
</div> <div v-if="cronError" class="form-group row">
<label class="col-sm-3 col-form-label">lastCron value:</label>
<div class="subsection-start form-inline"> <strong>{{ hero.lastCron | formatDate }}</strong>
API Token: &nbsp;
<form @submit.prevent="changeApiToken()">
<input
type="submit"
value="Change API Token"
class="btn btn-primary"
>
</form>
<div
v-if="tokenModified"
class="form-inline"
>
<strong>API Token has been changed. Tell the player something like this:</strong>
<br> <br>
I've given you a new API Token. <span class="errorMessage">
You'll need to log out of the website and mobile app then log back in ERROR: cron probably crashed before finishing
otherwise they won't work correctly. ("auth.timestamps.loggedin" and "lastCron" dates are different).
If you have trouble logging out, for the website go to </span>
https://habitica.com/static/clear-browser-data and click the red button there, </div>
and for the Android app, clear its data. <div class="form-group row">
For the iOS app, if you can't log out you might need to uninstall it, <label class="col-sm-3 col-form-label">Most recent cron:</label>
reboot your phone, then reinstall it.
<div class="col-sm-9 col-form-label">
<strong>
{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
<button
class="btn btn-warning btn-sm ml-4"
@click="resetCron()"
>
Reset Cron to Yesterday
</button>
</div>
</div>
<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:
<strong>{{ hero.preferences.timezoneOffsetAtLastCron | formatTimeZone }}</strong>
<div class="errorMessage">
<div v-if="timezoneDiffError">
ERROR: the player's current time zone is different than their time zone when
their previous cron ran. This can be because:
<ul>
<li>daylight savings started or stopped <sup>*</sup></li>
<li>the player changed zones due to travel <sup>*</sup></li>
<li>the player has devices set to different zones <sup>**</sup></li>
<li>the player uses a VPN with varying zones <sup>**</sup></li>
<li>something similarly unpleasant is happening. <sup>**</sup></li>
</ul>
<p>
<em>* The problem should fix itself in about a day.</em><br>
<em>** One of these causes is probably happening if the time zones stay
different for more than a day.</em>
</p>
</div>
<div v-if="timezoneMissingError">
ERROR: One of the player's time zones is missing.
This is expected and okay if it's the "Time zone at previous cron"
AND if it's their first day in Habitica.
Otherwise an error has occurred.
</div>
</div>
</div>
<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-danger"
@click="changeApiToken()"
>
Change API Token
</button>
<div
v-if="tokenModified"
>
<strong>API Token has been changed. Tell the player something like this:</strong>
<br>
I've given you a new API Token.
You'll need to log out of the website and mobile app then log back in
otherwise they won't work correctly.
If you have trouble logging out, for the website go to
https://habitica.com/static/clear-browser-data and click the red button there,
and for the Android app, clear its data.
For the iOS app, if you can't log out you might need to uninstall it,
reboot your phone, then reinstall it.
</div>
</div>
</div>
<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>
<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>
<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> </div>
<div
<div class="subsection-start"> v-if="expand"
Local authentication: class="card-footer"
<span v-if="hero.auth.local.email">Yes, &nbsp; >
<strong>{{ hero.auth.local.email }}</strong></span> <input
<span v-else><strong>None</strong></span> type="submit"
</div> value="Save"
<div> class="btn btn-primary mt-1"
Google authentication: >
<pre v-if="authMethodExists('google')">{{ hero.auth.google }}</pre>
<span v-else><strong>None</strong></span>
</div>
<div>
Facebook authentication:
<pre v-if="authMethodExists('facebook')">{{ hero.auth.facebook }}</pre>
<span v-else><strong>None</strong></span>
</div>
<div>
Apple ID authentication:
<pre v-if="authMethodExists('apple')">{{ hero.auth.apple }}</pre>
<span v-else><strong>None</strong></span>
</div>
<div class="subsection-start">
Full "auth" object for checking above is correct:
<pre>{{ hero.auth }}</pre>
</div> </div>
</div> </div>
</div> </form>
</template> </template>
<script> <script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,19 @@
<template> <template>
<div class="accordion-group"> <form @submit.prevent="saveHero({ hero, msg: 'Subscription Perks' })">
<h3 <div class="card mt-2">
class="expand-toggle" <div class="card-header">
:class="{'open': expand}" <h3
@click="expand = !expand" class="mb-0 mt-0"
> :class="{ 'open': expand }"
Subscription, Monthly Perks @click="expand = !expand"
</h3> >
<div v-if="expand"> Subscription, Monthly Perks
<form @submit.prevent="saveHero({ hero, msg: 'Subscription Perks' })"> </h3>
</div>
<div
v-if="expand"
class="card-body"
>
<div v-if="hero.purchased.plan.paymentMethod"> <div v-if="hero.purchased.plan.paymentMethod">
Payment method: Payment method:
<strong>{{ hero.purchased.plan.paymentMethod }}</strong> <strong>{{ hero.purchased.plan.paymentMethod }}</strong>
@@ -23,46 +28,72 @@
</div> </div>
<div <div
v-if="hero.purchased.plan.dateCreated" v-if="hero.purchased.plan.dateCreated"
class="form-inline" class="form-group row"
> >
<label> <label class="col-sm-3 col-form-label">
Creation date: Creation date:
<input
v-model="hero.purchased.plan.dateCreated"
class="form-control"
type="text"
> <strong class="ml-2">{{ dateFormat(hero.purchased.plan.dateCreated) }}</strong>
</label> </label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCreated) }}
</strong>
</div>
</div>
</div>
</div> </div>
<div <div
v-if="hero.purchased.plan.dateCurrentTypeCreated" v-if="hero.purchased.plan.dateCurrentTypeCreated"
class="form-inline" class="form-group row"
> >
<label> <label class="col-sm-3 col-form-label">
Start date for current subscription type: Current sub start date:
<input
v-model="hero.purchased.plan.dateCurrentTypeCreated"
class="form-control"
type="text"
>
</label> </label>
<strong class="ml-2">{{ dateFormat(hero.purchased.plan.dateCurrentTypeCreated) }}</strong> <div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCurrentTypeCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCurrentTypeCreated) }}
</strong>
</div>
</div>
</div>
</div> </div>
<div class="form-inline"> <div class="form-group row">
<label> <label class="col-sm-3 col-form-label">
Termination date: Termination date:
<div> </label>
<div class="col-sm-9">
<div class="input-group">
<input <input
v-model="hero.purchased.plan.dateTerminated" v-model="hero.purchased.plan.dateTerminated"
class="form-control" class="form-control"
type="text" 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>
</div> </div>
</label> </div>
</div> </div>
<div class="form-inline"> <div class="form-group row">
<label> <label class="col-sm-3 col-form-label">
Consecutive months: Consecutive months:
</label>
<div class="col-sm-9">
<input <input
v-model="hero.purchased.plan.consecutive.count" v-model="hero.purchased.plan.consecutive.count"
class="form-control" class="form-control"
@@ -70,11 +101,13 @@
min="0" min="0"
step="1" step="1"
> >
</label> </div>
</div> </div>
<div class="form-inline"> <div class="form-group row">
<label> <label class="col-sm-3 col-form-label">
Perk offset months: Perk offset months:
</label>
<div class="col-sm-9">
<input <input
v-model="hero.purchased.plan.consecutive.offset" v-model="hero.purchased.plan.consecutive.offset"
class="form-control" class="form-control"
@@ -82,26 +115,34 @@
min="0" min="0"
step="1" step="1"
> >
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Perk month count:
</label> </label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.perkMonthCount"
class="form-control"
type="number"
min="0"
max="2"
step="1"
>
</div>
</div> </div>
<div class="form-inline"> <div class="form-group row">
Perk month count: <label class="col-sm-3 col-form-label">
<input Next Mystic Hourglass:
v-model="hero.purchased.plan.perkMonthCount" </label>
class="form-control" <strong class="col-sm-9 col-form-label">{{ nextHourglassDate }}</strong>
type="number"
min="0"
max="2"
step="1"
>
</div> </div>
<div> <div class="form-group row">
Next Mystic Hourglass: <label class="col-sm-3 col-form-label">
<strong>{{ nextHourglassDate }}</strong>
</div>
<div class="form-inline">
<label>
Mystic Hourglasses: Mystic Hourglasses:
</label>
<div class="col-sm-9">
<input <input
v-model="hero.purchased.plan.consecutive.trinkets" v-model="hero.purchased.plan.consecutive.trinkets"
class="form-control" class="form-control"
@@ -109,11 +150,13 @@
min="0" min="0"
step="1" step="1"
> >
</label> </div>
</div> </div>
<div class="form-inline"> <div class="form-group row">
<label> <label class="col-sm-3 col-form-label">
Gem cap increase: Gem cap increase:
</label>
<div class="col-sm-9">
<input <input
v-model="hero.purchased.plan.consecutive.gemCapExtra" v-model="hero.purchased.plan.consecutive.gemCapExtra"
class="form-control" class="form-control"
@@ -122,15 +165,21 @@
max="25" max="25"
step="5" step="5"
> >
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Total Gem cap:
</label> </label>
<strong class="col-sm-9 col-form-label">
{{ Number(hero.purchased.plan.consecutive.gemCapExtra) + 25 }}
</strong>
</div> </div>
<div> <div class="form-group row">
Total Gem cap: <label class="col-sm-3 col-form-label">
<strong>{{ Number(hero.purchased.plan.consecutive.gemCapExtra) + 25 }}</strong>
</div>
<div class="form-inline">
<label>
Gems bought this month: Gems bought this month:
</label>
<div class="col-sm-9">
<input <input
v-model="hero.purchased.plan.gemsBought" v-model="hero.purchased.plan.gemsBought"
class="form-control" class="form-control"
@@ -139,43 +188,64 @@
:max="hero.purchased.plan.consecutive.gemCapExtra + 25" :max="hero.purchased.plan.consecutive.gemCapExtra + 25"
step="1" step="1"
> >
</label> </div>
</div> </div>
<div <div v-if="hero.purchased.plan.extraMonths > 0">
v-if="hero.purchased.plan.extraMonths > 0"
>
Additional credit (applied upon cancellation): Additional credit (applied upon cancellation):
<strong>{{ hero.purchased.plan.extraMonths }}</strong> <strong>{{ hero.purchased.plan.extraMonths }}</strong>
</div> </div>
<div> <div class="form-group row">
Mystery Items: <label class="col-sm-3 col-form-label">
<span Mystery Items:
v-if="hero.purchased.plan.mysteryItems.length > 0" </label>
> <div class="col-sm-9 col-form-label">
<span <span v-if="hero.purchased.plan.mysteryItems.length > 0">
v-for="(item, index) in hero.purchased.plan.mysteryItems" <span
:key="index" v-for="(item, index) in hero.purchased.plan.mysteryItems"
> :key="index"
<strong v-if="index < hero.purchased.plan.mysteryItems.length - 1"> >
{{ item }}, <strong v-if="index < hero.purchased.plan.mysteryItems.length - 1">
</strong> {{ item }},
<strong v-else> {{ item }} </strong> </strong>
<strong v-else> {{ item }} </strong>
</span>
</span> </span>
</span> <span v-else>
<span v-else> <strong>None</strong>
<strong>None</strong> </span>
</span> </div>
</div> </div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input <input
type="submit" type="submit"
value="Save" value="Save"
class="btn btn-primary mt-1" class="btn btn-primary mt-1"
> >
</form> </div>
</div> </div>
</div> </form>
</template> </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> <script>
import moment from 'moment'; import moment from 'moment';
import { getPlanContext } from '@/../../common/script/cron'; import { getPlanContext } from '@/../../common/script/cron';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ const HeroesPage = () => import(/* webpackChunkName: "hall" */'@/components/hall
// Admin Panel // Admin Panel
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel'); const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel');
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/user-support'); 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 // Except for tasks that are always loaded all the other main level
// All the main level // All the main level
@@ -193,9 +194,19 @@ const router = new VueRouter({
], ],
}, },
children: [ children: [
{
name: 'adminPanelSearch',
path: 'search/:userIdentifier',
component: AdminPanelSearchPage,
meta: {
privilegeNeeded: [
'userSupport',
],
},
},
{ {
name: 'adminPanelUser', name: 'adminPanelUser',
path: ':userIdentifier', // User ID or Username path: ':userIdentifier',
component: AdminPanelUserPage, component: AdminPanelUserPage,
meta: { meta: {
privilegeNeeded: [ 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 { flattenAndNamespace } from '@/libs/store/helpers/internals';
import * as adminPanel from './adminPanel';
import * as common from './common'; import * as common from './common';
import * as user from './user'; import * as user from './user';
import * as tasks from './tasks'; import * as tasks from './tasks';
@@ -24,6 +25,7 @@ import * as faq from './faq';
// Example: fetch in user.js -> 'user:fetch' // Example: fetch in user.js -> 'user:fetch'
const actions = flattenAndNamespace({ const actions = flattenAndNamespace({
adminPanel,
common, common,
user, user,
tasks, tasks,

View File

@@ -389,12 +389,17 @@ api.updateHero = {
hero.markModified('items'); hero.markModified('items');
} }
if (updateData.auth && updateData.auth.blocked === true) { if (updateData.auth) {
hero.auth.blocked = updateData.auth.blocked; if (updateData.auth.blocked === true) {
hero.preferences.sleep = true; // when blocking, have them rest at an inn to prevent damage 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; 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)) { if (updateData.flags && _.isBoolean(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;