feat: ability to delete user account and data (#2863)

* feat: add gql mutation

* feat: added delete account section in profile page

* feat: separate shortcodes section to a component

* feat: delete user modal

* feat: delete user account

* feat: navigate to homepage after delete

* chore: improve ui

* fix: delete user mutation

* chore: minor ui improvements

* chore: correct grammar in certain i18n strings

* feat: delection section separated to component

* feat: separate user delete section into component

* feat: defer fetch my teams

* feat: disable delete account button on loading state

* Update Shortcodes.vue

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Anwarul Islam
2022-12-17 10:01:39 +06:00
committed by GitHub
parent 012f9b5314
commit d36ab337d7
7 changed files with 390 additions and 158 deletions

View File

@@ -0,0 +1,170 @@
<template>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.short_codes") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.short_codes_description") }}
</div>
<div class="relative py-4 overflow-x-auto">
<div v-if="loading" class="flex flex-col items-center justify-center">
<SmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-if="!loading && myShortcodes.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/add_files.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
:alt="`${t('empty.shortcodes')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.shortcodes") }}
</span>
</div>
<div v-else-if="!loading">
<div
class="hidden w-full border-t rounded-t bg-primaryLight lg:flex border-x border-dividerLight"
>
<div class="flex w-full overflow-y-scroll">
<div class="table-box">
{{ t("shortcodes.short_code") }}
</div>
<div class="table-box">
{{ t("shortcodes.method") }}
</div>
<div class="table-box">
{{ t("shortcodes.url") }}
</div>
<div class="table-box">
{{ t("shortcodes.created_on") }}
</div>
<div class="justify-center table-box">
{{ t("shortcodes.actions") }}
</div>
</div>
</div>
<div
class="flex flex-col items-center justify-between w-full overflow-y-scroll border rounded max-h-sm lg:rounded-t-none lg:divide-y border-dividerLight divide-dividerLight"
>
<ProfileShortcode
v-for="(shortcode, shortcodeIndex) in myShortcodes"
:key="`shortcode-${shortcodeIndex}`"
:shortcode="shortcode"
@delete-shortcode="deleteShortcode"
/>
<SmartIntersection
v-if="hasMoreShortcodes && myShortcodes.length > 0"
@intersecting="loadMoreShortcodes()"
>
<div v-if="adapterLoading" class="flex flex-col items-center py-3">
<SmartSpinner />
</div>
</SmartIntersection>
</div>
</div>
<div
v-if="!loading && adapterError"
class="flex flex-col items-center py-4"
>
<icon-lucide-help-circle class="mb-4 svg-icons" />
{{ getErrorMessage(adapterError) }}
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, watchEffect, computed } from "vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { GQLError } from "~/helpers/backend/GQLClient"
import { currentUser$ } from "~/helpers/fb/auth"
import { onAuthEvent, onLoggedIn } from "@composables/auth"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import { usePageHead } from "@composables/head"
import ShortcodeListAdapter from "~/helpers/shortcodes/ShortcodeListAdapter"
import { deleteShortcode as backendDeleteShortcode } from "~/helpers/backend/mutations/Shortcode"
const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
usePageHead({
title: computed(() => t("navigation.profile")),
})
const currentUser = useReadonlyStream(currentUser$, null)
const displayName = ref(currentUser.value?.displayName)
watchEffect(() => (displayName.value = currentUser.value?.displayName))
const emailAddress = ref(currentUser.value?.email)
watchEffect(() => (emailAddress.value = currentUser.value?.email))
const adapter = new ShortcodeListAdapter(true)
const adapterLoading = useReadonlyStream(adapter.loading$, false)
const adapterError = useReadonlyStream(adapter.error$, null)
const myShortcodes = useReadonlyStream(adapter.shortcodes$, [])
const hasMoreShortcodes = useReadonlyStream(adapter.hasMoreShortcodes$, true)
const loading = computed(
() => adapterLoading.value && myShortcodes.value.length === 0
)
onLoggedIn(() => {
adapter.initialize()
})
onAuthEvent((ev) => {
if (ev.event === "logout" && adapter.isInitialized()) {
adapter.dispose()
return
}
})
const deleteShortcode = (codeID: string) => {
pipe(
backendDeleteShortcode(codeID),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
},
() => {
toast.success(`${t("shortcodes.deleted")}`)
}
)
)()
}
const loadMoreShortcodes = () => {
adapter.loadMore()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "shortcode/not_found":
return t("shortcodes.not_found")
default:
return t("error.something_went_wrong")
}
}
}
</script>
<style lang="scss" scoped>
.table-box {
@apply flex flex-1 items-center px-4 py-2 truncate;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.delete_account") }}
</h4>
<div class="my-1 text-secondaryLight mb-4">
{{ t("settings.delete_account_description") }}
</div>
<ButtonSecondary
filled
outline
:label="t('settings.delete_account')"
type="submit"
@click="showDeleteAccountModal = true"
/>
<SmartModal
v-if="showDeleteAccountModal"
dialog
:title="t('settings.delete_account')"
@close="showDeleteAccountModal = false"
>
<template #body>
<div v-if="loading" class="flex flex-col items-center justify-center">
<SmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-else-if="myTeams.length"
class="flex flex-col p-4 space-y-2 border border-red-500 border-dashed rounded-lg text-secondaryDark bg-error"
>
<h2 class="font-bold text-red-500">
{{ t("error.danger_zone") }}
</h2>
<div>
{{ t("error.delete_account") }}
<ul class="my-4 ml-8 space-y-2 list-disc">
<li v-for="team in myTeams" :key="team.id">
{{ team.name }}
</li>
</ul>
<span class="font-semibold">
{{ t("error.delete_account_description") }}
</span>
</div>
</div>
<div v-else>
<div
class="flex flex-col p-4 mb-4 space-y-2 border border-red-500 border-dashed rounded-lg text-secondaryDark bg-error"
>
<h2 class="font-bold text-red-500">
{{ t("error.danger_zone") }}
</h2>
<div class="font-medium text-secondaryDark">
{{ t("settings.delete_account_description") }}
</div>
</div>
<div class="flex flex-col">
<input
id="deleteUserAccount"
v-model="userVerificationInput"
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
/>
<label for="deleteUserAccount">
Type
<span class="font-bold"> delete my account </span>
to confirm
</label>
</div>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
:label="t('settings.delete_account')"
:loading="deletingUser"
filled
outline
:disabled="
loading ||
myTeams.length > 0 ||
userVerificationInput !== 'delete my account'
"
class="!bg-red-500 !hover:bg-red-600 !border-red-500 !hover:border-red-600"
@click="deleteUserAccount"
/>
<ButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="showDeleteAccountModal = false"
/>
</span>
</template>
</SmartModal>
</section>
</template>
<script setup lang="ts">
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { ref, watch } from "vue"
import { GQLError, runGQLQuery } from "~/helpers/backend/GQLClient"
import * as E from "fp-ts/Either"
import { useRouter } from "vue-router"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { GetMyTeamsDocument, GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { deleteUser } from "~/helpers/backend/mutations/Profile"
import { signOutUser } from "~/helpers/fb/auth"
const t = useI18n()
const toast = useToast()
const router = useRouter()
const showDeleteAccountModal = ref(false)
const userVerificationInput = ref("")
const loading = ref(true)
const myTeams = ref<GetMyTeamsQuery["myTeams"]>([])
watch(showDeleteAccountModal, (isModalOpen) => {
if (isModalOpen) {
fetchMyTeams()
}
})
const fetchMyTeams = async () => {
loading.value = true
const result = await runGQLQuery({
query: GetMyTeamsDocument,
})
loading.value = false
if (E.isLeft(result)) {
throw new Error(
`Failed fetching teams list: ${JSON.stringify(result.left)}`
)
}
myTeams.value = result.right.myTeams.filter((team) => {
return team.ownersCount === 1 && team.myRole === "OWNER"
})
}
const deletingUser = ref(false)
const deleteUserAccount = async () => {
if (deletingUser.value) return
deletingUser.value = true
pipe(
deleteUser(),
TE.match(
(err: GQLError<string>) => {
deletingUser.value = false
toast.error(getErrorMessage(err))
},
() => {
deletingUser.value = false
showDeleteAccountModal.value = false
toast.success(t("settings.account_deleted"))
signOutUser()
router.push(`/`)
}
)
)()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "shortcode/not_found":
return t("shortcodes.not_found")
default:
return t("error.something_went_wrong")
}
}
}
</script>