Refactor Admin Panel with more features (#384)

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala
2024-04-22 19:41:21 +05:30
committed by GitHub
parent eeb3ec3b77
commit 053a4a4976
12 changed files with 586 additions and 111 deletions

View File

@@ -146,6 +146,14 @@ export default {
"ring-offset": "focus:ring-offset-gray-200",
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") {
return {
main: "bg-red-600",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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