A227b new admin features (#388)
* wip: adminfeatures * wip: admin features * wip: admin features, password reset, deleted forms * fix linting * fix pinting * fix bug * fix bug * remove testing * fixes on deleted forms, removed unused functions * fix pint * admin feature updated * fix linting warning * fix workspace subscription tag * Final touches * Clean console.log * Added admin logs * Fix linting --------- Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
6d50bba76b
commit
80cdce9502
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||
use App\Models\Forms\Form;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Laravel\Cashier\Cashier;
|
||||
|
||||
class AdminController extends Controller
|
||||
|
|
@ -41,9 +42,30 @@ class AdminController extends Controller
|
|||
'message' => 'You cannot fetch an admin.'
|
||||
]);
|
||||
}
|
||||
|
||||
$workspaces = $user->workspaces()
|
||||
->withCount('forms')
|
||||
->get()
|
||||
->map(function ($workspace) {
|
||||
$plan = 'free';
|
||||
if ($workspace->is_trialing) {
|
||||
$plan = 'trialing';
|
||||
}
|
||||
if ($workspace->is_pro) {
|
||||
$plan = 'pro';
|
||||
}
|
||||
if ($workspace->is_enterprise) {
|
||||
$plan = 'enterprise';
|
||||
}
|
||||
return [
|
||||
'id' => $workspace->id,
|
||||
'name' => $workspace->name,
|
||||
'plan' => $plan,
|
||||
'forms_count' => $workspace->forms_count
|
||||
];
|
||||
});
|
||||
return $this->success([
|
||||
'user' => $user
|
||||
'user' => $user,
|
||||
'workspaces' => $workspaces
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +99,7 @@ class AdminController extends Controller
|
|||
'coupon' => $couponId
|
||||
]);
|
||||
|
||||
\Log::warning(self::ADMIN_LOG_PREFIX . 'Applying NGO/Student discount to sub', [
|
||||
self::log('Applying NGO/Student discount to sub', [
|
||||
'user_id' => $user->id,
|
||||
'subcription_id' => $subscription->id,
|
||||
'coupon_id' => $couponId,
|
||||
|
|
@ -105,7 +127,7 @@ class AdminController extends Controller
|
|||
$trialEndDate = now()->addDays($request->get('number_of_day'));
|
||||
$subscription->extendTrial($trialEndDate);
|
||||
|
||||
\Log::warning(self::ADMIN_LOG_PREFIX . 'Trial extended', [
|
||||
self::log('Trial extended', [
|
||||
'user_id' => $user->id,
|
||||
'subcription_id' => $subscription->id,
|
||||
'nb_days' => $request->get('number_of_day'),
|
||||
|
|
@ -140,7 +162,7 @@ class AdminController extends Controller
|
|||
$subscription = $activeSubscriptions->first();
|
||||
$subscription->cancel();
|
||||
|
||||
\Log::warning(self::ADMIN_LOG_PREFIX . 'Cancel Subscription', [
|
||||
self::log('Cancel Subscription', [
|
||||
'user_id' => $user->id,
|
||||
'cancel_reason' => $request->get('cancellation_reason'),
|
||||
'moderator_id' => auth()->id(),
|
||||
|
|
@ -152,4 +174,31 @@ class AdminController extends Controller
|
|||
"message" => "The subscription cancellation has been successfully completed."
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendPasswordResetEmail(Request $request)
|
||||
{
|
||||
$user = User::findOrFail($request->user_id);
|
||||
|
||||
$status = Password::sendResetLink(['email' => $user->email]);
|
||||
|
||||
if ($status !== Password::RESET_LINK_SENT) {
|
||||
return $this->error([
|
||||
'message' => "Password reset email failed to send"
|
||||
]);
|
||||
}
|
||||
|
||||
self::log('Sent password reset email', [
|
||||
'user_id' => $user->id,
|
||||
'moderator_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'message' => "Password reset email has been sent to the user's email address"
|
||||
]);
|
||||
}
|
||||
|
||||
public static function log($message, $data = [])
|
||||
{
|
||||
\Log::warning(self::ADMIN_LOG_PREFIX . $message, $data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BillingController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('moderator');
|
||||
}
|
||||
|
||||
public function getEmail($userId)
|
||||
{
|
||||
$user = User::find($userId);
|
||||
|
||||
if (!$user->hasStripeId()) {
|
||||
return $this->error([
|
||||
"message" => "Stripe user not created",
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $user->asStripeCustomer();
|
||||
|
||||
return $this->success([
|
||||
'billing_email' => $user->email
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateEmail(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'user_id' => 'required',
|
||||
'billing_email' => 'required|email'
|
||||
]);
|
||||
|
||||
$user = User::findOrFail($request->get("user_id"));
|
||||
|
||||
if (!$user->hasStripeId()) {
|
||||
return $this->error([
|
||||
"message" => "Stripe user not created",
|
||||
]);
|
||||
}
|
||||
AdminController::log('Update billing email', [
|
||||
'user_id' => $user->id,
|
||||
'stripe_id' => $user->stripe_id,
|
||||
'moderator_id' => auth()->id()
|
||||
]);
|
||||
$user->updateStripeCustomer(['email' => $request->billing_email]);
|
||||
|
||||
return $this->success(['message' => 'Billing email updated successfully']);
|
||||
}
|
||||
|
||||
public function getSubscriptions($userId)
|
||||
{
|
||||
$user = User::find($userId);
|
||||
if (!$user->hasStripeId()) {
|
||||
return $this->error([
|
||||
"message" => "Stripe user not created",
|
||||
]);
|
||||
}
|
||||
$subscriptions = $user->subscriptions()->latest()->take(100)->get()->map(function ($subscription) use ($user) {
|
||||
return [
|
||||
"id" => $subscription->id,
|
||||
"stripe_id" => $subscription->stripe_id,
|
||||
"name" => ucfirst($user->name),
|
||||
"plan" => $subscription->name,
|
||||
"status" => $subscription->stripe_status,
|
||||
"creation_date" => $subscription->created_at->format('Y-m-d')
|
||||
];
|
||||
});
|
||||
return $this->success([
|
||||
'subscriptions' => $subscriptions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getPayments($userId)
|
||||
{
|
||||
$user = User::find($userId);
|
||||
if (!$user->hasStripeId()) {
|
||||
return $this->error([
|
||||
"message" => "Stripe user not created",
|
||||
]);
|
||||
}
|
||||
$payments = $user->invoices();
|
||||
$payments = $payments->map(function ($payment) use ($user) {
|
||||
return [
|
||||
"id" => $payment->id,
|
||||
"amount_paid" => ($payment->amount_paid),
|
||||
"name" => ucfirst($payment->account_name),
|
||||
"creation_date" => Carbon::parse($payment->created)->format("Y-m-d H:i:s"),
|
||||
"status" => $payment->status,
|
||||
];
|
||||
});
|
||||
return $this->success([
|
||||
'payments' => $payments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Forms\Form;
|
||||
use App\Models\User;
|
||||
|
||||
class FormController extends Controller
|
||||
{
|
||||
public function getDeletedForms($userId)
|
||||
{
|
||||
$user = User::find($userId);
|
||||
$deletedForms = $user->forms()->with('creator')->onlyTrashed()->get()->map(function ($form) {
|
||||
return [
|
||||
"id" => $form->id,
|
||||
"slug" => $form->slug,
|
||||
"title" => $form->title,
|
||||
"created_by" => $form->creator->email,
|
||||
"deleted_at" => $form->deleted_at->format('Y-m-d'),
|
||||
];
|
||||
});
|
||||
return $this->success(['forms' => $deletedForms]);
|
||||
}
|
||||
|
||||
public function restoreDeletedForm(string $slug)
|
||||
{
|
||||
$form = Form::onlyTrashed()->whereSlug($slug)->firstOrFail();
|
||||
$form->restore();
|
||||
|
||||
AdminController::log('Restore deleted form', [
|
||||
'form_id' => $form->id,
|
||||
'moderator_id' => auth()->id()
|
||||
]);
|
||||
|
||||
return $this->success(['message' => 'Form restored successfully']);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,17 +25,19 @@ class ImpersonationController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
\Log::warning(AdminController::ADMIN_LOG_PREFIX . 'Impersonation started', [
|
||||
AdminController::log('Impersonation started', [
|
||||
'from_id' => auth()->id(),
|
||||
'from_email' => auth()->user()->email,
|
||||
'target_id' => $user->id,
|
||||
'target_email' => $user->id,
|
||||
]);
|
||||
|
||||
$token = auth()->claims(auth()->user()->admin ? [] : [
|
||||
$token = auth()->claims(
|
||||
auth()->user()->admin ? [] : [
|
||||
'impersonating' => true,
|
||||
'impersonator_id' => auth()->id(),
|
||||
])->login($user);
|
||||
]
|
||||
)->login($user);
|
||||
|
||||
return $this->success([
|
||||
'token' => $token,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="w-full relative">
|
||||
<div class="w-full relative flex items-center">
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
@click="trigger"
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<slot name="title" />
|
||||
</div>
|
||||
<div
|
||||
class="text-gray-400 hover:text-gray-600 absolute -right-2 -top-1 cursor-pointer p-2"
|
||||
class="text-gray-400 hover:text-gray-600 absolute -right-2 cursor-pointer p-2"
|
||||
@click="trigger"
|
||||
>
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
<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">
|
||||
<collapse
|
||||
v-model="show"
|
||||
class="p-2 w-full"
|
||||
>
|
||||
<template #title>
|
||||
<div class="w-full flex px-4 py-2">
|
||||
<Icon
|
||||
:name="props.icon"
|
||||
class="w-6 h-6 text-nt-blue"
|
||||
|
|
@ -9,9 +14,11 @@
|
|||
{{ props.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 flex-grow">
|
||||
<slot />
|
||||
</div>
|
||||
</collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -20,4 +27,6 @@ const props = defineProps({
|
|||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true }
|
||||
})
|
||||
|
||||
const show = ref(true)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
v-if="props.user.stripe_id"
|
||||
title="Billing email"
|
||||
icon="heroicons:envelope-16-solid"
|
||||
>
|
||||
<p class="text-xs text-gray-500">
|
||||
You can update the billing email of the subscriber.
|
||||
</p>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Loader class="h-6 w-6 mx-auto m-10" />
|
||||
</div>
|
||||
<form
|
||||
v-else
|
||||
class="mt-6 space-y-6 flex flex-col justify-between"
|
||||
@submit.prevent="updateUserBillingEmail"
|
||||
>
|
||||
<div>
|
||||
<text-input
|
||||
name="billing_email"
|
||||
:form="form"
|
||||
label="Billing email"
|
||||
native-type="email"
|
||||
:required="true"
|
||||
help="Billing email"
|
||||
placeholder="Billing email"
|
||||
:disabled="!userCreated"
|
||||
/>
|
||||
<v-button
|
||||
:loading="loading"
|
||||
type="success"
|
||||
class="w-full"
|
||||
color="white"
|
||||
:disabled="!userCreated"
|
||||
>
|
||||
Update billing email
|
||||
</v-button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const loadingBillingEmail = ref(false)
|
||||
const loading = ref(false)
|
||||
const userCreated = ref(false)
|
||||
const form = useForm({
|
||||
billing_email: '',
|
||||
user_id: props.user.id
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.user.stripe_id) return
|
||||
loadingBillingEmail.value = true
|
||||
opnFetch("/moderator/billing/" + props.user.id + "/email",).then(data => {
|
||||
loadingBillingEmail.value = false
|
||||
userCreated.value = true
|
||||
form.billing_email = data.billing_email
|
||||
}).catch(error => {
|
||||
loadingBillingEmail.value = false
|
||||
userCreated.value = false
|
||||
})
|
||||
})
|
||||
|
||||
const updateUserBillingEmail = () => {
|
||||
loading.value = true
|
||||
form.patch("/moderator/billing/email")
|
||||
.then(async (data) => {
|
||||
loading.value = false
|
||||
useAlert().success(data.message)
|
||||
})
|
||||
.catch((error) => {
|
||||
useAlert().error(error.data.message)
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
v-if="props.user.stripe_id"
|
||||
title="Cancel subscription"
|
||||
icon="heroicons:trash-16-solid"
|
||||
>
|
||||
|
|
@ -55,6 +56,7 @@ const askCancel = () => {
|
|||
}
|
||||
|
||||
const cancelSubscription = () => {
|
||||
if (!props.user.stripe_id) return
|
||||
loading = true
|
||||
form
|
||||
.patch('/moderator/cancellation-subscription')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
title="Deleted forms"
|
||||
icon="heroicons:trash-16-solid"
|
||||
>
|
||||
<UTable
|
||||
:loading="loading"
|
||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||
:progress="{ color: 'primary', animation: 'carousel' }"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'No items.' }"
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
class="-mx-6"
|
||||
>
|
||||
<template #actions-data="{ row }">
|
||||
<VButton
|
||||
:loading="restoringForm"
|
||||
native-type="button"
|
||||
size="small"
|
||||
color="white"
|
||||
@click.prevent="restoreForm(row.slug)"
|
||||
>
|
||||
Restore
|
||||
</VButton>
|
||||
</template>
|
||||
</UTable>
|
||||
<div
|
||||
v-if="forms?.length > pageCount"
|
||||
class="flex justify-end px-3 py-3.5 border-t border-gray-200 dark:border-gray-700">
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:page-count="pageCount"
|
||||
:total="forms.length"
|
||||
/>
|
||||
</div>
|
||||
</AdminCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const restoringForm = ref(false)
|
||||
const forms = ref([])
|
||||
const page = ref(1)
|
||||
const pageCount = 5
|
||||
|
||||
const rows = computed(() => {
|
||||
return forms.value.slice((page.value - 1) * pageCount, (page.value) * pageCount)
|
||||
})
|
||||
onMounted(() => {
|
||||
getDeletedForms()
|
||||
})
|
||||
|
||||
const getDeletedForms = () => {
|
||||
loading.value = true
|
||||
opnFetch("/moderator/forms/" + props.user.id + "/deleted-forms",).then(data => {
|
||||
loading.value = false
|
||||
forms.value = data.forms
|
||||
}).catch(error => {
|
||||
useAlert().error(error.message)
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const restoreForm = (slug) => {
|
||||
return useAlert().confirm(
|
||||
"Are you sure you want to restore this form?",
|
||||
() => {
|
||||
restoringForm.value = true
|
||||
opnFetch("/moderator/forms/" + slug + "/restore", {
|
||||
method: 'PATCH',
|
||||
}).then(data => {
|
||||
restoringForm.value = false
|
||||
useAlert().success(data.message)
|
||||
getDeletedForms()
|
||||
}).catch(error => {
|
||||
restoringForm.value = false
|
||||
useAlert().error(error.data.message)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const columns = [{
|
||||
key: 'id',
|
||||
label: 'ID'
|
||||
}, {
|
||||
key: 'slug',
|
||||
label: 'Slug',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'created_by',
|
||||
label: 'Created by',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'deleted_at',
|
||||
label: 'Deleted at',
|
||||
sortable: true,
|
||||
}, {
|
||||
key: 'actions',
|
||||
label: 'Restore',
|
||||
sortable: false,
|
||||
}]
|
||||
|
||||
</script>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
v-if="props.user.stripe_id"
|
||||
title="Apply discount"
|
||||
icon="heroicons:tag-20-solid"
|
||||
>
|
||||
|
|
@ -36,6 +37,7 @@ const form = useForm({
|
|||
})
|
||||
|
||||
const applyDiscount = () => {
|
||||
if (!props.user.stripe_id) return
|
||||
loading = true
|
||||
form
|
||||
.patch('/moderator/apply-discount')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
v-if="props.user.stripe_id"
|
||||
title="Extend trial"
|
||||
icon="heroicons:calendar-16-solid"
|
||||
>
|
||||
|
|
@ -47,6 +48,7 @@ const form = useForm({
|
|||
})
|
||||
|
||||
const extendTrial = () => {
|
||||
if (!props.user.stripe_id) return
|
||||
loading = true
|
||||
form
|
||||
.patch('/moderator/extend-trial')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="white"
|
||||
icon="i-heroicons-key-16-solid"
|
||||
:loading="loading"
|
||||
@click="resetPassword"
|
||||
>
|
||||
Reset Password
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const form = useForm({
|
||||
user_id: props.user.id
|
||||
})
|
||||
|
||||
const resetPassword = ()=>{
|
||||
return useAlert().confirm(
|
||||
"Are you sure you want to send a password reset email?",
|
||||
() => {
|
||||
loading.value = true
|
||||
form
|
||||
.patch('/moderator/send-password-reset-email')
|
||||
.then(async (data) => {
|
||||
loading.value = false
|
||||
useAlert().success(data.message)
|
||||
})
|
||||
.catch((error) => {
|
||||
useAlert().error(error.data.message)
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
v-if="props.user.stripe_id"
|
||||
title="Payments"
|
||||
icon="heroicons:currency-dollar-16-solid"
|
||||
>
|
||||
<UTable
|
||||
:loading="loading"
|
||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||
:progress="{ color: 'primary', animation: 'carousel' }"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'No items.' }"
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
class="-mx-6"
|
||||
>
|
||||
<template #id-data="{ row }">
|
||||
<a
|
||||
:href="'https://dashboard.stripe.com/invoices/' + row.id"
|
||||
target="_blank"
|
||||
class="text-xs select-all bg-purple-50 border-purple-200 text-purple-500 rounded-md px-2 py-1 border"
|
||||
>
|
||||
<Icon
|
||||
name="bx:bxl-stripe"
|
||||
class="h-4 w-4 inline-block"
|
||||
/>
|
||||
{{ row.id }}
|
||||
</a>
|
||||
</template>
|
||||
<template #amount_paid-data="{ row }">
|
||||
<span class="font-semibold">${{ parseFloat(row.amount_paid / 100).toFixed(2) }}</span>
|
||||
</template>
|
||||
<template #status-data="{ row }">
|
||||
<span
|
||||
class="text-xs select-all rounded-md px-2 py-1 border"
|
||||
:class="row.status == 'paid' ? 'bg-green-50 border-green-200 text-green-500' : 'bg-yellow-50 border-yellow-200 text-yellow-500'"
|
||||
>
|
||||
{{ row.status }}
|
||||
</span>
|
||||
</template>
|
||||
</UTable>
|
||||
<div
|
||||
v-if="payments?.length > pageCount"
|
||||
class="flex justify-end px-3 py-3.5 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:page-count="pageCount"
|
||||
:total="payments?.length"
|
||||
/>
|
||||
</div>
|
||||
</AdminCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const props = defineProps({
|
||||
user: {type: Object, required: true}
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const payments = ref([])
|
||||
const page = ref(1)
|
||||
const pageCount = 5
|
||||
|
||||
const rows = computed(() => {
|
||||
return payments.value.slice((page.value - 1) * pageCount, (page.value) * pageCount)
|
||||
})
|
||||
onMounted(() => {
|
||||
getPayments()
|
||||
})
|
||||
|
||||
const getPayments = () => {
|
||||
if (!props.user.stripe_id) return
|
||||
loading.value = true
|
||||
opnFetch("/moderator/billing/" + props.user.id + "/payments",).then(data => {
|
||||
loading.value = false
|
||||
payments.value = data.payments
|
||||
}).catch(error => {
|
||||
useAlert().error(error.data.message)
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const columns = [{
|
||||
key: 'id',
|
||||
label: 'ID'
|
||||
}, {
|
||||
key: 'amount_paid',
|
||||
label: 'Amount paid',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'creation_date',
|
||||
label: 'Creation date',
|
||||
sortable: true
|
||||
}]
|
||||
|
||||
</script>
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
v-if="props.user.stripe_id"
|
||||
title="Subscriptions"
|
||||
icon="heroicons:credit-card-16-solid"
|
||||
>
|
||||
<UTable
|
||||
:loading="loading"
|
||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||
:progress="{ color: 'primary', animation: 'carousel' }"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'No items.' }"
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
class="-mx-6"
|
||||
>
|
||||
<template #stripe_id-data="{ row }">
|
||||
<a
|
||||
:href="'https://dashboard.stripe.com/subscriptions/' + row.stripe_id"
|
||||
target="_blank"
|
||||
class="text-xs select-all bg-purple-50 border-purple-200 text-purple-500 rounded-md px-2 py-1 border"
|
||||
>
|
||||
<Icon
|
||||
name="bx:bxl-stripe"
|
||||
class="h-4 w-4 inline-block"
|
||||
/>
|
||||
{{ row.stripe_id }}
|
||||
</a>
|
||||
</template>
|
||||
<template #status-data="{ row }">
|
||||
<span
|
||||
class="text-xs select-all rounded-md px-2 py-1 border"
|
||||
:class="row.status == 'active' ? 'bg-green-50 border-green-200 text-green-500' : 'bg-yellow-50 border-yellow-200 text-yellow-500'"
|
||||
>
|
||||
{{ row.status }}
|
||||
</span>
|
||||
</template>
|
||||
</UTable>
|
||||
<div
|
||||
v-if="subscriptions?.length > pageCount"
|
||||
class="flex justify-end px-3 py-3.5 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:page-count="pageCount"
|
||||
:total="subscriptions.length"
|
||||
/>
|
||||
</div>
|
||||
</AdminCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const subscriptions = ref([])
|
||||
const page = ref(1)
|
||||
const pageCount = 5
|
||||
|
||||
const rows = computed(() => {
|
||||
return subscriptions.value.slice((page.value - 1) * pageCount, (page.value) * pageCount)
|
||||
})
|
||||
onMounted(() => {
|
||||
getSubscriptions()
|
||||
})
|
||||
|
||||
const getSubscriptions = () => {
|
||||
if (!props.user.stripe_id) return
|
||||
loading.value = true
|
||||
opnFetch("/moderator/billing/" + props.user.id + "/subscriptions",).then(data => {
|
||||
loading.value = false
|
||||
subscriptions.value = data.subscriptions
|
||||
}).catch(error => {
|
||||
useAlert().error(error.data.message)
|
||||
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const columns = [{
|
||||
key: 'id',
|
||||
label: 'ID'
|
||||
}, {
|
||||
key: 'stripe_id',
|
||||
label: 'Stripe ID'
|
||||
}, {
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'creation_date',
|
||||
label: 'Creation date',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'plan',
|
||||
label: 'Plan',
|
||||
sortable: true,
|
||||
direction: 'desc'
|
||||
}, {
|
||||
key: 'status',
|
||||
label: 'Status'
|
||||
}]
|
||||
|
||||
</script>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<AdminCard
|
||||
title="Workspaces"
|
||||
icon="heroicons:globe-alt"
|
||||
>
|
||||
<UTable
|
||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||
:progress="{ color: 'primary', animation: 'carousel' }"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'No items.' }"
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
class="-mx-6"
|
||||
>
|
||||
<template #plan-data="{ row }">
|
||||
<span
|
||||
class="text-xs select-all rounded-md px-2 py-1 border"
|
||||
:class="userPlanStyles(row.plan)"
|
||||
>
|
||||
{{ row.plan }}
|
||||
</span>
|
||||
</template>
|
||||
</UTable>
|
||||
<div
|
||||
v-if="workspaces?.length > pageCount"
|
||||
class="flex justify-end px-3 py-3.5 border-t border-gray-200 dark:border-gray-700">
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:page-count="pageCount"
|
||||
:total="workspaces?.length"
|
||||
/>
|
||||
</div>
|
||||
</AdminCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const workspaces = ref([])
|
||||
const page = ref(1)
|
||||
const pageCount = 2
|
||||
|
||||
const rows = computed(() => {
|
||||
return props.user.workspaces.slice((page.value - 1) * pageCount, (page.value) * pageCount)
|
||||
})
|
||||
|
||||
|
||||
const columns = [{
|
||||
key: 'id',
|
||||
label: 'ID'
|
||||
}, {
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'plan',
|
||||
label: 'Plan',
|
||||
sortable: true
|
||||
}, {
|
||||
key: 'forms_count',
|
||||
label: '# of forms',
|
||||
sortable: true
|
||||
}]
|
||||
|
||||
function userPlanStyles(plan) {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
return 'capitalize text-xs select-all bg-green-50 rounded-md px-2 py-1 border border-green-200 text-green-500'
|
||||
case 'enterprise':
|
||||
return 'capitalize text-xs select-all bg-blue-50 rounded-md px-2 py-1 border border-blue-200 text-blue-500'
|
||||
default:
|
||||
return 'capitalize text-xs select-all bg-gray-50 rounded-md px-2 py-1 border'
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
|
@ -44,7 +44,6 @@ export default {
|
|||
script.setAttribute('defer', 'defer')
|
||||
document.head.appendChild(script)
|
||||
script.addEventListener('load', () => {
|
||||
console.log('resizeing')
|
||||
window.iFrameResize(
|
||||
{
|
||||
log: false,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<div
|
||||
v-if="userInfo"
|
||||
class="flex gap-2 items-center"
|
||||
class="flex gap-2 items-center flex-wrap"
|
||||
>
|
||||
<h1 class="text-xl">
|
||||
{{ userInfo.name }}
|
||||
|
|
@ -25,6 +25,12 @@
|
|||
/>
|
||||
{{ userInfo.stripe_id }}
|
||||
</a>
|
||||
<div
|
||||
v-if="userPlan"
|
||||
:class="userPlanStyles"
|
||||
>
|
||||
{{ userPlan }}
|
||||
</div>
|
||||
</div>
|
||||
<h3
|
||||
v-else
|
||||
|
|
@ -67,6 +73,7 @@
|
|||
class="flex gap-1 my-4"
|
||||
>
|
||||
<impersonate-user :user="userInfo" />
|
||||
<send-password-reset-email :user="userInfo" />
|
||||
</div>
|
||||
<div
|
||||
class="w-full grid gap-2 grid-cols-1 lg:grid-cols-2"
|
||||
|
|
@ -80,6 +87,24 @@
|
|||
<cancel-subscription
|
||||
:user="userInfo"
|
||||
/>
|
||||
<billing-email
|
||||
:user="userInfo"
|
||||
/>
|
||||
<user-workspaces
|
||||
:user="userInfo"
|
||||
/>
|
||||
<user-subscriptions
|
||||
:user="userInfo"
|
||||
class="lg:col-span-2"
|
||||
/>
|
||||
<user-payments
|
||||
:user="userInfo"
|
||||
class="lg:col-span-2"
|
||||
/>
|
||||
<deleted-forms
|
||||
:user="userInfo"
|
||||
class="lg:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -107,6 +132,7 @@ export default {
|
|||
|
||||
data: () => ({
|
||||
userInfo: null,
|
||||
userPlan: 'free',
|
||||
fetchUserForm: useForm({
|
||||
identifier: ''
|
||||
}),
|
||||
|
|
@ -114,8 +140,15 @@ export default {
|
|||
}),
|
||||
|
||||
computed: {
|
||||
isAdmin () {
|
||||
return this.user.admin
|
||||
userPlanStyles() {
|
||||
switch (this.userPlan) {
|
||||
case 'pro':
|
||||
return 'capitalize text-xs select-all bg-green-50 rounded-md px-2 py-1 border border-green-200 text-green-500'
|
||||
case 'enterprise':
|
||||
return 'capitalize text-xs select-all bg-blue-50 rounded-md px-2 py-1 border border-blue-200 text-blue-500'
|
||||
default:
|
||||
return 'capitalize text-xs select-all bg-gray-50 rounded-md px-2 py-1 border'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -129,6 +162,9 @@ export default {
|
|||
if (params.user_id) {
|
||||
this.fetchUserForm.identifier = params.user_id
|
||||
}
|
||||
if (this.fetchUserForm.identifier) {
|
||||
this.fetchUser()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -141,13 +177,22 @@ export default {
|
|||
this.loading = true
|
||||
opnFetch(`/moderator/fetch-user/${encodeURI(this.fetchUserForm.identifier)}`).then(async (data) => {
|
||||
this.loading = false
|
||||
this.userInfo = data.user
|
||||
this.userInfo = { ...data.user, workspaces: data.workspaces }
|
||||
this.getUserPlan(data.workspaces)
|
||||
this.useAlert.success(`User Fetched: ${this.userInfo.name}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.useAlert.error(error.data.message)
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
|
||||
getUserPlan(workspaces) {
|
||||
if (workspaces.some(w => w.plan === 'enterprise')) {
|
||||
this.userPlan = 'enterprise'
|
||||
} else if (workspaces.some(w => w.plan === 'pro')) {
|
||||
this.userPlan = 'pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,23 @@ Route::group(['middleware' => 'auth:api'], function () {
|
|||
'cancellation-subscription',
|
||||
[\App\Http\Controllers\Admin\AdminController::class, 'cancelSubscription']
|
||||
);
|
||||
|
||||
Route::patch(
|
||||
'send-password-reset-email',
|
||||
[\App\Http\Controllers\Admin\AdminController::class, 'sendPasswordResetEmail']
|
||||
);
|
||||
|
||||
Route::group(['prefix' => 'billing'], function () {
|
||||
Route::get('{userId}/email', [\App\Http\Controllers\Admin\BillingController::class, 'getEmail']);
|
||||
Route::patch('/email', [\App\Http\Controllers\Admin\BillingController::class, 'updateEmail']);
|
||||
Route::get('{userId}/subscriptions', [\App\Http\Controllers\Admin\BillingController::class, 'getSubscriptions']);
|
||||
Route::get('{userId}/payments', [\App\Http\Controllers\Admin\BillingController::class, 'getPayments']);
|
||||
});
|
||||
|
||||
Route::group(['prefix' => 'forms'], function () {
|
||||
Route::get('{userId}/deleted-forms', [\App\Http\Controllers\Admin\FormController::class, 'getDeletedForms']);
|
||||
Route::patch('{slug}/restore', [\App\Http\Controllers\Admin\FormController::class, 'restoreDeletedForm']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue