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:
@@ -1,17 +1,24 @@
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
<h3 class="text-md font-semibold ml-2">
|
||||
{{ 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>
|
||||
|
||||
84
client/components/pages/admin/BillingEmail.vue
Normal file
84
client/components/pages/admin/BillingEmail.vue
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
113
client/components/pages/admin/DeletedForms.vue
Normal file
113
client/components/pages/admin/DeletedForms.vue
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
40
client/components/pages/admin/SendPasswordResetEmail.vue
Normal file
40
client/components/pages/admin/SendPasswordResetEmail.vue
Normal 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>
|
||||
106
client/components/pages/admin/UserPayments.vue
Normal file
106
client/components/pages/admin/UserPayments.vue
Normal 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>
|
||||
107
client/components/pages/admin/UserSubscriptions.vue
Normal file
107
client/components/pages/admin/UserSubscriptions.vue
Normal 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>
|
||||
79
client/components/pages/admin/UserWorkspaces.vue
Normal file
79
client/components/pages/admin/UserWorkspaces.vue
Normal 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>
|
||||
|
||||
@@ -44,7 +44,6 @@ export default {
|
||||
script.setAttribute('defer', 'defer')
|
||||
document.head.appendChild(script)
|
||||
script.addEventListener('load', () => {
|
||||
console.log('resizeing')
|
||||
window.iFrameResize(
|
||||
{
|
||||
log: false,
|
||||
|
||||
Reference in New Issue
Block a user