@ciromaciel/auth-react 1.2.0 → 1.4.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
@@ -3,9 +3,9 @@ import { useState, useCallback, useEffect, useMemo, createContext, useRef } from
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, Anchor, NavLink, ActionIcon, Loader, Avatar, Button, Divider, TextInput, Alert, Center, Box, Collapse, FileButton, Tooltip, ThemeIcon, Badge, rem } 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, UnstyledButton } from '@mantine/core';
7
7
  import { useForm } from '@mantine/form';
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';
8
+ import { IconX, IconArrowRight, IconBrandGoogle, IconArrowLeft, IconRefresh, IconAlertCircle, IconUser, IconPhoto, IconTrash, IconCheck, IconPencil, IconMail, IconShield, IconDevices, IconDeviceMobile, IconLogout, IconUserCircle, IconBuilding, IconCreditCard } from '@tabler/icons-react';
9
9
 
10
10
  /**
11
11
  * The accounts that already signed in on this browser.
@@ -110,6 +110,28 @@ function forgetAccount(email) {
110
110
  return next;
111
111
  }
112
112
 
113
+ /**
114
+ * Replaces the local copy with the list the worker returned.
115
+ *
116
+ * The worker's list is the one every panel shares, so when it has accounts it
117
+ * wins: an account removed on another panel must not come back from this
118
+ * panel's stale copy. An EMPTY answer does not wipe the local one — it is what
119
+ * a browser that signed in before the shared list existed gets, and those
120
+ * shortcuts are still true.
121
+ *
122
+ * @returns the list to show
123
+ */
124
+ function adoptRecentAccounts(remote) {
125
+ if (!Array.isArray(remote) || remote.length === 0) return listRecentAccounts();
126
+ const next = remote.filter(account => account && typeof account.email === 'string' && account.email.includes('@')).map(account => ({
127
+ email: normalizeEmail(account.email),
128
+ method: typeof account.method === 'string' && account.method ? account.method : 'code',
129
+ lastUsedAt: Number(account.lastUsedAt) || 0
130
+ })).sort((a, b) => b.lastUsedAt - a.lastUsedAt).slice(0, MAX_RECENT_ACCOUNTS);
131
+ writeRecentAccounts(next);
132
+ return next;
133
+ }
134
+
113
135
  /** Records the provider this tab is leaving for. */
114
136
  function markSocialDeparture(provider) {
115
137
  try {
@@ -691,6 +713,64 @@ const updateProfile = async data => {
691
713
  });
692
714
  };
693
715
 
716
+ /*--- Recent accounts ------------------------------------------------------*/
717
+
718
+ /*
719
+ * The server's copy of this browser's sign-in shortcuts.
720
+ *
721
+ * `localStorage` is per origin, so a list kept only there stayed on the panel
722
+ * where the person signed in. The worker keeps it in an HttpOnly cookie on its
723
+ * own host, which every panel of the application reaches — and answers only to
724
+ * the origins the application allows (`routes/recent-accounts.js`).
725
+ *
726
+ * All three fail soft: the shortcuts are a convenience, and a network error or
727
+ * an origin the worker refuses must never cost the sign-in. `null` means "no
728
+ * answer", which the screen reads as "keep what you have".
729
+ */
730
+
731
+ /** The list for this application, or `null` when the worker did not answer. */
732
+ const fetchRecentAccounts = async () => {
733
+ try {
734
+ const response = await api('/auth/recent-accounts');
735
+ return Array.isArray(response?.items) ? response.items : null;
736
+ } catch {
737
+ return null;
738
+ }
739
+ };
740
+
741
+ /**
742
+ * Records the account of the CURRENT session. The worker reads the email from
743
+ * the session, never from here. `keepalive` because the screen is usually
744
+ * navigating away at this very moment.
745
+ */
746
+ const saveRecentAccount = async (method = 'code') => {
747
+ try {
748
+ const response = await api('/auth/recent-accounts', {
749
+ method: 'POST',
750
+ body: JSON.stringify({
751
+ method
752
+ }),
753
+ keepalive: true
754
+ });
755
+ return Array.isArray(response?.items) ? response.items : null;
756
+ } catch {
757
+ return null;
758
+ }
759
+ };
760
+
761
+ /** Takes one account off this browser's list, on every panel. */
762
+ const deleteRecentAccount = async email => {
763
+ try {
764
+ await api(`/auth/recent-accounts/${encodeURIComponent(email)}`, {
765
+ method: 'DELETE',
766
+ keepalive: true
767
+ });
768
+ return true;
769
+ } catch {
770
+ return false;
771
+ }
772
+ };
773
+
694
774
  /*--- Social sign-in -------------------------------------------------------*/
