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:
Favour Olayinka 2024-05-06 13:12:05 +01:00 committed by GitHub
parent 6d50bba76b
commit 80cdce9502
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 831 additions and 34 deletions

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Forms\Form; use App\Models\Forms\Form;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
class AdminController extends Controller class AdminController extends Controller
@ -41,9 +42,30 @@ class AdminController extends Controller
'message' => 'You cannot fetch an admin.' '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([ return $this->success([
'user' => $user 'user' => $user,
'workspaces' => $workspaces
]); ]);
} }
@ -77,7 +99,7 @@ class AdminController extends Controller
'coupon' => $couponId '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, 'user_id' => $user->id,
'subcription_id' => $subscription->id, 'subcription_id' => $subscription->id,
'coupon_id' => $couponId, 'coupon_id' => $couponId,
@ -105,7 +127,7 @@ class AdminController extends Controller
$trialEndDate = now()->addDays($request->get('number_of_day')); $trialEndDate = now()->addDays($request->get('number_of_day'));
$subscription->extendTrial($trialEndDate); $subscription->extendTrial($trialEndDate);
\Log::warning(self::ADMIN_LOG_PREFIX . 'Trial extended', [ self::log('Trial extended', [
'user_id' => $user->id, 'user_id' => $user->id,
'subcription_id' => $subscription->id, 'subcription_id' => $subscription->id,
'nb_days' => $request->get('number_of_day'), 'nb_days' => $request->get('number_of_day'),
@ -140,7 +162,7 @@ class AdminController extends Controller
$subscription = $activeSubscriptions->first(); $subscription = $activeSubscriptions->first();
$subscription->cancel(); $subscription->cancel();
\Log::warning(self::ADMIN_LOG_PREFIX . 'Cancel Subscription', [ self::log('Cancel Subscription', [
'user_id' => $user->id, 'user_id' => $user->id,
'cancel_reason' => $request->get('cancellation_reason'), 'cancel_reason' => $request->get('cancellation_reason'),
'moderator_id' => auth()->id(), 'moderator_id' => auth()->id(),
@ -152,4 +174,31 @@ class AdminController extends Controller
"message" => "The subscription cancellation has been successfully completed." "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);
}
} }

View File

@ -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,
]);
}
}

View File

@ -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']);
}
}

View File

