@ciromaciel/auth-react 1.1.0 → 1.3.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.js CHANGED
@@ -112,6 +112,28 @@ function forgetAccount(email) {
112
112
  return next;
113
113
  }
114
114
 
115
+ /**
116
+ * Replaces the local copy with the list the worker returned.
117
+ *
118
+ * The worker's list is the one every panel shares, so when it has accounts it
119
+ * wins: an account removed on another panel must not come back from this
120
+ * panel's stale copy. An EMPTY answer does not wipe the local one — it is what
121
+ * a browser that signed in before the shared list existed gets, and those
122
+ * shortcuts are still true.
123
+ *
124
+ * @returns the list to show
125
+ */
126
+ function adoptRecentAccounts(remote) {
127
+ if (!Array.isArray(remote) || remote.length === 0) return listRecentAccounts();
128
+ const next = remote.filter(account => account && typeof account.email === 'string' && account.email.includes('@')).map(account => ({
129
+ email: normalizeEmail(account.email),
130
+ method: typeof account.method === 'string' && account.method ? account.method : 'code',
131
+ lastUsedAt: Number(account.lastUsedAt) || 0
132
+ })).sort((a, b) => b.lastUsedAt - a.lastUsedAt).slice(0, MAX_RECENT_ACCOUNTS);
133
+ writeRecentAccounts(next);
134
+ return next;
135
+ }
136
+
115
137
  /** Records the provider this tab is leaving for. */
116
138
  function markSocialDeparture(provider) {
117
139
  try {
@@ -693,6 +715,64 @@ const updateProfile = async data => {
693
715
  });
694
716
  };
695
717
 
718
+ /*--- Recent accounts ------------------------------------------------------*/
719
+
720
+ /*
721
+ * The server's copy of this browser's sign-in shortcuts.
722
+ *
723
+ * `localStorage` is per origin, so a list kept only there stayed on the panel
724
+ * where the person signed in. The worker keeps it in an HttpOnly cookie on its
725
+ * own host, which every panel of the application reaches — and answers only to
726
+ * the origins the application allows (`routes/recent-accounts.js`).
727
+ *
728
+ * All three fail soft: the shortcuts are a convenience, and a network error or
729
+ * an origin the worker refuses must never cost the sign-in. `null` means "no
730
+ * answer", which the screen reads as "keep what you have".
731
+ */
732
+
733
+ /** The list for this application, or `null` when the worker did not answer. */
734
+ const fetchRecentAccounts = async () => {
735
+ try {
736
+ const response = await api('/auth/recent-accounts');
737
+ return Array.isArray(response?.items) ? response.items : null;
738
+ } catch {
739
+ return null;
740
+ }
741
+ };
742
+
743
+ /**
744
+ * Records the account of the CURRENT session. The worker reads the email from
745
+ * the session, never from here. `keepalive` because the screen is usually
746
+ * navigating away at this very moment.
747
+ */
748
+ const saveRecentAccount = async (method = 'code') => {
749
+ try {
750
+ const response = await api('/auth/recent-accounts', {
751
+ method: 'POST',
752
+ body: JSON.stringify({
753
+ method
754
+ }),
755
+ keepalive: true
756
+ });
757
+ return Array.isArray(response?.items) ? response.items : null;
758
+ } catch {
759
+ return null;
760
+ }
761
+ };
762
+
763
+ /** Takes one account off this browser's list, on every panel. */
764
+ const deleteRecentAccount = async email => {
765
+ try {
766
+ await api(`/auth/recent-accounts/${encodeURIComponent(email)}`, {
767
+ method: 'DELETE',
768
+ keepalive: true
769
+ });
770
+ return true;
771
+ } catch {
772
+ return false;
773
+ }
774
+ };
775
+
696
776
  /*--- Social sign-in -------------------------------------------------------*/
697
777
 
698
778
  /**
@@ -801,6 +881,9 @@ const consumeSocialToken = () => {
801
881
  if (provider) {
802
882
  const email = decodeJWT(token)?.email;
803
883
  if (email) rememberAccount(email, provider);
884
+ // The shared copy, so the other panels learn it too. Not awaited: the
885
+ // token is already stored, and the sign-in must not wait on a shortcut.
886
+ saveRecentAccount(provider);
804
887
  }
805
888
  params.delete('token');
806
889
  const rest = params.toString();
@@ -2520,6 +2603,102 @@ function TermsNotice({
2520
2603
  });
2521
2604
  }
2522
2605
 
2606
+ /**
2607
+ * What a failed `verify` means for the person, read from the worker's answer.
2608
+ *
2609
+ * The HTTP status carries the case — `code` is `VALIDATION_ERROR` for both a
2610
+ * wrong and an expired code, so it cannot tell them apart:
2611
+ * - 400 with `details.attemptsLeft`: wrong code, the request is still open;
2612
+ * - 400 without it: no open request for this email (already replaced);
2613
+ * - 429: the fifth wrong try destroyed the request;
2614
+ * - 410: the code outlived its minutes, and was destroyed too.
2615
+ *
2616
+ * Anything else — the network, a 500 — is not about the code, and must not
2617
+ * lock the field.
2618
+ */
2619
+ function describeCodeFailure(error) {
2620
+ if (error?.status === 429) return {
2621
+ kind: 'exhausted',
2622
+ isLocked: true
2623
+ };
2624
+ if (error?.status === 410) return {
2625
+ kind: 'expired',
2626
+ isLocked: true
2627
+ };
2628
+ if (error?.status === 400) {
2629
+ const attemptsLeft = error?.details?.attemptsLeft;
2630
+ return {
2631
+ kind: 'wrong',
2632
+ isLocked: false,
2633
+ attemptsLeft: Number.isInteger(attemptsLeft) ? attemptsLeft : null
2634
+ };
2635
+ }
2636
+ return {
2637
+ kind: 'other',
2638
+ isLocked: false,
2639
+ message: error?.message || null
2640
+ };
2641
+ }
2642
+
2643
+ /**
2644
+ * The notice above the code field.
2645
+ *
2646
+ * It sits ABOVE the field, not under it, and says what to do next — not only
2647
+ * that something failed. The field's own error line was 12px of red under a
2648
+ * cleared input, next to a greyed-out button: it read as a frozen screen.
2649
+ *
2650
+ * The most common cause gets named: a new code invalidates the previous one,
2651
+ * and the person is often reading an older email.
2652
+ */
2653
+ function CodeFailureNotice({
2654
+ failure,
2655
+ labels
2656
+ }) {
2657
+ if (!failure) return null;
2658
+ const texts = {
2659
+ wrong: {
2660
+ title: labels.wrongCodeTitle || 'Código incorreto',
2661
+ 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(' ')
2662
+ },
2663
+ exhausted: {
2664
+ title: labels.attemptsExhaustedTitle || 'Tentativas esgotadas',
2665
+ body: labels.attemptsExhausted || 'Por segurança, este código foi cancelado. Peça um novo para continuar.'
2666
+ },
2667
+ expired: {
2668
+ title: labels.codeExpiredTitle || 'Código expirado',
2669
+ body: labels.codeExpired || 'O código vale por poucos minutos. Peça um novo para continuar.'
2670
+ },
2671
+ other: {
2672
+ title: labels.codeFailedTitle || 'Não foi possível entrar',
2673
+ body: failure.message || labels.invalidCode || 'Tente de novo em instantes.'
2674
+ }
2675
+ }[failure.kind];
2676
+ return /*#__PURE__*/jsxRuntime.jsx(core.Alert, {
2677
+ color: "red",
2678
+ variant: "light",
2679
+ radius: 0,
2680
+ icon: /*#__PURE__*/jsxRuntime.jsx(iconsReact.IconAlertCircle, {
2681
+ size: 18
2682
+ }),
2683
+ title: texts.title
2684
+ /*
2685
+ * `role="alert"` is Mantine's default and is what makes a screen
2686
+ * reader announce the failure without the person moving focus away
2687
+ * from the field they are about to retype in.
2688
+ */,
2689
+ styles: {
2690
+ root: {
2691
+ border: '1px solid var(--mantine-color-red-2)'
2692
+ }
2693
+ },
2694
+ children: /*#__PURE__*/jsxRuntime.jsx(core.Text, {
2695
+ size: "sm",
2696
+ lh: 1.45,
2697
+ children: texts.body
2698
+ })
2699
+ });
2700
+ }
2701
+
2523
2702
  // The OAuth flow's pass-through screen.
2524
2703
  //
2525
2704
  // The panel is not the destination here: the user is authorizing an MCP client
@@ -2632,7 +2811,9 @@ function SignIn({
2632
2811
  // present it again: it is the (email, code) pair the server validates.
2633
2812
  const [sentTo, setSentTo] = react.useState(null);
2634
2813
  const [code, setCode] = react.useState('');
2635
- const [codeError, setCodeError] = react.useState(null);
2814
+ const [codeFailure, setCodeFailure] = react.useState(null);
2815
+ const [isCodeResent, setIsCodeResent] = react.useState(false);
2816
+ const codeInputRef = react.useRef(null);
2636
2817
 
2637
2818
  // Read once, on mount: the list only changes through this screen, and
2638
2819
  // every change below writes the new list back into state.
@@ -2641,6 +2822,7 @@ function SignIn({
2641
2822
  const [isManaging, setIsManaging] = react.useState(false);
2642
2823
  const [pickingEmail, setPickingEmail] = react.useState(null);
2643
2824
  const isShowingAccounts = recentAccounts && accounts.length > 0 && !isChoosingOther;
2825
+ const isCodeLocked = !!codeFailure?.isLocked;
2644
2826
 
2645
2827
  // Hook that fetches the application's logo
2646
2828
  const applicationLogo = useApplicationLogo();
@@ -2685,20 +2867,64 @@ function SignIn({
2685
2867
  // eslint-disable-next-line react-hooks/exhaustive-deps -- redirectOrigins enters through the serialized key above
2686
2868
  }, [authLoading, user, authenticatedRedirect, handleRedirect, redirectOriginsKey, navigate]);
2687
2869
 
2870
+ /*
2871
+ * The shared list, from the worker.
2872
+ *
2873
+ * The local copy renders at once; this replaces it when the answer
2874
+ * arrives. That is what makes an account used on the Auth panel appear
2875
+ * on Hoster: `localStorage` never crosses between the two origins.
2876
+ *
2877
+ * If the person has already started typing an email, the list does not
2878
+ * yank the form away from under them — it only feeds the "Contas salvas"
2879
+ * link, one click away.
2880
+ */
2881
+ react.useEffect(() => {
2882
+ if (!recentAccounts) return;
2883
+ let isActive = true;
2884
+ fetchRecentAccounts().then(remote => {
2885
+ if (!isActive || remote === null) return;
2886
+ const next = adoptRecentAccounts(remote);
2887
+ if (form$1.isDirty()) setIsChoosingOther(true);
2888
+ setAccounts(next);
2889
+ });
2890
+ return () => {
2891
+ isActive = false;
2892
+ };
2893
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount, like the local read
2894
+ }, [recentAccounts]);
2895
+
2688
2896
  // Step 1 — ask for the code.
2689
2897
  const handleRequest = async values => {
2690
- if (sending) return;
2898
+ if (sending) return false;
2691
2899
  try {
2692
2900
  await requestCode(values.email);
2693
2901
  setSentTo(values.email);
2694
2902
  setCode('');
2695
- setCodeError(null);
2903
+ setCodeFailure(null);
2696
2904
  onCodeSent?.(values.email);
2905
+ return true;
2697
2906
  } catch (error) {
2698
- onError?.(error);
2907
+ // Nothing on the card shows this one: the app's notification is
2908
+ // the only place the person learns the code was not sent.
2909
+ onError?.(error, {
2910
+ step: 'request',
2911
+ isShownOnCard: false
2912
+ });
2913
+ return false;
2699
2914
  }
2700
2915
  };
2701
2916
 
2917
+ // A new code, from the code step. The worker replaces the request, so the
2918
+ // attempts start over and the previous code stops working — the notice
2919
+ // says so, or the person keeps typing the one from the older email.
2920
+ const handleResend = async () => {
2921
+ const isSent = await handleRequest({
2922
+ email: sentTo
2923
+ });
2924
+ setIsCodeResent(isSent);
2925
+ if (isSent) codeInputRef.current?.focus();
2926
+ };
2927
+
2702
2928
  // Step 1, from the list — the click on a saved account IS the request.
2703
2929
  //
2704
2930
  // An account that came in through a provider goes back to that provider,
@@ -2726,6 +2952,9 @@ function SignIn({
2726
2952
  const handleForget = account => {
2727
2953
  const next = forgetAccount(account.email);
2728
2954
  setAccounts(next);
2955
+ // On every panel, not just this one. The local removal above already
2956
+ // took it off this screen, so a failure here costs nothing visible.
2957
+ deleteRecentAccount(account.email);
2729
2958
  if (next.length === 0) setIsManaging(false);
2730
2959
  };
2731
2960
 
@@ -2746,14 +2975,18 @@ function SignIn({
2746
2975
  // signals that navigation was taken over — now as information, not as a
2747
2976
  // trap.
2748
2977
  const handleVerify = async value => {
2749
- setCodeError(null);
2978
+ setCodeFailure(null);
2979
+ setIsCodeResent(false);
2750
2980
  try {
2751
2981
  const result = await verifyCode(sentTo, value);
2752
2982
 
2753
2983
  // Only now, with a session: an email that never received a valid
2754
2984
  // code never becomes a suggestion. Written before the redirect,
2755
2985
  // which may unmount this screen.
2756
- if (recentAccounts) setAccounts(rememberAccount(sentTo, 'code'));
2986
+ if (recentAccounts) {
2987
+ setAccounts(rememberAccount(sentTo, 'code'));
2988
+ saveRecentAccount('code');
2989
+ }
2757
2990
  const target = handleRedirect ? getRedirectFromLocation(redirectOrigins) : null;
2758
2991
  if (target) applyRedirect(target, navigate);
2759
2992
  onSuccess?.(result?.user ?? null, {
@@ -2761,12 +2994,19 @@ function SignIn({
2761
2994
  redirectHandled: !!target
2762
2995
  });
2763
2996
  } catch (error) {
2764
- // The code error belongs to the field, not to the global
2765
- // notification: the person is looking at the eight characters they
2766
- // just typed.
2767
- setCodeError(error?.message || labels.invalidCode || 'Código inválido.');
2997
+ // The failure belongs to this card, not to the global notification:
2998
+ // the person is looking at the eight characters they just typed.
2999
+ // The field goes back empty and focused, ready for the next try.
3000
+ setCodeFailure(describeCodeFailure(error));
2768
3001
  setCode('');
2769
- onError?.(error);
3002
+ codeInputRef.current?.focus();
3003
+ // Still reported — an app may log it — but flagged: the card
3004
+ // already explains it, and a notification repeating "Código
3005
+ // inválido" in the corner would say it twice.
3006
+ onError?.(error, {
3007
+ step: 'verify',
3008
+ isShownOnCard: true
3009
+ });
2770
3010
  }
2771
3011
  };
2772
3012
 
@@ -2876,7 +3116,27 @@ function SignIn({
2876
3116
  })
2877
3117
  }) : /*#__PURE__*/jsxRuntime.jsxs(core.Stack, {
2878
3118
  gap: "md",
2879
- children: [/*#__PURE__*/jsxRuntime.jsx(core.TextInput, {
3119
+ children: [isCodeResent && /*#__PURE__*/jsxRuntime.jsx(core.Alert, {
3120
+ color: "gray",
3121
+ variant: "light",
3122
+ radius: 0,
3123
+ p: "xs",
3124
+ children: /*#__PURE__*/jsxRuntime.jsxs(core.Text, {
3125
+ size: "xs",
3126
+ lh: 1.4,
3127
+ children: [/*#__PURE__*/jsxRuntime.jsx(core.Text, {
3128
+ span: true,
3129
+ inherit: true,
3130
+ fw: 700,
3131
+ c: "gray.9",
3132
+ children: labels.codeResentTitle || 'Novo código enviado.'
3133
+ }), ' ', labels.codeResent || 'O anterior deixou de valer.']
3134
+ })
3135
+ }), /*#__PURE__*/jsxRuntime.jsx(CodeFailureNotice, {
3136
+ failure: codeFailure,
3137
+ labels: labels
3138
+ }), /*#__PURE__*/jsxRuntime.jsx(core.TextInput, {
3139
+ ref: codeInputRef,
2880
3140
  label: labels.codeLabel || 'Código de acesso'
2881
3141
  /*
2882
3142
  * The email is the field's description, not the subtitle:
@@ -2896,26 +3156,56 @@ function SignIn({
2896
3156
  value: code,
2897
3157
  onChange: event => {
2898
3158
  setCode(event.currentTarget.value);
2899
- if (codeError) setCodeError(null);
3159
+ // Typing again is the correction: the notice has
3160
+ // done its job. A locked field cannot be typed in,
3161
+ // so an exhausted or expired notice stays.
3162
+ if (codeFailure) setCodeFailure(null);
2900
3163
  },
2901
3164
  onKeyDown: event => {
2902
3165
  if (event.key === 'Enter' && code.trim()) handleVerify(code);
2903
3166
  },
2904
3167
  autoFocus: true,
2905
3168
  autoComplete: "one-time-code",
2906
- readOnly: verifying,
2907
- error: codeError
2908
- }), /*#__PURE__*/jsxRuntime.jsx(core.Button, {
3169
+ readOnly: verifying
3170
+ /*
3171
+ * No `error` on the field: the notice above carries the
3172
+ * failure, and a red border around an empty field turned
3173
+ * the PLACEHOLDER red — "ABCD-EFGH" read as the code the
3174
+ * person had typed. With the request gone there is
3175
+ * nothing left to type into.
3176
+ */,
3177
+ disabled: isCodeLocked
3178
+ }), isCodeLocked ?
3179
+ /*#__PURE__*/
3180
+ /*
3181
+ * The request is gone: confirming can only fail again, so
3182
+ * the one action that works takes the button's place.
3183
+ */
3184
+ jsxRuntime.jsx(core.Button, {
3185
+ type: "button",
3186
+ fullWidth: true,
3187
+ "aria-disabled": sending,
3188
+ onClick: sending ? undefined : handleResend,
3189
+ leftSection: sending ? /*#__PURE__*/jsxRuntime.jsx(core.Loader, {
3190
+ size: 14,
3191
+ color: "gray.0"
3192
+ }) : /*#__PURE__*/jsxRuntime.jsx(iconsReact.IconRefresh, {
3193
+ size: 16
3194
+ }),
3195
+ children: sending ? labels.sendingCode || 'Enviando…' : labels.sendNewCode || 'Enviar novo código'
3196
+ }) : /*#__PURE__*/jsxRuntime.jsx(core.Button, {
2909
3197
  type: "button",
2910
3198
  fullWidth: true
2911
- // Same reason as the previous step: `disabled` would fade
2912
- // the button exactly while signing in happens. With no
2913
- // code typed it stays genuinely disabled — there is no
2914
- // action in progress to hide there.
3199
+ // Same reason as the previous step: `disabled` would
3200
+ // fade the button exactly while signing in happens.
3201
+ //
3202
+ // Never disabled for an empty field either. Right
3203
+ // after a failure the field is empty on purpose, and
3204
+ // a grey button there read as a frozen screen; the
3205
+ // click sends the cursor to the field instead.
2915
3206
  ,
2916
3207
  "aria-disabled": verifying,
2917
- disabled: !code.trim(),
2918
- onClick: verifying ? undefined : () => handleVerify(code),
3208
+ onClick: verifying ? undefined : () => code.trim() ? handleVerify(code) : codeInputRef.current?.focus(),
2919
3209
  leftSection: verifying ? /*#__PURE__*/jsxRuntime.jsx(core.Loader, {
2920
3210
  size: 14,
2921
3211
  color: "gray.0"
@@ -2933,18 +3223,17 @@ function SignIn({
2933
3223
  onClick: () => {
2934
3224
  setSentTo(null);
2935
3225
  setCode('');
2936
- setCodeError(null);
3226
+ setCodeFailure(null);
3227
+ setIsCodeResent(false);
2937
3228
  // The label promises another email: the form,
2938
3229
  // not the list the person may have come from.
2939
3230
  setIsChoosingOther(true);
2940
3231
  },
2941
3232
  children: labels.changeEmail || 'Usar outro e-mail'
2942
- }), /*#__PURE__*/jsxRuntime.jsx(core.Anchor, {
3233
+ }), !isCodeLocked && /*#__PURE__*/jsxRuntime.jsx(core.Anchor, {
2943
3234
  size: "sm",
2944
3235
  c: "dimmed",
2945
- onClick: sending ? undefined : () => handleRequest({
2946
- email: sentTo
2947
- }),
3236
+ onClick: sending ? undefined : handleResend,
2948
3237
  children: sending ? labels.sendingCode || 'Enviando…' : labels.resendCode || 'Reenviar código'
2949
3238
  })]
2950
3239
  })]
@@ -3947,6 +4236,7 @@ exports.TOKEN_STORAGE_KEY = TOKEN_STORAGE_KEY;
3947
4236
  exports.UserInformation = UserInformation;
3948
4237
  exports.UserProfile = UserProfile;
3949
4238
  exports.Wordmark = Wordmark;
4239
+ exports.adoptRecentAccounts = adoptRecentAccounts;
3950
4240
  exports.announceIdentityChange = announceIdentityChange;
3951
4241
  exports.applyRedirect = applyRedirect;
3952
4242
  exports.clearIdentitySwitching = clearIdentitySwitching;
@@ -3954,7 +4244,9 @@ exports.configure = configure;
3954
4244
  exports.consumeSocialError = consumeSocialError;
3955
4245
  exports.consumeSocialToken = consumeSocialToken;
3956
4246
  exports.decodeJWT = decodeJWT;
4247
+ exports.deleteRecentAccount = deleteRecentAccount;
3957
4248
  exports.endImpersonation = endImpersonation;
4249
+ exports.fetchRecentAccounts = fetchRecentAccounts;
3958
4250
  exports.forgetAccount = forgetAccount;
3959
4251
  exports.getApiUrl = getApiUrl;
3960
4252
  exports.getApplicationInfo = getApplicationInfo;
@@ -3976,6 +4268,7 @@ exports.requestCode = requestCode;
3976
4268
  exports.resolveRedirect = resolveRedirect;
3977
4269
  exports.revokeOtherSessions = revokeOtherSessions;
3978
4270
  exports.revokeSession = revokeSession;
4271
+ exports.saveRecentAccount = saveRecentAccount;
3979
4272
  exports.setStoredToken = setStoredToken;
3980
4273
  exports.shouldSignOutOn401 = shouldSignOutOn401;
3981
4274
  exports.signOut = signOut;