Account.svelte 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { onMount, getContext } from 'svelte';
  4. import { user, config, settings } from '$lib/stores';
  5. import { updateUserProfile, createAPIKey, getAPIKey } from '$lib/apis/auths';
  6. import UpdatePassword from './Account/UpdatePassword.svelte';
  7. import { getGravatarUrl } from '$lib/apis/utils';
  8. import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
  9. import { copyToClipboard } from '$lib/utils';
  10. import Plus from '$lib/components/icons/Plus.svelte';
  11. import Tooltip from '$lib/components/common/Tooltip.svelte';
  12. import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
  13. const i18n = getContext('i18n');
  14. export let saveHandler: Function;
  15. export let saveSettings: Function;
  16. let profileImageUrl = '';
  17. let name = '';
  18. let webhookUrl = '';
  19. let showAPIKeys = false;
  20. let JWTTokenCopied = false;
  21. let APIKey = '';
  22. let APIKeyCopied = false;
  23. let profileImageInputElement: HTMLInputElement;
  24. const submitHandler = async () => {
  25. if (name !== $user.name) {
  26. if (profileImageUrl === generateInitialsImage($user.name) || profileImageUrl === '') {
  27. profileImageUrl = generateInitialsImage(name);
  28. }
  29. }
  30. if (webhookUrl !== $settings?.notifications?.webhook_url) {
  31. saveSettings({
  32. notifications: {
  33. ...$settings.notifications,
  34. webhook_url: webhookUrl
  35. }
  36. });
  37. }
  38. const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
  39. (error) => {
  40. toast.error(`${error}`);
  41. }
  42. );
  43. if (updatedUser) {
  44. await user.set(updatedUser);
  45. return true;
  46. }
  47. return false;
  48. };
  49. const createAPIKeyHandler = async () => {
  50. APIKey = await createAPIKey(localStorage.token);
  51. if (APIKey) {
  52. toast.success($i18n.t('API Key created.'));
  53. } else {
  54. toast.error($i18n.t('Failed to create API Key.'));
  55. }
  56. };
  57. onMount(async () => {
  58. name = $user.name;
  59. profileImageUrl = $user.profile_image_url;
  60. webhookUrl = $settings?.notifications?.webhook_url ?? '';
  61. APIKey = await getAPIKey(localStorage.token).catch((error) => {
  62. console.log(error);
  63. return '';
  64. });
  65. });
  66. </script>
  67. <div class="flex flex-col h-full justify-between text-sm">
  68. <div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
  69. <input
  70. id="profile-image-input"
  71. bind:this={profileImageInputElement}
  72. type="file"
  73. hidden
  74. accept="image/*"
  75. on:change={(e) => {
  76. const files = profileImageInputElement.files ?? [];
  77. let reader = new FileReader();
  78. reader.onload = (event) => {
  79. let originalImageUrl = `${event.target.result}`;
  80. const img = new Image();
  81. img.src = originalImageUrl;
  82. img.onload = function () {
  83. const canvas = document.createElement('canvas');
  84. const ctx = canvas.getContext('2d');
  85. // Calculate the aspect ratio of the image
  86. const aspectRatio = img.width / img.height;
  87. // Calculate the new width and height to fit within 250x250
  88. let newWidth, newHeight;
  89. if (aspectRatio > 1) {
  90. newWidth = 250 * aspectRatio;
  91. newHeight = 250;
  92. } else {
  93. newWidth = 250;
  94. newHeight = 250 / aspectRatio;
  95. }
  96. // Set the canvas size
  97. canvas.width = 250;
  98. canvas.height = 250;
  99. // Calculate the position to center the image
  100. const offsetX = (250 - newWidth) / 2;
  101. const offsetY = (250 - newHeight) / 2;
  102. // Draw the image on the canvas
  103. ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
  104. // Get the base64 representation of the compressed image
  105. const compressedSrc = canvas.toDataURL('image/jpeg');
  106. // Display the compressed image
  107. profileImageUrl = compressedSrc;
  108. profileImageInputElement.files = null;
  109. };
  110. };
  111. if (
  112. files.length > 0 &&
  113. ['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(files[0]['type'])
  114. ) {
  115. reader.readAsDataURL(files[0]);
  116. }
  117. }}
  118. />
  119. <div class="space-y-1">
  120. <!-- <div class=" text-sm font-medium">{$i18n.t('Account')}</div> -->
  121. <div class="flex space-x-5">
  122. <div class="flex flex-col">
  123. <div class="self-center mt-2">
  124. <button
  125. class="relative rounded-full dark:bg-gray-700"
  126. type="button"
  127. on:click={() => {
  128. profileImageInputElement.click();
  129. }}
  130. >
  131. <img
  132. src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(name)}
  133. alt="profile"
  134. class=" rounded-full size-16 object-cover"
  135. />
  136. <div
  137. class="absolute flex justify-center rounded-full bottom-0 left-0 right-0 top-0 h-full w-full overflow-hidden bg-gray-700 bg-fixed opacity-0 transition duration-300 ease-in-out hover:opacity-50"
  138. >
  139. <div class="my-auto text-gray-100">
  140. <svg
  141. xmlns="http://www.w3.org/2000/svg"
  142. viewBox="0 0 20 20"
  143. fill="currentColor"
  144. class="w-5 h-5"
  145. >
  146. <path
  147. d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
  148. />
  149. </svg>
  150. </div>
  151. </div>
  152. </button>
  153. </div>
  154. </div>
  155. <div class="flex-1 flex flex-col self-center gap-0.5">
  156. <div class=" mb-0.5 text-sm font-medium">{$i18n.t('Profile Image')}</div>
  157. <div>
  158. <button
  159. class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-full px-4 py-0.5 bg-gray-100 dark:bg-gray-850"
  160. on:click={async () => {
  161. if (canvasPixelTest()) {
  162. profileImageUrl = generateInitialsImage(name);
  163. } else {
  164. toast.info(
  165. $i18n.t(
  166. 'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
  167. ),
  168. {
  169. duration: 1000 * 10
  170. }
  171. );
  172. }
  173. }}>{$i18n.t('Use Initials')}</button
  174. >
  175. <button
  176. class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-full px-4 py-0.5 bg-gray-100 dark:bg-gray-850"
  177. on:click={async () => {
  178. const url = await getGravatarUrl($user.email);
  179. profileImageUrl = url;
  180. }}>{$i18n.t('Use Gravatar')}</button
  181. >
  182. <button
  183. class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg px-2 py-1"
  184. on:click={async () => {
  185. profileImageUrl = '/user.png';
  186. }}>{$i18n.t('Remove')}</button
  187. >
  188. </div>
  189. </div>
  190. </div>
  191. <div class="pt-0.5">
  192. <div class="flex flex-col w-full">
  193. <div class=" mb-1 text-xs font-medium">{$i18n.t('Name')}</div>
  194. <div class="flex-1">
  195. <input
  196. class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
  197. type="text"
  198. bind:value={name}
  199. required
  200. />
  201. </div>
  202. </div>
  203. </div>
  204. <div class="pt-2">
  205. <div class="flex flex-col w-full">
  206. <div class=" mb-1 text-xs font-medium">{$i18n.t('Notification Webhook')}</div>
  207. <div class="flex-1">
  208. <input
  209. class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
  210. type="url"
  211. placeholder={$i18n.t('Enter your webhook URL')}
  212. bind:value={webhookUrl}
  213. required
  214. />
  215. </div>
  216. </div>
  217. </div>
  218. </div>
  219. <div class="py-0.5">
  220. <UpdatePassword />
  221. </div>
  222. <hr class=" dark:border-gray-850 my-4" />
  223. <div class="flex justify-between items-center text-sm">
  224. <div class=" font-medium">{$i18n.t('API keys')}</div>
  225. <button
  226. class=" text-xs font-medium text-gray-500"
  227. type="button"
  228. on:click={() => {
  229. showAPIKeys = !showAPIKeys;
  230. }}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
  231. >
  232. </div>
  233. {#if showAPIKeys}
  234. <div class="flex flex-col gap-4">
  235. <div class="justify-between w-full">
  236. <div class="flex justify-between w-full">
  237. <div class="self-center text-xs font-medium">{$i18n.t('JWT Token')}</div>
  238. </div>
  239. <div class="flex mt-2">
  240. <SensitiveInput value={localStorage.token} readOnly={true} />
  241. <button
  242. class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
  243. on:click={() => {
  244. copyToClipboard(localStorage.token);
  245. JWTTokenCopied = true;
  246. setTimeout(() => {
  247. JWTTokenCopied = false;
  248. }, 2000);
  249. }}
  250. >
  251. {#if JWTTokenCopied}
  252. <svg
  253. xmlns="http://www.w3.org/2000/svg"
  254. viewBox="0 0 20 20"
  255. fill="currentColor"
  256. class="w-4 h-4"
  257. >
  258. <path
  259. fill-rule="evenodd"
  260. d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
  261. clip-rule="evenodd"
  262. />
  263. </svg>
  264. {:else}
  265. <svg
  266. xmlns="http://www.w3.org/2000/svg"
  267. viewBox="0 0 16 16"
  268. fill="currentColor"
  269. class="w-4 h-4"
  270. >
  271. <path
  272. fill-rule="evenodd"
  273. d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
  274. clip-rule="evenodd"
  275. />
  276. <path
  277. fill-rule="evenodd"
  278. d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
  279. clip-rule="evenodd"
  280. />
  281. </svg>
  282. {/if}
  283. </button>
  284. </div>
  285. </div>
  286. {#if $config?.features?.enable_api_key ?? true}
  287. <div class="justify-between w-full">
  288. <div class="flex justify-between w-full">
  289. <div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
  290. </div>
  291. <div class="flex mt-2">
  292. {#if APIKey}
  293. <SensitiveInput value={APIKey} readOnly={true} />
  294. <button
  295. class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
  296. on:click={() => {
  297. copyToClipboard(APIKey);
  298. APIKeyCopied = true;
  299. setTimeout(() => {
  300. APIKeyCopied = false;
  301. }, 2000);
  302. }}
  303. >
  304. {#if APIKeyCopied}
  305. <svg
  306. xmlns="http://www.w3.org/2000/svg"
  307. viewBox="0 0 20 20"
  308. fill="currentColor"
  309. class="w-4 h-4"
  310. >
  311. <path
  312. fill-rule="evenodd"
  313. d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
  314. clip-rule="evenodd"
  315. />
  316. </svg>
  317. {:else}
  318. <svg
  319. xmlns="http://www.w3.org/2000/svg"
  320. viewBox="0 0 16 16"
  321. fill="currentColor"
  322. class="w-4 h-4"
  323. >
  324. <path
  325. fill-rule="evenodd"
  326. d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
  327. clip-rule="evenodd"
  328. />
  329. <path
  330. fill-rule="evenodd"
  331. d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
  332. clip-rule="evenodd"
  333. />
  334. </svg>
  335. {/if}
  336. </button>
  337. <Tooltip content={$i18n.t('Create new key')}>
  338. <button
  339. class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
  340. on:click={() => {
  341. createAPIKeyHandler();
  342. }}
  343. >
  344. <svg
  345. xmlns="http://www.w3.org/2000/svg"
  346. fill="none"
  347. viewBox="0 0 24 24"
  348. stroke-width="2"
  349. stroke="currentColor"
  350. class="size-4"
  351. >
  352. <path
  353. stroke-linecap="round"
  354. stroke-linejoin="round"
  355. d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
  356. />
  357. </svg>
  358. </button>
  359. </Tooltip>
  360. {:else}
  361. <button
  362. class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
  363. on:click={() => {
  364. createAPIKeyHandler();
  365. }}
  366. >
  367. <Plus strokeWidth="2" className=" size-3.5" />
  368. {$i18n.t('Create new secret key')}</button
  369. >
  370. {/if}
  371. </div>
  372. </div>
  373. {/if}
  374. </div>
  375. {/if}
  376. </div>
  377. <div class="flex justify-end pt-3 text-sm font-medium">
  378. <button
  379. class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
  380. on:click={async () => {
  381. const res = await submitHandler();
  382. if (res) {
  383. saveHandler();
  384. }
  385. }}
  386. >
  387. {$i18n.t('Save')}
  388. </button>
  389. </div>
  390. </div>