695
775
 
696
776
  /**
@@ -799,6 +879,9 @@ const consumeSocialToken = () => {
799
879
  if (provider) {
800
880
  const email = decodeJWT(token)?.email;
801
881
  if (email) rememberAccount(email, provider);
882
+ // The shared copy, so the other panels learn it too. Not awaited: the
883
+ // token is already stored, and the sign-in must not wait on a shortcut.
884
+ saveRecentAccount(provider);
802
885
  }
803
886
  params.delete('token');
804
887
  const rest = params.toString();
@@ -2782,6 +2865,32 @@ function SignIn({
2782
2865
  // eslint-disable-next-line react-hooks/exhaustive-deps -- redirectOrigins enters through the serialized key above
2783
2866
  }, [authLoading, user, authenticatedRedirect, handleRedirect, redirectOriginsKey, navigate]);
2784
2867
 
2868
+ /*
2869
+ * The shared list, from the worker.
2870
+ *
2871
+ * The local copy renders at once; this replaces it when the answer
2872
+ * arrives. That is what makes an account used on the Auth panel appear
2873
+ * on Hoster: `localStorage` never crosses between the two origins.
2874
+ *
2875
+ * If the person has already started typing an email, the list does not
2876
+ * yank the form away from under them — it only feeds the "Contas salvas"
2877
+ * link, one click away.
2878
+ */
2879
+ useEffect(() => {
2880
+ if (!recentAccounts) return;
2881
+ let isActive = true;
2882
+ fetchRecentAccounts().then(remote => {
2883
+ if (!isActive || remote === null) return;
2884
+ const next = adoptRecentAccounts(remote);
2885
+ if (form.isDirty()) setIsChoosingOther(true);
2886
+ setAccounts(next);
2887
+ });
2888
+ return () => {
2889
+ isActive = false;
2890
+ };
2891
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount, like the local read
2892
+ }, [recentAccounts]);
2893
+
2785
2894
  // Step 1 — ask for the code.
