@ciromaciel/auth-react 1.3.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/README.md CHANGED
@@ -139,6 +139,31 @@ Building your own screen? `fetchRecentAccounts()`, `saveRecentAccount(method)` a
139
139
  `rememberAccount(email, method)`, `forgetAccount(email)` and `adoptRecentAccounts(list)` manage
140
140
  the local copy.
141
141
 
142
+ ### The account card
143
+
144
+ `<UserInformation />` is what opens from the footer of a sidebar: who is signed in, in which
145
+ organization and on which plan, then one list of actions with "Sair" last.
146
+
147
+ ```jsx
148
+ <UserInformation
149
+ user={user}
150
+ signOut={signOut}
151
+ onAccountClick={openAccount}
152
+ onBillingClick={openBilling}
153
+ items={[{ id: 'docs', label: 'Documentação', icon: IconFileText, onClick: openDocs }]}
154
+ plan={{ name: 'Pro', used: 3, limit: 9, unit: 'projetos', onClick: openPlans }}
155
+ organization={{ name: 'Acme', role: 'owner' }}
156
+ />
157
+ ```
158
+
159
+ - The title is the user's name, or the email when there is none — never both saying the same
160
+ thing.
161
+ - `items` are your app's own rows, passed as data (`{ id, label, icon, onClick }`), shown in their
162
+ own group after billing. The component takes no JSX, so every app's card keeps the same shape.
163
+ - `plan` and `organization` draw the context strip; leave them out and it is not drawn.
164
+ `limit: null` hides the usage bar.
165
+ - `branded={false}` removes the "Protegido por Auth" line.
166
+
142
167
  ## API
143
168
 
144
169
  ### Components
@@ -147,7 +172,7 @@ the local copy.
147
172
  | ------------------------- | ------------------------------------------------------------ |
148
173
  | `<SignIn />` | The whole way in: asks for the email, then takes the code |
149
174
  | `<UserProfile />` | User profile management modal |
150
- | `<UserInformation />` | User details and account menu |
175
+ | `<UserInformation />` | The account card: who, where, which plan, then the actions |
151
176
  | `<SocialButtons />` | Sign-in buttons for the providers the application enabled |
152
177
  | `<AuthCard />` | The card shell the screens are built on |
153
178
  | `<Wordmark />` | Application wordmark |
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.
@@ -3982,151 +3982,358 @@ function UserProfile({
3982
3982
  });
3983
3983
  }
