+page.svelte 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. <script>
  2. import { goto } from '$app/navigation';
  3. import { browser } from '$app/environment';
  4. import { onMount, tick } from 'svelte';
  5. import { authToken, companyId as companyIdStore } from '$lib/utils/stores';
  6. const apiUrl = import.meta.env.VITE_API_URL;
  7. let email = '';
  8. let password = '';
  9. let remember = true;
  10. let loading = false;
  11. let error = '';
  12. let success = '';
  13. let successTimeout;
  14. const kycStatusEndpoint = `${apiUrl}/user/kyc/status`;
  15. const KYC_POLL_INTERVAL_MS = 10_000;
  16. const LOGIN_KYC_PENDING_KEY = 'login_kyc_pending_number_token';
  17. let kycMode = null;
  18. let kycLink = '';
  19. let kycNumberToken = '';
  20. let kycStatusMessage = '';
  21. let kycStatusCode = null;
  22. let kycError = '';
  23. let qrCodeContainer;
  24. let qrCodeInstance = null;
  25. let kycPollingId = null;
  26. let isCheckingKyc = false;
  27. function showTimedSuccess(message) {
  28. success = message;
  29. if (successTimeout) {
  30. clearTimeout(successTimeout);
  31. }
  32. successTimeout = setTimeout(() => {
  33. success = '';
  34. }, 4000);
  35. }
  36. function savePendingNumberToken(token) {
  37. if (!browser) return;
  38. try {
  39. if (!token) {
  40. localStorage.removeItem(LOGIN_KYC_PENDING_KEY);
  41. return;
  42. }
  43. localStorage.setItem(LOGIN_KYC_PENDING_KEY, token);
  44. } catch (err) {
  45. console.warn('Não foi possível salvar o estado de pendência de KYC:', err);
  46. }
  47. }
  48. function loadPendingNumberToken() {
  49. if (!browser) return '';
  50. try {
  51. return localStorage.getItem(LOGIN_KYC_PENDING_KEY) ?? '';
  52. } catch (err) {
  53. console.warn('Não foi possível restaurar o estado de pendência de KYC:', err);
  54. return '';
  55. }
  56. }
  57. function clearPendingState() {
  58. if (!browser) return;
  59. try {
  60. localStorage.removeItem(LOGIN_KYC_PENDING_KEY);
  61. } catch (err) {
  62. console.warn('Não foi possível limpar o estado de pendência de KYC:', err);
  63. }
  64. }
  65. function extractKycLinkData(data = {}) {
  66. return {
  67. link: data?.link ?? data?.tshield?.link,
  68. numberToken: data?.numberToken ?? data?.tshield?.numberToken ?? data?.tshield?.number
  69. };
  70. }
  71. async function handleLoginErrorResponse(status, payload) {
  72. const code = payload?.code;
  73. const message = payload?.msg ?? payload?.message;
  74. if (status === 401 || code === 'E_VALIDATE') {
  75. error = message ?? 'Credenciais inválidas.';
  76. return;
  77. }
  78. if (status === 403 && code === 'E_KYC') {
  79. const reason = payload?.data?.reason;
  80. if (reason === 'KYC_PJ_PENDING') {
  81. enterPjPending(message ?? 'Necessário finalizar análise PJ ou contatar o suporte.');
  82. return;
  83. }
  84. const { link, numberToken } = extractKycLinkData(payload?.data ?? {});
  85. if (link && numberToken) {
  86. await enterPfPending(link, numberToken, message ?? 'KYC pendente. Conclua pelo link disponibilizado.');
  87. return;
  88. }
  89. if (reason === 'KYC_PF_MISSING_DOCUMENT') {
  90. error = message ?? 'CPF não cadastrado. Contate o suporte para concluir a verificação.';
  91. return;
  92. }
  93. error = message ?? 'KYC pendente. Entre em contato com o suporte.';
  94. return;
  95. }
  96. if (status === 502 || code === 'E_EXTERNAL') {
  97. error = message ?? 'Não foi possível gerar o link de verificação. Tente novamente.';
  98. return;
  99. }
  100. if (code === 'E_VALIDATE') {
  101. error = message ?? 'Credenciais inválidas.';
  102. return;
  103. }
  104. error = message ?? 'Falha ao autenticar. Tente novamente.';
  105. }
  106. function stopKycPolling() {
  107. if (kycPollingId) {
  108. clearInterval(kycPollingId);
  109. kycPollingId = null;
  110. }
  111. isCheckingKyc = false;
  112. }
  113. function resetKycState() {
  114. stopKycPolling();
  115. kycMode = null;
  116. kycLink = '';
  117. kycNumberToken = '';
  118. kycStatusMessage = '';
  119. kycStatusCode = null;
  120. kycError = '';
  121. if (qrCodeContainer) {
  122. qrCodeContainer.innerHTML = '';
  123. }
  124. }
  125. async function renderQrCode(link) {
  126. if (!browser || !link) return;
  127. if (!qrCodeInstance) {
  128. const module = await import('qr-code-styling');
  129. const QRCodeStyles = module.default ?? module;
  130. qrCodeInstance = new QRCodeStyles({
  131. width: 240,
  132. height: 240,
  133. type: 'svg',
  134. data: link,
  135. dotsOptions: {
  136. color: '#0f172a',
  137. type: 'rounded'
  138. },
  139. backgroundOptions: {
  140. color: '#ffffff'
  141. }
  142. });
  143. } else {
  144. qrCodeInstance.update({ data: link });
  145. }
  146. await tick();
  147. if (qrCodeContainer) {
  148. qrCodeContainer.innerHTML = '';
  149. qrCodeInstance.append(qrCodeContainer);
  150. }
  151. }
  152. function startKycPolling() {
  153. stopKycPolling();
  154. checkKycStatus();
  155. kycPollingId = setInterval(checkKycStatus, KYC_POLL_INTERVAL_MS);
  156. }
  157. async function checkKycStatus() {
  158. if (!kycNumberToken || isCheckingKyc) return;
  159. isCheckingKyc = true;
  160. try {
  161. const res = await fetch(kycStatusEndpoint, {
  162. method: 'POST',
  163. headers: { 'content-type': 'application/json' },
  164. body: JSON.stringify({ numberToken: kycNumberToken })
  165. });
  166. const raw = await res.text();
  167. let body = null;
  168. if (raw) {
  169. try {
  170. body = JSON.parse(raw);
  171. } catch (err) {
  172. console.error('Resposta inválida do endpoint de status do KYC:', err);
  173. }
  174. }
  175. //console.log('[login] status KYC retornado pelo backend', body ?? raw ?? null);
  176. if (!res.ok) {
  177. const message = body?.message ?? body?.msg ?? 'Falha ao consultar status do KYC.';
  178. throw new Error(message);
  179. }
  180. const resolvedStatus = Number(body?.status);
  181. if (!Number.isNaN(resolvedStatus)) {
  182. kycStatusCode = resolvedStatus;
  183. if (resolvedStatus === 1) {
  184. kycStatusMessage = 'KYC validado com sucesso.';
  185. kycError = '';
  186. clearPendingState();
  187. stopKycPolling();
  188. handleBackToLogin('KYC validado com sucesso. Faça login novamente.');
  189. } else if (resolvedStatus === 2) {
  190. kycStatusMessage = 'Falha na validação do KYC. Entre em contato com o suporte.';
  191. clearPendingState();
  192. stopKycPolling();
  193. } else {
  194. kycStatusMessage = 'Aguardando validação do KYC...';
  195. }
  196. } else {
  197. kycStatusMessage = 'Aguardando validação do KYC...';
  198. }
  199. } catch (err) {
  200. console.error('Erro ao verificar status do KYC:', err);
  201. kycError = err?.message ?? 'Não foi possível verificar o status do KYC.';
  202. } finally {
  203. isCheckingKyc = false;
  204. }
  205. }
  206. async function enterPfPending(link, numberToken, message, options = {}) {
  207. const { persist = true } = options;
  208. resetKycState();
  209. kycMode = 'PF_PENDING';
  210. kycLink = link;
  211. kycNumberToken = numberToken;
  212. kycStatusMessage = message ?? 'KYC pendente. Conclua pelo QR Code.';
  213. kycStatusCode = null;
  214. kycError = '';
  215. error = '';
  216. success = '';
  217. if (persist) {
  218. savePendingNumberToken(numberToken);
  219. }
  220. await renderQrCode(link);
  221. startKycPolling();
  222. }
  223. function enterPjPending(message, options = {}) {
  224. const { persist = true } = options;
  225. resetKycState();
  226. kycMode = 'PJ_PENDING';
  227. kycStatusMessage = message ?? 'Necessário finalizar análise PJ ou contatar o suporte.';
  228. error = '';
  229. success = '';
  230. if (persist) {
  231. savePendingState({
  232. mode: 'PJ_PENDING',
  233. message: kycStatusMessage
  234. });
  235. }
  236. }
  237. function handleBackToLogin(message = '') {
  238. resetKycState();
  239. clearPendingState();
  240. if (message) {
  241. showTimedSuccess(message);
  242. }
  243. }
  244. function handleCancelKycFlow() {
  245. handleBackToLogin('');
  246. }
  247. function setDark(enabled) {
  248. if (enabled) document.documentElement.classList.add('dark');
  249. else document.documentElement.classList.remove('dark');
  250. }
  251. // Aplica tema conforme localStorage: 'darkmode' = 'true' | 'false'
  252. function applyDarkModeFromStorage() {
  253. if (!browser) return;
  254. try {
  255. const saved = localStorage.getItem('darkmode');
  256. if (saved === 'true') setDark(true);
  257. else if (saved === 'false') setDark(false);
  258. else setDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
  259. } catch (e) {}
  260. }
  261. onMount(() => {
  262. applyDarkModeFromStorage();
  263. const onStorage = (e) => {
  264. if (e.key === 'darkmode') applyDarkModeFromStorage();
  265. };
  266. if (browser) window.addEventListener('storage', onStorage);
  267. try {
  268. const stored = localStorage.getItem('registrationSuccessMessage');
  269. if (stored) {
  270. localStorage.removeItem('registrationSuccessMessage');
  271. showTimedSuccess(stored);
  272. }
  273. } catch (err) {
  274. console.warn('Falha ao recuperar mensagem de sucesso:', err);
  275. }
  276. const pendingToken = loadPendingNumberToken();
  277. if (pendingToken) {
  278. kycNumberToken = pendingToken;
  279. kycStatusMessage = 'KYC pendente. Aguarde a validação ou refaça o login para gerar um novo link.';
  280. startKycPolling();
  281. }
  282. return () => {
  283. if (browser) window.removeEventListener('storage', onStorage);
  284. if (successTimeout) {
  285. clearTimeout(successTimeout);
  286. }
  287. };
  288. });
  289. async function onSubmit(e) {
  290. e.preventDefault();
  291. error = '';
  292. success = '';
  293. loading = true;
  294. resetKycState();
  295. try {
  296. if (!email || !password) {
  297. throw new Error('Preencha e-mail e senha.');
  298. }
  299. if (!browser) {
  300. throw new Error('Ação disponível apenas no navegador.');
  301. }
  302. const res = await fetch(`${apiUrl}/login`, {
  303. method: 'POST',
  304. headers: { 'content-type': 'application/json' },
  305. body: JSON.stringify({ email, password })
  306. });
  307. const raw = await res.text();
  308. let payload = null;
  309. if (raw) {
  310. try {
  311. payload = JSON.parse(raw);
  312. } catch (err) {
  313. console.error('Resposta inválida do login:', err);
  314. }
  315. }
  316. //console.log('[login] resposta do backend', {
  317. status: res.status,
  318. payload,
  319. raw
  320. });
  321. if (!res.ok) {
  322. await handleLoginErrorResponse(res.status, payload);
  323. return;
  324. }
  325. const statusOk = payload?.status === 'ok' && payload?.code === 'S_OK';
  326. if (!statusOk) {
  327. await handleLoginErrorResponse(res.status, payload);
  328. return;
  329. }
  330. const token = payload?.data?.token;
  331. const companyIdFromApi = payload?.data?.company_id ?? payload?.data?.companyId;
  332. if (!token) {
  333. throw new Error('Resposta inválida do servidor.');
  334. }
  335. const attrs = [
  336. 'Path=/',
  337. 'SameSite=Lax',
  338. (typeof location !== 'undefined' && location.protocol === 'https:') ? 'Secure' : null,
  339. remember ? `Max-Age=${60 * 60 * 24 * 7}` : null
  340. ].filter(Boolean).join('; ');
  341. document.cookie = `auth_token=${encodeURIComponent(token)}; ${attrs}`;
  342. if (companyIdFromApi != null) {
  343. document.cookie = `company_id=${encodeURIComponent(companyIdFromApi)}; ${attrs}`;
  344. companyIdStore.set(companyIdFromApi);
  345. }
  346. authToken.set(token);
  347. clearPendingState();
  348. if (browser) {
  349. try {
  350. localStorage.removeItem('registrationSuccessMessage');
  351. } catch (err) {
  352. console.warn('Falha ao apagar mensagem de sucesso:', err);
  353. }
  354. }
  355. await goto('/dashboard');
  356. } catch (err) {
  357. error = err?.message ?? 'Falha ao autenticar, tente novamente.';
  358. } finally {
  359. loading = false;
  360. }
  361. }
  362. </script>
  363. <div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
  364. <div class="w-full max-w-md">
  365. {#if kycMode === 'PF_PENDING'}
  366. <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-8 space-y-6">
  367. <div class="text-center">
  368. <h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Validação necessária</h1>
  369. <p class="text-sm text-gray-600 dark:text-gray-400 mt-2">
  370. Escaneie o QR Code com o celular e conclua a validação facial no link da TShield.
  371. </p>
  372. </div>
  373. <div class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
  374. <p>1. Abra a câmera do celular e aponte para o QR Code.</p>
  375. <p>2. Siga as instruções para a verificação facial.</p>
  376. <p>3. Aguarde alguns minutos — verificamos o status automaticamente.</p>
  377. </div>
  378. <div class="flex justify-center">
  379. <div class="p-4 border border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
  380. <div
  381. class="w-56 h-56 flex items-center justify-center text-gray-500 dark:text-gray-400"
  382. bind:this={qrCodeContainer}
  383. >
  384. {#if !kycLink}
  385. Gerando QR Code...
  386. {/if}
  387. </div>
  388. </div>
  389. </div>
  390. <div class="rounded border border-blue-100 dark:border-blue-900/40 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-sm text-blue-800 dark:text-blue-100">
  391. {kycStatusMessage || 'Aguardando validação do KYC...'}
  392. </div>
  393. {#if kycError}
  394. <div class="rounded border border-red-200 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 px-3 py-2 text-sm">{kycError}</div>
  395. {/if}
  396. <div class="space-y-2 text-sm text-gray-600 dark:text-gray-300">
  397. <p>Se preferir, abra o link diretamente:</p>
  398. <a
  399. class="inline-flex items-center gap-2 text-blue-600 hover:text-blue-500 text-sm"
  400. href={kycLink}
  401. target="_blank"
  402. rel="noreferrer"
  403. >
  404. <span>📱 Abrir link de verificação</span>
  405. </a>
  406. </div>
  407. <div class="flex flex-col sm:flex-row gap-3">
  408. <button
  409. type="button"
  410. class="w-full inline-flex items-center justify-center rounded border border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 font-medium px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
  411. on:click={handleCancelKycFlow}
  412. >
  413. Voltar ao login
  414. </button>
  415. {#if kycStatusCode === 1}
  416. <button
  417. type="button"
  418. class="w-full inline-flex items-center justify-center rounded bg-green-600 hover:bg-green-700 text-white font-medium px-4 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
  419. on:click={() => handleBackToLogin('KYC concluído com sucesso. Faça login novamente.')}
  420. >
  421. Concluir
  422. </button>
  423. {/if}
  424. </div>
  425. </div>
  426. {:else if kycMode === 'PJ_PENDING'}
  427. <div class="bg-white dark:bg-gray-800 border border-amber-300 dark:border-amber-500/50 rounded-lg shadow-sm p-8 space-y-6">
  428. <div class="text-center space-y-2">
  429. <h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Análise PJ em andamento</h1>
  430. <p class="text-sm text-gray-700 dark:text-gray-300">
  431. {kycStatusMessage || 'Necessário finalizar análise PJ ou contatar o suporte.'}
  432. </p>
  433. </div>
  434. <div class="rounded border border-amber-200 bg-amber-50 dark:border-amber-500/40 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
  435. Nossa equipe está validando os documentos. Assim que concluir, tente acessar novamente ou fale com o suporte.
  436. </div>
  437. <div class="space-y-3">
  438. <a href="mailto:suporte@toeasy.com" class="inline-flex items-center gap-2 text-blue-600 hover:text-blue-500 text-sm">
  439. <span>💬 Contactar suporte</span>
  440. </a>
  441. <button
  442. type="button"
  443. class="w-full inline-flex items-center justify-center rounded bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
  444. on:click={handleCancelKycFlow}
  445. >
  446. Voltar ao login
  447. </button>
  448. </div>
  449. </div>
  450. {:else}
  451. <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-8">
  452. <h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-2">Entrar</h1>
  453. <p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Acesse sua conta para continuar.</p>
  454. {#if error}
  455. <div class="mb-4 rounded border border-red-300 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 px-3 py-2 text-sm">{error}</div>
  456. {/if}
  457. {#if success}
  458. <div class="mb-4 rounded border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 px-3 py-2 text-sm">
  459. {success}
  460. </div>
  461. {/if}
  462. <form on:submit|preventDefault={onSubmit} class="space-y-4">
  463. <div>
  464. <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="email">E-mail</label>
  465. <input
  466. id="email"
  467. name="email"
  468. type="email"
  469. bind:value={email}
  470. class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
  471. placeholder="seu@email.com"
  472. autocomplete="email"
  473. required
  474. />
  475. </div>
  476. <div>
  477. <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="password">Senha</label>
  478. <input
  479. id="password"
  480. name="password"
  481. type="password"
  482. bind:value={password}
  483. class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
  484. placeholder="••••••••"
  485. autocomplete="current-password"
  486. required
  487. />
  488. </div>
  489. <button
  490. class="w-full inline-flex items-center justify-center rounded bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
  491. disabled={loading}
  492. type="submit"
  493. >
  494. {#if loading}
  495. Entrando...
  496. {:else}
  497. Entrar
  498. {/if}
  499. </button>
  500. </form>
  501. </div>
  502. <p class="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
  503. Esqueceu a senha? <a href="https://wa.me/+19997048082" class="text-blue-600 hover:text-blue-500">Fale com o suporte</a>
  504. </p>
  505. <div class="mt-3">
  506. <a href="/register" class="block w-full text-center rounded border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 font-medium px-4 py-2">
  507. Ainda não tem conta? Crie agora
  508. </a>
  509. </div>
  510. {/if}
  511. </div>
  512. </div>