Refactor Admin Panel with more features (#384)
Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
eeb3ec3b77
commit
053a4a4976
|
|
@ -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."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,5 @@ return [
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'discount_coupon_id' => env('STRIPE_DISCOUNT_COUPON_ID', null),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue