@ciromaciel/auth-react 1.0.2 → 1.2.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.
package/dist/index.esm.js CHANGED
@@ -1,11 +1,148 @@
1
1
  import { create } from 'zustand';
2
- import { useState, useCallback, useEffect, useMemo, createContext } from 'react';
2
+ import { useState, useCallback, useEffect, useMemo, createContext, useRef } from 'react';
3
3
  import { useShallow } from 'zustand/react/shallow';
4
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
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';
6
+ import { Modal, Stack, Text, Group, Image, Title, Paper, Anchor, NavLink, ActionIcon, Loader, Avatar, Button, Divider, TextInput, Alert, Center, Box, Collapse, FileButton, Tooltip, ThemeIcon, Badge, rem } from '@mantine/core';
7
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';
8
+ import { IconX, IconArrowRight, IconBrandGoogle, IconArrowLeft, IconRefresh, IconAlertCircle, IconUser, IconPhoto, IconTrash, IconCheck, IconPencil, IconMail, IconShield, IconDevices, IconDeviceMobile, IconLogout, IconUserCircle, IconSettings, IconCreditCard, IconShieldCheck } from '@tabler/icons-react';
9
+
10
+ /**
11
+ * The accounts that already signed in on this browser.
12
+ *
13
+ * `<SignIn />` offers them before the empty email field: for a returning
14
+ * person, choosing the account IS the click that sends the code. Nothing here
15
+ * is a credential — a saved email still has to receive and type a fresh code —
16
+ * so the list is a shortcut, never a session.
17
+ *
18
+ * WHAT IS KEPT, AND WHEN
19
+ *
20
+ * Email, the way in (`'code'` or a provider id such as `'google'`) and the time
21
+ * of the last sign-in. No token, name or picture: that is everything needed to
22
+ * draw a row, and the rest arrives with the session.
23
+ *
24
+ * An email enters only after a session exists — the code confirmed, or the
25
+ * provider's token back in the fragment. Never on "send code": a typo there
26
+ * would otherwise become a permanent suggestion.
27
+ *
28
+ * WHY IT SURVIVES SIGN-OUT AND IDENTITY SWITCHES
29
+ *
30
+ * The list exists precisely for the person who left and is coming back, so
31
+ * signing out does not clear it; removing an account is an explicit action on
32
+ * the screen. The key is in `KEEP` in `identitySwitch.js` for the same reason:
33
+ * the list belongs to the browser, not to whichever account is signed in, and
34
+ * holds no data of any account beyond its own email.
35
+ *
36
+ * `localStorage` is per origin, so each panel keeps its own list.
37
+ */
38
+
39
+ const RECENT_ACCOUNTS_KEY = 'auth:recent-accounts';
40
+
41
+ // Five rows fit the 350px card with no scroll. The oldest drops out on its
42
+ // own; signing in with it again puts it back on top.
43
+ const MAX_RECENT_ACCOUNTS = 5;
44
+
45
+ // Which provider this tab left for. `sessionStorage` because the answer only
46
+ // matters to the tab that comes back, and it expires so an abandoned consent
47
+ // screen does not label a later, unrelated token.
48
+ const SOCIAL_DEPARTURE_KEY = 'auth:social-departure';
49
+ const SOCIAL_DEPARTURE_TTL_MS = 15 * 60 * 1000;
50
+ const normalizeEmail = email => String(email ?? '').trim().toLowerCase();
51
+
52
+ /**
53
+ * The saved accounts, most recent first. Never throws: a private window,
54
+ * blocked storage or a hand-edited value all read as an empty list.
55
+ *
56
+ * @returns {{ email: string, method: string, lastUsedAt: number }[]}
57
+ */
58
+ function listRecentAccounts() {
59
+ try {
60
+ const raw = window.localStorage.getItem(RECENT_ACCOUNTS_KEY);
61
+ if (!raw) return [];
62
+ const parsed = JSON.parse(raw);
63
+ if (!Array.isArray(parsed)) return [];
64
+ return parsed.filter(account => account && typeof account.email === 'string' && account.email.includes('@')).map(account => ({
65
+ email: normalizeEmail(account.email),
66
+ method: typeof account.method === 'string' && account.method ? account.method : 'code',
67
+ lastUsedAt: Number(account.lastUsedAt) || 0
68
+ })).sort((a, b) => b.lastUsedAt - a.lastUsedAt).slice(0, MAX_RECENT_ACCOUNTS);
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+ function writeRecentAccounts(accounts) {
74
+ try {
75
+ window.localStorage.setItem(RECENT_ACCOUNTS_KEY, JSON.stringify(accounts));
76
+ } catch {
77
+ // No storage: the screen simply keeps asking for the email.
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Puts the account on top of the list, creating it or refreshing it.
83
+ *
84
+ * @param {string} email
85
+ * @param {string} [method='code'] - `'code'` or the provider id
86
+ * @returns the list as it stands after the write
87
+ */
88
+ function rememberAccount(email, method = 'code') {
89
+ const normalized = normalizeEmail(email);
90
+ if (!normalized.includes('@')) return listRecentAccounts();
91
+ const next = [{
92
+ email: normalized,
93
+ method: method || 'code',
94
+ lastUsedAt: Date.now()
95
+ }, ...listRecentAccounts().filter(account => account.email !== normalized)].slice(0, MAX_RECENT_ACCOUNTS);
96
+ writeRecentAccounts(next);
97
+ return next;
98
+ }
99
+
100
+ /**
101
+ * Removes one account from this browser's list. The account itself, its
102
+ * sessions and the lists of other panels are untouched.
103
+ *
104
+ * @returns the list as it stands after the removal
105
+ */
106
+ function forgetAccount(email) {
107
+ const normalized = normalizeEmail(email);
108
+ const next = listRecentAccounts().filter(account => account.email !== normalized);
109
+ writeRecentAccounts(next);
110
+ return next;
111
+ }
112
+
113
+ /** Records the provider this tab is leaving for. */
114
+ function markSocialDeparture(provider) {
115
+ try {
116
+ window.sessionStorage.setItem(SOCIAL_DEPARTURE_KEY, JSON.stringify({
117
+ provider,
118
+ at: Date.now()
119
+ }));
120
+ } catch {
121
+ // No storage: the account is not remembered, and the sign-in still works.
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Reads and clears the provider this tab left for, if it left recently.
127
+ *
128
+ * Read BEFORE an identity switch runs: the switch clears `sessionStorage`.
129
+ */
130
+ function takeSocialDeparture() {
131
+ try {
132
+ const raw = window.sessionStorage.getItem(SOCIAL_DEPARTURE_KEY);
133
+ if (!raw) return null;
134
+ window.sessionStorage.removeItem(SOCIAL_DEPARTURE_KEY);
135
+ const {
136
+ provider,
137
+ at
138
+ } = JSON.parse(raw);
139
+ if (typeof provider !== 'string' || !provider) return null;
140
+ if (!(Date.now() - Number(at) < SOCIAL_DEPARTURE_TTL_MS)) return null;
141
+ return provider;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
9
146
 
10
147
  /**
11
148
  * The identity switch — when the panel stops being one person and becomes
@@ -104,7 +241,12 @@ const KEEP = new Set([
104
241
  // The switch beacon: a timestamp the sibling tabs listen for. Wiping it
105
242
  // would make the NEXT write look unchanged to the browser in some cases,
106
243
  // and the event that carries the switch would not fire.
107
- 'auth:identity-switched']);
244
+ 'auth:identity-switched',
245
+ // The accounts that signed in on this browser (`recent-accounts.js`). It
246
+ // belongs to the browser, not to whoever is signed in, and holds nothing
247
+ // of any account beyond its email — wiping it on every switch would empty
248
+ // the sign-in shortcuts each time an operator impersonates someone.
249
+ RECENT_ACCOUNTS_KEY]);
108
250
  function dropStoredAccountState() {
109
251
  try {
110
252
  const doomed = [];
@@ -583,14 +725,22 @@ const getSocialProviders = async () => {
583
725
  * `redirect` is where to come back to; the worker validates it against the
584
726
  * application's allowlist BEFORE leaving, and stores the validated value. It
585
727
  * defaults to the current page.
728
+ *
729
+ * `rememberAccount: false` keeps the account off this browser's recent list
730
+ * (`recent-accounts.js`) when the token comes back.
586
731
  */
587
732
  const startSocialSignIn = (provider, {
588
- redirect
733
+ redirect,
734
+ rememberAccount: shouldRemember = true
589
735
  } = {}) => {
590
736
  const destination = redirect || window.location.href.split('#')[0];
591
737
  const url = new URL(`${API_BASE}/auth/sign-in/${provider}`);
592
738
  url.searchParams.set('redirect', destination);
593
739
  if (API_KEY && !INTERNAL_MODE) url.searchParams.set('api_key', API_KEY);
740
+
741
+ // The fragment that comes back carries only the token, not which provider
742
+ // issued it — so the tab notes where it went before leaving.
743
+ if (shouldRemember) markSocialDeparture(provider);
594
744
  window.location.assign(url.toString());
595
745
  };
596
746
 
@@ -609,6 +759,11 @@ const consumeSocialToken = () => {
609
759
  const token = params.get('token');
610
760
  if (!token) return null;
611
761
 
762
+ // Read before the identity switch below: it clears `sessionStorage`, where
763
+ // the departure is noted. A token without a departure (a handoff between
764
+ // panels) is not a sign-in made here, and does not enter the list.
765
+ const provider = takeSocialDeparture();
766
+
612
767
  /*
613
768
  * A social sign-in can land on a tab that already belongs to SOMEBODY ELSE.
614
769
  *
@@ -641,6 +796,10 @@ const consumeSocialToken = () => {
641
796
  if (before && after && before !== after) markIdentitySwitching();
642
797
  }
643
798
  setStoredToken(token);
799
+ if (provider) {
800
+ const email = decodeJWT(token)?.email;
801
+ if (email) rememberAccount(email, provider);
802
+ }
644
803
  params.delete('token');
645
804
  const rest = params.toString();
646
805
  window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${rest ? `#${rest}` : ''}`);
@@ -659,6 +818,9 @@ const consumeSocialError = () => {
659
818
  const params = new URLSearchParams(window.location.search);
660
819
  const reason = params.get('social_error');
661
820
  if (!reason) return null;
821
+
822
+ // The trip failed: the departure noted for it must not label a later token.
823
+ takeSocialDeparture();
662
824
  params.delete('social_error');
663
825
  const rest = params.toString();
664
826
  window.history.replaceState(null, '', `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`);
@@ -2027,6 +2189,156 @@ function AuthCard({
2027
2189
  });
2028
2190
  }
2029
2191
 
2192
+ const PROVIDER_NAMES = {
2193
+ google: 'Google',
2194
+ github: 'GitHub'
2195
+ };
2196
+ const providerName = method => PROVIDER_NAMES[method] || method.charAt(0).toUpperCase() + method.slice(1);
2197
+ const RELATIVE = new Intl.RelativeTimeFormat('pt-BR', {
2198
+ numeric: 'auto'
2199
+ });
2200
+
2201
+ /** "há 2 horas", "ontem", "há 6 dias" — the unit that reads naturally. */
2202
+ function formatLastUsed(timestamp, now = Date.now()) {
2203
+ const minutes = Math.round((timestamp - now) / 60000);
2204
+ if (Math.abs(minutes) < 1) return RELATIVE.format(0, 'second');
2205
+ if (Math.abs(minutes) < 60) return RELATIVE.format(minutes, 'minute');
2206
+ const hours = Math.round(minutes / 60);
2207
+ if (Math.abs(hours) < 24) return RELATIVE.format(hours, 'hour');
2208
+ const days = Math.round(hours / 24);
2209
+ if (Math.abs(days) < 30) return RELATIVE.format(days, 'day');
2210
+ const months = Math.round(days / 30);
2211
+ if (Math.abs(months) < 12) return RELATIVE.format(months, 'month');
2212
+ return RELATIVE.format(Math.round(days / 365), 'year');
2213
+ }
2214
+
2215
+ /** "ciro" → "C", "qa+monitors" → "QM". */
2216
+ function initialsOf(email) {
2217
+ const local = email.split('@')[0];
2218
+ const parts = local.split(/[.+_-]/).filter(Boolean);
2219
+ return ((parts[0] || local).charAt(0) + (parts[1] ? parts[1].charAt(0) : '')).toUpperCase();
2220
+ }
2221
+
2222
+ /**
2223
+ * The first step for someone this browser already knows.
2224
+ *
2225
+ * Each row is the whole action: clicking it sends the code (or leaves for the
2226
+ * provider the account used last time), so a returning person goes from
2227
+ * opening the screen to typing the code in one click.
2228
+ *
2229
+ * Removing is behind "Gerenciar" on purpose. An × always in view sits right
2230
+ * next to the row the person came to click, and a slip would drop the account
2231
+ * they meant to use. Nothing is lost by removing — signing in again brings the
2232
+ * account back — so there is no confirmation step either.
2233
+ */
2234
+ function RecentAccounts({
2235
+ accounts,
2236
+ pickingEmail = null,
2237
+ managing = false,
2238
+ onToggleManage,
2239
+ onPick,
2240
+ onForget,
2241
+ onUseOther,
2242
+ labels = {}
2243
+ }) {
2244
+ const busy = !!pickingEmail;
2245
+ return /*#__PURE__*/jsxs(Stack, {
2246
+ gap: "md",
2247
+ children: [/*#__PURE__*/jsxs(Stack, {
2248
+ gap: 8,
2249
+ children: [/*#__PURE__*/jsxs(Group, {
2250
+ justify: "space-between",
2251
+ align: "baseline",
2252
+ wrap: "nowrap",
2253
+ children: [/*#__PURE__*/jsx(Text, {
2254
+ fz: 11,
2255
+ fw: 800,
2256
+ lh: 1,
2257
+ tt: "uppercase",
2258
+ lts: "1.5px",
2259
+ c: "gray.4",
2260
+ children: labels.recentAccountsHeading || 'Contas neste navegador'
2261
+ }), /*#__PURE__*/jsx(Anchor, {
2262
+ component: "button",
2263
+ type: "button"
2264
+ /*
2265
+ * The size of the heading it sits beside, not of body
2266
+ * text: it is a secondary control of the list, and at
2267
+ * 14px it outweighed the 11px label it annotates.
2268
+ */,
2269
+ fz: 12,
2270
+ lh: 1,
2271
+ c: "dimmed",
2272
+ onClick: busy ? undefined : onToggleManage,
2273
+ children: managing ? labels.recentAccountsDone || 'Concluir' : labels.recentAccountsManage || 'Gerenciar'
2274
+ })]
2275
+ }), /*#__PURE__*/jsx(Paper, {
2276
+ withBorder: true,
2277
+ radius: 0,
2278
+ p: 0,
2279
+ children: accounts.map((account, index) => {
2280
+ const isPicking = pickingEmail === account.email;
2281
+ const isSocial = account.method !== 'code';
2282
+ const description = isPicking ? isSocial ? `${labels.openingProvider || 'Abrindo o'} ${providerName(account.method)}…` : labels.sendingCode || 'Enviando código…' : `${labels.lastUsed || 'Último acesso'} ${formatLastUsed(account.lastUsedAt)}${isSocial ? ` · ${providerName(account.method)}` : ''}`;
2283
+ return /*#__PURE__*/jsx(NavLink, {
2284
+ /*
2285
+ * A `div` while managing: the row then holds the
2286
+ * remove button, and a button inside a button is
2287
+ * invalid HTML that browsers repair by splitting
2288
+ * the row in two.
2289
+ */
2290
+ component: managing ? 'div' : 'button',
2291
+ type: managing ? undefined : 'button',
2292
+ "aria-disabled": busy || undefined,
2293
+ onClick: managing || busy ? undefined : () => onPick(account),
2294
+ noWrap: true,
2295
+ label: /*#__PURE__*/jsx(Text, {
2296
+ fz: 14,
2297
+ fw: 600,
2298
+ c: "gray.9",
2299
+ truncate: true,
2300
+ children: account.email
2301
+ }),
2302
+ description: description,
2303
+ leftSection: /*#__PURE__*/jsx(Avatar, {
2304
+ radius: 0,
2305
+ size: 36,
2306
+ color: "gray",
2307
+ variant: "light",
2308
+ children: initialsOf(account.email)
2309
+ }),
2310
+ rightSection: managing ? /*#__PURE__*/jsx(ActionIcon, {
2311
+ variant: "subtle",
2312
+ color: "gray",
2313
+ "aria-label": `${labels.removeAccount || 'Remover'} ${account.email}`,
2314
+ onClick: () => onForget(account),
2315
+ children: /*#__PURE__*/jsx(IconX, {
2316
+ size: 16
2317
+ })
2318
+ }) : isPicking ? /*#__PURE__*/jsx(Loader, {
2319
+ size: 14
2320
+ }) : /*#__PURE__*/jsx(IconArrowRight, {
2321
+ size: 16,
2322
+ color: "var(--mantine-color-gray-5)"
2323
+ }),
2324
+ py: 10,
2325
+ style: index > 0 ? {
2326
+ borderTop: '1px solid var(--mantine-color-gray-2)'
2327
+ } : undefined
2328
+ }, account.email);
2329
+ })
2330
+ })]
2331
+ }), /*#__PURE__*/jsx(Button, {
2332
+ type: "button",
2333
+ variant: "default",
2334
+ fullWidth: true,
2335
+ "aria-disabled": busy,
2336
+ onClick: busy ? undefined : onUseOther,
2337
+ children: labels.useOtherEmail || 'Usar outro e-mail'
2338
+ })]
2339
+ });
2340
+ }
2341
+
2030
2342
  const MARKS = {
2031
2343
  google: IconBrandGoogle
2032
2344
  };
@@ -2048,7 +2360,11 @@ const MARKS = {
2048
2360
  function SocialButtons({
2049
2361
  labels = {},
2050
2362
  redirect,
2051
- disabled = false
2363
+ disabled = false,
2364
+ // Whether the account returning from the provider joins this browser's
2365
+ // recent list (`recent-accounts.js`). `<SignIn recentAccounts={false}>`
2366
+ // turns it off here too.
2367
+ rememberAccount = true
2052
2368
  }) {
2053
2369
  const [providers, setProviders] = useState(null);
2054
2370
  const [leaving, setLeaving] = useState(null);
@@ -2092,7 +2408,8 @@ function SocialButtons({
2092
2408
  // `oauth_states` row.
2093
2409
  setLeaving(provider.provider);
2094
2410
  startSocialSignIn(provider.provider, {
2095
- redirect
2411
+ redirect,
2412
+ rememberAccount
2096
2413
  });
2097
2414
  },
2098
2415
  children: /*#__PURE__*/jsxs(Group, {
@@ -2201,6 +2518,102 @@ function TermsNotice({
2201
2518
  });
2202
2519
  }
2203
2520
 
2521
+ /**
2522
+ * What a failed `verify` means for the person, read from the worker's answer.
2523
+ *
2524
+ * The HTTP status carries the case — `code` is `VALIDATION_ERROR` for both a
2525
+ * wrong and an expired code, so it cannot tell them apart:
2526
+ * - 400 with `details.attemptsLeft`: wrong code, the request is still open;
2527
+ * - 400 without it: no open request for this email (already replaced);
2528
+ * - 429: the fifth wrong try destroyed the request;
2529
+ * - 410: the code outlived its minutes, and was destroyed too.
2530
+ *
2531
+ * Anything else — the network, a 500 — is not about the code, and must not
2532
+ * lock the field.
2533
+ */
2534
+ function describeCodeFailure(error) {
2535
+ if (error?.status === 429) return {
2536
+ kind: 'exhausted',
2537
+ isLocked: true
2538
+ };
2539
+ if (error?.status === 410) return {
2540
+ kind: 'expired',
2541
+ isLocked: true
2542
+ };
2543
+ if (error?.status === 400) {
2544
+ const attemptsLeft = error?.details?.attemptsLeft;
2545
+ return {
2546
+ kind: 'wrong',
2547
+ isLocked: false,
2548
+ attemptsLeft: Number.isInteger(attemptsLeft) ? attemptsLeft : null
2549
+ };
2550
+ }
2551
+ return {
2552
+ kind: 'other',
2553
+ isLocked: false,
2554
+ message: error?.message || null
2555
+ };
2556
+ }
2557
+
2558
+ /**
2559
+ * The notice above the code field.
2560
+ *
2561
+ * It sits ABOVE the field, not under it, and says what to do next — not only
2562
+ * that something failed. The field's own error line was 12px of red under a
2563
+ * cleared input, next to a greyed-out button: it read as a frozen screen.
2564
+ *
2565
+ * The most common cause gets named: a new code invalidates the previous one,
2566
+ * and the person is often reading an older email.
2567
+ */
2568
+ function CodeFailureNotice({
2569
+ failure,
2570
+ labels
2571
+ }) {
2572
+ if (!failure) return null;
2573
+ const texts = {
2574
+ wrong: {
2575
+ title: labels.wrongCodeTitle || 'Código incorreto',
2576
+ body: [labels.wrongCodeHint || 'Confira o e-mail mais recente: um código novo invalida o anterior.', failure.attemptsLeft === 1 ? labels.lastAttempt || 'Esta é a última tentativa.' : failure.attemptsLeft > 1 ? labels.attemptsLeft ? labels.attemptsLeft(failure.attemptsLeft) : `Restam ${failure.attemptsLeft} tentativas.` : null].filter(Boolean).join(' ')
2577
+ },
2578
+ exhausted: {
2579
+ title: labels.attemptsExhaustedTitle || 'Tentativas esgotadas',
2580
+ body: labels.attemptsExhausted || 'Por segurança, este código foi cancelado. Peça um novo para continuar.'
2581
+ },
2582
+ expired: {
2583
+ title: labels.codeExpiredTitle || 'Código expirado',
2584
+ body: labels.codeExpired || 'O código vale por poucos minutos. Peça um novo para continuar.'
2585
+ },
2586
+ other: {
2587
+ title: labels.codeFailedTitle || 'Não foi possível entrar',
2588
+ body: failure.message || labels.invalidCode || 'Tente de novo em instantes.'
2589
+ }
2590
+ }[failure.kind];
2591
+ return /*#__PURE__*/jsx(Alert, {
2592
+ color: "red",
2593
+ variant: "light",
2594
+ radius: 0,
2595
+ icon: /*#__PURE__*/jsx(IconAlertCircle, {
2596
+ size: 18
2597
+ }),
2598
+ title: texts.title
2599
+ /*
2600
+ * `role="alert"` is Mantine's default and is what makes a screen
2601
+ * reader announce the failure without the person moving focus away
2602
+ * from the field they are about to retype in.
2603
+ */,
2604
+ styles: {
2605
+ root: {
2606
+ border: '1px solid var(--mantine-color-red-2)'
2607
+ }
2608
+ },
2609
+ children: /*#__PURE__*/jsx(Text, {
2610
+ size: "sm",
2611
+ lh: 1.45,
2612
+ children: texts.body
2613
+ })
2614
+ });
2615
+ }
2616
+
2204
2617
  // The OAuth flow's pass-through screen.
2205
2618
  //
2206
2619
  // The panel is not the destination here: the user is authorizing an MCP client
@@ -2291,6 +2704,15 @@ function SignIn({
2291
2704
  * `false` opts out entirely, for a screen that wants the emailed code only.
2292
2705
  */
2293
2706
  socialLogin = 'auto',
2707
+ /*
2708
+ * The accounts that already signed in on this browser, offered before the
2709
+ * empty field (`recent-accounts.js`). With none saved the screen is exactly
2710
+ * the email form, so the default changes nothing for a first visit.
2711
+ *
2712
+ * `false` neither shows nor saves: a shared computer, or an internal panel,
2713
+ * should not remember who used it.
2714
+ */
2715
+ recentAccounts = true,
2294
2716
  ...cardProps
2295
2717
  }) {
2296
2718
  const user = useAuthStore(s => s.user);
@@ -2304,7 +2726,18 @@ function SignIn({
2304
2726
  // present it again: it is the (email, code) pair the server validates.
2305
2727
  const [sentTo, setSentTo] = useState(null);
2306
2728
  const [code, setCode] = useState('');
2307
- const [codeError, setCodeError] = useState(null);
2729
+ const [codeFailure, setCodeFailure] = useState(null);
2730
+ const [isCodeResent, setIsCodeResent] = useState(false);
2731
+ const codeInputRef = useRef(null);
2732
+
2733
+ // Read once, on mount: the list only changes through this screen, and
2734
+ // every change below writes the new list back into state.
2735
+ const [accounts, setAccounts] = useState(() => recentAccounts ? listRecentAccounts() : []);
2736
+ const [isChoosingOther, setIsChoosingOther] = useState(false);
2737
+ const [isManaging, setIsManaging] = useState(false);
2738
+ const [pickingEmail, setPickingEmail] = useState(null);
2739
+ const isShowingAccounts = recentAccounts && accounts.length > 0 && !isChoosingOther;
2740
+ const isCodeLocked = !!codeFailure?.isLocked;
2308
2741
 
2309
2742
  // Hook that fetches the application's logo
2310
2743
  const applicationLogo = useApplicationLogo();
@@ -2351,18 +2784,66 @@ function SignIn({
2351
2784
 
2352
2785
  // Step 1 — ask for the code.
2353
2786
  const handleRequest = async values => {
2354
- if (sending) return;
2787
+ if (sending) return false;
2355
2788
  try {
2356
2789
  await requestCode(values.email);
2357
2790
  setSentTo(values.email);
2358
2791
  setCode('');
2359
- setCodeError(null);
2792
+ setCodeFailure(null);
2360
2793
  onCodeSent?.(values.email);
2794
+ return true;
2361
2795
  } catch (error) {
2362
- onError?.(error);
2796
+ // Nothing on the card shows this one: the app's notification is
2797
+ // the only place the person learns the code was not sent.
2798
+ onError?.(error, {
2799
+ step: 'request',
2800
+ isShownOnCard: false
2801
+ });
2802
+ return false;
2363
2803
  }
2364
2804
  };
2365
2805
 
2806
+ // A new code, from the code step. The worker replaces the request, so the
2807
+ // attempts start over and the previous code stops working — the notice
2808
+ // says so, or the person keeps typing the one from the older email.
2809
+ const handleResend = async () => {
2810
+ const isSent = await handleRequest({
2811
+ email: sentTo
2812
+ });
2813
+ setIsCodeResent(isSent);
2814
+ if (isSent) codeInputRef.current?.focus();
2815
+ };
2816
+
2817
+ // Step 1, from the list — the click on a saved account IS the request.
2818
+ //
2819
+ // An account that came in through a provider goes back to that provider,
2820
+ // as long as the application still offers it. If the owner turned it off
2821
+ // in the meantime the emailed code still works for the same email, so that
2822
+ // is the fallback rather than a dead row.
2823
+ const handlePick = async account => {
2824
+ if (pickingEmail || sending) return;
2825
+ setPickingEmail(account.email);
2826
+ if (account.method !== 'code' && socialLogin !== false) {
2827
+ const providers = await getSocialProviders();
2828
+ if (providers?.some(provider => provider.provider === account.method)) {
2829
+ // The page is leaving; the row keeps its spinner until it does.
2830
+ startSocialSignIn(account.method, {
2831
+ rememberAccount: recentAccounts
2832
+ });
2833
+ return;
2834
+ }
2835
+ }
2836
+ await handleRequest({
2837
+ email: account.email
2838
+ });
2839
+ setPickingEmail(null);
2840
+ };
2841
+ const handleForget = account => {
2842
+ const next = forgetAccount(account.email);
2843
+ setAccounts(next);
2844
+ if (next.length === 0) setIsManaging(false);
2845
+ };
2846
+
2366
2847
  // Step 2 — trade the code for the session.
2367
2848
  //
2368
2849
  // The redirect is decided AND EXECUTED before onSuccess.
@@ -2380,9 +2861,15 @@ function SignIn({
2380
2861
  // signals that navigation was taken over — now as information, not as a
2381
2862
  // trap.
2382
2863
  const handleVerify = async value => {
2383
- setCodeError(null);
2864
+ setCodeFailure(null);
2865
+ setIsCodeResent(false);
2384
2866
  try {
2385
2867
  const result = await verifyCode(sentTo, value);
2868
+
2869
+ // Only now, with a session: an email that never received a valid
2870
+ // code never becomes a suggestion. Written before the redirect,
2871
+ // which may unmount this screen.
2872
+ if (recentAccounts) setAccounts(rememberAccount(sentTo, 'code'));
2386
2873
  const target = handleRedirect ? getRedirectFromLocation(redirectOrigins) : null;
2387
2874
  if (target) applyRedirect(target, navigate);
2388
2875
  onSuccess?.(result?.user ?? null, {
@@ -2390,12 +2877,19 @@ function SignIn({
2390
2877
  redirectHandled: !!target
2391
2878
  });
2392
2879
  } 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.');
2880
+ // The failure belongs to this card, not to the global notification:
2881
+ // the person is looking at the eight characters they just typed.
2882
+ // The field goes back empty and focused, ready for the next try.
2883
+ setCodeFailure(describeCodeFailure(error));
2397
2884
  setCode('');
2398
- onError?.(error);
2885
+ codeInputRef.current?.focus();
2886
+ // Still reported — an app may log it — but flagged: the card
2887
+ // already explains it, and a notification repeating "Código
2888
+ // inválido" in the corner would say it twice.
2889
+ onError?.(error, {
2890
+ step: 'verify',
2891
+ isShownOnCard: true
2892
+ });
2399
2893
  }
2400
2894
  };
2401
2895
 
@@ -2417,17 +2911,43 @@ function SignIn({
2417
2911
  logo: finalLogo,
2418
2912
  logoWidth: logoWidth,
2419
2913
  title: title,
2420
- subtitle: sentTo ? labels.codeSent || 'Digite o código que enviamos' : subtitle,
2914
+ subtitle: sentTo ? labels.codeSent || 'Digite o código que enviamos' : isShowingAccounts ? labels.recentAccountsSubtitle || 'Escolha a conta que vai receber o código' : subtitle,
2421
2915
  variant: variant,
2422
2916
  opened: opened,
2423
2917
  onClose: onClose,
2424
2918
  modalProps: modalProps,
2425
2919
  ...cardProps,
2426
- children: !sentTo ? /*#__PURE__*/jsx("form", {
2920
+ children: !sentTo && isShowingAccounts ? /*#__PURE__*/jsx(RecentAccounts, {
2921
+ accounts: accounts,
2922
+ pickingEmail: pickingEmail,
2923
+ managing: isManaging,
2924
+ onToggleManage: () => setIsManaging(value => !value),
2925
+ onPick: handlePick,
2926
+ onForget: handleForget,
2927
+ onUseOther: () => {
2928
+ setIsManaging(false);
2929
+ setIsChoosingOther(true);
2930
+ },
2931
+ labels: labels
2932
+ }) : !sentTo ? /*#__PURE__*/jsx("form", {
2427
2933
  onSubmit: form.onSubmit(handleRequest),
2428
2934
  children: /*#__PURE__*/jsxs(Stack, {
2429
2935
  gap: "md",
2430
- children: [/*#__PURE__*/jsx(TextInput, {
2936
+ children: [recentAccounts && accounts.length > 0 && /*#__PURE__*/jsx(Anchor, {
2937
+ component: "button",
2938
+ type: "button",
2939
+ size: "sm",
2940
+ c: "dimmed",
2941
+ w: "fit-content",
2942
+ onClick: () => setIsChoosingOther(false),
2943
+ children: /*#__PURE__*/jsxs(Group, {
2944
+ gap: 6,
2945
+ wrap: "nowrap",
2946
+ children: [/*#__PURE__*/jsx(IconArrowLeft, {
2947
+ size: 14
2948
+ }), `${labels.savedAccounts || 'Contas salvas'} (${accounts.length})`]
2949
+ })
2950
+ }), /*#__PURE__*/jsx(TextInput, {
2431
2951
  label: labels.email || 'Email',
2432
2952
  placeholder: labels.emailPlaceholder || 'seu@email.com',
2433
2953
  type: "email",
@@ -2469,7 +2989,8 @@ function SignIn({
2469
2989
  children: sending ? labels.sendingCode || 'Enviando…' : labels.sendCodeButton || 'Enviar código'
2470
2990
  }), socialLogin !== false && /*#__PURE__*/jsx(SocialButtons, {
2471
2991
  labels: labels,
2472
- disabled: sending
2992
+ disabled: sending,
2993
+ rememberAccount: recentAccounts
2473
2994
  }), /*#__PURE__*/jsx(TermsNotice, {
2474
2995
  url: termsUrl,
2475
2996
  text: labels.termsNotice || 'Criando uma conta, você concorda com todos os nossos',
@@ -2478,7 +2999,27 @@ function SignIn({
2478
2999
  })
2479
3000
  }) : /*#__PURE__*/jsxs(Stack, {
2480
3001
  gap: "md",
2481
- children: [/*#__PURE__*/jsx(TextInput, {
3002
+ children: [isCodeResent && /*#__PURE__*/jsx(Alert, {
3003
+ color: "gray",
3004
+ variant: "light",
3005
+ radius: 0,
3006
+ p: "xs",
3007
+ children: /*#__PURE__*/jsxs(Text, {
3008
+ size: "xs",
3009
+ lh: 1.4,
3010
+ children: [/*#__PURE__*/jsx(Text, {
3011
+ span: true,
3012
+ inherit: true,
3013
+ fw: 700,
3014
+ c: "gray.9",
3015
+ children: labels.codeResentTitle || 'Novo código enviado.'
3016
+ }), ' ', labels.codeResent || 'O anterior deixou de valer.']
3017
+ })
3018
+ }), /*#__PURE__*/jsx(CodeFailureNotice, {
3019
+ failure: codeFailure,
3020
+ labels: labels
3021
+ }), /*#__PURE__*/jsx(TextInput, {
3022
+ ref: codeInputRef,
2482
3023
  label: labels.codeLabel || 'Código de acesso'
2483
3024
  /*
2484
3025
  * The email is the field's description, not the subtitle:
@@ -2498,26 +3039,56 @@ function SignIn({
2498
3039
  value: code,
2499
3040
  onChange: event => {
2500
3041
  setCode(event.currentTarget.value);
2501
- if (codeError) setCodeError(null);
3042
+ // Typing again is the correction: the notice has
3043
+ // done its job. A locked field cannot be typed in,
3044
+ // so an exhausted or expired notice stays.
3045
+ if (codeFailure) setCodeFailure(null);
2502
3046
  },
2503
3047
  onKeyDown: event => {
2504
3048
  if (event.key === 'Enter' && code.trim()) handleVerify(code);
2505
3049
  },
2506
3050
  autoFocus: true,
2507
3051
  autoComplete: "one-time-code",
2508
- readOnly: verifying,
2509
- error: codeError
2510
- }), /*#__PURE__*/jsx(Button, {
3052
+ readOnly: verifying
3053
+ /*
3054
+ * No `error` on the field: the notice above carries the
3055
+ * failure, and a red border around an empty field turned
3056
+ * the PLACEHOLDER red — "ABCD-EFGH" read as the code the
3057
+ * person had typed. With the request gone there is
3058
+ * nothing left to type into.
3059
+ */,
3060
+ disabled: isCodeLocked
3061
+ }), isCodeLocked ?
3062
+ /*#__PURE__*/
3063
+ /*
3064
+ * The request is gone: confirming can only fail again, so
3065
+ * the one action that works takes the button's place.
3066
+ */
3067
+ jsx(Button, {
3068
+ type: "button",
3069
+ fullWidth: true,
3070
+ "aria-disabled": sending,
3071
+ onClick: sending ? undefined : handleResend,
3072
+ leftSection: sending ? /*#__PURE__*/jsx(Loader, {
3073
+ size: 14,
3074
+ color: "gray.0"
3075
+ }) : /*#__PURE__*/jsx(IconRefresh, {
3076
+ size: 16
3077
+ }),
3078
+ children: sending ? labels.sendingCode || 'Enviando…' : labels.sendNewCode || 'Enviar novo código'
3079
+ }) : /*#__PURE__*/jsx(Button, {
2511
3080
  type: "button",
2512
3081
  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.
3082
+ // Same reason as the previous step: `disabled` would
3083
+ // fade the button exactly while signing in happens.
3084
+ //
3085
+ // Never disabled for an empty field either. Right
3086
+ // after a failure the field is empty on purpose, and
3087
+ // a grey button there read as a frozen screen; the
3088
+ // click sends the cursor to the field instead.
2517
3089
  ,
2518
3090
  "aria-disabled": verifying,
2519
- disabled: !code.trim(),
2520
- onClick: verifying ? undefined : () => handleVerify(code),
3091
+ onClick: verifying ? undefined : () => code.trim() ? handleVerify(code) : codeInputRef.current?.focus(),
2521
3092
  leftSection: verifying ? /*#__PURE__*/jsx(Loader, {
2522
3093
  size: 14,
2523
3094
  color: "gray.0"
@@ -2535,15 +3106,17 @@ function SignIn({
2535
3106
  onClick: () => {
2536
3107
  setSentTo(null);
2537
3108
  setCode('');
2538
- setCodeError(null);
3109
+ setCodeFailure(null);
3110
+ setIsCodeResent(false);
3111
+ // The label promises another email: the form,
3112
+ // not the list the person may have come from.
3113
+ setIsChoosingOther(true);
2539
3114
  },
2540
3115
  children: labels.changeEmail || 'Usar outro e-mail'
2541
- }), /*#__PURE__*/jsx(Anchor, {
3116
+ }), !isCodeLocked && /*#__PURE__*/jsx(Anchor, {
2542
3117
  size: "sm",
2543
3118
  c: "dimmed",
2544
- onClick: sending ? undefined : () => handleRequest({
2545
- email: sentTo
2546
- }),
3119
+ onClick: sending ? undefined : handleResend,
2547
3120
  children: sending ? labels.sendingCode || 'Enviando…' : labels.resendCode || 'Reenviar código'
2548
3121
  })]
2549
3122
  })]
@@ -3527,5 +4100,5 @@ function SignOutButton({
3527
4100
  });
3528
4101
  }
3529
4102
 
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 };
4103
+ export { AuthCard, AuthLoaded, AuthLoading, AuthProvider, GuestOnly, IDENTITY_CHANGED_EVENT, MAX_RECENT_ACCOUNTS, Protect, RECENT_ACCOUNTS_KEY, SignIn, SignInButton, SignOutButton, SignedIn, SignedOut, SocialButtons, TOKEN_STORAGE_KEY, UserInformation, UserProfile, Wordmark, announceIdentityChange, applyRedirect, clearIdentitySwitching, configure, consumeSocialError, consumeSocialToken, decodeJWT, endImpersonation, forgetAccount, getApiUrl, getApplicationInfo, getCurrentUser, getLinkedProviders, getRedirectFromLocation, getSession, getSocialProviders, isAuthenticated, isIdentitySwitching, isInternal, listRecentAccounts, listSessions, markIdentitySwitching, pollCode, refreshToken, rememberAccount, requestCode, resolveRedirect, revokeOtherSessions, revokeSession, setStoredToken, shouldSignOutOn401, signOut, startSocialLink, startSocialSignIn, unlinkSocialProvider, updateProfile, useApplicationLogo, useAuth, useAuthLoading, useAuthStore, useCheckToken, useImpersonation, useSession, useSessions, useSignIn, useSignOut, useUser, verifyCode };
3531
4104
  //# sourceMappingURL=index.esm.js.map