3984
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
+ */
3985
4025
  function UserInformation({
3986
4026
  user,
3987
4027
  signOut,
3988
4028
  onAccountClick,
3989
4029
  onBillingClick,
4030
+ items = [],
4031
+ plan,
4032
+ organization,
4033
+ branded = true,
3990
4034
  accountLabel = 'Conta',
3991
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.
3992
4039
  padded = true,
3993
- size = 'sm',
4040
+ size,
4041
+ // eslint-disable-line no-unused-vars -- accepted and ignored, see above
3994
4042
  style,
3995
4043
  ...others
3996
4044
  }) {
3997
4045
  if (!user) return null;
4046
+ const name = (user.fullName || user.name || '').trim();
4047
+ const email = (user.primaryEmailAddress || user.email || '').trim();
3998
4048
 
3999
- // Size mappings
4000
- const avatarSizeMap = {
4001
- sm: 32,
4002
- md: 40,
4003
- lg: 48
4004
- };
4005
- const fontSizeTitleMap = {
4006
- sm: 'sm',
4007
- md: 'md',
4008
- lg: 'lg'
4009
- };
4010
- const fontSizeEmailMap = {
4011
- sm: '10px',
4012
- md: 'xs',
4013
- lg: 'sm'
4014
- };
4015
- const btnSizeMap = {
4016
- sm: 'xs',
4017
- md: 'sm',
4018
- lg: 'md'
4019
- };
4020
- const gapMap = {
4021
- sm: 'xs',
4022
- md: 'sm',
4023
- lg: 'md'
4024
- };
4025
- const widthMap = {
4026
- sm: 280,
4027
- md: 320,
4028
- lg: 400
4029
- };
4030
- const name = user.fullName || user.name || 'User';
4031
- const email = user.primaryEmailAddress || user.email || '';
4032
- const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
4033
- return /*#__PURE__*/jsx(Box, {
4034
- p: padded ? gapMap[size] : 0,
4035
- 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%",
4036
4072
  style: style,
4037
4073
  ...others,
4038
- children: /*#__PURE__*/jsxs(Stack, {
4039
- gap: gapMap[size],
4040
- children: [/*#__PURE__*/jsxs(Group, {
4041
- wrap: "nowrap",
4042
- gap: "xs",
4043
- children: [/*#__PURE__*/jsx(Avatar, {
4044
- src: user.imageUrl || user.image,
4045
- size: avatarSizeMap[size],
4046
- radius: "xl",
4047
- bg: "gray.1",
4048
- c: "gray.6",
4049
- styles: {
4050
- placeholder: {
4051
- fontSize: rem(avatarSizeMap[size] / 2.2),
4052
- fontWeight: 600
4053
- }
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
4054
4089
  },
4055
- children: initials || 'CC'
4056
- }), /*#__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,
4057
4188
  style: {
4058
- flex: 1,
4059
- overflow: 'hidden'
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",
4197
+ style: {
4198
+ minWidth: 0
4060
4199
  },
4061
- children: [/*#__PURE__*/jsx(Text, {
4062
- size: fontSizeTitleMap[size],
4063
- fw: 700,
4064
- truncate: "end",
4065
- c: "dark.9",
4066
- lh: 1.1,
4067
- children: name
4068
- }), /*#__PURE__*/jsx(Text, {
4069
- size: fontSizeEmailMap[size],
4070
- c: "gray.5",
4071
- truncate: "end",
4072
- lh: 1.1,
4073
- children: email
4074
- })]
4075
- }), /*#__PURE__*/jsx(ActionIcon, {
4076
- size: btnSizeMap[size],
4077
- onClick: signOut,
4078
- children: /*#__PURE__*/jsx(IconLogout, {
4079
- size: 16,
4080
- stroke: 1.5
4081
- })
4082
- })]
4083
- }), /*#__PURE__*/jsxs(Group, {
4084
- grow: true,
4085
- children: [/*#__PURE__*/jsx(Button, {
4086
- variant: "default",
4087
- size: btnSizeMap[size],
4088
- leftSection: /*#__PURE__*/jsx(IconSettings, {
4089
- size: 16,
4090
- stroke: 1.5
4091
- }),
4092
- onClick: onAccountClick,
4093
- children: accountLabel
4094
- }), /*#__PURE__*/jsx(Button, {
4095
- variant: "default",
4096
- size: btnSizeMap[size],
4097
- leftSection: /*#__PURE__*/jsx(IconCreditCard, {
4098
- size: 16,
4099
- stroke: 1.5
4100
- }),
4101
- onClick: onBillingClick,
4102
- 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]
4103
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
+ })
4104
4240
  }), /*#__PURE__*/jsxs(Group, {
4105
- justify: "center",
4106
- gap: 4,
4107
- opacity: 0.3,
4241
+ justify: "space-between",
4242
+ gap: 8,
4243
+ wrap: "nowrap",
4108
4244
  children: [/*#__PURE__*/jsx(Text, {
4109
- size: "10px",
4245
+ fz: 12,
4246
+ fw: 500,
4110
4247
  c: "gray.6",
4111
- fw: 600,
4112
- children: "Secured by"
4113
- }), /*#__PURE__*/jsxs(Group, {
4114
- gap: 2,
4115
- children: [/*#__PURE__*/jsx(IconShieldCheck, {
4116
- size: 10,
4117
- stroke: 2
4118
- }), /*#__PURE__*/jsx(Text, {
4119
- size: "10px",
4120
- fw: 800,
4121
- c: "dark.9",
4122
- children: "Auth"
4123
- })]
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'
4124
4258
  })]
4125
4259
  })]
4126
- })
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))
4276
+ });
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
+ })]
4127
4317
  });
4128
4318
  }
4129
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
+ }
4336
+
4130
4337
  /**
4131
4338
  * Renderiza children apenas quando o usuário está autenticado
4132
4339
  * Equivalente ao <SignedIn> do Clerk