@ciromaciel/auth-react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3531 @@
1
+ import { create } from 'zustand';
2
+ import { useState, useCallback, useEffect, useMemo, createContext } from 'react';
3
+ import { useShallow } from 'zustand/react/shallow';
4
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
+ import { Navigate, Outlet, useNavigate } from 'react-router-dom';
6
+ import { Modal, Stack, Text, Group, Image, Title, Paper, Divider, Button, TextInput, Loader, Anchor, Center, Box, Avatar, Collapse, FileButton, Tooltip, ThemeIcon, Badge, rem, ActionIcon } from '@mantine/core';
7
+ import { useForm } from '@mantine/form';
8
+ import { IconBrandGoogle, IconArrowRight, IconUser, IconPhoto, IconTrash, IconCheck, IconPencil, IconMail, IconShield, IconDevices, IconDeviceMobile, IconLogout, IconUserCircle, IconSettings, IconCreditCard, IconShieldCheck } from '@tabler/icons-react';
9
+
10
+ /**
11
+ * The identity switch — when the panel stops being one person and becomes
12
+ * another in the middle of a live session.
13
+ *
14
+ * It happens in both directions of an impersonation: entering (the response
15
+ * carries a `Set-Cookie` that changes who the server sees in that instant) and
16
+ * leaving (by the button, by the clock, or because another tab ended it). In
17
+ * every case the panel stays mounted for a short interval until the navigation
18
+ * — and during that interval the mounted screens refetch holding the old
19
+ * identity.
20
+ *
21
+ * The server answers those calls with 403 and 401, and it is right to: they are
22
+ * another person's resources, or a session that stopped being valid. What is
23
+ * wrong is SHOWING it — on screen it read as "Acesso negado: Sem permissão para
24
+ * esta organização" in the middle of a switch that was working.
25
+ *
26
+ * The flag lives on `window` because whoever sets it (the SDK) and whoever reads
27
+ * it (each panel's HTTP client) do not share a module. It is never turned off:
28
+ * the whole page is replaced right after, and `window` dies with it.
29
+ *
30
+ * Exported by the package so any panel can mark its own switches — the Auth
31
+ * panel marks the START of an impersonation, a moment only it knows about.
32
+ */
33
+
34
+ const FLAG = '__riligarSwitchingIdentity';
35
+
36
+ /**
37
+ * The event that announces: the person behind this session changed.
38
+ *
39
+ * The SDK knows WHEN the identity switches, but not what each panel keeps — and
40
+ * they all keep something account-shaped. The Auth panel persists the selected
41
+ * organization, and it was that persistence surviving the reload that made the
42
+ * panel ask for `/applications/<the operator's organization>` already as the
43
+ * target. The server refused with 403, correctly, and the screen showed "Acesso
44
+ * negado" AFTER the switch had finished — outside any transition window, which
45
+ * is why silencing the interceptor never fixed it.
46
+ *
47
+ * Each panel listens and clears what is its own. The SDK needs to know no key,
48
+ * and a new panel that persists something solves its own case without touching
49
+ * this file.
50
+ *
51
+ * Fired by `markIdentitySwitching`, which every identity switch already goes
52
+ * through — so a client of this package gets it by listening, with no call of
53
+ * its own to make and no knowledge of when a switch happens.
54
+ */
55
+ const IDENTITY_CHANGED_EVENT = 'riligar:identity-changed';
56
+
57
+ /**
58
+ * Announces the switch, giving listeners a chance to clear before the reload.
59
+ *
60
+ * Synchronous on purpose: `dispatchEvent` only returns once every listener has
61
+ * run, so the caller can reload on the next line knowing storage is already
62
+ * clean.
63
+ */
64
+ function announceIdentityChange(detail) {
65
+ try {
66
+ window.dispatchEvent(new CustomEvent(IDENTITY_CHANGED_EVENT, {
67
+ detail
68
+ }));
69
+ } catch {
70
+ // No `window` (SSR, tests): there is no panel to notify.
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Everything a panel remembers about an account, wiped on every switch.
76
+ *
77
+ * ALLOWLIST, NOT BLOCKLIST — and that is the whole point.
78
+ *
79
+ * The seven panels each persist something account-shaped, under names that
80
+ * follow no single pattern: `organizations` (the selected company),
81
+ * `hoster:pinned`, `rlg-payments-project-storage`,
82
+ * `riligar_storage_active_tenant`, per-product API keys. Listing them would be
83
+ * a blocklist, and a blocklist is wrong here in the way that hurts: a panel
84
+ * that persists something new is silently left out, and the next person to open
85
+ * that tab sees another account's data with nobody's name on it.
86
+ *
87
+ * So the rule is inverted. Everything goes, except the few keys that are about
88
+ * the BROWSER rather than about the person — the session token, which the SDK
89
+ * manages itself and rewrites on the next load, and per-device preferences that
90
+ * carry no account data.
91
+ *
92
+ * Dropping the rest costs a refetch. Keeping it costs showing one person's data
93
+ * under another person's name, which cannot be undone after it is on screen.
94
+ */
95
+ const KEEP = new Set([
96
+ // The SDK's own slot: cleared where it must be (ending an impersonation),
97
+ // and rewritten from the shared cookie on the next load. Wiping it here too
98
+ // would sign the operator out of a tab that only needed to forget the
99
+ // target's lists.
100
+ 'auth:token',
101
+ // Cross-tab logout beacon. It is a timestamp, not account data, and clearing
102
+ // it would break the very synchronisation this file exists to serve.
103
+ 'auth:logout',
104
+ // The switch beacon: a timestamp the sibling tabs listen for. Wiping it
105
+ // would make the NEXT write look unchanged to the browser in some cases,
106
+ // and the event that carries the switch would not fire.
107
+ 'auth:identity-switched']);
108
+ function dropStoredAccountState() {
109
+ try {
110
+ const doomed = [];
111
+ for (let i = 0; i < window.localStorage.length; i++) {
112
+ const k = window.localStorage.key(i);
113
+ if (k && !KEEP.has(k)) doomed.push(k);
114
+ }
115
+ for (const k of doomed) window.localStorage.removeItem(k);
116
+
117
+ // `sessionStorage` too: it survives a reload in the same tab, which is
118
+ // exactly the window this function exists to close.
119
+ window.sessionStorage?.clear();
120
+ } catch {
121
+ // No storage (SSR, private window, blocked cookies): there is nothing
122
+ // persisted to leak.
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The beacon sibling tabs listen for.
128
+ *
129
+ * `storage` only fires in OTHER tabs of the same origin, and only when a value
130
+ * actually changes — so a timestamp is what turns "this tab switched identity"
131
+ * into an event the siblings receive. The SDK writes it, the SDK reads it
132
+ * (`AuthProvider`), and every panel that mounts the provider follows along
133
+ * without writing a line: that is what makes it work for our own seven panels
134
+ * and for anyone else's app on the same footing.
135
+ *
136
+ * It cannot reach ANOTHER origin — no browser mechanism can. Those tabs catch up
137
+ * through the poll, which asks the server on its own schedule.
138
+ */
139
+ const SWITCH_BEACON = 'auth:identity-switched';
140
+ function markIdentitySwitching() {
141
+ try {
142
+ window[FLAG] = true;
143
+ } catch {
144
+ // No `window` (SSR, tests): there is no panel to notify.
145
+ }
146
+ try {
147
+ // Written BEFORE the wipe below, because the wipe removes it again —
148
+ // and it is the write itself, not the value, that the siblings hear.
149
+ window.localStorage.setItem(SWITCH_BEACON, String(Date.now()));
150
+ } catch {
151
+ // No storage: the sibling tabs fall back to the poll.
152
+ }
153
+ // Applies to EVERY identity switch — entering and leaving an impersonation —
154
+ // because in both directions everything remembered starts belonging to
155
+ // another account.
156
+ dropStoredAccountState();
157
+
158
+ /*
159
+ * Wiping storage is not enough on its own: a store that keeps its state in
160
+ * MEMORY and only mirrors it to storage — every `zustand/persist` store is
161
+ * one — survives the wipe untouched, and rewrites the old value on its next
162
+ * `set()`. That is the whole bug this event exists for: the Auth panel's
163
+ * selected organization stayed in memory across the switch, and the screen
164
+ * asked for `/applications/<the target's organization>` as the operator,
165
+ * which the server refused with 403 — correctly.
166
+ *
167
+ * Announced LAST, so a listener that reads storage sees it already clean.
168
+ */
169
+ announceIdentityChange({
170
+ reason: 'switch'
171
+ });
172
+ }
173
+ function clearIdentitySwitching() {
174
+ try {
175
+ window[FLAG] = false;
176
+ } catch {
177
+ // Same reasoning as above.
178
+ }
179
+ }
180
+ function isIdentitySwitching() {
181
+ try {
182
+ return Boolean(window[FLAG]);
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Should a 401 sign the person out — or is it the expected 401 of a switch?
190
+ *
191
+ * Every panel's HTTP client reacts to a 401 by signing out. On the server that
192
+ * `signOut` deletes EVERY session of the person's e-mail across all products
193
+ * (one account, one e-mail, many application rows) — the right thing for a real
194
+ * "sign out everywhere", and a catastrophe for a transient 401.
195
+ *
196
+ * Leaving an impersonation keeps the panel mounted for a moment while it
197
+ * reloads, and calls already in flight carry the dying impersonation token. The
198
+ * server answers 401, correctly. Acting on THAT 401 signed the operator out of
199
+ * everything — the account came back for a second and was then wiped by its own
200
+ * panel. Exiting from the Auth tab happened to dodge it; exiting from Functions,
201
+ * Monitors or Hoster did not, which is why it looked panel-specific.
202
+ *
203
+ * Pure and exported so each panel shares one decision instead of three copies of
204
+ * an inline `&& !isIdentitySwitching()`, and so a single test covers all of
205
+ * them. Pass the flag in (do not read it here) to keep it testable without a
206
+ * `window`.
207
+ */
208
+ function shouldSignOutOn401(switching) {
209
+ return !switching;
210
+ }
211
+
212
+ // The default this package resolves to when an integration does not pass a URL.
213
+ // It ships inside the published bundle, so a stale value here fails on the
214
+ // customer's machine, not on ours.
215
+ let API_BASE = 'https://auth.worker.myinfrastructure.click';
216
+ let API_KEY = null;
217
+ let INTERNAL_MODE = false; // Internal mode: no API key required (same-domain apps)
218
+
219
+ // Permite configurar API key e modo interno externamente (chamado pelo AuthProvider)
220
+ function configure({
221
+ apiKey,
222
+ apiUrl,
223
+ internal = false
224
+ }) {
225
+ if (apiKey) API_KEY = apiKey;
226
+ if (apiUrl) API_BASE = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
227
+ INTERNAL_MODE = internal;
228
+ }
229
+ const isInternal = () => INTERNAL_MODE;
230
+
231
+ /** A URL da API de autenticação em uso. É a origem confiável por definição. */
232
+ const getApiUrl = () => API_BASE;
233
+
234
+ /** A chave onde o token de sessão é guardado. Exposta para quem precisa lê-lo. */
235
+ const TOKEN_STORAGE_KEY = 'auth:token';
236
+
237
+ // helper fetch pré-configurado
238
+ async function api(route, opts = {}) {
239
+ // Garante que a rota comece com /
240
+ const cleanRoute = route.startsWith('/') ? route : `/${route}`;
241
+
242
+ // Constrói URL completa (API_BASE já teve trailing slash removido no configure)
243
+ const url = `${API_BASE}${cleanRoute}`;
244
+ const token = getStoredToken();
245
+ const headers = {
246
+ 'Content-Type': 'application/json',
247
+ Accept: 'application/json',
248
+ ...opts.headers
249
+ };
250
+
251
+ // Adiciona Authorization header se tiver token
252
+ if (token) {
253
+ headers.Authorization = `Bearer ${token}`;
254
+ }
255
+
256
+ // Adiciona API Key se configurada e não estiver em modo interno
257
+ if (API_KEY && !INTERNAL_MODE) {
258
+ headers['X-API-Key'] = API_KEY;
259
+ }
260
+ const res = await fetch(url, {
261
+ headers,
262
+ credentials: 'include',
263
+ // Required for sending session cookies
264
+ ...opts
265
+ });
266
+
267
+ // Converte JSON automaticamente e lança erro legível
268
+ const data = res.status !== 204 ? await res.json().catch(() => ({})) : null;
269
+ if (!res.ok) {
270
+ /*
271
+ * O contrato: `{error: {code, message, details?}}`.
272
+ *
273
+ * `code` é o identificador estável — programe contra ele. `message` é
274
+ * a frase para o humano e pode mudar de redação a qualquer momento.
275
+ * `details` traz o que dá para agir (o limite estourado, o campo que
276
+ * faltou), e vai no Error para quem trata sem precisar do corpo.
277
+ */
278
+ const payloadError = data?.error;
279
+ const failure = new Error(payloadError?.message || res.statusText);
280
+ failure.res = res;
281
+ failure.data = data;
282
+ failure.status = res.status;
283
+ failure.code = payloadError?.code ?? null;
284
+ failure.details = payloadError?.details ?? null;
285
+ failure.retriable = Boolean(payloadError?.retriable);
286
+ throw failure;
287
+ }
288
+ return data;
289
+ }
290
+
291
+ // Gerenciamento de token no localStorage
292
+ function getStoredToken() {
293
+ if (typeof window === 'undefined') return null;
294
+ return window.localStorage.getItem(TOKEN_STORAGE_KEY);
295
+ }
296
+
297
+ // Exportada: o store precisa DESCARTAR o token quando o servidor não
298
+ // reconhece a sessão. Sem isso, um valor obsoleto sobrevive no localStorage e
299
+ // o handoff do OAuth o envia no `#token=`, derrubando a autorização.
300
+ function setStoredToken(token) {
301
+ if (typeof window === 'undefined') return;
302
+ if (token) {
303
+ window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
304
+ } else {
305
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
306
+ }
307
+ }
308
+ // Helper para processar resposta de autenticação e salvar token
309
+ function handleAuthResponse(result) {
310
+ // Tenta encontrar o token em vários lugares possíveis
311
+ const token = result.token || result.session?.token || result.session?.sessionToken;
312
+ if (token) {
313
+ setStoredToken(token);
314
+ }
315
+ return result;
316
+ }
317
+
318
+ // Decodifica JWT (apenas payload, sem verificação de assinatura)
319
+ function decodeJWT(token) {
320
+ try {
321
+ const parts = token.split('.');
322
+ if (parts.length !== 3) return null;
323
+
324
+ // Safe base64 decode
325
+ const base64Url = parts[1];
326
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
327
+
328
+ // Verifica ambiente para decodificar
329
+ const jsonPayload = typeof window !== 'undefined' ? window.atob(base64) : Buffer.from(base64, 'base64').toString();
330
+ return JSON.parse(jsonPayload);
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ // Verifica se o token está expirado
337
+ function isTokenExpired(token) {
338
+ const payload = decodeJWT(token);
339
+
340
+ // Se não for um JWT válido (payload null), assumimos que é um token opaco (session token)
341
+ // Nesse caso, deixamos o servidor validar via 401
342
+ if (!payload) return false;
343
+ if (!payload.exp) return false;
344
+ const now = Date.now();
345
+ const exp = payload.exp * 1000;
346
+ const isExpired = now >= exp;
347
+ if (isExpired) {
348
+ console.log('[AuthSDK] Token expired:', {
349
+ now,
350
+ exp,
351
+ diff: exp - now
352
+ });
353
+ }
354
+ return isExpired;
355
+ }
356
+
357
+ // Verifica se o usuário está autenticado
358
+ function isAuthenticated() {
359
+ const token = getStoredToken();
360
+ const valid = token && !isTokenExpired(token);
361
+ return valid;
362
+ }
363
+
364
+ // Obtém dados do usuário do token
365
+ function getCurrentUser() {
366
+ const token = getStoredToken();
367
+ if (!token || isTokenExpired(token)) return null;
368
+ const payload = decodeJWT(token);
369
+ return payload ? {
370
+ id: payload.sub,
371
+ email: payload.email,
372
+ name: payload.name,
373
+ ...payload
374
+ } : null;
375
+ }
376
+
377
+ /*--- sign in: ask for the code, trade the code ----*/
378
+ //
379
+ // These are the only two calls that authenticate. There is no separate sign-up:
380
+ // someone who never signed in and someone who already did walk exactly the same
381
+ // path, and `requestCode`'s answer is the same in both cases — it does not tell
382
+ // whether the email already had an account.
383
+
384
+ /**
385
+ * Asks for the sign-in code. The email goes out right away; the response does
386
+ * not wait for it.
387
+ *
388
+ * @param {string} email
389
+ * @param {{ name?: string }} [options] Name used only if the account is created now.
390
+ * @returns {Promise<{ sent: boolean, expiresIn: number, interval: number, deviceCode: string }>}
391
+ */
392
+ const requestCode = async (email, {
393
+ name
394
+ } = {}) => {
395
+ return await api('/auth/code/start', {
396
+ method: 'POST',
397
+ body: JSON.stringify({
398
+ email,
399
+ ...(name ? {
400
+ name
401
+ } : {})
402
+ })
403
+ });
404
+ };
405
+
406
+ /**
407
+ * Trades the code for the session. This is the whole sign-in.
408
+ *
409
+ * @param {string} email The same email that asked for the code.
410
+ * @param {string} code As the person typed it — case, spaces and the hyphen are optional.
411
+ * @returns {Promise<{ user: object, token: string, session: object }>}
412
+ */
413
+ const verifyCode = async (email, code) => {
414
+ const result = await api('/auth/code/verify', {
415
+ method: 'POST',
416
+ body: JSON.stringify({
417
+ email,
418
+ code
419
+ })
420
+ });
421
+ return handleAuthResponse(result);
422
+ };
423
+
424
+ /**
425
+ * Variant for whoever CANNOT read the email — an agent waiting for someone else
426
+ * to present the code. Returns `{ pending: true, interval }` while nobody has
427
+ * approved; in the browser, use `verifyCode`.
428
+ *
429
+ * @param {string} deviceCode The value returned by `requestCode`.
430
+ */
431
+ const pollCode = async deviceCode => {
432
+ try {
433
+ const result = await api('/auth/code/poll', {
434
+ method: 'POST',
435
+ body: JSON.stringify({
436
+ deviceCode
437
+ })
438
+ });
439
+ return handleAuthResponse(result);
440
+ } catch (error) {
441
+ // 428/429 are not failures: they mean "not yet" and "slow down".
442
+ // Whoever polls needs to tell those apart from a dead code, which is a
443
+ // real error.
444
+ if (error?.status === 428 || error?.status === 429) {
445
+ return {
446
+ pending: true,
447
+ interval: error?.details?.interval ?? 5
448
+ };
449
+ }
450
+ throw error;
451
+ }
452
+ };
453
+ const signOut = async () => {
454
+ try {
455
+ await api('/auth/sign-out', {
456
+ method: 'POST'
457
+ });
458
+ } catch {
459
+ // Ignores sign-out errors on the server
460
+ } finally {
461
+ setStoredToken(null);
462
+ }
463
+ };
464
+ const refreshToken = async () => {
465
+ try {
466
+ const result = await api('/auth/refresh', {
467
+ method: 'POST'
468
+ });
469
+ return handleAuthResponse(result);
470
+ } catch (error) {
471
+ setStoredToken(null);
472
+ throw error;
473
+ }
474
+ };
475
+
476
+ /*--- Impersonation ------------------------------*/
477
+ /*
478
+ * Encerra a impersonação em curso.
479
+ *
480
+ * Não manda token nem id de sessão: o cookie de impersonação viaja sozinho
481
+ * (`credentials: 'include'`) e é ele que autoriza. Quem está dentro da
482
+ * impersonação é, por definição, quem pode fechá-la.
483
+ *
484
+ * O servidor responde limpando o cookie; depois disso o painel volta a ser o
485
+ * operador sozinho, e um reload basta para a tela acompanhar.
486
+ */
487
+ const endImpersonation = async impersonationId => api(`/impersonation/${impersonationId}/end`, {
488
+ method: 'POST'
489
+ });
490
+
491
+ /*--- Session ------------------------------------*/
492
+ // O cookie `riligar.session_token` é compartilhado por `.myinfrastructure.click`, então
493
+ // um painel recém-aberto autentica aqui SEM ter nada no localStorage (que é
494
+ // isolado por origem). Nesse caso semeamos o token local a partir da resposta:
495
+ // `isAuthenticated()` e o refresh em background leem do localStorage, e sem
496
+ // isto o painel ficaria autenticado no servidor mas "deslogado" no cliente —
497
+ // e o token venceria sem nunca ser renovado naquela origem.
498
+ // A gravação é INCONDICIONAL, e não só quando o slot está vazio. A chave é
499
+ // fixa por origem, então um token de sessão já encerrada sobrevive a logout,
500
+ // troca de conta e expiração — e `if (!getStoredToken())` nunca o substituía.
501
+ // O servidor acabou de dizer qual é a sessão desta requisição; ele é a
502
+ // autoridade, e o valor local que discorda dele é resíduo.
503
+ //
504
+ // Importa além do painel: o handoff do OAuth lê ESTE valor para montar o
505
+ // `#token=`, então um resíduo aqui derruba a autorização inteira com
506
+ // "Sessão inválida ou expirada", num ponto que não aponta para a causa.
507
+ const getSession = async () => {
508
+ const result = await api('/auth/session');
509
+ const token = result?.token || result?.session?.token;
510
+ if (token && token !== getStoredToken()) setStoredToken(token);
511
+ return result;
512
+ };
513
+ const listSessions = async () => {
514
+ // As SESSÕES, não o envelope: a assinatura sempre devolveu a lista, e é a
515
+ // ergonomia certa para quem só quer renderizá-las.
516
+ const body = await api('/auth/list-sessions');
517
+ return Array.isArray(body?.items) ? body.items : [];
518
+ };
519
+ const revokeSession = async id => {
520
+ return await api('/auth/revoke-session-by-id', {
521
+ method: 'POST',
522
+ body: JSON.stringify({
523
+ id
524
+ })
525
+ });
526
+ };
527
+ const revokeOtherSessions = async () => {
528
+ return await api('/auth/revoke-other-sessions', {
529
+ method: 'POST'
530
+ });
531
+ };
532
+
533
+ /*--- Application Info ----------------------------*/
534
+ const getApplicationInfo = async () => {
535
+ try {
536
+ // O recurso vem na RAIZ.
537
+ return (await api('/application/by-api-key')) || null;
538
+ } catch (error) {
539
+ console.warn('[AuthSDK] Failed to fetch application info:', error.message);
540
+ return null;
541
+ }
542
+ };
543
+
544
+ /*--- Profile Management ---------------------------*/
545
+ const updateProfile = async data => {
546
+ return await api('/auth/update-user', {
547
+ method: 'POST',
548
+ body: JSON.stringify(data)
549
+ });
550
+ };
551
+
552
+ /*--- Social sign-in -------------------------------------------------------*/
553
+
554
+ /**
555
+ * The providers this application has enabled.
556
+ *
557
+ * Public: it answers a screen where nobody is signed in yet. It carries only
558
+ * `{ provider, name }` — never the client id, which identifies the owner's
559
+ * project at Google.
560
+ *
561
+ * Returns `[]` on failure rather than throwing: a sign-in screen that cannot
562
+ * reach this endpoint must still render the email field, which is the path that
563
+ * always works.
564
+ */
565
+ const getSocialProviders = async () => {
566
+ try {
567
+ const response = await api('/auth/providers');
568
+ return response?.items || [];
569
+ } catch (error) {
570
+ console.warn('[AuthSDK] Failed to fetch social providers:', error.message);
571
+ return [];
572
+ }
573
+ };
574
+
575
+ /**
576
+ * Leaves for the provider.
577
+ *
578
+ * A full-page navigation, not `fetch`: the person has to SEE Google's consent
579
+ * screen, and an XHR would be blocked by CORS anyway. The API key travels in the
580
+ * query string because a redirect carries no headers — it is the public `pu_`,
581
+ * which is already in the bundle.
582
+ *
583
+ * `redirect` is where to come back to; the worker validates it against the
584
+ * application's allowlist BEFORE leaving, and stores the validated value. It
585
+ * defaults to the current page.
586
+ */
587
+ const startSocialSignIn = (provider, {
588
+ redirect
589
+ } = {}) => {
590
+ const destination = redirect || window.location.href.split('#')[0];
591
+ const url = new URL(`${API_BASE}/auth/sign-in/${provider}`);
592
+ url.searchParams.set('redirect', destination);
593
+ if (API_KEY && !INTERNAL_MODE) url.searchParams.set('api_key', API_KEY);
594
+ window.location.assign(url.toString());
595
+ };
596
+
597
+ /**
598
+ * Reads the token the callback left in the fragment, and cleans the URL.
599
+ *
600
+ * The token comes back after `#` precisely so it never reaches a server, a log
601
+ * or a `Referer`. Once read it is removed from the address bar with
602
+ * `replaceState`, so a copied URL does not carry a live session.
603
+ *
604
+ * Returns the token when there was one, `null` otherwise.
605
+ */
606
+ const consumeSocialToken = () => {
607
+ if (typeof window === 'undefined' || !window.location.hash) return null;
608
+ const params = new URLSearchParams(window.location.hash.slice(1));
609
+ const token = params.get('token');
610
+ if (!token) return null;
611
+
612
+ /*
613
+ * A social sign-in can land on a tab that already belongs to SOMEBODY ELSE.
614
+ *
615
+ * Coming back from Google replaces the session, and the person behind it may
616
+ * not be the one who left: signing in with a Google account whose email has
617
+ * no user here creates a NEW user, with an organization of its own.
618
+ *
619
+ * Swapping the token alone is not enough. Every panel keeps account-shaped
620
+ * state — the Auth panel persists the selected organization — and a
621
+ * `zustand/persist` store holds it in MEMORY, where clearing storage does
622
+ * not reach. The stale organization then rides the NEW token into the next
623
+ * request, and the server answers 403 "Sem permissão para esta organização"
624
+ * — correctly, about a switch that actually worked.
625
+ *
626
+ * That is the exact failure `markIdentitySwitching` exists for, and which
627
+ * impersonation already routes through. Social sign-in is one more identity
628
+ * switch and has to go through it too.
629
+ *
630
+ * Compared by `sub`, not by token string: a refresh rotates the token for
631
+ * the SAME person, and treating that as a switch would wipe their panel
632
+ * state for nothing.
633
+ */
634
+ const previous = getStoredToken();
635
+ if (previous && previous !== token) {
636
+ const before = decodeJWT(previous)?.sub;
637
+ const after = decodeJWT(token)?.sub;
638
+ // Only when we can read both and they disagree. An unreadable token is
639
+ // not evidence of a switch, and guessing here would clear state on a
640
+ // malformed value.
641
+ if (before && after && before !== after) markIdentitySwitching();
642
+ }
643
+ setStoredToken(token);
644
+ params.delete('token');
645
+ const rest = params.toString();
646
+ window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${rest ? `#${rest}` : ''}`);
647
+ return token;
648
+ };
649
+
650
+ /**
651
+ * The failure the callback reported, if any. Also clears it from the URL.
652
+ *
653
+ * The worker sends a short opaque code — never the provider's own error text,
654
+ * which echoes back parts of our request. Phrasing lives here, next to the other
655
+ * user-facing strings.
656
+ */
657
+ const consumeSocialError = () => {
658
+ if (typeof window === 'undefined') return null;
659
+ const params = new URLSearchParams(window.location.search);
660
+ const reason = params.get('social_error');
661
+ if (!reason) return null;
662
+ params.delete('social_error');
663
+ const rest = params.toString();
664
+ window.history.replaceState(null, '', `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`);
665
+ return reason;
666
+ };
667
+
668
+ /** Starts linking a provider to the account of the CURRENT session. */
669
+ const startSocialLink = async (provider, {
670
+ redirect
671
+ } = {}) => {
672
+ const destination = redirect || window.location.href.split('#')[0];
673
+ const response = await api(`/auth/link/${provider}`, {
674
+ method: 'POST',
675
+ body: JSON.stringify({
676
+ redirect: destination
677
+ })
678
+ });
679
+ if (response?.authorizeUrl) window.location.assign(response.authorizeUrl);
680
+ return response;
681
+ };
682
+ const unlinkSocialProvider = async provider => {
683
+ return await api(`/auth/unlink/${provider}`, {
684
+ method: 'POST'
685
+ });
686
+ };
687
+
688
+ /** The providers linked to the current session's user. */
689
+ const getLinkedProviders = async () => {
690
+ const response = await api('/auth/linked-providers');
691
+ return response?.items || [];
692
+ };
693
+
694
+ // ============================================================================
695
+ // REDIRECT PÓS-LOGIN
696
+ // ============================================================================
697
+ //
698
+ // Quem inicia um fluxo OAuth (o servidor MCP, por exemplo) manda o usuário ao
699
+ // painel com `?redirect=` apontando de volta. Sem tratar esse parâmetro, o
700
+ // login termina na home do painel e a autorização fica órfã.
701
+ //
702
+ // A lógica vive aqui, e não em cada aplicação, porque ela tem duas armadilhas
703
+ // que não se quer reimplementar três vezes:
704
+ //
705
+ // 1. `redirect` sem allowlist transforma a tela de login em open redirect —
706
+ // um link `?redirect=https://phishing.example` levaria o usuário para lá
707
+ // logo após ele digitar a senha, num domínio que ele confia.
708
+ //
709
+ // 2. O token vive em `localStorage`, que é isolado POR ORIGEM. O worker do
710
+ // Auth (auth.worker.*) não consegue lê-lo do painel (produto.dashboard.*),
711
+ // então ele precisa ser entregue — e o fragmento (`#token=`) é o único
712
+ // canal que não chega ao servidor nem entra em log de acesso.
713
+ // ============================================================================
714
+
715
+
716
+ /**
717
+ * Origens para as quais é seguro redirecionar após o login.
718
+ *
719
+ * A origem da própria API entra sempre: é para lá que o fluxo OAuth volta, e
720
+ * o SDK já a conhece por `configure({ apiUrl })`. A origem atual também, por
721
+ * ser a mesma página. `extraOrigins` cobre o resto (um site institucional que
722
+ * inicie o login, por exemplo).
723
+ */
724
+ function allowedOrigins(extraOrigins = []) {
725
+ const list = [];
726
+ try {
727
+ list.push(new URL(getApiUrl()).origin);
728
+ } catch {}
729
+ if (typeof window !== 'undefined') list.push(window.location.origin);
730
+ for (const raw of extraOrigins) {
731
+ try {
732
+ list.push(new URL(raw).origin);
733
+ } catch {}
734
+ }
735
+ return list;
736
+ }
737
+ const isLocalhost = hostname => hostname === 'localhost' || hostname === '127.0.0.1';
738
+
739
+ /**
740
+ * Valida o `redirect` recebido e devolve o destino, ou `null`.
741
+ *
742
+ * Caminho interno (`/algo`) passa sempre — não sai do domínio. URL absoluta só
743
+ * passa se a origem constar na allowlist e for HTTPS: aceitar `http://` num
744
+ * host permitido exporia o token em trânsito.
745
+ */
746
+ function resolveRedirect(raw, extraOrigins = []) {
747
+ if (!raw || typeof raw !== 'string') return null;
748
+
749
+ // `//` seria protocol-relative, que sai do domínio apesar de parecer path.
750
+ if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
751
+ let url;
752
+ try {
753
+ url = new URL(raw);
754
+ } catch {
755
+ return null;
756
+ }
757
+
758
+ // Localhost passa em desenvolvimento: o destino é a máquina do próprio
759
+ // usuário, não um servidor de terceiro.
760
+ if (isLocalhost(url.hostname)) return raw;
761
+ if (url.protocol !== 'https:') return null;
762
+ return allowedOrigins(extraOrigins).includes(url.origin) ? raw : null;
763
+ }
764
+
765
+ /**
766
+ * Executa o redirect pós-login.
767
+ *
768
+ * Devolve `true` quando assumiu a navegação — o chamador então não deve
769
+ * navegar por conta própria. `false` significa "não havia redirect válido,
770
+ * siga o seu fluxo normal".
771
+ *
772
+ * @param {string|null} target destino já validado por `resolveRedirect`
773
+ * @param {(path: string) => void} navigate roteador da aplicação, para paths internos
774
+ * @param {boolean} withToken anexar o token no fragmento (necessário quando o
775
+ * destino é outra origem que precisa da sessão, como o handoff do OAuth)
776
+ */
777
+ function applyRedirect(target, navigate, {
778
+ withToken = true
779
+ } = {}) {
780
+ if (!target) return false;
781
+
782
+ // Caminho interno: o roteador resolve, sem recarregar a página.
783
+ if (target.startsWith('/')) {
784
+ navigate?.(target, {
785
+ replace: true
786
+ });
787
+ return true;
788
+ }
789
+ if (typeof window === 'undefined') return false;
790
+ let finalUrl = target;
791
+ if (withToken) {
792
+ const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
793
+ // O fragmento nunca é enviado ao servidor: não aparece em log de
794
+ // acesso nem no Referer. É o canal certo para um token.
795
+ if (token) finalUrl = `${target}#token=${encodeURIComponent(token)}`;
796
+ }
797
+ window.location.replace(finalUrl);
798
+ return true;
799
+ }
800
+
801
+ /**
802
+ * Lê o `redirect` da URL atual, valida e devolve o destino (ou `null`).
803
+ *
804
+ * Atalho para o caso comum: o componente não precisa mexer em `URLSearchParams`
805
+ * nem lembrar o nome do parâmetro.
806
+ */
807
+ function getRedirectFromLocation(extraOrigins = [], paramName = 'redirect') {
808
+ if (typeof window === 'undefined') return null;
809
+ const raw = new URLSearchParams(window.location.search).get(paramName);
810
+ return resolveRedirect(raw, extraOrigins);
811
+ }
812
+
813
+ // Shortest gap between two focus-driven session reads.
814
+ const REVALIDATE_THROTTLE_MS = 5000;
815
+
816
+ // Estado: { user, loading, error }
817
+ const useAuthStore = create((set, get) => ({
818
+ user: null,
819
+ loading: true,
820
+ error: null,
821
+ // Timestamp of the last focus revalidation, for throttling.
822
+ lastRevalidatedAt: 0,
823
+ // Session management
824
+ sessions: [],
825
+ currentSession: null,
826
+ // Loading states granulares
827
+ loadingStates: {
828
+ requestCode: false,
829
+ verifyCode: false,
830
+ signOut: false,
831
+ updateProfile: false,
832
+ listSessions: false,
833
+ revokeSession: null // null or sessionId being revoked
834
+ },
835
+ // Application info (logo, nome, etc)
836
+ applicationInfo: null,
837
+ /*
838
+ * Preenchido quando ESTA sessão é emprestada — `/auth/session` devolve
839
+ * `{ actor, expiresAt }`. É o que a barra lê para avisar, em qualquer painel,
840
+ * que quem está logado não é quem está olhando.
841
+ */
842
+ impersonation: null,
843
+ // Helper para atualizar loading states
844
+ setLoading: (key, value) => set(state => ({
845
+ loadingStates: {
846
+ ...state.loadingStates,
847
+ [key]: value
848
+ }
849
+ })),
850
+ // Buscar informações da aplicação
851
+ fetchApplicationInfo: async () => {
852
+ try {
853
+ const appInfo = await getApplicationInfo();
854
+ set({
855
+ applicationInfo: appInfo
856
+ });
857
+ } catch (error) {
858
+ console.warn('[AuthStore] Failed to fetch application info:', error);
859
+ set({
860
+ applicationInfo: null
861
+ });
862
+ }
863
+ },
864
+ /*
865
+ * Reads the session from the server and mirrors it into the store.
866
+ *
867
+ * Shared by `init` (on mount) and `revalidate` (on focus). `trusted` says
868
+ * whether a failure is allowed to sign the user out: on mount there is
869
+ * nothing to lose, but on a revalidation the user is already signed in and
870
+ * a flaky network must not drop them.
871
+ */
872
+ syncSession: async ({
873
+ trusted
874
+ }) => {
875
+ try {
876
+ // Busca a sessão (que agora também traz applicationInfo)
877
+ const sessionData = await getSession();
878
+
879
+ // Se veio aplicação no redirecionamento/sessão, salva no store
880
+ if (sessionData?.application) {
881
+ set({
882
+ applicationInfo: sessionData.application
883
+ });
884
+ }
885
+
886
+ // `?? null` e não `|| undefined`: a ausência do campo é a resposta
887
+ // normal (ninguém impersonando) e tem que LIMPAR o estado anterior,
888
+ // senão a barra sobrevive ao encerramento.
889
+ set({
890
+ impersonation: sessionData?.impersonation ?? null
891
+ });
892
+ const user = sessionData?.user ?? null;
893
+ if (sessionData?.session) {
894
+ set({
895
+ currentSession: sessionData.session
896
+ });
897
+ }
898
+
899
+ // Se não encontrou sessão via cookies, verifica localStorage token (JWT)
900
+ //
901
+ // `isAuthenticated()` só confere o `exp` do JWT — não fala com o
902
+ // servidor. Uma sessão revogada tem `exp` no futuro e passaria por
903
+ // aqui: o painel se mostraria logado com um token que o servidor já
904
+ // não reconhece. Como o handoff do OAuth envia ESTE token no
905
+ // `#token=`, o resíduo derrubava a autorização com "Sessão inválida
906
+ // ou expirada" — longe da causa.
907
+ //
908
+ // O servidor acabou de responder sem sessão; ele é a autoridade.
909
+ if (!user) {
910
+ /*
911
+ * "No user" WITH a stored token is not a logout — it is a token
912
+ * that stopped being valid. The right answer is to ask again,
913
+ * without it.
914
+ *
915
+ * This is the path of whoever ended the impersonation in ANOTHER
916
+ * tab. `/auth/session` with a dead token answers 200 and WITHOUT
917
+ * a user (not 401: a session that does not exist is the normal
918
+ * "nobody signed in" path). This branch then concluded "logged
919
+ * out" and stopped there — while the operator's session was
920
+ * alive in the cookie, shared across the zone and never touched.
921
+ *
922
+ * The retry goes without `Authorization`, the cookie speaks, and
923
+ * the operator becomes themselves again. One attempt only: if
924
+ * this one also comes back without a user, there is no session
925
+ * behind it and logging out is correct.
926
+ */
927
+ if (isAuthenticated()) {
928
+ setStoredToken(null);
929
+ const semToken = await getSession().catch(() => null);
930
+ if (semToken?.user) {
931
+ const anterior = get().user;
932
+ const trocou = anterior && anterior.id !== semToken.user.id;
933
+ set({
934
+ user: semToken.user,
935
+ currentSession: semToken.session ?? null,
936
+ impersonation: semToken.impersonation ?? null,
937
+ loading: false
938
+ });
939
+ if (trocou && typeof window !== 'undefined') {
940
+ markIdentitySwitching();
941
+ window.location.reload();
942
+ }
943
+ return;
944
+ }
945
+ }
946
+ set({
947
+ user: null,
948
+ currentSession: null,
949
+ impersonation: null,
950
+ loading: false
951
+ });
952
+ return;
953
+ }
954
+
955
+ /*
956
+ * THE IDENTITY CHANGED UNDER THE SCREEN: drop everything and reload.
957
+ *
958
+ * This is the guard that makes the server the single truth. The
959
+ * seven panels hold data in memory and in their own stores —
960
+ * projects, lists, counters — and none of them knows when the person
961
+ * behind the session stopped being the same one.
962
+ *
963
+ * It happens for real when an impersonation is ended in ANOTHER tab:
964
+ * `localStorage` is per origin, so Hoster never learns that Functions
965
+ * ended it. On the next focus it revalidates, the server answers with
966
+ * the operator — and the screen was left with one person's footer and
967
+ * another's list, two people's data at once.
968
+ *
969
+ * Swapping `user` in the store would not be enough: whoever already
970
+ * read the list does not read it again. Reloading is the only way to
971
+ * guarantee that NOTHING of the previous identity survives — and it
972
+ * is cheap, because it only happens on the transition.
973
+ *
974
+ * `previous` comes from the state, not from a module variable: under
975
+ * SSR and in tests the module is shared, and the comparison has to be
976
+ * per store.
977
+ */
978
+ const previous = get().user;
979
+ const identityChanged = previous && previous.id !== user.id;
980
+ if (identityChanged && typeof window !== 'undefined') {
981
+ /*
982
+ * Mark the switch BEFORE reloading.
983
+ *
984
+ * The reload is not instantaneous: in-flight calls finish, and
985
+ * mounted screens still fire their own. All of them carry the old
986
+ * identity and get 403 — which surfaced as "Acesso negado: Sem
987
+ * permissão para esta organização" in the middle of a switch that
988
+ * was working.
989
+ *
990
+ * This guard reloaded without marking, and was the last place the
991
+ * notification escaped through.
992
+ */
993
+ markIdentitySwitching();
994
+ set({
995
+ user,
996
+ loading: false
997
+ });
998
+ window.location.reload();
999
+ return;
1000
+ }
1001
+ set({
1002
+ user,
1003
+ loading: false
1004
+ });
1005
+ } catch (error) {
1006
+ // Só descartamos o token quando o SERVIDOR o recusou (401). Uma
1007
+ // falha de rede não diz nada sobre a validade da sessão, e limpar
1008
+ // aqui deslogaria quem só perdeu conexão por um instante.
1009
+ const rejected = error?.res?.status === 401;
1010
+
1011
+ /*
1012
+ * Token refused: drop it AND ASK AGAIN, without it.
1013
+ *
1014
+ * Clearing alone was not enough, and that is what made the operator
1015
+ * lose their account when ending an impersonation in ANOTHER tab.
1016
+ * `localStorage` is per origin: ending in Functions does not remove
1017
+ * the target's token stored in Hoster and Auth. Coming back to that
1018
+ * tab, it revalidated with a dead token, took a 401, cleared it — and
1019
+ * stopped there, concluding "logged out".
1020
+ *
1021
+ * But the operator's session is alive in the cookie, shared across
1022
+ * the zone and never touched. The second attempt goes without
1023
+ * `Authorization`, the cookie speaks, and they become themselves
1024
+ * again — which is what the person expected when ending it in any of
1025
+ * the tabs.
1026
+ *
1027
+ * One attempt only: if this one fails too, there is no session
1028
+ * behind it, and the path below (logging out) is correct.
1029
+ */
1030
+ if (rejected) {
1031
+ setStoredToken(null);
1032
+ try {
1033
+ const semToken = await getSession();
1034
+ if (semToken?.user) {
1035
+ /*
1036
+ * This path ALSO swaps the identity on screen.
1037
+ *
1038
+ * It is the one taken when the impersonation is ended in
1039
+ * another tab: the target's token is refused, the cookie
1040
+ * returns the operator — but the screen stays mounted
1041
+ * with the target's lists, which nobody will re-read.
1042
+ *
1043
+ * Same guard as the ordinary revalidation: mark the
1044
+ * switch to silence the in-flight 403s, and reload so
1045
+ * nothing of the previous identity survives.
1046
+ */
1047
+ const anterior = get().user;
1048
+ const trocou = anterior && anterior.id !== semToken.user.id;
1049
+ set({
1050
+ user: semToken.user,
1051
+ currentSession: semToken.session ?? null,
1052
+ impersonation: semToken.impersonation ?? null,
1053
+ loading: false
1054
+ });
1055
+ if (trocou && typeof window !== 'undefined') {
1056
+ markIdentitySwitching();
1057
+ window.location.reload();
1058
+ }
1059
+ return;
1060
+ }
1061
+ } catch {
1062
+ // Sem sessão atrás do token morto: segue para o caminho
1063
+ // normal e desloga, que é o comportamento correto.
1064
+ }
1065
+ }
1066
+
1067
+ // An unreachable server says nothing about an established session.
1068
+ // On a revalidation the user is already signed in: keep them, and
1069
+ // try again on the next focus.
1070
+ if (!rejected && !trusted) {
1071
+ console.warn('[AuthStore] Revalidação falhou, mantendo a sessão:', error);
1072
+ return;
1073
+ }
1074
+ console.error('Erro na inicialização:', error);
1075
+ set({
1076
+ user: null,
1077
+ currentSession: null,
1078
+ loading: false
1079
+ });
1080
+ }
1081
+ },
1082
+ /* Init ao montar o Provider */
1083
+ init: async () => {
1084
+ await get().syncSession({
1085
+ trusted: true
1086
+ });
1087
+ },
1088
+ /*
1089
+ * Re-reads the session when the tab regains focus.
1090
+ *
1091
+ * `init` runs once, so a tab opened BEFORE the user signed in elsewhere
1092
+ * stays on `user: null` until a manual refresh. The session cookie is
1093
+ * shared across the zone, and localStorage is not — it is per origin — so
1094
+ * the `storage` event never crosses between two dashboards. Asking the
1095
+ * server on focus is what carries a sign-in (and a sign-out) between them.
1096
+ *
1097
+ * Throttled: `focus` and `visibilitychange` fire together, and alt-tabbing
1098
+ * would otherwise turn into a burst of `/auth/session` calls.
1099
+ */
1100
+ revalidate: async ({
1101
+ force = false
1102
+ } = {}) => {
1103
+ const now = Date.now();
1104
+ // `force` skips the floor between reads: a caller on its own interval
1105
+ // (the impersonation poll) already controls the frequency, and the floor
1106
+ // exists for focus, which fires several times in a row when windows
1107
+ // change.
1108
+ if (!force && now - get().lastRevalidatedAt < REVALIDATE_THROTTLE_MS) return;
1109
+ set({
1110
+ lastRevalidatedAt: now
1111
+ });
1112
+ await get().syncSession({
1113
+ trusted: false
1114
+ });
1115
+ },
1116
+ /* Ações de Autenticação */
1117
+ /* Sign in — ask for the code and trade it for the session. No third step. */
1118
+
1119
+ requestCode: async (email, options) => {
1120
+ const {
1121
+ setLoading
1122
+ } = get();
1123
+ setLoading('requestCode', true);
1124
+ set({
1125
+ error: null
1126
+ });
1127
+ try {
1128
+ return await requestCode(email, options);
1129
+ } catch (err) {
1130
+ set({
1131
+ error: err
1132
+ });
1133
+ throw err;
1134
+ } finally {
1135
+ setLoading('requestCode', false);
1136
+ }
1137
+ },
1138
+ verifyCode: async (email, code) => {
1139
+ const {
1140
+ setLoading
1141
+ } = get();
1142
+ setLoading('verifyCode', true);
1143
+ set({
1144
+ error: null
1145
+ });
1146
+ try {
1147
+ const result = await verifyCode(email, code);
1148
+
1149
+ // The session comes with the response; without it the "this device"
1150
+ // badge cannot identify the current session.
1151
+ if (result.session) set({
1152
+ currentSession: result.session
1153
+ });
1154
+ set({
1155
+ user: result.user || null,
1156
+ loading: false
1157
+ });
1158
+ return result;
1159
+ } catch (err) {
1160
+ set({
1161
+ error: err
1162
+ });
1163
+ throw err;
1164
+ } finally {
1165
+ setLoading('verifyCode', false);
1166
+ }
1167
+ },
1168
+ signOut: async () => {
1169
+ const {
1170
+ setLoading
1171
+ } = get();
1172
+ setLoading('signOut', true);
1173
+ try {
1174
+ await signOut();
1175
+ set({
1176
+ user: null
1177
+ });
1178
+ // Sincronizar logout entre abas
1179
+ if (typeof window !== 'undefined') {
1180
+ window.localStorage.setItem('auth:logout', Date.now());
1181
+ }
1182
+ } finally {
1183
+ setLoading('signOut', false);
1184
+ }
1185
+ },
1186
+ /* Session */
1187
+ getSession: async () => {
1188
+ try {
1189
+ const sessionData = await getSession();
1190
+ // Store current session for comparison (includes id)
1191
+ if (sessionData?.session) {
1192
+ set({
1193
+ currentSession: sessionData.session
1194
+ });
1195
+ }
1196
+ return sessionData;
1197
+ } catch (err) {
1198
+ set({
1199
+ error: err
1200
+ });
1201
+ throw err;
1202
+ }
1203
+ },
1204
+ listSessions: async () => {
1205
+ const {
1206
+ setLoading
1207
+ } = get();
1208
+ setLoading('listSessions', true);
1209
+ set({
1210
+ error: null
1211
+ });
1212
+ try {
1213
+ const result = await listSessions();
1214
+ set({
1215
+ sessions: result || []
1216
+ });
1217
+ setLoading('listSessions', false);
1218
+ return result;
1219
+ } catch (err) {
1220
+ set({
1221
+ error: err,
1222
+ sessions: []
1223
+ });
1224
+ setLoading('listSessions', false);
1225
+ throw err;
1226
+ }
1227
+ },
1228
+ revokeSession: async sessionId => {
1229
+ const {
1230
+ setLoading,
1231
+ currentSession,
1232
+ sessions,
1233
+ signOut
1234
+ } = get();
1235
+ setLoading('revokeSession', sessionId);
1236
+ set({
1237
+ error: null
1238
+ });
1239
+ try {
1240
+ // Detect if current session OR last remaining session
1241
+ const isCurrent = sessionId === currentSession?.id;
1242
+ const isLast = sessions.length === 1 && sessions[0].id === sessionId;
1243
+
1244
+ // Se for a sessão atual ou a última, faz logout normal
1245
+ if (isCurrent || isLast) {
1246
+ await signOut();
1247
+ // signOut já limpa loading states e erros no finally, mas
1248
+ // como estamos dentro do fluxo deste método, garantimos:
1249
+ setLoading('revokeSession', null);
1250
+ return;
1251
+ }
1252
+ await revokeSession(sessionId);
1253
+
1254
+ // Remove a sessão revogada da lista local
1255
+ set(state => ({
1256
+ sessions: state.sessions.filter(s => s.id !== sessionId)
1257
+ }));
1258
+ setLoading('revokeSession', null);
1259
+ } catch (err) {
1260
+ set({
1261
+ error: err
1262
+ });
1263
+ setLoading('revokeSession', null);
1264
+ throw err;
1265
+ }
1266
+ },
1267
+ revokeOtherSessions: async () => {
1268
+ const {
1269
+ setLoading,
1270
+ listSessions
1271
+ } = get();
1272
+ setLoading('revokeSession', 'all');
1273
+ set({
1274
+ error: null
1275
+ });
1276
+ try {
1277
+ await revokeOtherSessions();
1278
+ // Refresh sessions list after revocation
1279
+ await listSessions();
1280
+ setLoading('revokeSession', null);
1281
+ } catch (err) {
1282
+ set({
1283
+ error: err
1284
+ });
1285
+ setLoading('revokeSession', null);
1286
+ throw err;
1287
+ }
1288
+ },
1289
+ /* Refresh do token em background */
1290
+ startRefresh: () => {
1291
+ if (typeof window === 'undefined') return;
1292
+ const refreshInterval = setInterval(async () => {
1293
+ try {
1294
+ // Se o usuário está autenticado mas o token está próximo do vencimento
1295
+ if (isAuthenticated()) {
1296
+ const token = window.localStorage.getItem('auth:token');
1297
+ if (token) {
1298
+ // Usa o decoder oficial do SDK que é mais seguro
1299
+ const payload = decodeJWT(token);
1300
+
1301
+ // Se não for um JWT ou não tiver expiração, não fazemos refresh em background
1302
+ // O backend cuidará da expiração da sessão opaca via 401 nas requisições normais
1303
+ if (!payload || !payload.exp) return;
1304
+ const now = Date.now() / 1000;
1305
+ const timeUntilExpiry = payload.exp - now;
1306
+
1307
+ // Se o token expira em menos de 5 minutos, tenta o refresh
1308
+ if (timeUntilExpiry < 300) {
1309
+ try {
1310
+ const refreshed = await refreshToken();
1311
+ const user = getCurrentUser();
1312
+ set({
1313
+ user
1314
+ });
1315
+ // O refresh rotaciona o token mantendo o mesmo id de sessão.
1316
+ if (refreshed?.session) set({
1317
+ currentSession: refreshed.session
1318
+ });
1319
+ } catch (refreshErr) {
1320
+ console.warn('[AuthStore] Falha ao renovar token:', refreshErr);
1321
+ // Só desloga se for um erro de autenticação explícito (401)
1322
+ if (refreshErr.res?.status === 401) {
1323
+ set({
1324
+ user: null
1325
+ });
1326
+ window.localStorage.removeItem('auth:token');
1327
+ }
1328
+ }
1329
+ }
1330
+ }
1331
+ }
1332
+ } catch (error) {
1333
+ // Erros de processamento interno não devem deslogar o usuário
1334
+ console.error('[AuthStore] Erro no ciclo de refresh automático:', error);
1335
+ }
1336
+ }, 4 * 60 * 1000); // Verifica a cada 4 minutos
1337
+
1338
+ // Limpa o intervalo quando necessário
1339
+ if (typeof window !== 'undefined') {
1340
+ window.addEventListener('beforeunload', () => {
1341
+ clearInterval(refreshInterval);
1342
+ });
1343
+ }
1344
+ },
1345
+ /* Verifica se o token ainda é válido */
1346
+ checkTokenValidity: () => {
1347
+ if (!isAuthenticated()) {
1348
+ set({
1349
+ user: null
1350
+ });
1351
+ return false;
1352
+ }
1353
+ return true;
1354
+ },
1355
+ /* Atualizar usuário manualmente */
1356
+ setUser: user => set({
1357
+ user
1358
+ }),
1359
+ /* Profile Management */
1360
+ updateProfile: async data => {
1361
+ const {
1362
+ setLoading
1363
+ } = get();
1364
+ setLoading('updateProfile', true);
1365
+ set({
1366
+ error: null
1367
+ });
1368
+ try {
1369
+ const result = await updateProfile(data);
1370
+ // Atualiza o user no store com os novos dados
1371
+ set(state => ({
1372
+ user: state.user ? {
1373
+ ...state.user,
1374
+ ...data
1375
+ } : null
1376
+ }));
1377
+ setLoading('updateProfile', false);
1378
+ return result;
1379
+ } catch (err) {
1380
+ set({
1381
+ error: err
1382
+ });
1383
+ setLoading('updateProfile', false);
1384
+ throw err;
1385
+ }
1386
+ }
1387
+ }));
1388
+
1389
+ function remaining(expiresAt) {
1390
+ if (!expiresAt) return null;
1391
+ const ms = new Date(expiresAt).getTime() - Date.now();
1392
+ if (ms <= 0) return null;
1393
+ const totalSeconds = Math.floor(ms / 1000);
1394
+ return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
1395
+ }
1396
+
1397
+ /*
1398
+ * A FLOATING PILL, NOT A STRIP AT THE TOP — and this is a layout decision, not
1399
+ * a cosmetic one.
1400
+ *
1401
+ * The bar used to be `position: sticky; top: 0`, rendered just above the panel's
1402
+ * own tree. That takes vertical space in the document flow, and all seven panels
1403
+ * lay themselves out with Mantine's `AppShell`, which positions its Header and
1404
+ * Navbar at offsets it computes from its OWN props (`header={{ height }}`, and
1405
+ * that height is responsive — 56 on mobile, 0 on desktop in some panels). The
1406
+ * strip pushed that whole construction down: the panel's header ended up under
1407
+ * the bar, and the sidebar got clipped.
1408
+ *
1409
+ * The SDK cannot fix that by measuring: it would have to know each panel's
1410
+ * header height, at every breakpoint, and stay in sync with seven layouts it
1411
+ * does not own. Any number it picks is wrong somewhere.
1412
+ *
1413
+ * So the bar stops competing for the top of the page. `position: fixed` with
1414
+ * `inset: auto 0 20px` takes it out of the flow entirely — it reserves no
1415
+ * space, displaces nothing, and floats over the content at the BOTTOM, where no
1416
+ * panel puts a fixed header. The visibility that the feature depends on is
1417
+ * preserved: it is always on screen, centred, and impossible to scroll away
1418
+ * from. The risk this component exists to cover is the operator FORGETTING they
1419
+ * are inside someone else's account, and a floating pill answers that as well
1420
+ * as a strip did — without breaking the screen underneath.
1421
+ *
1422
+ * `pointerEvents` is handled in two layers: the full-width wrapper lets clicks
1423
+ * through (it spans the viewport and would otherwise swallow a row of the UI),
1424
+ * and the pill itself takes them back so the button stays clickable.
1425
+ */
1426
+ const styles = {
1427
+ wrap: {
1428
+ position: 'fixed',
1429
+ left: 0,
1430
+ right: 0,
1431
+ bottom: 20,
1432
+ zIndex: 2147483647,
1433
+ display: 'flex',
1434
+ justifyContent: 'center',
1435
+ // The strip spans the viewport; without this it would eat clicks on
1436
+ // whatever sits behind it for the full width of the screen.
1437
+ pointerEvents: 'none',
1438
+ // Mobile: never let the pill run under the home indicator.
1439
+ paddingLeft: 'max(12px, env(safe-area-inset-left))',
1440
+ paddingRight: 'max(12px, env(safe-area-inset-right))'
1441
+ },
1442
+ bar: {
1443
+ display: 'flex',
1444
+ alignItems: 'center',
1445
+ justifyContent: 'center',
1446
+ flexWrap: 'wrap',
1447
+ gap: 10,
1448
+ padding: '10px 16px',
1449
+ borderRadius: 999,
1450
+ // The brand's black. It stands out by INVERSION, not by an alert
1451
+ // colour: red would say "something went wrong", and the state is
1452
+ // "you are borrowed".
1453
+ background: '#11181C',
1454
+ color: '#FFFFFF',
1455
+ fontSize: 13,
1456
+ fontFamily: 'inherit',
1457
+ lineHeight: 1.4,
1458
+ maxWidth: '100%',
1459
+ // Detaches the pill from the content it floats over, which a flat strip
1460
+ // did not need because it had an edge to sit against.
1461
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)',
1462
+ pointerEvents: 'auto'
1463
+ },
1464
+ strong: {
1465
+ fontWeight: 700
1466
+ },
1467
+ clock: {
1468
+ fontVariantNumeric: 'tabular-nums',
1469
+ opacity: 0.75
1470
+ },
1471
+ button: {
1472
+ marginLeft: 4,
1473
+ padding: '4px 12px',
1474
+ border: 0,
1475
+ borderRadius: 999,
1476
+ background: '#FFFFFF',
1477
+ color: '#11181C',
1478
+ font: 'inherit',
1479
+ fontSize: 12,
1480
+ fontWeight: 700,
1481
+ cursor: 'pointer'
1482
+ }
1483
+ };
1484
+ function ImpersonationBanner() {
1485
+ const impersonation = useAuthStore(s => s.impersonation);
1486
+ const user = useAuthStore(s => s.user);
1487
+ const [left, setLeft] = useState(() => remaining(impersonation?.expiresAt));
1488
+ const [ending, setEnding] = useState(false);
1489
+
1490
+ /*
1491
+ * Encerrar recarrega a página.
1492
+ *
1493
+ * The whole panel is already mounted with the target's data — lists,
1494
+ * stores, caches. Swapping the identity underneath would leave half the
1495
+ * screen with one person's data and half with another's. Reloading is the
1496
+ * simplest way to make everything come back coherent, and it happens once,
1497
+ * at the end of the session.
1498
+ */
1499
+ /*
1500
+ * O vencimento, sem clique.
1501
+ *
1502
+ * It does not call `/impersonation/:id/end`: the session is already gone,
1503
+ * and the route would answer 403 ("belongs to another session") because the
1504
+ * presented token died. What is left to do is local — drop the target's
1505
+ * token — and let the server say who the person is on the next load.
1506
+ *
1507
+ * The impersonation cookie needs no explicit clearing: it was issued with a
1508
+ * `Max-Age` equal to the session TTL, so the browser discards it on its own.
1509
+ * And even if it lingered a moment, `requireAuth` tries the candidates in
1510
+ * order and falls through to the operator's session.
1511
+ */
1512
+ const endedByTimer = useCallback(() => {
1513
+ markIdentitySwitching();
1514
+ setStoredToken(null);
1515
+ window.location.assign('/');
1516
+ }, []);
1517
+ const handleEnd = async () => {
1518
+ if (!impersonation?.id) return;
1519
+ setEnding(true);
1520
+ markIdentitySwitching();
1521
+ try {
1522
+ await endImpersonation(impersonation.id);
1523
+
1524
+ /*
1525
+ * The TARGET's token is in localStorage — the SDK wrote it there
1526
+ * so the other products would send the right header. Ending without
1527
+ * dropping it would leave the panel presenting itself as the target
1528
+ * in every product that reads that key, even with the cookie gone.
1529
+ *
1530
+ * The reload below asks for the session again, and the operator's
1531
+ * session cookie (never touched) rewrites the correct value.
1532
+ */
1533
+ setStoredToken(null);
1534
+ } catch {
1535
+ // If it fails, the session expires on its own within minutes.
1536
+ // Reloading anyway avoids leaving the operator stuck in a bar that
1537
+ // does not answer.
1538
+ }
1539
+ /*
1540
+ * Goes to the panel's ROOT, instead of reloading the current URL.
1541
+ *
1542
+ * The URL where an impersonation ends usually belongs to the TARGET — a
1543
+ * project of theirs in Hoster, an application of theirs in Auth.
1544
+ * Reloading there returns the operator to a route that may not be
1545
+ * theirs, and the screen answers 403 or sits in a skeleton forever.
1546
+ *
1547
+ * `/` is the home of every panel in the suite, and the only route that
1548
+ * answers for any identity. Each product decides what it means.
1549
+ */
1550
+ window.location.assign('/');
1551
+ };
1552
+ useEffect(() => {
1553
+ if (!impersonation) return;
1554
+ const timer = setInterval(() => {
1555
+ const restante = remaining(impersonation.expiresAt);
1556
+ setLeft(restante);
1557
+
1558
+ /*
1559
+ * Vencido: a tela tem que acompanhar, e sem ninguém clicar em nada.
1560
+ *
1561
+ * Until now the clock hit zero and nothing else happened: the bar
1562
+ * stayed on screen, `localStorage` kept the target's dead token, and
1563
+ * every product answered 401 — which panels read as the end of a
1564
+ * session and handle by signing out. The operator lost their own
1565
+ * account because a borrowed session expired.
1566
+ *
1567
+ * `endedByTimer` walks the same path as the button: drop the
1568
+ * target's token and go to the root. What decides who shows up next
1569
+ * is the SERVER — the operator's session cookie was never touched,
1570
+ * so if it is alive the home loads as them; if there is no session
1571
+ * behind it, `/auth/session` answers without a user and the panel
1572
+ * sends them to the login, which is correct. There is nothing to
1573
+ * guess here.
1574
+ */
1575
+ if (!restante) endedByTimer();
1576
+ }, 1000);
1577
+ return () => clearInterval(timer);
1578
+ }, [impersonation, endedByTimer]);
1579
+ if (!impersonation) return null;
1580
+
1581
+ // Durante o primeiro segundo `left` ainda é o valor de outra impersonação
1582
+ // (ou nulo); derivar aqui evita um setState no corpo do efeito só para isso.
1583
+ const clock = left ?? remaining(impersonation.expiresAt);
1584
+ return /*#__PURE__*/jsx("div", {
1585
+ style: styles.wrap,
1586
+ children: /*#__PURE__*/jsxs("div", {
1587
+ style: styles.bar,
1588
+ role: "status",
1589
+ children: [/*#__PURE__*/jsxs("span", {
1590
+ children: ["Voc\xEA est\xE1 vendo como ", /*#__PURE__*/jsx("span", {
1591
+ style: styles.strong,
1592
+ children: user?.name || user?.email
1593
+ }), impersonation.actor ? /*#__PURE__*/jsxs(Fragment, {
1594
+ children: [" \xB7 sess\xE3o aberta por ", impersonation.actor]
1595
+ }) : null]
1596
+ }), clock ? /*#__PURE__*/jsxs("span", {
1597
+ style: styles.clock,
1598
+ children: ["encerra em ", clock]
1599
+ }) : null, impersonation.id ? /*#__PURE__*/jsx("button", {
1600
+ type: "button",
1601
+ style: styles.button,
1602
+ onClick: handleEnd,
1603
+ disabled: ending,
1604
+ children: ending ? 'Encerrando…' : 'Encerrar'
1605
+ }) : null]
1606
+ })
1607
+ });
1608
+ }
1609
+
1610
+ const AuthContext = /*#__PURE__*/createContext(); // só para ter o Provider em JSX
1611
+
1612
+ // How often a tab asks the server who it is. Short enough for an identity
1613
+ // switch to surface on its own, long enough not to weigh on an idle panel.
1614
+ const IMPERSONATION_POLL_MS = 10 * 1000;
1615
+ function AuthProvider({
1616
+ children,
1617
+ apiKey,
1618
+ // API Key para header X-API-Key (obrigatória exceto em modo internal)
1619
+ apiUrl,
1620
+ // URL do manager (opcional, padrão: http://auth.worker.myinfrastructure.click)
1621
+ internal = false,
1622
+ // Modo interno: não exige API Key (para aplicações same-domain como dashboard)
1623
+ onError // Callback de erro global
1624
+ }) {
1625
+ // Validação de props obrigatórias
1626
+ // apiKey só é obrigatória se não estiver em modo internal
1627
+ if (!internal && !apiKey) {
1628
+ throw new Error('[@ciromaciel/auth-react] apiKey é obrigatória no AuthProvider. ' + 'Obtenha sua API Key no dashboard em https://auth.dashboard.myinfrastructure.click');
1629
+ }
1630
+ const init = useAuthStore(s => s.init);
1631
+ const startRefresh = useAuthStore(s => s.startRefresh);
1632
+ const revalidate = useAuthStore(s => s.revalidate);
1633
+ const checkTokenValidity = useAuthStore(s => s.checkTokenValidity);
1634
+
1635
+ // Configura SDK com apiKey, apiUrl e modo interno
1636
+ // Usamos useMemo para garantir que a configuração ocorra ANTES dos efeitos dos componentes filhos
1637
+ useMemo(() => {
1638
+ configure({
1639
+ apiKey,
1640
+ apiUrl,
1641
+ internal
1642
+ });
1643
+
1644
+ /*
1645
+ * A social sign-in landing here brings its token in the fragment.
1646
+ *
1647
+ * It is consumed in this `useMemo`, BEFORE `init()` runs in the effect
1648
+ * below: `init` reads the stored token to resolve the session, so a
1649
+ * token still sitting in the URL at that moment would be missed, and the
1650
+ * person would land signed out on the page they just signed into.
1651
+ *
1652
+ * Reading it also strips it from the address bar, so a copied URL never
1653
+ * carries a live session.
1654
+ */
1655
+ consumeSocialToken();
1656
+ }, [apiKey, apiUrl, internal]);
1657
+ useEffect(() => {
1658
+ init();
1659
+ startRefresh();
1660
+ }, [init, startRefresh]);
1661
+
1662
+ // Sincronização entre abas - escuta logout e mudanças no token
1663
+ useEffect(() => {
1664
+ if (typeof window === 'undefined') return;
1665
+ const handleStorageChange = event => {
1666
+ if (event.key === 'auth:logout') {
1667
+ // Limpa o user do store - a aplicação redireciona automaticamente quando user é null
1668
+ useAuthStore.setState({
1669
+ user: null,
1670
+ currentSession: null,
1671
+ sessions: []
1672
+ });
1673
+ }
1674
+ /*
1675
+ * Another tab of this origin switched identity.
1676
+ *
1677
+ * It clears its own storage and reloads, but each tab has its own
1678
+ * mounted screens — lists and counters that belong to whoever was
1679
+ * signed in a moment ago. Wiping here and reloading is what makes a
1680
+ * sibling tab follow along instead of showing the previous person's
1681
+ * data under the new person's name.
1682
+ *
1683
+ * This is the SDK's job, not each app's: every consumer of
1684
+ * `AuthProvider` gets it without writing anything, ours and our
1685
+ * customers' alike.
1686
+ */
1687
+ if (event.key === 'auth:identity-switched') {
1688
+ markIdentitySwitching();
1689
+ window.location.assign('/');
1690
+ return;
1691
+ }
1692
+
1693
+ // The token changed in another tab of THIS origin.
1694
+ //
1695
+ // `checkTokenValidity` only reads the stored JWT's `exp`, which
1696
+ // cannot tell a different person from the same one — and after an
1697
+ // impersonation starts or ends, a different person is exactly what
1698
+ // it is. Asking the server is what makes the sibling tab follow the
1699
+ // switch instead of sitting on the previous identity's data.
1700
+ //
1701
+ // `force` skips the focus throttle: this is an event, not a stream,
1702
+ // and a background tab may never get focus to catch up.
1703
+ if (event.key === 'auth:token') {
1704
+ revalidate({
1705
+ force: true
1706
+ });
1707
+ }
1708
+ };
1709
+
1710
+ // Escuta evento de sessão revogada (quando o usuário revoga sua própria sessão)
1711
+ const handleSessionRevoked = () => {
1712
+ // Limpa o usuário do store
1713
+ useAuthStore.setState({
1714
+ user: null,
1715
+ currentSession: null,
1716
+ sessions: []
1717
+ });
1718
+ // Dispara evento de logout para sincronizar entre abas
1719
+ localStorage.setItem('auth:logout', Date.now());
1720
+ };
1721
+ window.addEventListener('storage', handleStorageChange);
1722
+ window.addEventListener('auth:session-revoked', handleSessionRevoked);
1723
+ return () => {
1724
+ window.removeEventListener('storage', handleStorageChange);
1725
+ window.removeEventListener('auth:session-revoked', handleSessionRevoked);
1726
+ };
1727
+ }, [revalidate]);
1728
+
1729
+ // Revalidate the session when the tab regains focus.
1730
+ //
1731
+ // `init` runs once: a tab opened BEFORE the user signed in on another
1732
+ // dashboard stays on `user: null` until a manual refresh. The session
1733
+ // cookie belongs to the zone, but localStorage is per origin — so the
1734
+ // `storage` event never crosses from one dashboard to another. Asking the
1735
+ // server on focus is what carries a sign-in (and a sign-out) between them.
1736
+ useEffect(() => {
1737
+ if (typeof window === 'undefined') return;
1738
+ const handleFocus = () => {
1739
+ if (document.visibilityState === 'visible') revalidate();
1740
+ };
1741
+ document.addEventListener('visibilitychange', handleFocus);
1742
+ window.addEventListener('focus', handleFocus);
1743
+ return () => {
1744
+ document.removeEventListener('visibilitychange', handleFocus);
1745
+ window.removeEventListener('focus', handleFocus);
1746
+ };
1747
+ }, [revalidate]);
1748
+
1749
+ // Verifica validade do token periodicamente
1750
+ useEffect(() => {
1751
+ if (typeof window === 'undefined') return;
1752
+ const interval = setInterval(() => {
1753
+ checkTokenValidity();
1754
+ }, 30 * 1000); // Verifica a cada 30 segundos
1755
+
1756
+ return () => clearInterval(interval);
1757
+ }, [checkTokenValidity]);
1758
+
1759
+ // Ask the server every so often — even with the tab in the background, and
1760
+ // WITHOUT depending on knowing an impersonation is open.
1761
+ //
1762
+ // `checkTokenValidity` above only reads the `exp` of the stored JWT, and a
1763
+ // target's token has an `exp` in the future: it stays "valid" after the
1764
+ // impersonation ended. Focus revalidation does not help a tab nobody
1765
+ // touched. That is what left Functions showing the target after ending
1766
+ // elsewhere: nothing in that tab had a reason to ask again.
1767
+ //
1768
+ // Gating this on `impersonation` was the obvious move and the wrong one: it
1769
+ // makes the recovery depend on the very state that goes stale. When the
1770
+ // store lost `impersonation` while still holding the target as `user` — the
1771
+ // bar disappeared and the data did not — the poll stopped with it, and the
1772
+ // tab had no way back at all.
1773
+ //
1774
+ // Unconditional, the tab always has a way back. One request every ten
1775
+ // seconds against a route that answers from the session is cheap; being
1776
+ // stuck as another person is not.
1777
+ useEffect(() => {
1778
+ if (typeof window === 'undefined') return;
1779
+ const interval = setInterval(() => revalidate({
1780
+ force: true
1781
+ }), IMPERSONATION_POLL_MS);
1782
+ return () => clearInterval(interval);
1783
+ }, [revalidate]);
1784
+
1785
+ // Contexto com onError callback
1786
+ const contextValue = useMemo(() => ({
1787
+ onError
1788
+ }), [onError]);
1789
+
1790
+ /*
1791
+ * The impersonation bar ships WITH the Provider.
1792
+ *
1793
+ * That is what makes all seven panels warn without any of them being
1794
+ * changed: they all already wrap the app in `AuthProvider`. Letting each
1795
+ * panel mount it by hand is how one of them gets left out — and the panel
1796
+ * left out is precisely where the operator forgets whose account they are
1797
+ * in.
1798
+ *
1799
+ * It renders nothing when no impersonation is open, so it costs nothing in
1800
+ * the normal case.
1801
+ */
1802
+ return /*#__PURE__*/jsxs(AuthContext.Provider, {
1803
+ value: contextValue,
1804
+ children: [/*#__PURE__*/jsx(ImpersonationBanner, {}), children]
1805
+ });
1806
+ }
1807
+
1808
+ /* Hooks "facade" que a app vai usar */
1809
+ const useAuth = () => useAuthStore(useShallow(s => ({
1810
+ user: s.user,
1811
+ loading: s.loading,
1812
+ error: s.error,
1813
+ isAuthenticated: s.user !== null,
1814
+ requestCode: s.requestCode,
1815
+ verifyCode: s.verifyCode,
1816
+ signOut: s.signOut
1817
+ })));
1818
+
1819
+ /**
1820
+ * Sign in: the two steps, and nothing else.
1821
+ *
1822
+ * `requestCode(email, { name })` sends the code. `verifyCode(email, code)`
1823
+ * returns the session. There is no separate sign-up — the first entry creates
1824
+ * the account.
1825
+ */
1826
+ const useSignIn = () => useAuthStore(useShallow(s => ({
1827
+ requestCode: s.requestCode,
1828
+ verifyCode: s.verifyCode,
1829
+ sending: s.loadingStates.requestCode,
1830
+ verifying: s.loadingStates.verifyCode,
1831
+ error: s.error
1832
+ })));
1833
+
1834
+ // Auth Actions
1835
+ const useSignOut = () => useAuthStore(s => s.signOut);
1836
+ const useCheckToken = () => useAuthStore(s => s.checkTokenValidity);
1837
+
1838
+ // Session Hook
1839
+ const useSession = () => useAuthStore(useShallow(s => ({
1840
+ getSession: s.getSession,
1841
+ user: s.user,
1842
+ setUser: s.setUser
1843
+ })));
1844
+
1845
+ // Loading States Hook
1846
+ const useAuthLoading = () => useAuthStore(s => s.loadingStates);
1847
+
1848
+ // Profile Management Hook (novo nome estilo Clerk)
1849
+ const useUser = () => useAuthStore(useShallow(s => ({
1850
+ user: s.user,
1851
+ updateProfile: s.updateProfile,
1852
+ loadingUpdateProfile: s.loadingStates.updateProfile,
1853
+ error: s.error
1854
+ })));
1855
+
1856
+ // Sessions Management Hook
1857
+ const useSessions = () => useAuthStore(useShallow(s => ({
1858
+ currentSession: s.currentSession,
1859
+ sessions: s.sessions,
1860
+ getSession: s.getSession,
1861
+ listSessions: s.listSessions,
1862
+ revokeSession: s.revokeSession,
1863
+ revokeOtherSessions: s.revokeOtherSessions,
1864
+ loadingListSessions: s.loadingStates.listSessions,
1865
+ loadingRevokeSession: s.loadingStates.revokeSession,
1866
+ error: s.error
1867
+ })));
1868
+
1869
+ /**
1870
+ * The impersonation in progress, or `null`.
1871
+ *
1872
+ * Exposed so a panel can adapt what it OFFERS while the session is borrowed.
1873
+ * The banner already says whose account this is; this is for the screens that
1874
+ * would otherwise invite the target to do something only an administrator does
1875
+ * — "create your first company" makes no sense for an end user being looked at.
1876
+ *
1877
+ * Returns the record (`{ id, actor, expiresAt }`), so a screen can name who
1878
+ * opened the session without a second call.
1879
+ */
1880
+ const useImpersonation = () => useAuthStore(s => s.impersonation);
1881
+
1882
+ // Application Logo Hook
1883
+ const useApplicationLogo = () => {
1884
+ const applicationInfo = useAuthStore(s => s.applicationInfo);
1885
+ // Retorna o logo da aplicação ou null (componentes usam fallback padrão)
1886
+ return applicationInfo?.image || null;
1887
+ };
1888
+
1889
+ function Protect({
1890
+ fallback = /*#__PURE__*/jsx("p", {
1891
+ children: "\u231B Carregando..."
1892
+ }),
1893
+ redirectTo = '/login'
1894
+ }) {
1895
+ const {
1896
+ user,
1897
+ loading
1898
+ } = useAuth();
1899
+ if (loading) return fallback;
1900
+ if (!user) return /*#__PURE__*/jsx(Navigate, {
1901
+ to: redirectTo,
1902
+ replace: true
1903
+ });
1904
+ return /*#__PURE__*/jsx(Outlet, {});
1905
+ }
1906
+
1907
+ function GuestOnly({
1908
+ children,
1909
+ fallback = null,
1910
+ redirectTo = '/',
1911
+ extraOrigins = []
1912
+ }) {
1913
+ const {
1914
+ user,
1915
+ loading
1916
+ } = useAuth();
1917
+ if (loading) return fallback;
1918
+ if (!user) return children ?? /*#__PURE__*/jsx(Outlet, {});
1919
+ const to = getRedirectFromLocation(extraOrigins) || redirectTo;
1920
+
1921
+ // `//alvo` parece caminho e sai do domínio: é externo, e o roteador desta
1922
+ // aplicação não o alcança.
1923
+ const isInternal = to.startsWith('/') && !to.startsWith('//');
1924
+ if (!isInternal) {
1925
+ window.location.replace(to);
1926
+ return fallback;
1927
+ }
1928
+ return /*#__PURE__*/jsx(Navigate, {
1929
+ to: to,
1930
+ replace: true
1931
+ });
1932
+ }
1933
+
1934
+ function AuthCard({
1935
+ children,
1936
+ title,
1937
+ subtitle,
1938
+ logo,
1939
+ logoWidth = 133,
1940
+ width = 350,
1941
+ // Variant props
1942
+ variant = 'card',
1943
+ opened,
1944
+ onClose,
1945
+ modalProps = {},
1946
+ ...props
1947
+ }) {
1948
+ // Conteúdo interno compartilhado entre Card e Modal
1949
+ const content = /*#__PURE__*/jsxs(Stack, {
1950
+ gap: "sm",
1951
+ children: [(logo || title || subtitle) && /*#__PURE__*/jsxs(Stack, {
1952
+ gap: 6,
1953
+ align: "center",
1954
+ ta: "center",
1955
+ children: [logo && (typeof logo === 'string' ? /*#__PURE__*/jsx(Image, {
1956
+ src: logo,
1957
+ alt: "Auth",
1958
+ mx: "auto",
1959
+ w: logoWidth,
1960
+ fit: "contain"
1961
+ }) : logo), title && /*#__PURE__*/jsx(Title, {
1962
+ order: 3,
1963
+ ta: "center",
1964
+ children: title
1965
+ }), subtitle && /*#__PURE__*/jsx(Text, {
1966
+ size: "sm",
1967
+ c: "dimmed",
1968
+ ta: "center",
1969
+ children: subtitle
1970
+ })]
1971
+ }), children]
1972
+ });
1973
+
1974
+ // Renderizar como Modal
1975
+ if (variant === 'modal') {
1976
+ return /*#__PURE__*/jsx(Modal, {
1977
+ opened: opened,
1978
+ onClose: onClose,
1979
+ size: width + 50,
1980
+ withCloseButton: true,
1981
+ radius: 0,
1982
+ overlayProps: {
1983
+ backgroundOpacity: 0.55,
1984
+ blur: 3
1985
+ },
1986
+ title: /*#__PURE__*/jsxs(Group, {
1987
+ gap: "sm",
1988
+ wrap: "nowrap",
1989
+ children: [logo && (typeof logo === 'string' ? /*#__PURE__*/jsx(Image, {
1990
+ src: logo,
1991
+ alt: "Auth",
1992
+ h: logoWidth,
1993
+ fit: "contain"
1994
+ }) : logo), title && /*#__PURE__*/jsx(Title, {
1995
+ order: 4,
1996
+ children: title
1997
+ })]
1998
+ }),
1999
+ ...modalProps,
2000
+ children: /*#__PURE__*/jsxs(Stack, {
2001
+ gap: "sm",
2002
+ children: [subtitle && /*#__PURE__*/jsx(Text, {
2003
+ size: "sm",
2004
+ c: "dimmed",
2005
+ children: subtitle
2006
+ }), children]
2007
+ })
2008
+ });
2009
+ }
2010
+
2011
+ // Renderizar como Card (default)
2012
+ return /*#__PURE__*/jsx(Paper, {
2013
+ withBorder: true,
2014
+ shadow: "none",
2015
+ p: 24
2016
+ /*
2017
+ * `w` fixo nao encolhe: num celular de 375px o cartao de 350px
2018
+ * mais o padding da pagina estourava a tela e a coluna da direita
2019
+ * ficava cortada. `maw` mantem a MESMA largura onde ela cabe e
2020
+ * cede onde nao cabe.
2021
+ */,
2022
+ w: "100%",
2023
+ maw: width,
2024
+ radius: 0,
2025
+ ...props,
2026
+ children: content
2027
+ });
2028
+ }
2029
+
2030
+ const MARKS = {
2031
+ google: IconBrandGoogle
2032
+ };
2033
+
2034
+ /**
2035
+ * The social sign-in buttons, driven by what the APPLICATION has enabled.
2036
+ *
2037
+ * WHY THE LIST IS FETCHED AND NOT PASSED IN
2038
+ *
2039
+ * The owner configures Google in the Auth panel. Asking the integrator to ALSO
2040
+ * declare it in code would be two sources for one fact, and they would drift:
2041
+ * turning the provider off in the panel would leave a button that fails. So the
2042
+ * component asks the worker which providers are live.
2043
+ *
2044
+ * It renders NOTHING while it does not know, and nothing when the answer is
2045
+ * empty — an application with no social provider sees no divider, no gap, no
2046
+ * trace of this component.
2047
+ */
2048
+ function SocialButtons({
2049
+ labels = {},
2050
+ redirect,
2051
+ disabled = false
2052
+ }) {
2053
+ const [providers, setProviders] = useState(null);
2054
+ const [leaving, setLeaving] = useState(null);
2055
+ useEffect(() => {
2056
+ let active = true;
2057
+ getSocialProviders().then(list => {
2058
+ // The screen may have unmounted while the request was in flight.
2059
+ if (active) setProviders(list);
2060
+ });
2061
+ return () => {
2062
+ active = false;
2063
+ };
2064
+ }, []);
2065
+
2066
+ // `null` is "still asking", `[]` is "asked, and there are none". Both render
2067
+ // nothing, but only the second is a final answer.
2068
+ if (!providers || providers.length === 0) return null;
2069
+ return /*#__PURE__*/jsxs(Stack, {
2070
+ gap: "md",
2071
+ children: [/*#__PURE__*/jsx(Divider, {
2072
+ label: labels.socialDivider || 'ou',
2073
+ labelPosition: "center"
2074
+ }), providers.map(provider => {
2075
+ const Mark = MARKS[provider.provider];
2076
+ return /*#__PURE__*/jsx(Button, {
2077
+ variant: "default",
2078
+ fullWidth: true,
2079
+ size: "md"
2080
+ /*
2081
+ * `aria-disabled`, not `disabled`: Mantine's `disabled`
2082
+ * repaints the button grey and hides its label, and this
2083
+ * button is what the person is trying to click. The
2084
+ * handler below is what actually refuses.
2085
+ */,
2086
+ "aria-disabled": disabled || leaving !== null,
2087
+ loading: leaving === provider.provider,
2088
+ onClick: () => {
2089
+ if (disabled || leaving !== null) return;
2090
+ // Kept in state so a double click does not fire two
2091
+ // navigations — the second would orphan the first
2092
+ // `oauth_states` row.
2093
+ setLeaving(provider.provider);
2094
+ startSocialSignIn(provider.provider, {
2095
+ redirect
2096
+ });
2097
+ },
2098
+ children: /*#__PURE__*/jsxs(Group, {
2099
+ gap: 10,
2100
+ wrap: "nowrap",
2101
+ justify: "center",
2102
+ children: [Mark && /*#__PURE__*/jsx(Mark, {
2103
+ size: 16,
2104
+ stroke: 1.5
2105
+ }), /*#__PURE__*/jsx(Text, {
2106
+ fz: 14,
2107
+ fw: 700,
2108
+ children: labels.socialButton ? labels.socialButton(provider.name) : `Entrar com ${provider.name}`
2109
+ })]
2110
+ })
2111
+ }, provider.provider);
2112
+ })]
2113
+ });
2114
+ }
2115
+
2116
+ function Wordmark({
2117
+ fz = 11,
2118
+ c = 'gray.4',
2119
+ ...props
2120
+ }) {
2121
+ return /*#__PURE__*/jsx(Text, {
2122
+ component: "span",
2123
+ display: "block",
2124
+ ta: "center",
2125
+ fz: fz,
2126
+ fw: 800,
2127
+ lh: 1,
2128
+ tt: "uppercase",
2129
+ lts: "1.5px",
2130
+ c: c,
2131
+ ...props,
2132
+ children: "Auth"
2133
+ });
2134
+ }
2135
+
2136
+ const TERMS_URL = 'https://myinfrastructure.click/legal/terms';
2137
+
2138
+ /**
2139
+ * The terms notice under the first step.
2140
+ *
2141
+ * It sits on step ONE and nowhere else: the account is born on the first
2142
+ * `verify` and there is no separate sign-up screen to put it on — this form IS
2143
+ * the sign-up for whoever has never entered. On step two the person already
2144
+ * agreed by asking for the code; repeating it there would only push the code
2145
+ * field down.
2146
+ */
2147
+ function TermsNotice({
2148
+ url,
2149
+ text,
2150
+ linkText
2151
+ }) {
2152
+ if (!url) return null;
2153
+ return /*#__PURE__*/jsxs(Text, {
2154
+ size: "xs",
2155
+ c: "dimmed",
2156
+ ta: "center"
2157
+ /*
2158
+ * Tighter than the `gap="md"` of the Stack around it, and a hair
2159
+ * tighter than default leading. This is fine print under the action,
2160
+ * not a third step of the form — spaced like the field and the
2161
+ * button it would read as one more thing to do.
2162
+ */,
2163
+ mt: -4,
2164
+ lh: 1.4
2165
+ /*
2166
+ * Two short lines instead of one full-width line plus an orphan.
2167
+ * At the card's 350px the sentence wrapped as "…todos os / nossos
2168
+ * termos e condições", splitting the phrase away from the words that
2169
+ * govern it. Balanced, the break falls where the meaning does.
2170
+ */,
2171
+ style: {
2172
+ textWrap: 'balance'
2173
+ },
2174
+ children: [text, ' ', /*#__PURE__*/jsx(Anchor, {
2175
+ href: url,
2176
+ target: "_blank"
2177
+ /*
2178
+ * `noopener noreferrer` with `target="_blank"`: the same pair the
2179
+ * footers of the house use for anything leaving the origin
2180
+ * (`site-chrome.jsx`, `ProductLayout.jsx`). Opening in a new tab
2181
+ * is not decoration here — the person is mid-sign-in, and
2182
+ * navigating away would throw the typed email out.
2183
+ */,
2184
+ rel: "noopener noreferrer"
2185
+ /*
2186
+ * `inherit` takes the size and weight of the sentence around it,
2187
+ * so the link does not turn into a bold island inside the fine
2188
+ * print — but it also takes the COLOR, and in the Zen palette
2189
+ * `primaryColor: 'gray'` paints the Anchor near-black. Against
2190
+ * dimmed text that reads as emphasis, not as something to click.
2191
+ *
2192
+ * The underline is what says "link" here: it survives whatever
2193
+ * `primaryColor` each panel sets, and it is the only affordance
2194
+ * left once the colour matches the sentence.
2195
+ */,
2196
+ inherit: true,
2197
+ c: "inherit",
2198
+ underline: "always",
2199
+ children: linkText
2200
+ }), "."]
2201
+ });
2202
+ }
2203
+
2204
+ // The OAuth flow's pass-through screen.
2205
+ //
2206
+ // The panel is not the destination here: the user is authorizing an MCP client
2207
+ // and only passes through this origin because it is where the token lives. A
2208
+ // blank screen during the detour looks like a freeze; this one says what is
2209
+ // happening.
2210
+ function AuthTransition({
2211
+ label = 'Conectando…'
2212
+ }) {
2213
+ return /*#__PURE__*/jsx(Center, {
2214
+ style: {
2215
+ minHeight: '60vh'
2216
+ },
2217
+ children: /*#__PURE__*/jsxs(Stack, {
2218
+ align: "center",
2219
+ gap: "sm",
2220
+ children: [/*#__PURE__*/jsx(Loader, {
2221
+ size: "xs"
2222
+ }), /*#__PURE__*/jsx(Text, {
2223
+ size: "sm",
2224
+ c: "dimmed",
2225
+ children: label
2226
+ })]
2227
+ })
2228
+ });
2229
+ }
2230
+
2231
+ /**
2232
+ * Sign in — the only path there is.
2233
+ *
2234
+ * Two steps: the email receives a code, the code becomes a session. There is no
2235
+ * password, magic link, sign-up or recovery — first-time arrivals and returning
2236
+ * people walk exactly this screen, and the account is born on the first entry.
2237
+ *
2238
+ * Nothing here builds a callback URL. The code is typed in this very tab, so
2239
+ * there is no destination to validate — which is why the family of
2240
+ * origin/redirect bugs does not reach this flow.
2241
+ *
2242
+ * @param {object} props
2243
+ * @param {'card'|'modal'} [props.variant='card'] - Rendering mode
2244
+ * @param {boolean} [props.opened] - Visibility (only for variant="modal")
2245
+ * @param {function} [props.onClose] - Close callback (only for variant="modal")
2246
+ */
2247
+ function SignIn({
2248
+ // Configuration
2249
+ logo,
2250
+ // No default: it is computed below
2251
+ logoWidth = 133,
2252
+ title = 'Entrar',
2253
+ subtitle = 'Enviaremos um código para o seu e-mail',
2254
+ // Variant
2255
+ variant = 'card',
2256
+ opened,
2257
+ onClose,
2258
+ modalProps = {},
2259
+ // Where to send someone who ALREADY has a session and lands on this screen.
2260
+ // The landings link straight to /auth/signin without knowing a session
2261
+ // exists; without this, a signed-in user faced the form again. `null`
2262
+ // disables the detour.
2263
+ authenticatedRedirect = '/',
2264
+ // Shown while the session is being resolved and during the detour of an
2265
+ // already authenticated person. Avoids flashing the form to someone who
2266
+ // should never see it.
2267
+ redirectingFallback = null,
2268
+ // Callbacks
2269
+ onSuccess,
2270
+ // Post-sign-in redirect: by default the component honours `?redirect=` from
2271
+ // the URL, validating against the API's origin and the current one.
2272
+ // `redirectOrigins` adds domains; `handleRedirect={false}` hands control back
2273
+ // to the app.
2274
+ handleRedirect = true,
2275
+ redirectOrigins = [],
2276
+ onError,
2277
+ onCodeSent,
2278
+ // Custom labels
2279
+ labels = {},
2280
+ // The terms notice under the first step. `null` removes it — an internal
2281
+ // panel behind a VPN has no one to present terms to.
2282
+ termsUrl = TERMS_URL,
2283
+ /*
2284
+ * Social sign-in buttons.
2285
+ *
2286
+ * `'auto'` — the default — shows whatever the APPLICATION enabled in the
2287
+ * Auth panel, and nothing at all when it enabled none. It is additive: an
2288
+ * application with no provider configured renders exactly what it rendered
2289
+ * before this prop existed, so the default does not break anyone.
2290
+ *
2291
+ * `false` opts out entirely, for a screen that wants the emailed code only.
2292
+ */
2293
+ socialLogin = 'auto',
2294
+ ...cardProps
2295
+ }) {
2296
+ const user = useAuthStore(s => s.user);
2297
+ const authLoading = useAuthStore(s => s.loading);
2298
+ const requestCode = useAuthStore(s => s.requestCode);
2299
+ const verifyCode = useAuthStore(s => s.verifyCode);
2300
+ const sending = useAuthStore(s => s.loadingStates.requestCode);
2301
+ const verifying = useAuthStore(s => s.loadingStates.verifyCode);
2302
+
2303
+ // Which step we are on. The email is kept because the second step has to
2304
+ // present it again: it is the (email, code) pair the server validates.
2305
+ const [sentTo, setSentTo] = useState(null);
2306
+ const [code, setCode] = useState('');
2307
+ const [codeError, setCodeError] = useState(null);
2308
+
2309
+ // Hook that fetches the application's logo
2310
+ const applicationLogo = useApplicationLogo();
2311
+ const finalLogo = logo || applicationLogo || /*#__PURE__*/jsx(Wordmark, {});
2312
+ const navigate = useNavigate();
2313
+ const form = useForm({
2314
+ initialValues: {
2315
+ email: ''
2316
+ },
2317
+ validate: {
2318
+ email: value => /^\S+@\S+$/.test(value) ? null : labels.invalidEmail || 'Email inválido'
2319
+ }
2320
+ });
2321
+
2322
+ // Whoever already has a session must not see the form. The landings point
2323
+ // to /auth/signin unconditionally, and since the session became shared
2324
+ // across `.myinfrastructure.click` (domain cookie) it is common to arrive here
2325
+ // already authenticated — the panel used to learn that only after a
2326
+ // redundant sign-in.
2327
+ //
2328
+ // It waits for `authLoading`: the provider's `init` resolves the session
2329
+ // asynchronously (cookie → /auth/session). Deciding before that would flash
2330
+ // the form to someone already signed in, or worse, redirect based on a
2331
+ // `user` that has not loaded yet.
2332
+ // `redirectOrigins` defaults to `[]` — a new array on every render. Used raw
2333
+ // as a dependency, it would re-run the effect in a loop.
2334
+ const redirectOriginsKey = JSON.stringify(redirectOrigins);
2335
+ useEffect(() => {
2336
+ if (authLoading || !user) return;
2337
+
2338
+ // `?redirect=` takes priority: whoever arrived through an OAuth flow
2339
+ // needs to go back there, not to the panel's home. Same decision as the
2340
+ // post-sign-in one, with the same origin validation.
2341
+ const target = (handleRedirect ? getRedirectFromLocation(redirectOrigins) : null) || authenticatedRedirect;
2342
+
2343
+ // `authenticatedRedirect={null}` disables the courtesy detour for
2344
+ // someone already signed in — but NOT the return of an OAuth flow, which
2345
+ // is an authorization in progress, not a convenience. The guard above
2346
+ // used to block both together.
2347
+ if (!target) return;
2348
+ applyRedirect(target, navigate);
2349
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- redirectOrigins enters through the serialized key above
2350
+ }, [authLoading, user, authenticatedRedirect, handleRedirect, redirectOriginsKey, navigate]);
2351
+
2352
+ // Step 1 — ask for the code.
2353
+ const handleRequest = async values => {
2354
+ if (sending) return;
2355
+ try {
2356
+ await requestCode(values.email);
2357
+ setSentTo(values.email);
2358
+ setCode('');
2359
+ setCodeError(null);
2360
+ onCodeSent?.(values.email);
2361
+ } catch (error) {
2362
+ onError?.(error);
2363
+ }
2364
+ };
2365
+
2366
+ // Step 2 — trade the code for the session.
2367
+ //
2368
+ // The redirect is decided AND EXECUTED before onSuccess.
2369
+ //
2370
+ // Deciding beforehand was not enough: the app usually calls `navigate('/')`
2371
+ // inside onSuccess, so that navigation happened first and the following
2372
+ // `applyRedirect` ran with the route already changed. `redirectHandled`
2373
+ // protected against that, but only for whoever remembered to honour it — and
2374
+ // half the panels did not, which made the OAuth handoff lose the `#token=`
2375
+ // and end in "Sessão inválida ou expirada".
2376
+ //
2377
+ // Executing here, the OAuth flow's destination no longer depends on each
2378
+ // application getting the contract right. `onSuccess` is still always called
2379
+ // (the app still shows its notification), and `redirectHandled` still
2380
+ // signals that navigation was taken over — now as information, not as a
2381
+ // trap.
2382
+ const handleVerify = async value => {
2383
+ setCodeError(null);
2384
+ try {
2385
+ const result = await verifyCode(sentTo, value);
2386
+ const target = handleRedirect ? getRedirectFromLocation(redirectOrigins) : null;
2387
+ if (target) applyRedirect(target, navigate);
2388
+ onSuccess?.(result?.user ?? null, {
2389
+ result,
2390
+ redirectHandled: !!target
2391
+ });
2392
+ } catch (error) {
2393
+ // The code error belongs to the field, not to the global
2394
+ // notification: the person is looking at the eight characters they
2395
+ // just typed.
2396
+ setCodeError(error?.message || labels.invalidCode || 'Código inválido.');
2397
+ setCode('');
2398
+ onError?.(error);
2399
+ }
2400
+ };
2401
+
2402
+ // Does not render the form for someone who already has a session: the effect
2403
+ // above is redirecting, and showing the fields in that window would flash
2404
+ // the screen "in between" — the symptom the landing linking straight here
2405
+ // exposed. `authLoading` covers the instant before the cookie resolves.
2406
+ //
2407
+ // `?redirect=` counts on its own. In an OAuth flow the panel is a
2408
+ // PASS-THROUGH, not a destination: the token lives in this origin's
2409
+ // localStorage and the worker cannot reach it, so the browser has to come
2410
+ // through here — but the user should not notice. Without this part of the
2411
+ // condition, whoever arrived with an unresolved session still saw the panel
2412
+ // screen before the detour.
2413
+ const oauthPending = handleRedirect && !!getRedirectFromLocation(redirectOrigins);
2414
+ const willRedirect = (!!authenticatedRedirect || oauthPending) && (authLoading || !!user);
2415
+ if (willRedirect) return oauthPending ? redirectingFallback ?? /*#__PURE__*/jsx(AuthTransition, {}) : redirectingFallback;
2416
+ return /*#__PURE__*/jsx(AuthCard, {
2417
+ logo: finalLogo,
2418
+ logoWidth: logoWidth,
2419
+ title: title,
2420
+ subtitle: sentTo ? labels.codeSent || 'Digite o código que enviamos' : subtitle,
2421
+ variant: variant,
2422
+ opened: opened,
2423
+ onClose: onClose,
2424
+ modalProps: modalProps,
2425
+ ...cardProps,
2426
+ children: !sentTo ? /*#__PURE__*/jsx("form", {
2427
+ onSubmit: form.onSubmit(handleRequest),
2428
+ children: /*#__PURE__*/jsxs(Stack, {
2429
+ gap: "md",
2430
+ children: [/*#__PURE__*/jsx(TextInput, {
2431
+ label: labels.email || 'Email',
2432
+ placeholder: labels.emailPlaceholder || 'seu@email.com',
2433
+ type: "email",
2434
+ autoFocus: true,
2435
+ autoComplete: "email",
2436
+ ...form.getInputProps('email'),
2437
+ /*
2438
+ * `readOnly`, not `disabled`: Mantine's `disabled`
2439
+ * fades the field while the request is in flight, and
2440
+ * the value the person just typed is what they are
2441
+ * looking at. `readOnly` locks editing and leaves the
2442
+ * field legible.
2443
+ */
2444
+ readOnly: sending
2445
+ }), /*#__PURE__*/jsx(Button, {
2446
+ type: "submit",
2447
+ fullWidth: true
2448
+ /*
2449
+ * `aria-disabled`, never `disabled`: `disabled`
2450
+ * repaints the button grey, and the white Loader
2451
+ * vanishes inside it — the action IN PROGRESS ends up
2452
+ * weighing less on screen than a button standing
2453
+ * still. With `aria-disabled` the button stays black,
2454
+ * the spinner shows, and the guard on submit blocks
2455
+ * the repeated click.
2456
+ *
2457
+ * And the spinner goes in `leftSection`: Mantine's
2458
+ * `loading` prop hides the label, and a button with
2459
+ * no word does not say what is happening.
2460
+ */,
2461
+ "aria-disabled": sending,
2462
+ leftSection: sending ? /*#__PURE__*/jsx(Loader, {
2463
+ size: 14,
2464
+ color: "gray.0"
2465
+ }) : null,
2466
+ rightSection: /*#__PURE__*/jsx(IconArrowRight, {
2467
+ size: 16
2468
+ }),
2469
+ children: sending ? labels.sendingCode || 'Enviando…' : labels.sendCodeButton || 'Enviar código'
2470
+ }), socialLogin !== false && /*#__PURE__*/jsx(SocialButtons, {
2471
+ labels: labels,
2472
+ disabled: sending
2473
+ }), /*#__PURE__*/jsx(TermsNotice, {
2474
+ url: termsUrl,
2475
+ text: labels.termsNotice || 'Criando uma conta, você concorda com todos os nossos',
2476
+ linkText: labels.termsLink || 'termos e condições'
2477
+ })]
2478
+ })
2479
+ }) : /*#__PURE__*/jsxs(Stack, {
2480
+ gap: "md",
2481
+ children: [/*#__PURE__*/jsx(TextInput, {
2482
+ label: labels.codeLabel || 'Código de acesso'
2483
+ /*
2484
+ * The email is the field's description, not the subtitle:
2485
+ * as a subtitle it wrapped onto two lines and competed for
2486
+ * weight with the title. Here it sits where the person
2487
+ * checks it — right above what they are about to type.
2488
+ */,
2489
+ description: `${labels.codeSentTo || 'Enviado para'} ${sentTo}`,
2490
+ placeholder: "ABCD-EFGH"
2491
+ /*
2492
+ * One field, not eight boxes. Eight boxes is the numeric
2493
+ * OTP pattern; this code is alphanumeric and dictated
2494
+ * aloud — in a single field paste works, the screen reader
2495
+ * reads one thing, and the hyphen the person types is
2496
+ * accepted (`normalizeUserCode` in the worker drops it).
2497
+ */,
2498
+ value: code,
2499
+ onChange: event => {
2500
+ setCode(event.currentTarget.value);
2501
+ if (codeError) setCodeError(null);
2502
+ },
2503
+ onKeyDown: event => {
2504
+ if (event.key === 'Enter' && code.trim()) handleVerify(code);
2505
+ },
2506
+ autoFocus: true,
2507
+ autoComplete: "one-time-code",
2508
+ readOnly: verifying,
2509
+ error: codeError
2510
+ }), /*#__PURE__*/jsx(Button, {
2511
+ type: "button",
2512
+ fullWidth: true
2513
+ // Same reason as the previous step: `disabled` would fade
2514
+ // the button exactly while signing in happens. With no
2515
+ // code typed it stays genuinely disabled — there is no
2516
+ // action in progress to hide there.
2517
+ ,
2518
+ "aria-disabled": verifying,
2519
+ disabled: !code.trim(),
2520
+ onClick: verifying ? undefined : () => handleVerify(code),
2521
+ leftSection: verifying ? /*#__PURE__*/jsx(Loader, {
2522
+ size: 14,
2523
+ color: "gray.0"
2524
+ }) : null,
2525
+ rightSection: /*#__PURE__*/jsx(IconArrowRight, {
2526
+ size: 16
2527
+ }),
2528
+ children: verifying ? labels.verifyingCode || 'Entrando…' : labels.confirmCode || 'Confirmar'
2529
+ }), /*#__PURE__*/jsxs(Group, {
2530
+ justify: "space-between",
2531
+ gap: "xs",
2532
+ children: [/*#__PURE__*/jsx(Anchor, {
2533
+ size: "sm",
2534
+ c: "dimmed",
2535
+ onClick: () => {
2536
+ setSentTo(null);
2537
+ setCode('');
2538
+ setCodeError(null);
2539
+ },
2540
+ children: labels.changeEmail || 'Usar outro e-mail'
2541
+ }), /*#__PURE__*/jsx(Anchor, {
2542
+ size: "sm",
2543
+ c: "dimmed",
2544
+ onClick: sending ? undefined : () => handleRequest({
2545
+ email: sentTo
2546
+ }),
2547
+ children: sending ? labels.sendingCode || 'Enviando…' : labels.resendCode || 'Reenviar código'
2548
+ })]
2549
+ })]
2550
+ })
2551
+ });
2552
+ }
2553
+
2554
+ function UserProfile({
2555
+ // Variant
2556
+ variant = 'modal',
2557
+ opened,
2558
+ onClose,
2559
+ // Callbacks
2560
+ onProfileUpdate,
2561
+ onSessionRevoked,
2562
+ onOtherSessionsRevoked,
2563
+ onError,
2564
+ // Features toggle
2565
+ showAvatar = true,
2566
+ showName = true,
2567
+ showEmail = true,
2568
+ showSessions = true,
2569
+ // Customização
2570
+ labels = {},
2571
+ title = 'Account',
2572
+ subtitle = 'Manage your account info.',
2573
+ logo,
2574
+ logoHeight = 28,
2575
+ width = 500,
2576
+ // Avatar config
2577
+ maxAvatarSize = 500 * 1024,
2578
+ // 500KB
2579
+
2580
+ // Custom sections (React nodes to render after built-in sections)
2581
+ customSections,
2582
+ ...containerProps
2583
+ }) {
2584
+ // Local state - which section is expanded
2585
+ const [editingSection, setEditingSection] = useState(null); // 'password' | 'email' | 'name' | 'avatar' | null
2586
+
2587
+ // Hook para profile
2588
+ const {
2589
+ user,
2590
+ updateProfile,
2591
+ loadingUpdateProfile
2592
+ } = useUser();
2593
+
2594
+ // Hook para sessions
2595
+ const {
2596
+ currentSession,
2597
+ sessions,
2598
+ listSessions,
2599
+ getSession,
2600
+ revokeSession,
2601
+ revokeOtherSessions,
2602
+ loadingListSessions,
2603
+ loadingRevokeSession
2604
+ } = useSessions();
2605
+
2606
+ // Load sessions when opened (modal) or component mounts (card), and when sessions section is opened
2607
+ useEffect(() => {
2608
+ if (showSessions && (variant === 'card' || opened)) {
2609
+ // Fetch current session first to get the session ID, then list all sessions
2610
+ getSession().catch(err => console.warn('Failed to get current session:', err));
2611
+ listSessions().catch(err => console.warn('Failed to load sessions:', err));
2612
+ }
2613
+ }, [opened, showSessions, variant]);
2614
+
2615
+ // Helper to parse user agent string
2616
+ const parseUserAgent = ua => {
2617
+ if (!ua) return {
2618
+ browser: 'Unknown Browser',
2619
+ os: 'Unknown OS'
2620
+ };
2621
+ let browser = 'Unknown Browser';
2622
+ let os = 'Unknown OS';
2623
+
2624
+ // Detect browser
2625
+ if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';else if (ua.includes('Firefox')) browser = 'Firefox';else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';else if (ua.includes('Edg')) browser = 'Edge';else if (ua.includes('Opera') || ua.includes('OPR')) browser = 'Opera';
2626
+
2627
+ // Detect OS
2628
+ if (ua.includes('Windows')) os = 'Windows';else if (ua.includes('Mac OS')) os = 'macOS';else if (ua.includes('Linux')) os = 'Linux';else if (ua.includes('Android')) os = 'Android';else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
2629
+ return {
2630
+ browser,
2631
+ os
2632
+ };
2633
+ };
2634
+
2635
+ // Session handlers
2636
+ const handleRevokeSession = async sessionId => {
2637
+ // Check if revoking current session
2638
+ const isCurrentSession = sessionId === currentSession?.id || sessions.length === 1;
2639
+
2640
+ // If revoking current session, handle logout immediately after revocation
2641
+ if (isCurrentSession) {
2642
+ try {
2643
+ await revokeSession(sessionId);
2644
+ onSessionRevoked?.(sessionId);
2645
+ } catch (error) {
2646
+ onError?.(error);
2647
+ // Even if it fails, we're revoking our own session, so just logout
2648
+ // The server already revoked our session
2649
+ }
2650
+ // Clear auth state and redirect
2651
+ localStorage.removeItem('auth:token');
2652
+ window.dispatchEvent(new CustomEvent('auth:session-revoked'));
2653
+ if (variant === 'modal') onClose?.();
2654
+ return;
2655
+ }
2656
+
2657
+ // Revoking another session
2658
+ try {
2659
+ await revokeSession(sessionId);
2660
+ onSessionRevoked?.(sessionId);
2661
+ } catch (error) {
2662
+ // Check for 401 error (our SDK uses error.res, axios uses error.response)
2663
+ const status = error.res?.status || error.response?.status;
2664
+ if (status === 401) {
2665
+ // This means our session was revoked, not the target one - do logout
2666
+ localStorage.removeItem('auth:token');
2667
+ window.dispatchEvent(new CustomEvent('auth:session-revoked'));
2668
+ if (variant === 'modal') onClose?.();
2669
+ return;
2670
+ }
2671
+ onError?.(error);
2672
+ }
2673
+ };
2674
+ const handleRevokeOtherSessions = async () => {
2675
+ try {
2676
+ await revokeOtherSessions();
2677
+ onOtherSessionsRevoked?.();
2678
+ } catch (error) {
2679
+ onError?.(error);
2680
+ }
2681
+ };
2682
+
2683
+ // Name form
2684
+ const nameForm = useForm({
2685
+ initialValues: {
2686
+ name: ''
2687
+ },
2688
+ validate: {
2689
+ name: v => !v ? labels.nameRequired || 'Nome obrigatório' : null
2690
+ }
2691
+ });
2692
+
2693
+ // Avatar state (base64)
2694
+ const [avatarPreview, setAvatarPreview] = useState(null);
2695
+ const [avatarFile, setAvatarFile] = useState(null);
2696
+
2697
+ // Handle file selection and convert to base64
2698
+ const handleAvatarFileChange = file => {
2699
+ if (!file) {
2700
+ setAvatarPreview(null);
2701
+ setAvatarFile(null);
2702
+ return;
2703
+ }
2704
+
2705
+ // Validate file type
2706
+ if (!file.type.startsWith('image/')) {
2707
+ onError?.(new Error(labels.avatarInvalidType || 'Por favor, selecione uma imagem válida'));
2708
+ return;
2709
+ }
2710
+
2711
+ // Validate file size
2712
+ if (file.size > maxAvatarSize) {
2713
+ onError?.(new Error(labels.avatarTooLarge || `Imagem muito grande. Máximo ${Math.round(maxAvatarSize / 1024)}KB.`));
2714
+ return;
2715
+ }
2716
+ setAvatarFile(file);
2717
+
2718
+ // Convert to base64
2719
+ const reader = new FileReader();
2720
+ reader.onloadend = () => {
2721
+ setAvatarPreview(reader.result);
2722
+ };
2723
+ reader.readAsDataURL(file);
2724
+ };
2725
+
2726
+ // Populate forms when user data is available or section opens
2727
+ useEffect(() => {
2728
+ if (editingSection === 'name' && user?.name) {
2729
+ nameForm.setValues({
2730
+ name: user.name
2731
+ });
2732
+ }
2733
+ if (editingSection === 'avatar' && user?.image) {
2734
+ setAvatarPreview(user.image);
2735
+ }
2736
+ }, [editingSection, user]);
2737
+ const handleToggleSection = section => {
2738
+ if (editingSection === section) {
2739
+ setEditingSection(null);
2740
+ nameForm.reset();
2741
+ setAvatarPreview(null);
2742
+ setAvatarFile(null);
2743
+ } else {
2744
+ setEditingSection(section);
2745
+ }
2746
+ };
2747
+ const handleChangeName = async values => {
2748
+ try {
2749
+ await updateProfile({
2750
+ name: values.name
2751
+ });
2752
+ nameForm.reset();
2753
+ setEditingSection(null);
2754
+ onProfileUpdate?.({
2755
+ name: values.name
2756
+ });
2757
+ } catch (error) {
2758
+ onError?.(error);
2759
+ }
2760
+ };
2761
+ const handleChangeAvatar = async () => {
2762
+ if (!avatarPreview) {
2763
+ // Optionally handle this validation error via callback or just return?
2764
+ // Since it's a validation error before async call, we might want to expose it too?
2765
+ // The original code used notifications. Let's send to onError for consistency or just return if it's UI state.
2766
+ // Actually, for validation within the component, maybe we can just let it be silent or use form error if applicable?
2767
+ // But this is outside form context. Let's use onError with a custom error object.
2768
+ onError?.(new Error(labels.avatarRequired || 'Selecione uma imagem'));
2769
+ return;
2770
+ }
2771
+ try {
2772
+ await updateProfile({
2773
+ image: avatarPreview
2774
+ });
2775
+ setAvatarPreview(null);
2776
+ setAvatarFile(null);
2777
+ setEditingSection(null);
2778
+ onProfileUpdate?.({
2779
+ image: avatarPreview
2780
+ });
2781
+ } catch (error) {
2782
+ onError?.(error);
2783
+ }
2784
+ };
2785
+ const handleRemoveAvatar = async () => {
2786
+ try {
2787
+ await updateProfile({
2788
+ image: ''
2789
+ });
2790
+ setAvatarPreview(null);
2791
+ setAvatarFile(null);
2792
+ setEditingSection(null);
2793
+ onProfileUpdate?.({
2794
+ image: ''
2795
+ });
2796
+ } catch (error) {
2797
+ onError?.(error);
2798
+ }
2799
+ };
2800
+
2801
+ // Reusable Section Header
2802
+ const SectionHeader = ({
2803
+ icon: Icon,
2804
+ sectionTitle,
2805
+ description
2806
+ }) => /*#__PURE__*/jsxs(Group, {
2807
+ gap: "sm",
2808
+ mb: "lg",
2809
+ children: [/*#__PURE__*/jsx(ThemeIcon, {
2810
+ size: 36,
2811
+ variant: "subtle",
2812
+ color: "gray",
2813
+ children: /*#__PURE__*/jsx(Icon, {
2814
+ size: 28,
2815
+ stroke: 1.5
2816
+ })
2817
+ }), /*#__PURE__*/jsxs(Stack, {
2818
+ gap: 0,
2819
+ children: [/*#__PURE__*/jsx(Text, {
2820
+ fw: 600,
2821
+ size: "sm",
2822
+ children: sectionTitle
2823
+ }), description && /*#__PURE__*/jsx(Text, {
2824
+ size: "xs",
2825
+ c: "dimmed",
2826
+ children: description
2827
+ })]
2828
+ })]
2829
+ });
2830
+
2831
+ // Reusable Row component
2832
+ const SettingRow = ({
2833
+ label,
2834
+ children,
2835
+ action,
2836
+ actionLabel,
2837
+ onClick,
2838
+ expanded
2839
+ }) => /*#__PURE__*/jsx(Box, {
2840
+ py: "xs",
2841
+ children: /*#__PURE__*/jsxs(Group, {
2842
+ justify: "space-between",
2843
+ wrap: "nowrap",
2844
+ align: "center",
2845
+ children: [/*#__PURE__*/jsxs(Group, {
2846
+ gap: "xl",
2847
+ wrap: "nowrap",
2848
+ flex: 1,
2849
+ children: [/*#__PURE__*/jsx(Text, {
2850
+ size: "sm",
2851
+ c: "dimmed",
2852
+ w: 100,
2853
+ children: label
2854
+ }), /*#__PURE__*/jsx(Box, {
2855
+ flex: 1,
2856
+ children: children
2857
+ })]
2858
+ }), action && /*#__PURE__*/jsx(Tooltip, {
2859
+ label: actionLabel || action,
2860
+ position: "left",
2861
+ children: /*#__PURE__*/jsx(Anchor, {
2862
+ size: "xs",
2863
+ fw: 300,
2864
+ onClick: onClick,
2865
+ c: "gray",
2866
+ underline: "none",
2867
+ children: expanded ? labels.cancel || 'Cancel' : action
2868
+ })
2869
+ })]
2870
+ })
2871
+ });
2872
+
2873
+ // Conteúdo interno compartilhado
2874
+ const profileContent = /*#__PURE__*/jsxs(Fragment, {
2875
+ children: [(showAvatar || showName || showEmail) && /*#__PURE__*/jsxs(Box, {
2876
+ mb: "lg",
2877
+ children: [/*#__PURE__*/jsx(SectionHeader, {
2878
+ icon: IconUser,
2879
+ sectionTitle: labels.profileSection || 'Profile',
2880
+ description: labels.profileDescription || 'Your personal information'
2881
+ }), /*#__PURE__*/jsxs(Stack, {
2882
+ gap: "sm",
2883
+ children: [showAvatar && /*#__PURE__*/jsxs(Fragment, {
2884
+ children: [/*#__PURE__*/jsx(SettingRow, {
2885
+ label: labels.avatar || 'Avatar',
2886
+ action: labels.update || 'Update',
2887
+ actionLabel: labels.updateAvatar || 'Update your profile picture',
2888
+ onClick: () => handleToggleSection('avatar'),
2889
+ expanded: editingSection === 'avatar',
2890
+ children: /*#__PURE__*/jsx(Group, {
2891
+ gap: "md",
2892
+ children: /*#__PURE__*/jsx(Avatar, {
2893
+ src: user?.image,
2894
+ name: user?.name || user?.email,
2895
+ size: 48,
2896
+ radius: "xl"
2897
+ // color="initials"
2898
+ })
2899
+ })
2900
+ }), /*#__PURE__*/jsx(Collapse, {
2901
+ in: editingSection === 'avatar',
2902
+ children: /*#__PURE__*/jsx(Paper, {
2903
+ p: "sm",
2904
+ withBorder: true,
2905
+ radius: "sm",
2906
+ children: /*#__PURE__*/jsxs(Stack, {
2907
+ gap: "md",
2908
+ align: "center",
2909
+ children: [/*#__PURE__*/jsx(FileButton, {
2910
+ onChange: handleAvatarFileChange,
2911
+ accept: "image/*",
2912
+ children: props => /*#__PURE__*/jsx(Tooltip, {
2913
+ label: labels.clickToChange || 'Clique para alterar',
2914
+ position: "bottom",
2915
+ children: /*#__PURE__*/jsxs(Box, {
2916
+ ...props,
2917
+ pos: "relative",
2918
+ style: {
2919
+ cursor: 'pointer'
2920
+ },
2921
+ children: [/*#__PURE__*/jsx(Avatar, {
2922
+ src: avatarPreview || user?.image,
2923
+ name: user?.name || user?.email,
2924
+ size: 80,
2925
+ radius: 80,
2926
+ color: "gray"
2927
+ }), /*#__PURE__*/jsx(ThemeIcon, {
2928
+ size: 26,
2929
+ radius: "xl",
2930
+ color: "gray",
2931
+ pos: "absolute",
2932
+ bottom: 0,
2933
+ right: 0,
2934
+ bd: "2px solid body",
2935
+ children: /*#__PURE__*/jsx(IconPhoto, {
2936
+ size: 14,
2937
+ stroke: 1.5
2938
+ })
2939
+ })]
2940
+ })
2941
+ })
2942
+ }), /*#__PURE__*/jsx(Text, {
2943
+ size: "xs",
2944
+ c: "dimmed",
2945
+ ta: "center",
2946
+ children: labels.avatarHint || `Máximo ${Math.round(maxAvatarSize / 1024)}KB • JPG, PNG, GIF, WebP`
2947
+ }), /*#__PURE__*/jsxs(Group, {
2948
+ justify: "center",
2949
+ gap: "xs",
2950
+ children: [user?.image && /*#__PURE__*/jsx(Button, {
2951
+ variant: "subtle",
2952
+ color: "gray",
2953
+ size: "xs",
2954
+ onClick: handleRemoveAvatar,
2955
+ loading: loadingUpdateProfile,
2956
+ loaderProps: {
2957
+ size: 12
2958
+ },
2959
+ leftSection: /*#__PURE__*/jsx(IconTrash, {
2960
+ size: 14,
2961
+ stroke: 1.5
2962
+ }),
2963
+ children: labels.remove || 'Remover'
2964
+ }), /*#__PURE__*/jsx(Button, {
2965
+ variant: "default",
2966
+ size: "xs",
2967
+ onClick: () => handleToggleSection('avatar'),
2968
+ children: labels.cancel || 'Cancelar'
2969
+ }), /*#__PURE__*/jsx(Button, {
2970
+ size: "xs",
2971
+ loading: loadingUpdateProfile,
2972
+ loaderProps: {
2973
+ size: 12
2974
+ },
2975
+ leftSection: /*#__PURE__*/jsx(IconCheck, {
2976
+ size: 14,
2977
+ stroke: 1.5
2978
+ }),
2979
+ onClick: handleChangeAvatar,
2980
+ disabled: !avatarPreview || avatarPreview === user?.image,
2981
+ children: labels.save || 'Salvar'
2982
+ })]
2983
+ })]
2984
+ })
2985
+ })
2986
+ })]
2987
+ }), showName && /*#__PURE__*/jsxs(Fragment, {
2988
+ children: [/*#__PURE__*/jsx(SettingRow, {
2989
+ label: labels.name || 'Nome',
2990
+ action: labels.change || 'Change',
2991
+ actionLabel: labels.changeName || 'Change your display name',
2992
+ onClick: () => handleToggleSection('name'),
2993
+ expanded: editingSection === 'name',
2994
+ children: /*#__PURE__*/jsxs(Group, {
2995
+ gap: "xs",
2996
+ children: [/*#__PURE__*/jsx(IconPencil, {
2997
+ size: 18,
2998
+ stroke: 1.5,
2999
+ color: "var(--mantine-color-dimmed)"
3000
+ }), /*#__PURE__*/jsx(Text, {
3001
+ size: "sm",
3002
+ children: user?.name || labels.notDefined || 'Não definido'
3003
+ })]
3004
+ })
3005
+ }), /*#__PURE__*/jsx(Collapse, {
3006
+ in: editingSection === 'name',
3007
+ children: /*#__PURE__*/jsx(Paper, {
3008
+ p: "sm",
3009
+ withBorder: true,
3010
+ radius: "sm",
3011
+ children: /*#__PURE__*/jsx("form", {
3012
+ onSubmit: nameForm.onSubmit(handleChangeName),
3013
+ children: /*#__PURE__*/jsxs(Stack, {
3014
+ gap: "sm",
3015
+ children: [/*#__PURE__*/jsx(TextInput, {
3016
+ label: labels.name || 'Nome',
3017
+ placeholder: labels.namePlaceholder || 'Digite seu nome',
3018
+ leftSection: /*#__PURE__*/jsx(IconUser, {
3019
+ size: 16,
3020
+ stroke: 1.5
3021
+ }),
3022
+ ...nameForm.getInputProps('name')
3023
+ }), /*#__PURE__*/jsxs(Group, {
3024
+ justify: "flex-end",
3025
+ gap: "xs",
3026
+ children: [/*#__PURE__*/jsx(Button, {
3027
+ variant: "default",
3028
+ size: "xs",
3029
+ onClick: () => handleToggleSection('name'),
3030
+ children: labels.cancel || 'Cancelar'
3031
+ }), /*#__PURE__*/jsx(Button, {
3032
+ type: "submit",
3033
+ size: "xs",
3034
+ loading: loadingUpdateProfile,
3035
+ loaderProps: {
3036
+ size: 12
3037
+ },
3038
+ leftSection: /*#__PURE__*/jsx(IconCheck, {
3039
+ size: 14,
3040
+ stroke: 1.5
3041
+ }),
3042
+ children: labels.save || 'Salvar'
3043
+ })]
3044
+ })]
3045
+ })
3046
+ })
3047
+ })
3048
+ })]
3049
+ }), showEmail && /*#__PURE__*/jsx(SettingRow, {
3050
+ label: labels.email || 'Email',
3051
+ children: /*#__PURE__*/jsxs(Group, {
3052
+ gap: "xs",
3053
+ children: [/*#__PURE__*/jsx(IconMail, {
3054
+ size: 18,
3055
+ stroke: 1.5,
3056
+ color: "var(--mantine-color-dimmed)"
3057
+ }), /*#__PURE__*/jsx(Text, {
3058
+ size: "sm",
3059
+ children: user?.email || 'email@exemplo.com'
3060
+ })]
3061
+ })
3062
+ })]
3063
+ })]
3064
+ }), showSessions && /*#__PURE__*/jsxs(Box, {
3065
+ mb: "md",
3066
+ children: [/*#__PURE__*/jsx(SectionHeader, {
3067
+ icon: IconShield,
3068
+ sectionTitle: labels.securitySection || 'Security',
3069
+ description: labels.securityDescription || 'Protect your account'
3070
+ }), /*#__PURE__*/jsx(Stack, {
3071
+ gap: "sm",
3072
+ children: showSessions && /*#__PURE__*/jsxs(Fragment, {
3073
+ children: [/*#__PURE__*/jsx(SettingRow, {
3074
+ label: labels.sessions || 'Sessions',
3075
+ action: editingSection === 'sessions' ? labels.close || 'Close' : labels.manage || 'Manage',
3076
+ actionLabel: labels.manageSessions || 'Manage your active sessions',
3077
+ onClick: () => handleToggleSection('sessions'),
3078
+ expanded: editingSection === 'sessions',
3079
+ children: /*#__PURE__*/jsxs(Group, {
3080
+ gap: "xs",
3081
+ children: [/*#__PURE__*/jsx(IconDevices, {
3082
+ size: 18,
3083
+ stroke: 1.5,
3084
+ color: "var(--mantine-color-dimmed)"
3085
+ }), /*#__PURE__*/jsx(Text, {
3086
+ size: "sm",
3087
+ c: "dimmed",
3088
+ children: sessions.length > 0 ? `${sessions.length} active session${sessions.length > 1 ? 's' : ''}` : loadingListSessions ? 'Loading...' : 'No sessions'
3089
+ })]
3090
+ })
3091
+ }), /*#__PURE__*/jsx(Collapse, {
3092
+ in: editingSection === 'sessions',
3093
+ children: /*#__PURE__*/jsx(Paper, {
3094
+ p: "sm",
3095
+ withBorder: true,
3096
+ radius: "sm",
3097
+ children: /*#__PURE__*/jsx(Stack, {
3098
+ gap: "xs",
3099
+ children: loadingListSessions ? /*#__PURE__*/jsx(Text, {
3100
+ size: "xs",
3101
+ c: "dimmed",
3102
+ ta: "center",
3103
+ py: "md",
3104
+ children: labels.loadingSessions || 'Carregando sessões...'
3105
+ }) : sessions.length === 0 ? /*#__PURE__*/jsx(Text, {
3106
+ size: "xs",
3107
+ c: "dimmed",
3108
+ ta: "center",
3109
+ py: "md",
3110
+ children: labels.noSessionsFound || 'Nenhuma sessão encontrada'
3111
+ }) : /*#__PURE__*/jsxs(Fragment, {
3112
+ children: [sessions.map(sessionItem => {
3113
+ const isCurrentSession = sessionItem.id === currentSession?.id;
3114
+ const deviceInfo = parseUserAgent(sessionItem.userAgent);
3115
+ const createdDate = new Date(sessionItem.createdAt);
3116
+ return /*#__PURE__*/jsx(Paper, {
3117
+ p: "xs",
3118
+ withBorder: isCurrentSession,
3119
+ bd: isCurrentSession ? '1px solid gray' : undefined,
3120
+ radius: "sm",
3121
+ children: /*#__PURE__*/jsxs(Group, {
3122
+ justify: "space-between",
3123
+ wrap: "nowrap",
3124
+ align: "center",
3125
+ children: [/*#__PURE__*/jsxs(Group, {
3126
+ gap: "sm",
3127
+ wrap: "nowrap",
3128
+ flex: 1,
3129
+ children: [/*#__PURE__*/jsx(ThemeIcon, {
3130
+ size: 32,
3131
+ variant: "subtle",
3132
+ color: "gray",
3133
+ children: /*#__PURE__*/jsx(IconDeviceMobile, {
3134
+ size: 18,
3135
+ stroke: 1.5
3136
+ })
3137
+ }), /*#__PURE__*/jsxs(Box, {
3138
+ flex: 1,
3139
+ children: [/*#__PURE__*/jsxs(Group, {
3140
+ gap: "xs",
3141
+ children: [/*#__PURE__*/jsx(Text, {
3142
+ size: "xs",
3143
+ fw: 600,
3144
+ children: deviceInfo.browser
3145
+ }), isCurrentSession && /*#__PURE__*/jsx(Badge, {
3146
+ size: "xs",
3147
+ variant: "light",
3148
+ color: "gray",
3149
+ children: labels.thisDevice || 'Este dispositivo'
3150
+ })]
3151
+ }), /*#__PURE__*/jsxs(Text, {
3152
+ size: "xs",
3153
+ c: "dimmed",
3154
+ children: [deviceInfo.os, " \u2022 ", sessionItem.ipAddress || labels.unknownIP || 'IP desconhecido']
3155
+ }), /*#__PURE__*/jsxs(Text, {
3156
+ size: "xs",
3157
+ c: "dimmed",
3158
+ children: [labels.createdAt || 'Criada em', " ", createdDate.toLocaleDateString('pt-BR'), " ", labels.at || 'às', ' ', createdDate.toLocaleTimeString('pt-BR', {
3159
+ hour: '2-digit',
3160
+ minute: '2-digit'
3161
+ })]
3162
+ })]
3163
+ })]
3164
+ }), /*#__PURE__*/jsx(Tooltip, {
3165
+ label: isCurrentSession ? labels.signOutAndEnd || 'Encerrar e sair' : labels.endSession || 'Encerrar sessão',
3166
+ children: /*#__PURE__*/jsx(Button, {
3167
+ variant: "subtle",
3168
+ color: "gray",
3169
+ size: "xs",
3170
+ onClick: () => handleRevokeSession(sessionItem.id),
3171
+ loading: loadingRevokeSession === sessionItem.id,
3172
+ loaderProps: {
3173
+ size: 12
3174
+ },
3175
+ leftSection: /*#__PURE__*/jsx(IconLogout, {
3176
+ size: 14,
3177
+ stroke: 1.5
3178
+ }),
3179
+ children: labels.end || 'Encerrar'
3180
+ })
3181
+ })]
3182
+ })
3183
+ }, sessionItem.id);
3184
+ }), sessions.length > 1 && /*#__PURE__*/jsx(Group, {
3185
+ justify: "flex-end",
3186
+ mt: "xs",
3187
+ children: /*#__PURE__*/jsx(Button, {
3188
+ variant: "subtle",
3189
+ color: "gray",
3190
+ size: "xs",
3191
+ onClick: handleRevokeOtherSessions,
3192
+ loading: loadingRevokeSession === 'all',
3193
+ loaderProps: {
3194
+ size: 12
3195
+ },
3196
+ leftSection: /*#__PURE__*/jsx(IconLogout, {
3197
+ size: 14,
3198
+ stroke: 1.5
3199
+ }),
3200
+ children: labels.endOtherSessions || 'Encerrar todas as outras sessões'
3201
+ })
3202
+ })]
3203
+ })
3204
+ })
3205
+ })
3206
+ })]
3207
+ })
3208
+ })]
3209
+ }), customSections]
3210
+ });
3211
+
3212
+ // Renderizar como Modal
3213
+ if (variant === 'modal') {
3214
+ return /*#__PURE__*/jsxs(Modal, {
3215
+ opened: opened,
3216
+ onClose: onClose,
3217
+ size: width,
3218
+ withCloseButton: true,
3219
+ radius: "md",
3220
+ overlayProps: {
3221
+ backgroundOpacity: 0.5,
3222
+ blur: 4
3223
+ },
3224
+ title: /*#__PURE__*/jsxs(Group, {
3225
+ gap: "sm",
3226
+ children: [/*#__PURE__*/jsx(ThemeIcon, {
3227
+ size: 32,
3228
+ variant: "subtle",
3229
+ color: "gray",
3230
+ children: /*#__PURE__*/jsx(IconUserCircle, {
3231
+ size: 24,
3232
+ stroke: 1.5
3233
+ })
3234
+ }), /*#__PURE__*/jsxs(Stack, {
3235
+ gap: 0,
3236
+ children: [/*#__PURE__*/jsx(Title, {
3237
+ order: 5,
3238
+ fw: 600,
3239
+ children: title
3240
+ }), /*#__PURE__*/jsx(Text, {
3241
+ size: "xs",
3242
+ c: "dimmed",
3243
+ children: subtitle
3244
+ })]
3245
+ })]
3246
+ }),
3247
+ ...containerProps,
3248
+ children: [/*#__PURE__*/jsx(Divider, {
3249
+ mb: "md"
3250
+ }), profileContent]
3251
+ });
3252
+ }
3253
+
3254
+ // Renderizar como Card
3255
+ return /*#__PURE__*/jsx(Paper, {
3256
+ withBorder: true
3257
+ // shadow="md"
3258
+ ,
3259
+ p: "md",
3260
+ w: width,
3261
+ radius: "md",
3262
+ ...containerProps,
3263
+ children: /*#__PURE__*/jsxs(Stack, {
3264
+ gap: "sm",
3265
+ children: [/*#__PURE__*/jsxs(Group, {
3266
+ gap: "sm",
3267
+ children: [logo ? /*#__PURE__*/jsx(Image, {
3268
+ src: logo,
3269
+ alt: "Auth",
3270
+ h: logoHeight,
3271
+ fit: "contain"
3272
+ }) : /*#__PURE__*/jsx(ThemeIcon, {
3273
+ size: 32,
3274
+ variant: "subtle",
3275
+ color: "gray",
3276
+ children: /*#__PURE__*/jsx(IconUserCircle, {
3277
+ size: 24,
3278
+ stroke: 1.5
3279
+ })
3280
+ }), /*#__PURE__*/jsxs(Stack, {
3281
+ gap: 0,
3282
+ children: [/*#__PURE__*/jsx(Title, {
3283
+ order: 5,
3284
+ fw: 600,
3285
+ children: title
3286
+ }), /*#__PURE__*/jsx(Text, {
3287
+ size: "xs",
3288
+ c: "dimmed",
3289
+ children: subtitle
3290
+ })]
3291
+ })]
3292
+ }), /*#__PURE__*/jsx(Divider, {}), profileContent]
3293
+ })
3294
+ });
3295
+ }
3296
+
3297
+ function UserInformation({
3298
+ user,
3299
+ signOut,
3300
+ onAccountClick,
3301
+ onBillingClick,
3302
+ accountLabel = 'Conta',
3303
+ billingLabel = 'Assinatura',
3304
+ padded = true,
3305
+ size = 'sm',
3306
+ style,
3307
+ ...others
3308
+ }) {
3309
+ if (!user) return null;
3310
+
3311
+ // Size mappings
3312
+ const avatarSizeMap = {
3313
+ sm: 32,
3314
+ md: 40,
3315
+ lg: 48
3316
+ };
3317
+ const fontSizeTitleMap = {
3318
+ sm: 'sm',
3319
+ md: 'md',
3320
+ lg: 'lg'
3321
+ };
3322
+ const fontSizeEmailMap = {
3323
+ sm: '10px',
3324
+ md: 'xs',
3325
+ lg: 'sm'
3326
+ };
3327
+ const btnSizeMap = {
3328
+ sm: 'xs',
3329
+ md: 'sm',
3330
+ lg: 'md'
3331
+ };
3332
+ const gapMap = {
3333
+ sm: 'xs',
3334
+ md: 'sm',
3335
+ lg: 'md'
3336
+ };
3337
+ const widthMap = {
3338
+ sm: 280,
3339
+ md: 320,
3340
+ lg: 400
3341
+ };
3342
+ const name = user.fullName || user.name || 'User';
3343
+ const email = user.primaryEmailAddress || user.email || '';
3344
+ const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
3345
+ return /*#__PURE__*/jsx(Box, {
3346
+ p: padded ? gapMap[size] : 0,
3347
+ w: widthMap[size],
3348
+ style: style,
3349
+ ...others,
3350
+ children: /*#__PURE__*/jsxs(Stack, {
3351
+ gap: gapMap[size],
3352
+ children: [/*#__PURE__*/jsxs(Group, {
3353
+ wrap: "nowrap",
3354
+ gap: "xs",
3355
+ children: [/*#__PURE__*/jsx(Avatar, {
3356
+ src: user.imageUrl || user.image,
3357
+ size: avatarSizeMap[size],
3358
+ radius: "xl",
3359
+ bg: "gray.1",
3360
+ c: "gray.6",
3361
+ styles: {
3362
+ placeholder: {
3363
+ fontSize: rem(avatarSizeMap[size] / 2.2),
3364
+ fontWeight: 600
3365
+ }
3366
+ },
3367
+ children: initials || 'CC'
3368
+ }), /*#__PURE__*/jsxs(Box, {
3369
+ style: {
3370
+ flex: 1,
3371
+ overflow: 'hidden'
3372
+ },
3373
+ children: [/*#__PURE__*/jsx(Text, {
3374
+ size: fontSizeTitleMap[size],
3375
+ fw: 700,
3376
+ truncate: "end",
3377
+ c: "dark.9",
3378
+ lh: 1.1,
3379
+ children: name
3380
+ }), /*#__PURE__*/jsx(Text, {
3381
+ size: fontSizeEmailMap[size],
3382
+ c: "gray.5",
3383
+ truncate: "end",
3384
+ lh: 1.1,
3385
+ children: email
3386
+ })]
3387
+ }), /*#__PURE__*/jsx(ActionIcon, {
3388
+ size: btnSizeMap[size],
3389
+ onClick: signOut,
3390
+ children: /*#__PURE__*/jsx(IconLogout, {
3391
+ size: 16,
3392
+ stroke: 1.5
3393
+ })
3394
+ })]
3395
+ }), /*#__PURE__*/jsxs(Group, {
3396
+ grow: true,
3397
+ children: [/*#__PURE__*/jsx(Button, {
3398
+ variant: "default",
3399
+ size: btnSizeMap[size],
3400
+ leftSection: /*#__PURE__*/jsx(IconSettings, {
3401
+ size: 16,
3402
+ stroke: 1.5
3403
+ }),
3404
+ onClick: onAccountClick,
3405
+ children: accountLabel
3406
+ }), /*#__PURE__*/jsx(Button, {
3407
+ variant: "default",
3408
+ size: btnSizeMap[size],
3409
+ leftSection: /*#__PURE__*/jsx(IconCreditCard, {
3410
+ size: 16,
3411
+ stroke: 1.5
3412
+ }),
3413
+ onClick: onBillingClick,
3414
+ children: billingLabel
3415
+ })]
3416
+ }), /*#__PURE__*/jsxs(Group, {
3417
+ justify: "center",
3418
+ gap: 4,
3419
+ opacity: 0.3,
3420
+ children: [/*#__PURE__*/jsx(Text, {
3421
+ size: "10px",
3422
+ c: "gray.6",
3423
+ fw: 600,
3424
+ children: "Secured by"
3425
+ }), /*#__PURE__*/jsxs(Group, {
3426
+ gap: 2,
3427
+ children: [/*#__PURE__*/jsx(IconShieldCheck, {
3428
+ size: 10,
3429
+ stroke: 2
3430
+ }), /*#__PURE__*/jsx(Text, {
3431
+ size: "10px",
3432
+ fw: 800,
3433
+ c: "dark.9",
3434
+ children: "Auth"
3435
+ })]
3436
+ })]
3437
+ })]
3438
+ })
3439
+ });
3440
+ }
3441
+
3442
+ /**
3443
+ * Renderiza children apenas quando o usuário está autenticado
3444
+ * Equivalente ao <SignedIn> do Clerk
3445
+ */
3446
+ function SignedIn({
3447
+ children
3448
+ }) {
3449
+ const {
3450
+ user,
3451
+ loading
3452
+ } = useAuth();
3453
+ if (loading || !user) return null;
3454
+ return children;
3455
+ }
3456
+
3457
+ /**
3458
+ * Renderiza children apenas quando o usuário NÃO está autenticado
3459
+ * Equivalente ao <SignedOut> do Clerk
3460
+ */
3461
+ function SignedOut({
3462
+ children
3463
+ }) {
3464
+ const {
3465
+ user,
3466
+ loading
3467
+ } = useAuth();
3468
+ if (loading || user) return null;
3469
+ return children;
3470
+ }
3471
+
3472
+ /**
3473
+ * Renderiza children enquanto a autenticação está carregando
3474
+ * Equivalente ao <ClerkLoading> do Clerk
3475
+ */
3476
+ function AuthLoading({
3477
+ children
3478
+ }) {
3479
+ const {
3480
+ loading
3481
+ } = useAuth();
3482
+ if (!loading) return null;
3483
+ return children;
3484
+ }
3485
+
3486
+ /**
3487
+ * Renderiza children quando a autenticação terminou de carregar
3488
+ * Equivalente ao <ClerkLoaded> do Clerk
3489
+ */
3490
+ function AuthLoaded({
3491
+ children
3492
+ }) {
3493
+ const {
3494
+ loading
3495
+ } = useAuth();
3496
+ if (loading) return null;
3497
+ return children;
3498
+ }
3499
+
3500
+ function SignInButton({
3501
+ children,
3502
+ redirectTo = '/login',
3503
+ ...props
3504
+ }) {
3505
+ const navigate = useNavigate();
3506
+ return /*#__PURE__*/jsx("button", {
3507
+ onClick: () => navigate(redirectTo),
3508
+ ...props,
3509
+ children: children || 'Sign In'
3510
+ });
3511
+ }
3512
+
3513
+ function SignOutButton({
3514
+ children,
3515
+ onSignOut,
3516
+ ...props
3517
+ }) {
3518
+ const signOut = useSignOut();
3519
+ const handleClick = async () => {
3520
+ await signOut();
3521
+ onSignOut?.();
3522
+ };
3523
+ return /*#__PURE__*/jsx("button", {
3524
+ onClick: handleClick,
3525
+ ...props,
3526
+ children: children || 'Sign Out'
3527
+ });
3528
+ }
3529
+
3530
+ export { AuthCard, AuthLoaded, AuthLoading, AuthProvider, GuestOnly, IDENTITY_CHANGED_EVENT, Protect, SignIn, SignInButton, SignOutButton, SignedIn, SignedOut, SocialButtons, TOKEN_STORAGE_KEY, UserInformation, UserProfile, Wordmark, announceIdentityChange, applyRedirect, clearIdentitySwitching, configure, consumeSocialError, consumeSocialToken, decodeJWT, endImpersonation, getApiUrl, getApplicationInfo, getCurrentUser, getLinkedProviders, getRedirectFromLocation, getSession, getSocialProviders, isAuthenticated, isIdentitySwitching, isInternal, listSessions, markIdentitySwitching, pollCode, refreshToken, requestCode, resolveRedirect, revokeOtherSessions, revokeSession, setStoredToken, shouldSignOutOn401, signOut, startSocialLink, startSocialSignIn, unlinkSocialProvider, updateProfile, useApplicationLogo, useAuth, useAuthLoading, useAuthStore, useCheckToken, useImpersonation, useSession, useSessions, useSignIn, useSignOut, useUser, verifyCode };
3531
+ //# sourceMappingURL=index.esm.js.map