@ciromaciel/auth-react 1.1.0 → 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.js CHANGED
@@ -2520,6 +2520,102 @@ function TermsNotice({
2520
2520
  });
2521
2521
  }
2522
2522
 
2523
+ /**
2524
+ * What a failed `verify` means for the person, read from the worker's answer.
2525
+ *
2526
+ * The HTTP status carries the case — `code` is `VALIDATION_ERROR` for both a
2527
+ * wrong and an expired code, so it cannot tell them apart:
2528
+ * - 400 with `details.attemptsLeft`: wrong code, the request is still open;
2529
+ * - 400 without it: no open request for this email (already replaced);
2530
+ * - 429: the fifth wrong try destroyed the request;
2531
+ * - 410: the code outlived its minutes, and was destroyed too.
2532
+ *
2533
+ * Anything else — the network, a 500 — is not about the code, and must not
2534
+ * lock the field.
2535
+ */
2536
+ function describeCodeFailure(error) {
2537
+ if (error?.status === 429) return {
2538
+ kind: 'exhausted',
2539
+ isLocked: true
2540
+ };
2541
+ if (error?.status === 410) return {
2542
+ kind: 'expired',
2543
+ isLocked: true
2544
+ };
2545
+ if (error?.status === 400) {
2546
+ const attemptsLeft = error?.details?.attemptsLeft;
2547
+ return {
2548
+ kind: 'wrong',
2549
+ isLocked: false,
2550
+ attemptsLeft: Number.isInteger(attemptsLeft) ? attemptsLeft : null
2551
+ };
2552
+ }
2553
+ return {
2554
+ kind: 'other',
2555
+ isLocked: false,
2556
+ message: error?.message || null
2557
+ };
2558
+ }
2559
+
2560
+ /**
2561
+ * The notice above the code field.
2562
+ *
2563
+ * It sits ABOVE the field, not under it, and says what to do next — not only
2564
+ * that something failed. The field's own error line was 12px of red under a
2565
+ * cleared input, next to a greyed-out button: it read as a frozen screen.
2566
+ *
2567
+ * The most common cause gets named: a new code invalidates the previous one,
2568
+ * and the person is often reading an older email.
2569
+ */
2570
+ function CodeFailureNotice({
2571
+ failure,
2572
+ labels
2573
+ }) {
2574
+ if (!failure) return null;
2575
+ const texts = {
2576
+ wrong: {
2577
+ title: labels.wrongCodeTitle || 'Código incorreto',
2578
+ 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(' ')
2579
+ },
2580
+ exhausted: {
2581
+ title: labels.attemptsExhaustedTitle || 'Tentativas esgotadas',
2582
+ body: labels.attemptsExhausted || 'Por segurança, este código foi cancelado. Peça um novo para continuar.'
2583
+ },
2584
+ expired: {
2585
+ title: labels.codeExpiredTitle || 'Código expirado',
2586
+ body: labels.codeExpired || 'O código vale por poucos minutos. Peça um novo para continuar.'
2587
+ },
2588
+ other: {
2589
+ title: labels.codeFailedTitle || 'Não foi possível entrar',
2590
+ body: failure.message || labels.invalidCode || 'Tente de novo em instantes.'
2591
+ }
2592
+ }[failure.kind];
2593
+ return /*#__PURE__*/jsxRuntime.jsx(core.Alert, {
2594
+ color: "red",
2595
+ variant: "light",
2596
+ radius: 0,
2597
+ icon: /*#__PURE__*/jsxRuntime.jsx(iconsReact.IconAlertCircle, {
2598
+ size: 18
2599
+ }),
2600
+ title: texts.title
2601
+ /*
2602
+ * `role="alert"` is Mantine's default and is what makes a screen
2603
+ * reader announce the failure without the person moving focus away
2604
+ * from the field they are about to retype in.
2605
+ */,
2606
+ styles: {
2607
+ root: {
2608
+ border: '1px solid var(--mantine-color-red-2)'
2609
+ }
2610
+ },
2611
+ children: /*#__PURE__*/jsxRuntime.jsx(core.Text, {
2612
+ size: "sm",
2613
+ lh: 1.45,
2614
+ children: texts.body
2615
+ })
2616
+ });
2617
+ }
2618
+
2523
2619
  // The OAuth flow's pass-through screen.
2524
2620
  //
2525
2621
  // The panel is not the destination here: the user is authorizing an MCP client
