profile.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. <template>
  2. <div>
  3. <div class="container">
  4. <div class="p-4">
  5. <div
  6. v-if="loadingCurrentUser"
  7. class="flex flex-col items-center justify-center flex-1 p-4"
  8. >
  9. <SmartSpinner class="mb-4" />
  10. </div>
  11. <div
  12. v-else-if="currentUser === null"
  13. class="flex flex-col items-center justify-center"
  14. >
  15. <img
  16. :src="`/images/states/${$colorMode.value}/login.svg`"
  17. loading="lazy"
  18. class="inline-flex flex-col object-contain object-center w-24 h-24 my-4"
  19. :alt="`${t('empty.parameters')}`"
  20. />
  21. <p class="pb-4 text-center text-secondaryLight">
  22. {{ t("empty.profile") }}
  23. </p>
  24. <ButtonPrimary
  25. :label="t('auth.login')"
  26. class="mb-4"
  27. @click.native="showLogin = true"
  28. />
  29. </div>
  30. <div v-else class="space-y-8">
  31. <div
  32. class="h-24 rounded bg-primaryLight -mb-11 md:h-32"
  33. style="background-image: url('/images/cover.svg')"
  34. ></div>
  35. <div class="flex flex-col justify-between px-4 space-y-8 md:flex-row">
  36. <div class="flex items-end">
  37. <ProfilePicture
  38. v-if="currentUser.photoURL"
  39. :url="currentUser.photoURL"
  40. :alt="currentUser.displayName"
  41. class="ring-primary ring-4"
  42. size="16"
  43. rounded="lg"
  44. />
  45. <ProfilePicture
  46. v-else
  47. :initial="currentUser.displayName"
  48. rounded="lg"
  49. size="16"
  50. class="ring-primary ring-4"
  51. />
  52. <div class="ml-4">
  53. <label class="heading">
  54. {{ currentUser.displayName || t("state.nothing_found") }}
  55. </label>
  56. <p class="flex items-center text-secondaryLight">
  57. {{ currentUser.email }}
  58. <SmartIcon
  59. v-if="currentUser.emailVerified"
  60. name="verified"
  61. class="ml-2 text-green-500 svg-icons"
  62. />
  63. <ButtonSecondary
  64. v-else
  65. :label="t('settings.verify_email')"
  66. svg="verified"
  67. class="px-1 py-0 ml-2"
  68. :loading="verifyingEmailAddress"
  69. @click.native="sendEmailVerification"
  70. />
  71. </p>
  72. </div>
  73. </div>
  74. <div class="flex items-end space-x-2">
  75. <div>
  76. <SmartItem
  77. to="/settings"
  78. svg="settings"
  79. :label="t('profile.app_settings')"
  80. outline
  81. />
  82. </div>
  83. <FirebaseLogout outline />
  84. </div>
  85. </div>
  86. <SmartTabs v-model="selectedProfileTab" render-inactive-tabs>
  87. <SmartTab :id="'sync'" :label="t('settings.account')">
  88. <div class="grid grid-cols-1">
  89. <section class="p-4">
  90. <h4 class="font-semibold text-secondaryDark">
  91. {{ t("settings.profile") }}
  92. </h4>
  93. <div class="my-1 text-secondaryLight">
  94. {{ t("settings.profile_description") }}
  95. </div>
  96. <div class="py-4">
  97. <label for="displayName">
  98. {{ t("settings.profile_name") }}
  99. </label>
  100. <form
  101. class="flex mt-2 md:max-w-sm"
  102. @submit.prevent="updateDisplayName"
  103. >
  104. <input
  105. id="displayName"
  106. v-model="displayName"
  107. class="input"
  108. :placeholder="`${t('settings.profile_name')}`"
  109. type="text"
  110. autocomplete="off"
  111. required
  112. />
  113. <ButtonSecondary
  114. filled
  115. outline
  116. :label="t('action.save')"
  117. class="ml-2 min-w-16"
  118. type="submit"
  119. :loading="updatingDisplayName"
  120. />
  121. </form>
  122. </div>
  123. <div class="py-4">
  124. <label for="emailAddress">
  125. {{ t("settings.profile_email") }}
  126. </label>
  127. <form
  128. class="flex mt-2 md:max-w-sm"
  129. @submit.prevent="updateEmailAddress"
  130. >
  131. <input
  132. id="emailAddress"
  133. v-model="emailAddress"
  134. class="input"
  135. :placeholder="`${t('settings.profile_name')}`"
  136. type="email"
  137. autocomplete="off"
  138. required
  139. />
  140. <ButtonSecondary
  141. filled
  142. outline
  143. :label="t('action.save')"
  144. class="ml-2 min-w-16"
  145. type="submit"
  146. :loading="updatingEmailAddress"
  147. />
  148. </form>
  149. </div>
  150. </section>
  151. <section class="p-4">
  152. <h4 class="font-semibold text-secondaryDark">
  153. {{ t("settings.sync") }}
  154. </h4>
  155. <div class="my-1 text-secondaryLight">
  156. {{ t("settings.sync_description") }}
  157. </div>
  158. <div class="py-4 space-y-4">
  159. <div class="flex items-center">
  160. <SmartToggle
  161. :on="SYNC_COLLECTIONS"
  162. @change="toggleSetting('syncCollections')"
  163. >
  164. {{ t("settings.sync_collections") }}
  165. </SmartToggle>
  166. </div>
  167. <div class="flex items-center">
  168. <SmartToggle
  169. :on="SYNC_ENVIRONMENTS"
  170. @change="toggleSetting('syncEnvironments')"
  171. >
  172. {{ t("settings.sync_environments") }}
  173. </SmartToggle>
  174. </div>
  175. <div class="flex items-center">
  176. <SmartToggle
  177. :on="SYNC_HISTORY"
  178. @change="toggleSetting('syncHistory')"
  179. >
  180. {{ t("settings.sync_history") }}
  181. </SmartToggle>
  182. </div>
  183. </div>
  184. </section>
  185. <section class="p-4">
  186. <h4 class="font-semibold text-secondaryDark">
  187. {{ t("settings.short_codes") }}
  188. </h4>
  189. <div class="my-1 text-secondaryLight">
  190. {{ t("settings.short_codes_description") }}
  191. </div>
  192. <div class="relative py-4 overflow-x-auto hide-scrollbar">
  193. <div
  194. v-if="loading"
  195. class="flex flex-col items-center justify-center"
  196. >
  197. <SmartSpinner class="mb-4" />
  198. <span class="text-secondaryLight">{{
  199. t("state.loading")
  200. }}</span>
  201. </div>
  202. <div
  203. v-if="!loading && myShortcodes.length === 0"
  204. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  205. >
  206. <img
  207. :src="`/images/states/${$colorMode.value}/add_files.svg`"
  208. loading="lazy"
  209. class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
  210. :alt="`${t('empty.shortcodes')}`"
  211. />
  212. <span class="mb-4 text-center">
  213. {{ t("empty.shortcodes") }}
  214. </span>
  215. </div>
  216. <div v-else-if="!loading">
  217. <div
  218. class="bg-primaryLight hidden lg:flex rounded-t w-full"
  219. >
  220. <div
  221. class="flex w-full overflow-y-scroll lg:divide-x divide-primaryLight"
  222. >
  223. <div class="flex flex-1 p-3 font-semibold">
  224. {{ t("shortcodes.short_code") }}
  225. </div>
  226. <div class="flex flex-1 p-3 font-semibold">
  227. {{ t("shortcodes.method") }}
  228. </div>
  229. <div class="flex flex-1 p-3 font-semibold">
  230. {{ t("shortcodes.url") }}
  231. </div>
  232. <div class="flex flex-1 p-3 font-semibold">
  233. {{ t("shortcodes.created_on") }}
  234. </div>
  235. <div
  236. class="flex flex-1 p-3 font-semibold justify-center"
  237. >
  238. {{ t("shortcodes.actions") }}
  239. </div>
  240. </div>
  241. </div>
  242. <div
  243. class="w-full max-h-sm flex flex-col items-center justify-between overflow-y-scroll rounded lg:rounded-t-none border lg:divide-y border-dividerLight divide-dividerLight"
  244. >
  245. <div
  246. class="flex flex-col h-auto h-full border-r border-dividerLight w-full"
  247. >
  248. <ProfileShortcode
  249. v-for="(shortcode, shortcodeIndex) in myShortcodes"
  250. :key="`shortcode-${shortcodeIndex}`"
  251. :shortcode="shortcode"
  252. @delete-shortcode="deleteShortcode"
  253. />
  254. <SmartIntersection
  255. v-if="hasMoreShortcodes && myShortcodes.length > 0"
  256. @intersecting="loadMoreShortcodes()"
  257. >
  258. <div
  259. v-if="adapterLoading"
  260. class="flex flex-col items-center py-3"
  261. >
  262. <SmartSpinner />
  263. </div>
  264. </SmartIntersection>
  265. </div>
  266. </div>
  267. </div>
  268. <div
  269. v-if="!loading && adapterError"
  270. class="flex flex-col items-center py-4"
  271. >
  272. <i class="mb-4 material-icons">help_outline</i>
  273. {{ getErrorMessage(adapterError) }}
  274. </div>
  275. </div>
  276. </section>
  277. </div>
  278. </SmartTab>
  279. <SmartTab :id="'teams'" :label="t('team.title')">
  280. <Teams :modal="false" />
  281. </SmartTab>
  282. </SmartTabs>
  283. </div>
  284. </div>
  285. <FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
  286. </div>
  287. </div>
  288. </template>
  289. <script setup lang="ts">
  290. import {
  291. ref,
  292. useMeta,
  293. defineComponent,
  294. watchEffect,
  295. computed,
  296. } from "@nuxtjs/composition-api"
  297. import { pipe } from "fp-ts/function"
  298. import * as TE from "fp-ts/TaskEither"
  299. import { GQLError } from "~/helpers/backend/GQLClient"
  300. import {
  301. currentUser$,
  302. probableUser$,
  303. setDisplayName,
  304. setEmailAddress,
  305. verifyEmailAddress,
  306. onLoggedIn,
  307. } from "~/helpers/fb/auth"
  308. import {
  309. useReadonlyStream,
  310. useI18n,
  311. useToast,
  312. } from "~/helpers/utils/composables"
  313. import { toggleSetting, useSetting } from "~/newstore/settings"
  314. import ShortcodeListAdapter from "~/helpers/shortcodes/ShortcodeListAdapter"
  315. import { deleteShortcode as backendDeleteShortcode } from "~/helpers/backend/mutations/Shortcode"
  316. type ProfileTabs = "sync" | "teams"
  317. const selectedProfileTab = ref<ProfileTabs>("sync")
  318. const t = useI18n()
  319. const toast = useToast()
  320. const showLogin = ref(false)
  321. const SYNC_COLLECTIONS = useSetting("syncCollections")
  322. const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
  323. const SYNC_HISTORY = useSetting("syncHistory")
  324. const currentUser = useReadonlyStream(currentUser$, null)
  325. const probableUser = useReadonlyStream(probableUser$, null)
  326. const loadingCurrentUser = computed(() => {
  327. if (!probableUser.value) return false
  328. else if (!currentUser.value) return true
  329. else return false
  330. })
  331. const displayName = ref(currentUser.value?.displayName)
  332. const updatingDisplayName = ref(false)
  333. watchEffect(() => (displayName.value = currentUser.value?.displayName))
  334. const updateDisplayName = () => {
  335. updatingDisplayName.value = true
  336. setDisplayName(displayName.value as string)
  337. .then(() => {
  338. toast.success(`${t("profile.updated")}`)
  339. })
  340. .catch(() => {
  341. toast.error(`${t("error.something_went_wrong")}`)
  342. })
  343. .finally(() => {
  344. updatingDisplayName.value = false
  345. })
  346. }
  347. const emailAddress = ref(currentUser.value?.email)
  348. const updatingEmailAddress = ref(false)
  349. watchEffect(() => (emailAddress.value = currentUser.value?.email))
  350. const updateEmailAddress = () => {
  351. updatingEmailAddress.value = true
  352. setEmailAddress(emailAddress.value as string)
  353. .then(() => {
  354. toast.success(`${t("profile.updated")}`)
  355. })
  356. .catch(() => {
  357. toast.error(`${t("error.something_went_wrong")}`)
  358. })
  359. .finally(() => {
  360. updatingEmailAddress.value = false
  361. })
  362. }
  363. const verifyingEmailAddress = ref(false)
  364. const sendEmailVerification = () => {
  365. verifyingEmailAddress.value = true
  366. verifyEmailAddress()
  367. .then(() => {
  368. toast.success(`${t("profile.email_verification_mail")}`)
  369. })
  370. .catch(() => {
  371. toast.error(`${t("error.something_went_wrong")}`)
  372. })
  373. .finally(() => {
  374. verifyingEmailAddress.value = false
  375. })
  376. }
  377. const adapter = new ShortcodeListAdapter(true)
  378. const adapterLoading = useReadonlyStream(adapter.loading$, false)
  379. const adapterError = useReadonlyStream(adapter.error$, null)
  380. const myShortcodes = useReadonlyStream(adapter.shortcodes$, [])
  381. const hasMoreShortcodes = useReadonlyStream(adapter.hasMoreShortcodes$, true)
  382. const loading = computed(
  383. () => adapterLoading.value && myShortcodes.value.length === 0
  384. )
  385. onLoggedIn(() => {
  386. adapter.initialize()
  387. })
  388. const deleteShortcode = (codeID: string) => {
  389. pipe(
  390. backendDeleteShortcode(codeID),
  391. TE.match(
  392. (err: GQLError<string>) => {
  393. toast.error(`${getErrorMessage(err)}`)
  394. },
  395. () => {
  396. toast.success(`${t("shortcodes.deleted")}`)
  397. }
  398. )
  399. )()
  400. }
  401. const loadMoreShortcodes = () => {
  402. adapter.loadMore()
  403. }
  404. const getErrorMessage = (err: GQLError<string>) => {
  405. if (err.type === "network_error") {
  406. return t("error.network_error")
  407. } else {
  408. switch (err.error) {
  409. case "shortcode/not_found":
  410. return t("shortcodes.not_found")
  411. default:
  412. return t("error.something_went_wrong")
  413. }
  414. }
  415. }
  416. useMeta({
  417. title: `${t("navigation.profile")} • Hoppscotch`,
  418. })
  419. </script>
  420. <script lang="ts">
  421. export default defineComponent({
  422. head: {},
  423. })
  424. </script>