@ -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_id' => auth()->id(),
'from_email' => auth()->user()->email, 'from_email' => auth()->user()->email,
'target_id' => $user->id, 'target_id' => $user->id,
'target_email' => $user->id, 'target_email' => $user->id,
]); ]);
$token = auth()->claims(auth()->user()->admin ? [] : [ $token = auth()->claims(
auth()->user()->admin ? [] : [
'impersonating' => true, 'impersonating' => true,
'impersonator_id' => auth()->id(), 'impersonator_id' => auth()->id(),
])->login($user); ]
)->login($user);
return $this->success([ return $this->success([
'token' => $token, 'token' => $token,

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="w-full relative"> <div class="w-full relative flex items-center">
<div <div
class="cursor-pointer" class="cursor-pointer"
@click="trigger" @click="trigger"
@ -8,7 +8,7 @@
<slot name="title" /> <slot name="title" />
</div> </div>
<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" @click="trigger"
> >
<svg <svg

View File

@ -1,6 +1,11 @@
<template> <template>
<div class="w-full bg-white border border-gray-200 rounded-lg shadow flex flex-col"> <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 <Icon
:name="props.icon" :name="props.icon"
class="w-6 h-6 text-nt-blue" class="w-6 h-6 text-nt-blue"
@ -9,9 +14,11 @@
{{ props.title }} {{ props.title }}
</h3> </h3>
</div> </div>
</template>
<div class="p-4 flex-grow"> <div class="p-4 flex-grow">
<slot /> <slot />
</div> </div>
</collapse>
</div> </div>
</template> </template>
@ -20,4 +27,6 @@ const props = defineProps({
title: { type: String, required: true }, title: { type: String, required: true },
icon: { type: String, required: true } icon: { type: String, required: true }
}) })
const show = ref(true)
</script> </script>

View File

@ -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>

View File

@ -1,5 +1,6 @@
<template> <template>
<AdminCard <AdminCard
v-if="props.user.stripe_id"
title="Cancel subscription" title="Cancel subscription"
icon="heroicons:trash-16-solid" icon="heroicons:trash-16-solid"
> >
@ -55,6 +56,7 @@ const askCancel = () => {
} }
const cancelSubscription = () => { const cancelSubscription = () => {
if (!props.user.stripe_id) return
loading = true loading = true
form form
.patch('/moderator/cancellation-subscription') .patch('/moderator/cancellation-subscription')

View File

@ -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>

View File

@ -1,5 +1,6 @@
<template> <template>
<AdminCard <AdminCard
v-if="props.user.stripe_id"
title="Apply discount" title="Apply discount"
icon="heroicons:tag-20-solid" icon="heroicons:tag-20-solid"
> >
@ -36,6 +37,7 @@ const form = useForm({
}) })
const applyDiscount = () => { const applyDiscount = () => {
if (!props.user.stripe_id) return
loading = true loading = true
form form
.patch('/moderator/apply-discount') .patch('/moderator/apply-discount')

View File

@ -1,5 +1,6 @@
<template> <template>
<AdminCard <AdminCard
v-if="props.user.stripe_id"
title="Extend trial" title="Extend trial"
icon="heroicons:calendar-16-solid" icon="heroicons:calendar-16-solid"
> >
@ -47,6 +48,7 @@ const form = useForm({
}) })
const extendTrial = () => { const extendTrial = () => {
if (!props.user.stripe_id) return
loading = true loading = true
form form
.patch('/moderator/extend-trial') .patch('/moderator/extend-trial')

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -44,7 +44,6 @@ export default {
script.setAttribute('defer', 'defer') script.setAttribute('defer', 'defer')
document.head.appendChild(script) document.head.appendChild(script)
script.addEventListener('load', () => { script.addEventListener('load', () => {
console.log('resizeing')
window.iFrameResize( window.iFrameResize(
{ {
log: false, log: false,

View File

@ -1,8 +1,8 @@
<template> <template>
<div> <div class="mb-8">
<div <div
v-if="userInfo" v-if="userInfo"
class="flex gap-2 items-center" class="flex gap-2 items-center flex-wrap"
> >
<h1 class="text-xl"> <h1 class="text-xl">
{{ userInfo.name }} {{ userInfo.name }}
@ -25,6 +25,12 @@
/> />
{{ userInfo.stripe_id }} {{ userInfo.stripe_id }}
</a> </a>
<div
v-if="userPlan"
:class="userPlanStyles"
>
{{ userPlan }}
</div>
</div> </div>
<h3 <h3
v-else v-else
@ -67,6 +73,7 @@
class="flex gap-1 my-4" class="flex gap-1 my-4"
> >
<impersonate-user :user="userInfo" /> <impersonate-user :user="userInfo" />
<send-password-reset-email :user="userInfo" />
</div> </div>
<div <div
class="w-full grid gap-2 grid-cols-1 lg:grid-cols-2" class="w-full grid gap-2 grid-cols-1 lg:grid-cols-2"
@ -80,6 +87,24 @@
<cancel-subscription <cancel-subscription
:user="userInfo" :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> </div>
</div> </div>
@ -89,7 +114,7 @@
import { computed } from 'vue' import { computed } from 'vue'
export default { export default {
setup () { setup() {
useOpnSeoMeta({ useOpnSeoMeta({
title: 'Admin' title: 'Admin'
}) })
@ -107,6 +132,7 @@ export default {
data: () => ({ data: () => ({
userInfo: null, userInfo: null,
userPlan: 'free',
fetchUserForm: useForm({ fetchUserForm: useForm({
identifier: '' identifier: ''
}), }),
@ -114,12 +140,19 @@ export default {
}), }),
computed: { computed: {
isAdmin () { userPlanStyles() {
return this.user.admin 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'
}
} }
}, },
mounted () { mounted() {
// Shortcut link to impersonate users // Shortcut link to impersonate users
const urlSearchParams = new URLSearchParams(window.location.search) const urlSearchParams = new URLSearchParams(window.location.search)
const params = Object.fromEntries(urlSearchParams.entries()) const params = Object.fromEntries(urlSearchParams.entries())
@ -129,10 +162,13 @@ export default {
if (params.user_id) { if (params.user_id) {
this.fetchUserForm.identifier = params.user_id this.fetchUserForm.identifier = params.user_id
} }
if (this.fetchUserForm.identifier) {
this.fetchUser()
}
}, },
methods: { methods: {
async fetchUser () { async fetchUser() {
if (!this.fetchUserForm.identifier) { if (!this.fetchUserForm.identifier) {
this.useAlert.error('Identifier is required.') this.useAlert.error('Identifier is required.')
return return
@ -141,13 +177,22 @@ export default {
this.loading = true this.loading = true
opnFetch(`/moderator/fetch-user/${encodeURI(this.fetchUserForm.identifier)}`).then(async (data) => { opnFetch(`/moderator/fetch-user/${encodeURI(this.fetchUserForm.identifier)}`).then(async (data) => {
this.loading = false 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}`) this.useAlert.success(`User Fetched: ${this.userInfo.name}`)
}) })
.catch((error) => { .catch((error) => {
this.useAlert.error(error.data.message) this.useAlert.error(error.data.message)
this.loading = false 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'
}
} }
} }
} }

View File

@ -181,6 +181,23 @@ Route::group(['middleware' => 'auth:api'], function () {
'cancellation-subscription', 'cancellation-subscription',
[\App\Http\Controllers\Admin\AdminController::class, 'cancelSubscription'] [\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']);
});
}); });
}); });