@ciromaciel/auth-react 1.0.1 → 1.1.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 +24 -1
- package/dist/index.esm.js +410 -11
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +412 -8
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -104,6 +104,29 @@ reads the token the callback leaves in the URL fragment — `AuthProvider` alrea
|
|
|
104
104
|
for you. `startSocialLink()`, `unlinkSocialProvider()` and `getLinkedProviders()` manage the
|
|
105
105
|
connections of an account that is already signed in.
|
|
106
106
|
|
|
107
|
+
### Recent accounts
|
|
108
|
+
|
|
109
|
+
`<SignIn />` remembers the accounts that signed in on this browser and, on the next visit, shows
|
|
110
|
+
them before the email field. One click on an account sends the code (or goes back to the provider
|
|
111
|
+
that account used last time) and opens the code step. With nothing saved, the screen is the plain
|
|
112
|
+
email form.
|
|
113
|
+
|
|
114
|
+
- An account is saved only after a session exists: the code confirmed, or the provider's token
|
|
115
|
+
back. A mistyped email never becomes a suggestion.
|
|
116
|
+
- The list keeps the five most recent accounts. Each entry holds the email, the way in and the
|
|
117
|
+
time of the last sign-in. It lives in `localStorage` under `auth:recent-accounts`, so each
|
|
118
|
+
origin keeps its own list.
|
|
119
|
+
- Signing out keeps the list. "Gerenciar" on the screen removes an account; nothing else about
|
|
120
|
+
that account changes.
|
|
121
|
+
|
|
122
|
+
```jsx
|
|
123
|
+
<SignIn /> {/* shows and saves recent accounts */}
|
|
124
|
+
<SignIn recentAccounts={false} /> {/* shared computers: neither shows nor saves */}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Building your own screen? `listRecentAccounts()`, `rememberAccount(email, method)` and
|
|
128
|
+
`forgetAccount(email)` read and write the same list.
|
|
129
|
+
|
|
107
130
|
## API
|
|
108
131
|
|
|
109
132
|
### Components
|
|
@@ -173,7 +196,7 @@ For code outside React, the HTTP layer is exported too: `configure`, `requestCod
|
|
|
173
196
|
|
|
174
197
|
## Links
|
|
175
198
|
|
|
176
|
-
- [
|
|
199
|
+
- [Auth documentation](https://myinfrastructure.click/products/auth)
|
|
177
200
|
- [Package on npm](https://www.npmjs.com/package/@ciromaciel/auth-react)
|
|
178
201
|
|
|
179
202
|
MIT © [Ciro Cesar Maciel](https://ciromaciel.click)
|
package/dist/index.esm.js
CHANGED
|
@@ -3,9 +3,146 @@ import { useState, useCallback, useEffect, useMemo, createContext } 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,
|
|
6
|
+
import { Modal, Stack, Text, Group, Image, Title, Paper, Anchor, NavLink, ActionIcon, Loader, Avatar, Button, Divider, TextInput, Center, Box, Collapse, FileButton, Tooltip, ThemeIcon, Badge, rem } from '@mantine/core';
|
|
7
7
|
import { useForm } from '@mantine/form';
|
|
8
|
-
import {
|
|
8
|
+
import { IconX, IconArrowRight, IconBrandGoogle, IconArrowLeft, 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, {
|
|
@@ -2291,6 +2608,15 @@ function SignIn({
|
|
|
2291
2608
|
* `false` opts out entirely, for a screen that wants the emailed code only.
|
|
2292
2609
|
*/
|
|
2293
2610
|
socialLogin = 'auto',
|
|
2611
|
+
/*
|
|
2612
|
+
* The accounts that already signed in on this browser, offered before the
|
|
2613
|
+
* empty field (`recent-accounts.js`). With none saved the screen is exactly
|
|
2614
|
+
* the email form, so the default changes nothing for a first visit.
|
|
2615
|
+
*
|
|
2616
|
+
* `false` neither shows nor saves: a shared computer, or an internal panel,
|
|
2617
|
+
* should not remember who used it.
|
|
2618
|
+
*/
|
|
2619
|
+
recentAccounts = true,
|
|
2294
2620
|
...cardProps
|
|
2295
2621
|
}) {
|
|
2296
2622
|
const user = useAuthStore(s => s.user);
|
|
@@ -2306,6 +2632,14 @@ function SignIn({
|
|
|
2306
2632
|
const [code, setCode] = useState('');
|
|
2307
2633
|
const [codeError, setCodeError] = useState(null);
|
|
2308
2634
|
|
|
2635
|
+
// Read once, on mount: the list only changes through this screen, and
|
|
2636
|
+
// every change below writes the new list back into state.
|
|
2637
|
+
const [accounts, setAccounts] = useState(() => recentAccounts ? listRecentAccounts() : []);
|
|
2638
|
+
const [isChoosingOther, setIsChoosingOther] = useState(false);
|
|
2639
|
+
const [isManaging, setIsManaging] = useState(false);
|
|
2640
|
+
const [pickingEmail, setPickingEmail] = useState(null);
|
|
2641
|
+
const isShowingAccounts = recentAccounts && accounts.length > 0 && !isChoosingOther;
|
|
2642
|
+
|
|
2309
2643
|
// Hook that fetches the application's logo
|
|
2310
2644
|
const applicationLogo = useApplicationLogo();
|
|
2311
2645
|
const finalLogo = logo || applicationLogo || /*#__PURE__*/jsx(Wordmark, {});
|
|
@@ -2363,6 +2697,36 @@ function SignIn({
|
|
|
2363
2697
|
}
|
|
2364
2698
|
};
|
|
2365
2699
|
|
|
2700
|
+
// Step 1, from the list — the click on a saved account IS the request.
|
|
2701
|
+
//
|
|
2702
|
+
// An account that came in through a provider goes back to that provider,
|
|
2703
|
+
// as long as the application still offers it. If the owner turned it off
|
|
2704
|
+
// in the meantime the emailed code still works for the same email, so that
|
|
2705
|
+
// is the fallback rather than a dead row.
|
|
2706
|
+
const handlePick = async account => {
|
|
2707
|
+
if (pickingEmail || sending) return;
|
|
2708
|
+
setPickingEmail(account.email);
|
|
2709
|
+
if (account.method !== 'code' && socialLogin !== false) {
|
|
2710
|
+
const providers = await getSocialProviders();
|
|
2711
|
+
if (providers?.some(provider => provider.provider === account.method)) {
|
|
2712
|
+
// The page is leaving; the row keeps its spinner until it does.
|
|
2713
|
+
startSocialSignIn(account.method, {
|
|
2714
|
+
rememberAccount: recentAccounts
|
|
2715
|
+
});
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
await handleRequest({
|
|
2720
|
+
email: account.email
|
|
2721
|
+
});
|
|
2722
|
+
setPickingEmail(null);
|
|
2723
|
+
};
|
|
2724
|
+
const handleForget = account => {
|
|
2725
|
+
const next = forgetAccount(account.email);
|
|
2726
|
+
setAccounts(next);
|
|
2727
|
+
if (next.length === 0) setIsManaging(false);
|
|
2728
|
+
};
|
|
2729
|
+
|
|
2366
2730
|
// Step 2 — trade the code for the session.
|
|
2367
2731
|
//
|
|
2368
2732
|
// The redirect is decided AND EXECUTED before onSuccess.
|
|
@@ -2383,6 +2747,11 @@ function SignIn({
|
|
|
2383
2747
|
setCodeError(null);
|
|
2384
2748
|
try {
|
|
2385
2749
|
const result = await verifyCode(sentTo, value);
|
|
2750
|
+
|
|
2751
|
+
// Only now, with a session: an email that never received a valid
|
|
2752
|
+
// code never becomes a suggestion. Written before the redirect,
|
|
2753
|
+
// which may unmount this screen.
|
|
2754
|
+
if (recentAccounts) setAccounts(rememberAccount(sentTo, 'code'));
|
|
2386
2755
|
const target = handleRedirect ? getRedirectFromLocation(redirectOrigins) : null;
|
|
2387
2756
|
if (target) applyRedirect(target, navigate);
|
|
2388
2757
|
onSuccess?.(result?.user ?? null, {
|
|
@@ -2417,17 +2786,43 @@ function SignIn({
|
|
|
2417
2786
|
logo: finalLogo,
|
|
2418
2787
|
logoWidth: logoWidth,
|
|
2419
2788
|
title: title,
|
|
2420
|
-
subtitle: sentTo ? labels.codeSent || 'Digite o código que enviamos' : subtitle,
|
|
2789
|
+
subtitle: sentTo ? labels.codeSent || 'Digite o código que enviamos' : isShowingAccounts ? labels.recentAccountsSubtitle || 'Escolha a conta que vai receber o código' : subtitle,
|
|
2421
2790
|
variant: variant,
|
|
2422
2791
|
opened: opened,
|
|
2423
2792
|
onClose: onClose,
|
|
2424
2793
|
modalProps: modalProps,
|
|
2425
2794
|
...cardProps,
|
|
2426
|
-
children: !sentTo ? /*#__PURE__*/jsx(
|
|
2795
|
+
children: !sentTo && isShowingAccounts ? /*#__PURE__*/jsx(RecentAccounts, {
|
|
2796
|
+
accounts: accounts,
|
|
2797
|
+
pickingEmail: pickingEmail,
|
|
2798
|
+
managing: isManaging,
|
|
2799
|
+
onToggleManage: () => setIsManaging(value => !value),
|
|
2800
|
+
onPick: handlePick,
|
|
2801
|
+
onForget: handleForget,
|
|
2802
|
+
onUseOther: () => {
|
|
2803
|
+
setIsManaging(false);
|
|
2804
|
+
setIsChoosingOther(true);
|
|
2805
|
+
},
|
|
2806
|
+
labels: labels
|
|
2807
|
+
}) : !sentTo ? /*#__PURE__*/jsx("form", {
|
|
2427
2808
|
onSubmit: form.onSubmit(handleRequest),
|
|
2428
2809
|
children: /*#__PURE__*/jsxs(Stack, {
|
|
2429
2810
|
gap: "md",
|
|
2430
|
-
children: [/*#__PURE__*/jsx(
|
|
2811
|
+
children: [recentAccounts && accounts.length > 0 && /*#__PURE__*/jsx(Anchor, {
|
|
2812
|
+
component: "button",
|
|
2813
|
+
type: "button",
|
|
2814
|
+
size: "sm",
|
|
2815
|
+
c: "dimmed",
|
|
2816
|
+
w: "fit-content",
|
|
2817
|
+
onClick: () => setIsChoosingOther(false),
|
|
2818
|
+
children: /*#__PURE__*/jsxs(Group, {
|
|
2819
|
+
gap: 6,
|
|
2820
|
+
wrap: "nowrap",
|
|
2821
|
+
children: [/*#__PURE__*/jsx(IconArrowLeft, {
|
|
2822
|
+
size: 14
|
|
2823
|
+
}), `${labels.savedAccounts || 'Contas salvas'} (${accounts.length})`]
|
|
2824
|
+
})
|
|
2825
|
+
}), /*#__PURE__*/jsx(TextInput, {
|
|
2431
2826
|
label: labels.email || 'Email',
|
|
2432
2827
|
placeholder: labels.emailPlaceholder || 'seu@email.com',
|
|
2433
2828
|
type: "email",
|
|
@@ -2469,7 +2864,8 @@ function SignIn({
|
|
|
2469
2864
|
children: sending ? labels.sendingCode || 'Enviando…' : labels.sendCodeButton || 'Enviar código'
|
|
2470
2865
|
}), socialLogin !== false && /*#__PURE__*/jsx(SocialButtons, {
|
|
2471
2866
|
labels: labels,
|
|
2472
|
-
disabled: sending
|
|
2867
|
+
disabled: sending,
|
|
2868
|
+
rememberAccount: recentAccounts
|
|
2473
2869
|
}), /*#__PURE__*/jsx(TermsNotice, {
|
|
2474
2870
|
url: termsUrl,
|
|
2475
2871
|
text: labels.termsNotice || 'Criando uma conta, você concorda com todos os nossos',
|
|
@@ -2536,6 +2932,9 @@ function SignIn({
|
|
|
2536
2932
|
setSentTo(null);
|
|
2537
2933
|
setCode('');
|
|
2538
2934
|
setCodeError(null);
|
|
2935
|
+
// The label promises another email: the form,
|
|
2936
|
+
// not the list the person may have come from.
|
|
2937
|
+
setIsChoosingOther(true);
|
|
2539
2938
|
},
|
|
2540
2939
|
children: labels.changeEmail || 'Usar outro e-mail'
|
|
2541
2940
|
}), /*#__PURE__*/jsx(Anchor, {
|
|
@@ -3527,5 +3926,5 @@ function SignOutButton({
|
|
|
3527
3926
|
});
|
|
3528
3927
|
}
|
|
3529
3928
|
|
|
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 };
|
|
3929
|
+
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
3930
|
//# sourceMappingURL=index.esm.js.map
|