@@ -2632,7 +2728,9 @@ function SignIn({
2632
2728
  // present it again: it is the (email, code) pair the server validates.
2633
2729
  const [sentTo, setSentTo] = react.useState(null);
2634
2730
  const [code, setCode] = react.useState('');
2635
- const [codeError, setCodeError] = react.useState(null);
2731
+ const [codeFailure, setCodeFailure] = react.useState(null);
2732
+ const [isCodeResent, setIsCodeResent] = react.useState(false);
2733
+ const codeInputRef = react.useRef(null);
2636
2734
 
2637
2735
  // Read once, on mount: the list only changes through this screen, and
2638
2736
  // every change below writes the new list back into state.
@@ -2641,6 +2739,7 @@ function SignIn({
2641
2739
  const [isManaging, setIsManaging] = react.useState(false);
2642
2740
  const [pickingEmail, setPickingEmail] = react.useState(null);
2643
2741
  const isShowingAccounts = recentAccounts && accounts.length > 0 && !isChoosingOther;
2742
+ const isCodeLocked = !!codeFailure?.isLocked;
2644
2743
 
2645
2744
  // Hook that fetches the application's logo
2646
2745
  const applicationLogo = useApplicationLogo();
@@ -2687,18 +2786,36 @@ function SignIn({
2687
2786
 
2688
2787
  // Step 1 — ask for the code.
2689
2788
  const handleRequest = async values => {
2690
- if (sending) return;
2789
+ if (sending) return false;
2691
2790
  try {
2692
2791
  await requestCode(values.email);
2693
2792
  setSentTo(values.email);
2694
2793
  setCode('');
2695
- setCodeError(null);
2794
+ setCodeFailure(null);
2696
2795
  onCodeSent?.(values.email);
2796
+ return true;
2697
2797
  } catch (error) {
2698
- onError?.(error);
2798
+ // Nothing on the card shows this one: the app's notification is
2799
+ // the only place the person learns the code was not sent.
2800
+ onError?.(error, {
2801
+ step: 'request',
2802
+ isShownOnCard: false
2803
+ });
2804
+ return false;
2699
2805
  }
2700
2806
  };
2701
2807
 
2808
+ // A new code, from the code step. The worker replaces the request, so the
2809
+ // attempts start over and the previous code stops working — the notice
2810
+ // says so, or the person keeps typing the one from the older email.
2811
+ const handleResend = async () => {
2812
+ const isSent = await handleRequest({
2813
+ email: sentTo
2814
+ });
2815
+ setIsCodeResent(isSent);
2816
+ if (isSent) codeInputRef.current?.focus();
2817
+ };
2818
+
2702
2819
  // Step 1, from the list — the click on a saved account IS the request.
2703
2820
  //
2704
2821
  // An account that came in through a provider goes back to that provider,
@@ -2746,7 +2863,8 @@ function SignIn({
2746
2863
  // signals that navigation was taken over — now as information, not as a
2747
2864
  // trap.
2748
2865
  const handleVerify = async value => {
2749
- setCodeError(null);
2866
+ setCodeFailure(null);
2867
+ setIsCodeResent(false);
2750
2868
  try {
2751
2869
  const result = await verifyCode(sentTo, value);
2752
2870
 
@@ -2761,12 +2879,19 @@ function SignIn({
2761
2879
  redirectHandled: !!target
2762
2880
  });
2763
2881
  } 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.');
2882
+ // The failure belongs to this card, not to the global notification:
2883
+ // the person is looking at the eight characters they just typed.
2884
+ // The field goes back empty and focused, ready for the next try.
2885
+ setCodeFailure(describeCodeFailure(error));
2768
2886
  setCode('');
2769
- onError?.(error);
2887
+ codeInputRef.current?.focus();
2888
+ // Still reported — an app may log it — but flagged: the card
2889
+ // already explains it, and a notification repeating "Código
2890
+ // inválido" in the corner would say it twice.
2891
+ onError?.(error, {
2892
+ step: 'verify',
2893
+ isShownOnCard: true
2894
+ });
2770
2895
  }
2771
2896
  };
2772
2897
 
@@ -2876,7 +3001,27 @@ function SignIn({
2876
3001
  })
2877
3002
  }) : /*#__PURE__*/jsxRuntime.jsxs(core.Stack, {
2878
3003
  gap: "md",
2879
- children: [/*#__PURE__*/jsxRuntime.jsx(core.TextInput, {
3004
+ children: [isCodeResent && /*#__PURE__*/jsxRuntime.jsx(core.Alert, {
3005
+ color: "gray",
3006
+ variant: "light",
3007
+ radius: 0,
3008
+ p: "xs",
3009
+ children: /*#__PURE__*/jsxRuntime.jsxs(core.Text, {
3010
+ size: "xs",
3011
+ lh: 1.4,
3012
+ children: [/*#__PURE__*/jsxRuntime.jsx(core.Text, {
3013
+ span: true,
3014
+ inherit: true,
3015
+ fw: 700,
3016
+ c: "gray.9",
3017
+ children: labels.codeResentTitle || 'Novo código enviado.'
3018
+ }), ' ', labels.codeResent || 'O anterior deixou de valer.']
3019
+ })
3020
+ }), /*#__PURE__*/jsxRuntime.jsx(CodeFailureNotice, {
3021
+ failure: codeFailure,
3022
+ labels: labels
3023
+ }), /*#__PURE__*/jsxRuntime.jsx(core.TextInput, {
3024
+ ref: codeInputRef,
2880
3025
  label: labels.codeLabel || 'Código de acesso'
2881
3026
  /*
2882
3027
  * The email is the field's description, not the subtitle:
@@ -2896,26 +3041,56 @@ function SignIn({
2896
3041
  value: code,
2897
3042
  onChange: event => {
2898
3043
  setCode(event.currentTarget.value);
2899
- if (codeError) setCodeError(null);
3044
+ // Typing again is the correction: the notice has
3045
+ // done its job. A locked field cannot be typed in,
3046
+ // so an exhausted or expired notice stays.
3047
+ if (codeFailure) setCodeFailure(null);
2900
3048
  },
2901
3049
  onKeyDown: event => {
2902
3050
  if (event.key === 'Enter' && code.trim()) handleVerify(code);
2903
3051
  },
2904
3052
  autoFocus: true,
2905
3053
  autoComplete: "one-time-code",
2906
- readOnly: verifying,
2907
- error: codeError
2908
- }), /*#__PURE__*/jsxRuntime.jsx(core.Button, {
3054
+ readOnly: verifying
3055
+ /*
3056
+ * No `error` on the field: the notice above carries the
3057
+ * failure, and a red border around an empty field turned
3058
+ * the PLACEHOLDER red — "ABCD-EFGH" read as the code the
3059
+ * person had typed. With the request gone there is
3060
+ * nothing left to type into.
3061
+ */,
3062
+ disabled: isCodeLocked
3063
+ }), isCodeLocked ?
3064
+ /*#__PURE__*/
3065
+ /*
3066
+ * The request is gone: confirming can only fail again, so
3067
+ * the one action that works takes the button's place.
3068
+ */
3069
+ jsxRuntime.jsx(core.Button, {
3070
+ type: "button",
3071
+ fullWidth: true,
3072
+ "aria-disabled": sending,
3073
+ onClick: sending ? undefined : handleResend,
3074
+ leftSection: sending ? /*#__PURE__*/jsxRuntime.jsx(core.Loader, {
3075
+ size: 14,
3076
+ color: "gray.0"
3077
+ }) : /*#__PURE__*/jsxRuntime.jsx(iconsReact.IconRefresh, {
3078
+ size: 16
3079
+ }),
3080
+ children: sending ? labels.sendingCode || 'Enviando…' : labels.sendNewCode || 'Enviar novo código'
3081
+ }) : /*#__PURE__*/jsxRuntime.jsx(core.Button, {
2909
3082
  type: "button",
2910
3083
  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.
3084
+ // Same reason as the previous step: `disabled` would
3085
+ // fade the button exactly while signing in happens.
3086
+ //
3087
+ // Never disabled for an empty field either. Right
3088
+ // after a failure the field is empty on purpose, and
3089
+ // a grey button there read as a frozen screen; the
3090
+ // click sends the cursor to the field instead.
2915
3091
  ,
2916
3092
  "aria-disabled": verifying,
2917
- disabled: !code.trim(),
2918
- onClick: verifying ? undefined : () => handleVerify(code),
3093
+ onClick: verifying ? undefined : () => code.trim() ? handleVerify(code) : codeInputRef.current?.focus(),
2919
3094
  leftSection: verifying ? /*#__PURE__*/jsxRuntime.jsx(core.Loader, {
2920
3095
  size: 14,
2921
3096
  color: "gray.0"
@@ -2933,18 +3108,17 @@ function SignIn({
2933
3108
  onClick: () => {
2934
3109
  setSentTo(null);
2935
3110
  setCode('');
2936
- setCodeError(null);
3111
+ setCodeFailure(null);
3112
+ setIsCodeResent(false);
2937
3113
  // The label promises another email: the form,
2938
3114
  // not the list the person may have come from.
2939
3115
  setIsChoosingOther(true);
2940
3116
  },
2941
3117
  children: labels.changeEmail || 'Usar outro e-mail'
2942
- }), /*#__PURE__*/jsxRuntime.jsx(core.Anchor, {
3118
+ }), !isCodeLocked && /*#__PURE__*/jsxRuntime.jsx(core.Anchor, {
2943
3119
  size: "sm",
2944
3120
  c: "dimmed",
2945
- onClick: sending ? undefined : () => handleRequest({
2946
- email: sentTo
2947
- }),
3121
+ onClick: sending ? undefined : handleResend,
2948
3122
  children: sending ? labels.sendingCode || 'Enviando…' : labels.resendCode || 'Reenviar código'
2949
3123
  })]
2950
3124
  })]