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

@@ -203,6 +203,9 @@
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.",
"curl_invalid_format": "cURL is not formatted properly",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Empty Request Name",
"f12_details": "(F12 for details)",
"gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Accent color",
"account": "Account",
"account_deleted": "Your account has been deleted",
"account_description": "Customize your account settings.",
"account_email_description": "Your primary email address.",
"account_name_description": "This is your display name.",
@@ -445,6 +449,8 @@
"change_font_size": "Change font size",
"choose_language": "Choose language",
"dark_mode": "Dark",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Experiments",
"experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ",

View File

@@ -98,20 +98,14 @@ declare module '@vue/runtime-core' {
HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default']
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
@@ -123,6 +117,7 @@ declare module '@vue/runtime-core' {
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ProfilePicture: typeof import('./components/profile/Picture.vue')['default']
ProfileShortcode: typeof import('./components/profile/Shortcode.vue')['default']
ProfileShortcodes: typeof import('./components/profile/Shortcodes.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']

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>

View File

@@ -0,0 +1,3 @@
mutation DeleteUser {
deleteUser
}

View File

@@ -0,0 +1,15 @@
import { runMutation } from "../GQLClient"
import {
DeleteUserDocument,
DeleteUserMutation,
DeleteUserMutationVariables,
} from "../graphql"
type DeleteUserErrors = "user/not_found"
export const deleteUser = () =>
runMutation<
DeleteUserMutation,
DeleteUserMutationVariables,
DeleteUserErrors
>(DeleteUserDocument, {})

View File

@@ -64,7 +64,7 @@
v-if="currentUser.emailVerified"
v-tippy="{ theme: 'tooltip' }"
:title="t('settings.verified_email')"
class="ml-2 text-green-500 svg-icons cursor-help"
class="ml-2 text-green-500 svg-icons focus:outline-none cursor-help"
/>
<ButtonSecondary
v-else
@@ -158,6 +158,9 @@
</form>
</div>
</section>
<ProfileUserDelete />
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.sync") }}
@@ -192,90 +195,8 @@
</div>
</div>
</section>
<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 flex-shrink-0 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"
>
<component :is="IconHelpCircle" class="mb-4 svg-icons" />
{{ getErrorMessage(adapterError) }}
</div>
</div>
</section>
<ProfileShortcodes />
</div>
</SmartTab>
<SmartTab :id="'teams'" :label="t('team.title')">
@@ -284,15 +205,13 @@
</SmartTabs>
</div>
</div>
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
</div>
</div>
</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$,
probableUser$,
@@ -301,7 +220,6 @@ import {
verifyEmailAddress,
} 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"
@@ -310,13 +228,9 @@ import { useColorMode } from "@composables/theming"
import { usePageHead } from "@composables/head"
import { toggleSetting } from "~/newstore/settings"
import ShortcodeListAdapter from "~/helpers/shortcodes/ShortcodeListAdapter"
import { deleteShortcode as backendDeleteShortcode } from "~/helpers/backend/mutations/Shortcode"
import IconVerified from "~icons/lucide/verified"
import IconSettings from "~icons/lucide/settings"
import IconHelpCircle from "~icons/lucide/help-circle"
import { invokeAction } from "~/helpers/actions"
type ProfileTabs = "sync" | "teams"
@@ -393,62 +307,4 @@ const sendEmailVerification = () => {
verifyingEmailAddress.value = false
})
}
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>