Refactor Admin Panel with more features (#384)

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala 2024-04-22 19:41:21 +05:30 committed by GitHub
parent eeb3ec3b77
commit 053a4a4976
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 586 additions and 111 deletions

View File

@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Cashier\Cashier;
class AdminController extends Controller
{
public const ADMIN_LOG_PREFIX = '[admin_action] ';
public function __construct()
{
$this->middleware('moderator');
}
public function fetchUser($identifier)
{
$user = null;
if (is_numeric($identifier)) {
$user = User::find($identifier);
} elseif (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
$user = User::whereEmail($identifier)->first();
} else {
// Find by form slug
$form = Form::whereSlug($identifier)->first();
if ($form) {
$user = $form->creator;
}
}
if (!$user) {
return $this->error([
'message' => 'User not found.'
]);
} elseif ($user->admin) {
return $this->error([
'message' => 'You cannot fetch an admin.'
]);
}
return $this->success([
'user' => $user
]);
}
public function applyDiscount(Request $request)
{
$request->validate([
'user_id' => 'required'
]);
$user = User::find($request->get("user_id"));
$activeSubscriptions = $user->subscriptions()->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
})->get();
if ($activeSubscriptions->count() != 1) {
return $this->error([
"message" => "The user has more than one active subscriptions or doesn't have one."
]);
}
$couponId = config('pricing.discount_coupon_id');
if (is_null($couponId)) {
return $this->error([
"message" => "Coupon id not defined."
]);
}
$subscription = $activeSubscriptions->first();
Cashier::stripe()->subscriptions->update($subscription->stripe_id, [
'coupon' => $couponId
]);
\Log::warning(self::ADMIN_LOG_PREFIX . 'Applying NGO/Student discount to sub', [
'user_id' => $user->id,
'subcription_id' => $subscription->id,
'coupon_id' => $couponId,
'subscription_stripe_id' => $subscription->stripe_id,
'moderator_id' => auth()->id(),
]);
return $this->success([
"message" => "40% Discount applied for the next 12 months."
]);
}
public function extendTrial(Request $request)
{
$request->validate([
'user_id' => 'required',
'number_of_day' => 'required|numeric|max:14'
]);
$user = User::find($request->get("user_id"));
$subscription = $user->subscriptions()
->where('stripe_status', 'trialing')
->firstOrFail();
$trialEndDate = now()->addDays($request->get('number_of_day'));
$subscription->extendTrial($trialEndDate);
\Log::warning(self::ADMIN_LOG_PREFIX . 'Trial extended', [
'user_id' => $user->id,
'subcription_id' => $subscription->id,
'nb_days' => $request->get('number_of_day'),
'subscription_stripe_id' => $subscription->stripe_id,
'moderator_id' => auth()->id(),
]);
return $this->success([
"message" => "Subscription trial extend until the " . $trialEndDate->format('d/m/Y')
]);
}
public function cancelSubscription(Request $request)
{
$request->validate([
'user_id' => 'required',
'cancellation_reason' => 'required'
]);
$user = User::find($request->get("user_id"));
$activeSubscriptions = $user->subscriptions()->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
})->get();
if ($activeSubscriptions->count() != 1) {
return $this->error([
"message" => "The user has more than one active subscriptions or doesn't have one."
]);
}
$subscription = $activeSubscriptions->first();
$subscription->cancel();
\Log::warning(self::ADMIN_LOG_PREFIX . 'Cancel Subscription', [
'user_id' => $user->id,
'cancel_reason' => $request->get('cancellation_reason'),
'moderator_id' => auth()->id(),
'subcription_id' => $subscription->id,
'subscription_stripe_id' => $subscription->stripe_id
]);
return $this->success([
"message" => "The subscription cancellation has been successfully completed."
]);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use App\Models\User; use App\Models\User;
class ImpersonationController extends Controller class ImpersonationController extends Controller
@ -13,22 +12,10 @@ class ImpersonationController extends Controller
$this->middleware('moderator'); $this->middleware('moderator');
} }
public function impersonate($identifier) public function impersonate($userId)
{ {
$user = null; $user = User::find($userId);
if (is_numeric($identifier)) { if (!$user) {
$user = User::find($identifier);
} elseif (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
$user = User::whereEmail($identifier)->first();
} else {
// Find by form slug
$form = Form::whereSlug($identifier)->first();
if ($form) {
$user = $form->creator;
}
}
if (! $user) {
return $this->error([ return $this->error([
'message' => 'User not found.', 'message' => 'User not found.',
]); ]);
@ -38,7 +25,7 @@ class ImpersonationController extends Controller
]); ]);
} }
\Log::warning('Impersonation started', [ \Log::warning(AdminController::ADMIN_LOG_PREFIX . 'Impersonation started', [
'from_id' => auth()->id(), 'from_id' => auth()->id(),
'from_email' => auth()->user()->email, 'from_email' => auth()->user()->email,
'target_id' => $user->id, 'target_id' => $user->id,

View File

@ -146,6 +146,14 @@ export default {
"ring-offset": "focus:ring-offset-gray-200", "ring-offset": "focus:ring-offset-gray-200",
text: "text-gray-500 hover:text-white", text: "text-gray-500 hover:text-white",
} }
} else if (this.color === "outline-red") {
return {
main: "bg-white border border-gray-300 text-red-700",
hover: "hover:bg-gray-100 hover:text-red-500",
ring: "focus:ring-gray-500",
"ring-offset": "focus:ring-offset-gray-200",
text: "text-gray-500",
}
} else if (this.color === "red") { } else if (this.color === "red") {
return { return {
main: "bg-red-600", main: "bg-red-600",

View File

@ -0,0 +1,23 @@
<template>
<div class="w-full bg-white border border-gray-200 rounded-lg shadow flex flex-col">
<div class="w-full flex border-b px-4 py-2">
<Icon
:name="props.icon"
class="w-6 h-6 text-nt-blue"
/>
<h3 class="text-md font-semibold ml-2">
{{ props.title }}
</h3>
</div>
<div class="p-4 flex-grow">
<slot />
</div>
</div>
</template>
<script setup>
const props = defineProps({
title: { type: String, required: true },
icon: { type: String, required: true }
})
</script>

View File

@ -0,0 +1,71 @@
<template>
<AdminCard
title="Cancel subscription"
icon="heroicons:trash-16-solid"
>
<form
class="space-y-6 flex flex-col h-full justify-between"
@submit.prevent="askCancel"
>
<p class="text-xs text-gray-500">
Ideally customers should cancel subscription themselves via the UI. If
you cancel the subscription for them, please provide a reason.
</p>
<div>
<text-input
name="cancellation_reason"
:form="form"
label="Cancellation reason"
native-type="reason"
:required="true"
help="Cancellation reason"
/>
<v-button
:loading="loading"
type="success"
class="w-full"
color="outline-red"
>
<Icon
class="inline w-4 h-4 text-red-600"
name="heroicons:exclamation-triangle-16-solid"
/>
Cancel subscription now
</v-button>
</div>
</form>
</AdminCard>
</template>
<script setup>
const props = defineProps({
user: { type: Object, required: true }
})
const alert = useAlert()
let loading = ref(false)
const form = useForm({
user_id: props.user.id,
cancellation_reason: ''
})
const askCancel = () => {
alert.confirm('Are you sure? This will cancel the subscription for this user.', cancelSubscription)
}
const cancelSubscription = () => {
loading = true
form
.patch('/moderator/cancellation-subscription')
.then(async (data) => {
loading = false
alert.success(data.message)
})
.catch((error) => {
alert.error(error.data.message)
loading = false
})
}
</script>

View File

@ -0,0 +1,52 @@
<template>
<AdminCard
title="Apply discount"
icon="heroicons:tag-20-solid"
>
<form
class="space-y-6 flex flex-col justify-between"
@submit.prevent="applyDiscount"
>
<p class="text-xs text-gray-500">
This is only for students, academics and NGOs. Make sure to verify
their status before applying discount (student/university email, NGO
website, proof of non-profit, etc). They need to create their
subscriptions before you can apply the 40% discount.
</p>
<v-button
:loading="loading"
type="success"
class="w-full"
color="white"
>
Apply Discount
</v-button>
</form>
</AdminCard>
</template>
<script setup>
const props = defineProps({
user: { type: Object, required: true }
})
let loading = ref(false)
const form = useForm({
user_id: props.user.id
})
const applyDiscount = () => {
loading = true
form
.patch('/moderator/apply-discount')
.then(async (data) => {
loading = false
useAlert().success(data.message)
})
.catch((error) => {
useAlert().error(error.data.message)
loading = false
})
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<AdminCard
title="Extend trial"
icon="heroicons:calendar-16-solid"
>
<form
class="space-y-6 flex flex-col justify-between"
@submit.prevent="extendTrial"
>
<p class="text-xs text-gray-500">
You can extend the trial of subscribers that are still in the trial
period. Usually, you should not offer more than 7 days of trial, but
you can add up to 14 days if needed.
</p>
<div>
<text-input
name="number_of_day"
:form="form"
label="Number of days"
native-type="day"
:required="true"
help="Number Of Days"
placeholder="7"
/>
<v-button
:loading="loading"
type="success"
class="w-full"
color="white"
>
Apply Extend Trial
</v-button>
</div>
</form>
</AdminCard>
</template>
<script setup>
const props = defineProps({
user: { type: Object, required: true }
})
let loading = ref(false)
const form = useForm({
user_id: props.user.id,
number_of_day: ''
})
const extendTrial = () => {
loading = true
form
.patch('/moderator/extend-trial')
.then(async (data) => {
loading = false
useAlert().success(data.message)
})
.catch((error) => {
useAlert().error(error.data.message)
loading = false
})
}
</script>

View File

@ -0,0 +1,53 @@
<template>
<UButton
size="sm"
color="white"
icon="i-heroicons-eye-16-solid"
@click="impersonate"
>
Impersonate User
</UButton>
</template>
<script setup>
const props = defineProps({
user: { type: Object, required: true }
})
const authStore = useAuthStore()
const formsStore = useFormsStore()
const workspacesStore = useWorkspacesStore()
let loading = ref(false)
const impersonate = () => {
loading = true
authStore.startImpersonating()
opnFetch(`/moderator/impersonate/${props.user.id}`).then(async (data) => {
loading = false
// Save the token.
authStore.setToken(data.token, false)
// Fetch the user.
const userData = await opnFetch('user')
authStore.setUser(userData)
// Redirect to the dashboard.
formsStore.set([])
workspacesStore.set([])
const workspaces = await fetchAllWorkspaces()
workspacesStore.set(workspaces.data.value)
formsStore.startLoading()
formsStore.loadAll(workspacesStore.currentId)
useAlert().success(`Impersonating ${authStore.user.name}`)
useRouter().push({ name: 'home' })
})
.catch((error) => {
useAlert().error(error.data.message)
loading = false
})
}
</script>

View File

@ -1,108 +1,154 @@
<template> <template>
<div> <div>
<h3 class="font-semibold text-2xl text-gray-900"> <div
Admin settings v-if="userInfo"
</h3> class="flex gap-2 items-center"
<small class="text-gray-600">Manage settings.</small>
<h3 class="mt-3 text-lg font-semibold mb-4">
Tools
</h3>
<div class="flex flex-wrap mb-5">
<a
:href="statsUrl"
target="_blank"
> >
<v-button <h1 class="text-xl">
class="mx-1" {{ userInfo.name }}
color="gray" </h1>
shade="lighter" <div class="text-xs select-all bg-gray-50 rounded-md px-2 py-1 border">
> Stats </v-button> {{ userInfo.id }}
</a> </div>
<div class="text-xs select-all bg-gray-50 rounded-md px-2 py-1 border">
{{ userInfo.email }}
</div>
<a <a
:href="horizonUrl" v-if="userInfo.stripe_id"
:href="'https://dashboard.stripe.com/customers/'+userInfo.stripe_id"
target="_blank" target="_blank"
class="text-xs select-all bg-purple-50 border-purple-200 text-purple-500 rounded-md px-2 py-1 border"
> >
<v-button <Icon
class="mx-1" name="bx:bxl-stripe"
color="gray" class="h-4 w-4 inline-block"
shade="lighter" />
> Horizon </v-button> {{ userInfo.stripe_id }}
</a> </a>
</div> </div>
<h3 class="text-lg font-semibold mb-4"> <h3
Impersonate User v-else
</h3> class="font-semibold text-2xl text-gray-900 mb-4"
<form >
@submit.prevent="impersonate" Admin settings
@keydown="form.onKeydown($event)" </h3>
<template v-if="!userInfo">
<form
class="pb-8 max-w-lg"
@submit.prevent="fetchUser"
@keydown="fetchUserForm.onKeydown($event)"
> >
<!-- Password -->
<text-input <text-input
name="identifier" name="identifier"
:form="form" :form="fetchUserForm"
label="Identifier" label="Identifier"
:required="true" :required="true"
help="User Id, User Email or Form Slug" help="User Id, User Email, Form Slug or View Slug"
/> />
<!-- Submit Button -->
<v-button <v-button
:loading="loading" :loading="loading"
class="mt-4" type="success"
color="blue"
class="mt-4 w-full"
> >
Impersonate User Fetch User
</v-button> </v-button>
</form> </form>
</template>
<div
v-else
class="flex flex-col"
>
<div
id="admin-buttons"
class="flex gap-1 my-4"
>
<impersonate-user :user="userInfo" />
</div>
<div
class="w-full grid gap-2 grid-cols-1 lg:grid-cols-2"
>
<discount-on-subscription
:user="userInfo"
/>
<extend-trial
:user="userInfo"
/>
<cancel-subscription
:user="userInfo"
/>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script>
import { useRouter } from "vue-router" import { computed } from 'vue'
import { opnFetch } from "~/composables/useOpnApi.js"
import { fetchAllWorkspaces } from "~/stores/workspaces.js"
definePageMeta({ export default {
middleware: "moderator", setup () {
}) useOpnSeoMeta({
title: 'Admin'
})
definePageMeta({
middleware: 'moderator'
})
useOpnSeoMeta({ const authStore = useAuthStore()
title: "Admin", return {
}) authStore,
user: computed(() => authStore.user),
useAlert: useAlert()
}
},
const authStore = useAuthStore() data: () => ({
const workspacesStore = useWorkspacesStore() userInfo: null,
const router = useRouter() fetchUserForm: useForm({
const form = useForm({ identifier: ''
identifier: "", }),
}) loading: false
const loading = ref(false) }),
const runtimeConfig = useRuntimeConfig() computed: {
const statsUrl = runtimeConfig.public.apiBase + "/stats" isAdmin () {
const horizonUrl = runtimeConfig.public.apiBase + "/horizon" return this.user.admin
}
},
const impersonate = () => { mounted () {
loading.value = true // Shortcut link to impersonate users
authStore.startImpersonating() const urlSearchParams = new URLSearchParams(window.location.search)
opnFetch("/admin/impersonate/" + encodeURI(form.identifier)) const params = Object.fromEntries(urlSearchParams.entries())
.then(async (data) => { if (params.impersonate) {
// Save the token. this.fetchUserForm.identifier = params.impersonate
authStore.setToken(data.token, false) }
if (params.user_id) {
this.fetchUserForm.identifier = params.user_id
}
},
// Fetch the user. methods: {
const userData = await opnFetch("user") async fetchUser () {
authStore.setUser(userData) if (!this.fetchUserForm.identifier) {
const workspaces = await fetchAllWorkspaces() this.useAlert.error('Identifier is required.')
workspacesStore.set(workspaces.data.value) return
loading.value = false }
router.push({ name: "home" }) this.loading = true
opnFetch(`/moderator/fetch-user/${encodeURI(this.fetchUserForm.identifier)}`).then(async (data) => {
this.loading = false
this.userInfo = data.user
this.useAlert.success(`User Fetched: ${this.userInfo.name}`)
}) })
.catch((error) => { .catch((error) => {
console.error(error) this.useAlert.error(error.data.message)
useAlert().error(error.data.message) this.loading = false
loading.value = false
}) })
}
}
} }
</script> </script>

View File

@ -21,7 +21,7 @@ export const useAuthStore = defineStore("auth", {
// Stop admin impersonation // Stop admin impersonation
stopImpersonating() { stopImpersonating() {
this.setToken(this.admin_token) this.setToken(this.admin_token)
this.admin_token = null this.setAdminToken(null)
}, },
setToken(token) { setToken(token) {

View File

@ -22,4 +22,5 @@ return [
], ],
], ],
'discount_coupon_id' => env('STRIPE_DISCOUNT_COUPON_ID', null),
]; ];

View File

@ -160,11 +160,27 @@ Route::group(['middleware' => 'auth:api'], function () {
}); });
}); });
Route::group(['middleware' => 'moderator', 'prefix' => 'admin'], function () { Route::group(['middleware' => 'moderator', 'prefix' => 'moderator'], function () {
Route::get( Route::get(
'impersonate/{identifier}', 'fetch-user/{identifier}',
[\App\Http\Controllers\Admin\AdminController::class, 'fetchUser']
);
Route::get(
'impersonate/{userId}',
[\App\Http\Controllers\Admin\ImpersonationController::class, 'impersonate'] [\App\Http\Controllers\Admin\ImpersonationController::class, 'impersonate']
); );
Route::patch(
'apply-discount',
[\App\Http\Controllers\Admin\AdminController::class, 'applyDiscount']
);
Route::patch(
'extend-trial',
[\App\Http\Controllers\Admin\AdminController::class, 'extendTrial']
);
Route::patch(
'cancellation-subscription',
[\App\Http\Controllers\Admin\AdminController::class, 'cancelSubscription']
);
}); });
}); });