2786
2895
  const handleRequest = async values => {
2787
2896
  if (sending) return false;
@@ -2841,6 +2950,9 @@ function SignIn({
2841
2950
  const handleForget = account => {
2842
2951
  const next = forgetAccount(account.email);
2843
2952
  setAccounts(next);
2953
+ // On every panel, not just this one. The local removal above already
2954
+ // took it off this screen, so a failure here costs nothing visible.
2955
+ deleteRecentAccount(account.email);
2844
2956
  if (next.length === 0) setIsManaging(false);
2845
2957
  };
2846
2958
 
@@ -2869,7 +2981,10 @@ function SignIn({
2869
2981
  // Only now, with a session: an email that never received a valid
2870
2982
  // code never becomes a suggestion. Written before the redirect,
2871
2983
  // which may unmount this screen.
2872
- if (recentAccounts) setAccounts(rememberAccount(sentTo, 'code'));
2984
+ if (recentAccounts) {
2985
+ setAccounts(rememberAccount(sentTo, 'code'));
2986
+ saveRecentAccount('code');
2987
+ }
2873
2988
  const target = handleRedirect ? getRedirectFromLocation(redirectOrigins) : null;
2874
2989
  if (target) applyRedirect(target, navigate);
2875
2990
  onSuccess?.(result?.user ?? null, {
@@ -3867,150 +3982,357 @@ function UserProfile({
3867
3982
  });
3868
3983
  }
3869
3984
 
3985
+ const ROLE_LABELS = {
3986
+ owner: 'Dono',
3987
+ admin: 'Administrador',
3988
+ member: 'Membro'
3989
+ };
3990
+
3991
+ /**
3992
+ * @typedef {Object} UserInformationItem
3993
+ * @property {string} [id] - Stable key
3994
+ * @property {string} label - The row's text
3995
+ * @property {Function} [icon] - A Tabler icon component
3996
+ * @property {Function} onClick
3997
+ *
3998
+ * @typedef {Object} UserInformationPlan
3999
+ * @property {string} [name] - "Pro", "Starter"… Shown as the badge
4000
+ * @property {number} [used] - How many of the plan's resources are in use
4001
+ * @property {number|null} [limit] - The plan's ceiling; `null` hides the meter
4002
+ * @property {string} [unit] - What is counted, in the plural: "projetos"
4003
+ * @property {string} [actionLabel='Ver planos']
4004
+ * @property {Function} [onClick] - Opens the plans
4005
+ *
4006
+ * @typedef {Object} UserInformationOrganization
4007
+ * @property {string} name
4008
+ * @property {string} [role] - `owner`, `admin`, `member`, or already a label
4009
+ */
4010
+
4011
+ /**
4012
+ * @param {Object} props
4013
+ * @param {Object} props.user - The signed-in user (`name`, `email`, `image`)
4014
+ * @param {Function} props.signOut
4015
+ * @param {Function} [props.onAccountClick]
4016
+ * @param {Function} [props.onBillingClick]
4017
+ * @param {UserInformationItem[]} [props.items] - The panel's own rows, shown in their own group
4018
+ * @param {UserInformationPlan} [props.plan] - The plan strip; omitted, the strip is not drawn
4019
+ * @param {UserInformationOrganization} [props.organization] - Where the person is acting
4020
+ * @param {boolean} [props.branded=true] - The "Protegido por Auth" line
4021
+ * @param {string} [props.accountLabel='Conta']
4022
+ * @param {string} [props.billingLabel='Assinatura']
4023
+ * @param {string} [props.signOutLabel='Sair']
4024
+ */
3870
4025
  function UserInformation({
3871
4026
  user,
3872
4027
  signOut,
3873
4028
  onAccountClick,
3874
4029
  onBillingClick,
4030
+ items = [],
4031
+ plan,
4032
+ organization,
4033
+ branded = true,
3875
4034
  accountLabel = 'Conta',
3876
4035
  billingLabel = 'Assinatura',
4036
+ signOutLabel = 'Sair',
4037
+ // Kept so existing calls keep working. The card has one density now: the
4038
+ // popover's. `padded={false}` still removes the outer padding.
3877
4039
  padded = true,
3878
- size = 'sm',
4040
+ size,
4041
+ // eslint-disable-line no-unused-vars -- accepted and ignored, see above
3879
4042
  style,
3880
4043
  ...others
3881
4044
  }) {
3882
4045
  if (!user) return null;
4046
+ const name = (user.fullName || user.name || '').trim();
4047
+ const email = (user.primaryEmailAddress || user.email || '').trim();
3883
4048
 
3884
- // Size mappings
3885
- const avatarSizeMap = {
3886
- sm: 32,
3887
- md: 40,
3888
- lg: 48
3889
- };
3890
- const fontSizeTitleMap = {
3891
- sm: 'sm',
3892
- md: 'md',
3893
- lg: 'lg'
3894
- };
3895
- const fontSizeEmailMap = {
3896
- sm: '10px',
3897
- md: 'xs',
3898
- lg: 'sm'
3899
- };
3900
- const btnSizeMap = {
3901
- sm: 'xs',
3902
- md: 'sm',
3903
- lg: 'md'
3904
- };
3905
- const gapMap = {
3906
- sm: 'xs',
3907
- md: 'sm',
3908
- lg: 'md'
3909
- };
3910
- const widthMap = {
3911
- sm: 280,
3912
- md: 320,
3913
- lg: 400
3914
- };
3915
- const name = user.fullName || user.name || 'User';
3916
- const email = user.primaryEmailAddress || user.email || '';
3917
- const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
3918
- return /*#__PURE__*/jsx(Box, {
3919
- p: padded ? gapMap[size] : 0,
3920
- w: widthMap[size],
4049
+ /*
4050
+ * A name equal to the email's local part is not a name: it is what an
4051
+ * account born from a code gets by default, and showing it on top of the
4052
+ * email repeats the same fact. It counts as "no name".
4053
+ */
4054
+ const hasRealName = Boolean(name) && name.toLowerCase() !== email.split('@')[0].toLowerCase() && name.toLowerCase() !== email.toLowerCase();
4055
+ const title = hasRealName ? name : email || name;
4056
+ const initials = hasRealName ? name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() : (email || name).charAt(0).toUpperCase();
4057
+ const baseRows = [onAccountClick && {
4058
+ id: 'account',
4059
+ label: accountLabel,
4060
+ icon: IconUser,
4061
+ onClick: onAccountClick
4062
+ }, onBillingClick && {
4063
+ id: 'billing',
4064
+ label: billingLabel,
4065
+ icon: IconCreditCard,
4066
+ onClick: onBillingClick
4067
+ }].filter(Boolean);
4068
+ const panelRows = items.filter(item => item && item.label && typeof item.onClick === 'function');
4069
+ return /*#__PURE__*/jsxs(Box, {
4070
+ w: 288,
4071
+ maw: "100%",
3921
4072
  style: style,
3922
4073
  ...others,
3923
- children: /*#__PURE__*/jsxs(Stack, {
3924
- gap: gapMap[size],
3925
- children: [/*#__PURE__*/jsxs(Group, {
3926
- wrap: "nowrap",
3927
- gap: "xs",
3928
- children: [/*#__PURE__*/jsx(Avatar, {
3929
- src: user.imageUrl || user.image,
3930
- size: avatarSizeMap[size],
3931
- radius: "xl",
3932
- bg: "gray.1",
3933
- c: "gray.6",
3934
- styles: {
3935
- placeholder: {
3936
- fontSize: rem(avatarSizeMap[size] / 2.2),
3937
- fontWeight: 600
3938
- }
4074
+ children: [/*#__PURE__*/jsxs(Group, {
4075
+ wrap: "nowrap",
4076
+ gap: 10,
4077
+ px: padded ? 16 : 0,
4078
+ py: 14,
4079
+ children: [/*#__PURE__*/jsx(Avatar, {
4080
+ src: user.imageUrl || user.image,
4081
+ alt: "",
4082
+ size: 32,
4083
+ radius: 0,
4084
+ color: "gray.9",
4085
+ variant: "filled",
4086
+ styles: {
4087
+ root: {
4088
+ borderRadius: 0
3939
4089
  },
3940
- children: initials || 'CC'
3941
- }), /*#__PURE__*/jsxs(Box, {
4090
+ placeholder: {
4091
+ fontSize: 12,
4092
+ fontWeight: 800
4093
+ }
4094
+ },
4095
+ children: initials
4096
+ }), /*#__PURE__*/jsxs(Box, {
4097
+ style: {
4098
+ flex: 1,
4099
+ minWidth: 0
4100
+ },
4101
+ children: [/*#__PURE__*/jsx(Text, {
4102
+ fz: 13,
4103
+ fw: 800,
4104
+ c: "gray.9",
4105
+ lh: 1.3,
4106
+ truncate: "end",
4107
+ children: title
4108
+ }), hasRealName && email && /*#__PURE__*/jsx(Text, {
4109
+ fz: 12,
4110
+ fw: 500,
4111
+ c: "gray.5",
4112
+ lh: 1.4,
4113
+ truncate: "end",
4114
+ children: email
4115
+ })]
4116
+ })]
4117
+ }), (organization?.name || plan) && /*#__PURE__*/jsx(ContextStrip, {
4118
+ organization: organization,
4119
+ plan: plan,
4120
+ padded: padded
4121
+ }), /*#__PURE__*/jsxs(Box, {
4122
+ role: "menu",
4123
+ "aria-label": "Conta",
4124
+ onKeyDown: moveFocus,
4125
+ children: [baseRows.length > 0 && /*#__PURE__*/jsx(RowGroup, {
4126
+ rows: baseRows,
4127
+ hasDivider: !organization?.name && !plan
4128
+ }), panelRows.length > 0 && /*#__PURE__*/jsx(RowGroup, {
4129
+ rows: panelRows,
4130
+ hasDivider: true
4131
+ }), signOut && /*#__PURE__*/jsx(RowGroup, {
4132
+ rows: [{
4133
+ id: 'sign-out',
4134
+ label: signOutLabel,
4135
+ icon: IconLogout,
4136
+ onClick: signOut
4137
+ }],
4138
+ hasDivider: true
4139
+ })]
4140
+ }), branded && /*#__PURE__*/jsxs(Group, {
4141
+ justify: "center",
4142
+ gap: 4,
4143
+ py: 8,
4144
+ bg: "gray.1",
4145
+ style: {
4146
+ borderTop: '1px solid var(--mantine-color-gray-2)'
4147
+ },
4148
+ children: [/*#__PURE__*/jsx(Text, {
4149
+ fz: 11,
4150
+ fw: 500,
4151
+ c: "gray.5",
4152
+ children: "Protegido por"
4153
+ }), /*#__PURE__*/jsx(Text, {
4154
+ fz: 11,
4155
+ fw: 800,
4156
+ c: "gray.7",
4157
+ children: "Auth"
4158
+ })]
4159
+ })]
4160
+ });
4161
+ }
4162
+
4163
+ /** The organization, the role and the plan's usage, on the alternate surface. */
4164
+ function ContextStrip({
4165
+ organization,
4166
+ plan,
4167
+ padded
4168
+ }) {
4169
+ const role = organization?.role ? ROLE_LABELS[organization.role] || organization.role : null;
4170
+ const hasMeter = plan && Number.isFinite(plan.used) && Number.isFinite(plan.limit) && plan.limit > 0;
4171
+ const ratio = hasMeter ? Math.min(1, Math.max(0, plan.used / plan.limit)) : 0;
4172
+ return /*#__PURE__*/jsxs(Stack, {
4173
+ gap: 8,
4174
+ px: padded ? 16 : 0,
4175
+ py: 12,
4176
+ bg: "gray.1",
4177
+ style: {
4178
+ borderTop: '1px solid var(--mantine-color-gray-2)',
4179
+ borderBottom: '1px solid var(--mantine-color-gray-2)'
4180
+ },
4181
+ children: [(organization?.name || plan?.name) && /*#__PURE__*/jsxs(Group, {
4182
+ gap: 8,
4183
+ wrap: "nowrap",
4184
+ children: [organization?.name && /*#__PURE__*/jsxs(Fragment, {
4185
+ children: [/*#__PURE__*/jsx(IconBuilding, {
4186
+ size: 14,
4187
+ stroke: 1.5,
4188
+ style: {
4189
+ flex: 'none',
4190
+ color: 'var(--mantine-color-gray-5)'
4191
+ }
4192
+ }), /*#__PURE__*/jsx(Text, {
4193
+ fz: 12,
4194
+ fw: 800,
4195
+ c: "gray.9",
4196
+ truncate: "end",
3942
4197
  style: {
3943
- flex: 1,
3944
- overflow: 'hidden'
4198
+ minWidth: 0
3945
4199
  },
3946
- children: [/*#__PURE__*/jsx(Text, {
3947
- size: fontSizeTitleMap[size],
3948
- fw: 700,
3949
- truncate: "end",
3950
- c: "dark.9",
3951
- lh: 1.1,
3952
- children: name
3953
- }), /*#__PURE__*/jsx(Text, {
3954
- size: fontSizeEmailMap[size],
3955
- c: "gray.5",
3956
- truncate: "end",
3957
- lh: 1.1,
3958
- children: email
3959
- })]
3960
- }), /*#__PURE__*/jsx(ActionIcon, {
3961
- size: btnSizeMap[size],
3962
- onClick: signOut,
3963
- children: /*#__PURE__*/jsx(IconLogout, {
3964
- size: 16,
3965
- stroke: 1.5
3966
- })
3967
- })]
3968
- }), /*#__PURE__*/jsxs(Group, {
3969
- grow: true,
3970
- children: [/*#__PURE__*/jsx(Button, {
3971
- variant: "default",
3972
- size: btnSizeMap[size],
3973
- leftSection: /*#__PURE__*/jsx(IconSettings, {
3974
- size: 16,
3975
- stroke: 1.5
3976
- }),
3977
- onClick: onAccountClick,
3978
- children: accountLabel
3979
- }), /*#__PURE__*/jsx(Button, {
3980
- variant: "default",
3981
- size: btnSizeMap[size],
3982
- leftSection: /*#__PURE__*/jsx(IconCreditCard, {
3983
- size: 16,
3984
- stroke: 1.5
3985
- }),
3986
- onClick: onBillingClick,
3987
- children: billingLabel
4200
+ children: organization.name
4201
+ }), role && /*#__PURE__*/jsxs(Text, {
4202
+ fz: 12,
4203
+ fw: 500,
4204
+ c: "gray.5",
4205
+ style: {
4206
+ flex: 'none'
4207
+ },
4208
+ children: ["\xB7 ", role]
3988
4209
  })]
4210
+ }), plan?.name && /*#__PURE__*/jsx(Text, {
4211
+ component: "span",
4212
+ fz: 10,
4213
+ fw: 800,
4214
+ tt: "uppercase",
4215
+ lts: "1.5px",
4216
+ c: "white",
4217
+ bg: "gray.9",
4218
+ px: 6,
4219
+ lh: 1.6,
4220
+ ml: "auto",
4221
+ style: {
4222
+ flex: 'none'
4223
+ },
4224
+ children: plan.name
4225
+ })]
4226
+ }), hasMeter && /*#__PURE__*/jsxs(Fragment, {
4227
+ children: [/*#__PURE__*/jsx(Box, {
4228
+ h: 4,
4229
+ bg: "gray.2",
4230
+ role: "meter",
4231
+ "aria-valuemin": 0,
4232
+ "aria-valuemax": plan.limit,
4233
+ "aria-valuenow": plan.used,
4234
+ "aria-label": plan.unit ? `${plan.unit} em uso` : 'Uso do plano',
4235
+ children: /*#__PURE__*/jsx(Box, {
4236
+ h: "100%",
4237
+ w: `${ratio * 100}%`,
4238
+ bg: "gray.9"
4239
+ })
3989
4240
  }), /*#__PURE__*/jsxs(Group, {
3990
- justify: "center",
3991
- gap: 4,
3992
- opacity: 0.3,
4241
+ justify: "space-between",
4242
+ gap: 8,
4243
+ wrap: "nowrap",
3993
4244
  children: [/*#__PURE__*/jsx(Text, {
3994
- size: "10px",
4245
+ fz: 12,
4246
+ fw: 500,
3995
4247
  c: "gray.6",
3996
- fw: 600,
3997
- children: "Secured by"
3998
- }), /*#__PURE__*/jsxs(Group, {
3999
- gap: 2,
4000
- children: [/*#__PURE__*/jsx(IconShieldCheck, {
4001
- size: 10,
4002
- stroke: 2
4003
- }), /*#__PURE__*/jsx(Text, {
4004
- size: "10px",
4005
- fw: 800,
4006
- c: "dark.9",
4007
- children: "Auth"
4008
- })]
4248
+ children: `${plan.used} de ${plan.limit}${plan.unit ? ` ${plan.unit}` : ''}`
4249
+ }), plan.onClick && /*#__PURE__*/jsx(Anchor, {
4250
+ component: "button",
4251
+ type: "button",
4252
+ fz: 12,
4253
+ fw: 700,
4254
+ c: "gray.9",
4255
+ underline: "always",
4256
+ onClick: plan.onClick,
4257
+ children: plan.actionLabel || 'Ver planos'
4009
4258
  })]
4010
4259
  })]
4011
- })
4260
+ })]
4261
+ });
4262
+ }
4263
+ function RowGroup({
4264
+ rows,
4265
+ hasDivider
4266
+ }) {
4267
+ return /*#__PURE__*/jsx(Stack, {
4268
+ gap: 0,
4269
+ p: 6,
4270
+ style: hasDivider ? {
4271
+ borderTop: '1px solid var(--mantine-color-gray-2)'
4272
+ } : undefined,
4273
+ children: rows.map(row => /*#__PURE__*/jsx(Row, {
4274
+ ...row
4275
+ }, row.id || row.label))
4012
4276
  });
4013
4277
  }
4278
+ function Row({
4279
+ label,
4280
+ icon: Icon,
4281
+ onClick
4282
+ }) {
4283
+ return /*#__PURE__*/jsxs(UnstyledButton, {
4284
+ role: "menuitem",
4285
+ onClick: onClick,
4286
+ px: 10,
4287
+ py: 7,
4288
+ w: "100%",
4289
+ style: {
4290
+ display: 'flex',
4291
+ alignItems: 'center',
4292
+ gap: 10,
4293
+ borderRadius: 0
4294
+ }
4295
+ /*
4296
+ * Hover and keyboard focus share the same surface: a row reached
4297
+ * with the arrows must look exactly like the one under the mouse.
4298
+ */,
4299
+ onMouseEnter: event => event.currentTarget.style.background = 'var(--mantine-color-gray-1)',
4300
+ onMouseLeave: event => event.currentTarget.style.background = 'transparent',
4301
+ onFocus: event => event.currentTarget.style.background = 'var(--mantine-color-gray-1)',
4302
+ onBlur: event => event.currentTarget.style.background = 'transparent',
4303
+ children: [Icon && /*#__PURE__*/jsx(Icon, {
4304
+ size: 16,
4305
+ stroke: 1.5,
4306
+ style: {
4307
+ flex: 'none',
4308
+ color: 'var(--mantine-color-gray-5)'
4309
+ }
4310
+ }), /*#__PURE__*/jsx(Text, {
4311
+ fz: 12,
4312
+ fw: 500,
4313
+ c: "gray.9",
4314
+ truncate: "end",
4315
+ children: label
4316
+ })]
4317
+ });
4318
+ }
4319
+
4320
+ /*
4321
+ * Arrow keys walk the rows, Home and End jump to the ends — the WAI-ARIA menu
4322
+ * pattern. Tab still leaves the card, so it never traps focus inside a popover
4323
+ * the panel owns.
4324
+ */
4325
+ function moveFocus(event) {
4326
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
4327
+ if (!keys.includes(event.key)) return;
4328
+ const rows = Array.from(event.currentTarget.querySelectorAll('[role="menuitem"]'));
4329
+ if (rows.length === 0) return;
4330
+ event.preventDefault();
4331
+ const current = rows.indexOf(document.activeElement);
4332
+ const last = rows.length - 1;
4333
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? last : event.key === 'ArrowDown' ? current < last ? current + 1 : 0 : current > 0 ? current - 1 : last;
4334
+ rows[next].focus();
4335
+ }
4014
4336
 
4015
4337
  /**
4016
4338
  * Renderiza children apenas quando o usuário está autenticado
@@ -4100,5 +4422,5 @@ function SignOutButton({
4100
4422
  });
4101
4423
  }
4102
4424
 
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 };
4425
+ 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, adoptRecentAccounts, announceIdentityChange, applyRedirect, clearIdentitySwitching, configure, consumeSocialError, consumeSocialToken, decodeJWT, deleteRecentAccount, endImpersonation, fetchRecentAccounts, forgetAccount, getApiUrl, getApplicationInfo, getCurrentUser, getLinkedProviders, getRedirectFromLocation, getSession, getSocialProviders, isAuthenticated, isIdentitySwitching, isInternal, listRecentAccounts, listSessions, markIdentitySwitching, pollCode, refreshToken, rememberAccount, requestCode, resolveRedirect, revokeOtherSessions, revokeSession, saveRecentAccount, setStoredToken, shouldSignOutOn401, signOut, startSocialLink, startSocialSignIn, unlinkSocialProvider, updateProfile, useApplicationLogo, useAuth, useAuthLoading, useAuthStore, useCheckToken, useImpersonation, useSession, useSessions, useSignIn, useSignOut, useUser, verifyCode };
4104
4426
  //# sourceMappingURL=index.esm.js.map