@faybl/react-utils 1.1.1 → 2.0.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 +75 -4
- package/package.json +8 -2
- package/src/SessionExpiredDialog.js +50 -0
- package/src/SessionProvider.js +117 -0
- package/src/index.js +5 -0
- package/src/pageNavigation.js +5 -0
- package/src/reloadStamp.js +26 -0
- package/src/sessionContext.js +3 -0
- package/src/sessionReaction.js +37 -0
- package/src/sessionState.js +42 -0
- package/src/signOutUrl.js +10 -0
- package/src/useConnection.js +19 -0
- package/src/useReturnCheck.js +26 -0
- package/src/useSession.js +37 -0
- package/src/useUser.js +31 -0
package/README.md
CHANGED
|
@@ -6,16 +6,26 @@ React hooks for Faybl web apps.
|
|
|
6
6
|
npm install @faybl/react-utils
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Every hook takes the faybl
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
Every hook but `useSession` takes the `@faybl/client` client as `client` and
|
|
10
|
+
does nothing until it has one; `useSession` reads it from `SessionProvider`.
|
|
11
|
+
`useConnection` and `SessionProvider` need client 4.1 or later. A list of rids
|
|
12
|
+
or ids is compared by its content, so a new array with the same ids does not
|
|
13
|
+
resubscribe.
|
|
12
14
|
|
|
13
15
|
```js
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
useChannelsLatest,
|
|
18
|
+
useChannelsSeries,
|
|
19
|
+
useConnection,
|
|
20
|
+
usePresence,
|
|
21
|
+
useSession,
|
|
22
|
+
} from '@faybl/react-utils';
|
|
15
23
|
|
|
16
24
|
const presenceById = usePresence({ client, identityIds });
|
|
17
25
|
const latest = useChannelsLatest({ client, rids, commitMs: 1000, onError });
|
|
18
26
|
const series = useChannelsSeries({ client, rids, windowSeconds: 3600 });
|
|
27
|
+
const connected = useConnection({ client });
|
|
28
|
+
const { expired, cause, user, signIn, signOut } = useSession();
|
|
19
29
|
```
|
|
20
30
|
|
|
21
31
|
| Hook | Returns |
|
|
@@ -23,6 +33,10 @@ const series = useChannelsSeries({ client, rids, windowSeconds: 3600 });
|
|
|
23
33
|
| `usePresence` | `{ [identityId]: { ts, connected } }`, live |
|
|
24
34
|
| `useChannelsLatest` | `{ [rid]: { ts, pl } }`, the last stored point, then live |
|
|
25
35
|
| `useChannelsSeries` | `{ [rid]: points }`, the last `windowSeconds`, oldest first, then live |
|
|
36
|
+
| `useConnection` | whether the MQTT connection is up |
|
|
37
|
+
| `useSession` | the session, see [An expired session](#an-expired-session) |
|
|
38
|
+
|
|
39
|
+
`connected` and `expired` are `undefined` until the client has reported them.
|
|
26
40
|
|
|
27
41
|
The two channel hooks take two options:
|
|
28
42
|
|
|
@@ -30,3 +44,60 @@ The two channel hooks take two options:
|
|
|
30
44
|
Without it, every point renders as it arrives.
|
|
31
45
|
- `onError(error, rid)` is called when a history read fails. The hooks report
|
|
32
46
|
nothing on their own.
|
|
47
|
+
|
|
48
|
+
## An expired session
|
|
49
|
+
|
|
50
|
+
`SessionProvider` follows the session behind the client and reacts when
|
|
51
|
+
it is gone. Put it around the app once the client exists:
|
|
52
|
+
|
|
53
|
+
```jsx
|
|
54
|
+
<SessionProvider client={client}>
|
|
55
|
+
<App />
|
|
56
|
+
</SessionProvider>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It finds an expiry two ways, and each has its own prop:
|
|
60
|
+
|
|
61
|
+
| Prop | When | Default |
|
|
62
|
+
| ------------------ | --------------------------------------------------------------- | -------- |
|
|
63
|
+
| `expiredOnReturn` | the user comes back to the tab; a request is sent then to check | `reload` |
|
|
64
|
+
| `expiredOnRequest` | a request is refused while the user works on the page | `dialog` |
|
|
65
|
+
|
|
66
|
+
Each takes `reload`, `dialog` or `none`, also exported as `REACTION`:
|
|
67
|
+
|
|
68
|
+
- `reload` reloads the page. While the identity provider still has a session
|
|
69
|
+
the user is back on the same page at once; otherwise the sign-in page shows.
|
|
70
|
+
- `dialog` shows `SessionExpiredDialog`, which keeps the page, and whatever the
|
|
71
|
+
user was typing, until the user chooses to sign in again.
|
|
72
|
+
- `none` does nothing, for an app that shows the expiry its own way through
|
|
73
|
+
`useSession`.
|
|
74
|
+
|
|
75
|
+
An expiry found while the tab is hidden counts as a return and waits until the
|
|
76
|
+
user comes back. After an automatic reload, a tab reloads on its own again only
|
|
77
|
+
once `reloadCooldownMs` (default 30 000) has passed; an expiry before that shows
|
|
78
|
+
the dialog, so a 401 that is not about the session cannot reload in a loop.
|
|
79
|
+
`dialogLabels` sets the dialog's `{ title, action }`, which are English by
|
|
80
|
+
default.
|
|
81
|
+
|
|
82
|
+
`useSession()` returns:
|
|
83
|
+
|
|
84
|
+
- `expired`: undefined until the client has reported, then whether the
|
|
85
|
+
session is gone.
|
|
86
|
+
- `cause`: how the expiry was found, `returned` or `request`, also exported as
|
|
87
|
+
`CAUSE`.
|
|
88
|
+
- `user`: `{ id, firstName, lastName, email }` from `auth.getUserInfo`, fetched
|
|
89
|
+
the first time a component asks and kept. Undefined until it arrives.
|
|
90
|
+
- `signIn()`: reloads the page.
|
|
91
|
+
- `signOut()`: goes to the login proxy's `sign_out` on the app's own host, which
|
|
92
|
+
ends the session there and at the identity provider, and comes back to the
|
|
93
|
+
page the user left, which asks the user to sign in again. `signOutUrl` on the
|
|
94
|
+
provider sends it somewhere else instead.
|
|
95
|
+
|
|
96
|
+
An app that handles the expiry itself can still use the dialog:
|
|
97
|
+
|
|
98
|
+
```jsx
|
|
99
|
+
const SessionNotice = () => {
|
|
100
|
+
const { expired, signIn } = useSession();
|
|
101
|
+
return <SessionExpiredDialog open={expired === true} onSignIn={signIn} />;
|
|
102
|
+
};
|
|
103
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faybl/react-utils",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "React hooks for Faybl web apps",
|
|
5
5
|
"license": "TBD",
|
|
6
6
|
"author": "Arcturitech",
|
|
@@ -21,10 +21,16 @@
|
|
|
21
21
|
"directory": "packages/react-utils"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
+
"@faybl/client": "^4.1.0",
|
|
24
25
|
"react": "^19.2.0"
|
|
25
26
|
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"@faybl/client": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
26
32
|
"dependencies": {
|
|
27
|
-
"@faybl/utils": "^
|
|
33
|
+
"@faybl/utils": "^2.0.0"
|
|
28
34
|
},
|
|
29
35
|
"engines": {
|
|
30
36
|
"node": ">=22"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createElement, useEffect, useId, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_LABELS = { title: 'You have been signed out', action: 'Sign in again' };
|
|
4
|
+
|
|
5
|
+
// Light or dark after the user's system, as apps here are.
|
|
6
|
+
const dialogStyle = {
|
|
7
|
+
colorScheme: 'light dark',
|
|
8
|
+
border: 'none',
|
|
9
|
+
borderRadius: 8,
|
|
10
|
+
padding: 24,
|
|
11
|
+
minWidth: 280,
|
|
12
|
+
font: 'inherit',
|
|
13
|
+
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
|
14
|
+
};
|
|
15
|
+
const titleStyle = { margin: '0 0 24px', fontSize: '1.25rem', fontWeight: 500 };
|
|
16
|
+
const actionStyle = { font: 'inherit', fontWeight: 500, padding: '6px 16px', cursor: 'pointer' };
|
|
17
|
+
|
|
18
|
+
// The only way out is to sign in again, so Escape does not close it.
|
|
19
|
+
const keepOpen = (event) => event.preventDefault();
|
|
20
|
+
|
|
21
|
+
const useModal = (open) => {
|
|
22
|
+
const ref = useRef(undefined);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const dialog = ref.current;
|
|
25
|
+
if (open && !dialog.open) dialog.showModal();
|
|
26
|
+
if (!open && dialog.open) dialog.close();
|
|
27
|
+
}, [open]);
|
|
28
|
+
return ref;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A modal telling the user the session is gone, with one action that signs in
|
|
33
|
+
* again. The provider shows it for the `dialog` reaction; an app that handles
|
|
34
|
+
* expiry itself can render it with `open` from useSession.
|
|
35
|
+
* @param {object} params
|
|
36
|
+
* @param {boolean} params.open
|
|
37
|
+
* @param {() => void} params.onSignIn Usually useSession's signIn
|
|
38
|
+
* @param {{ title?: string, action?: string }} [params.labels] English by default
|
|
39
|
+
*/
|
|
40
|
+
export const SessionExpiredDialog = ({ open, onSignIn, labels }) => {
|
|
41
|
+
const ref = useModal(open);
|
|
42
|
+
const titleId = useId();
|
|
43
|
+
const { title, action } = { ...DEFAULT_LABELS, ...labels };
|
|
44
|
+
return createElement(
|
|
45
|
+
'dialog',
|
|
46
|
+
{ ref, 'aria-labelledby': titleId, onCancel: keepOpen, style: dialogStyle },
|
|
47
|
+
createElement('h2', { id: titleId, style: titleStyle }, title),
|
|
48
|
+
createElement('button', { type: 'button', onClick: onSignIn, style: actionStyle }, action),
|
|
49
|
+
);
|
|
50
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createElement, useCallback, useEffect, useMemo, useReducer } from 'react';
|
|
2
|
+
|
|
3
|
+
import { navigateTo, reloadPage } from './pageNavigation.js';
|
|
4
|
+
import { readReloadStamp, writeReloadStamp } from './reloadStamp.js';
|
|
5
|
+
import { SessionContext } from './sessionContext.js';
|
|
6
|
+
import { SessionExpiredDialog } from './SessionExpiredDialog.js';
|
|
7
|
+
import { CAUSE, DEFAULT_RELOAD_COOLDOWN_MS, REACTION } from './sessionReaction.js';
|
|
8
|
+
import { INITIAL_SESSION_STATE, SESSION_EVENT, sessionReducer } from './sessionState.js';
|
|
9
|
+
import { defaultSignOutUrl } from './signOutUrl.js';
|
|
10
|
+
import { useReturnCheck } from './useReturnCheck.js';
|
|
11
|
+
import { useUser } from './useUser.js';
|
|
12
|
+
|
|
13
|
+
const isVisible = () => document.visibilityState === 'visible';
|
|
14
|
+
|
|
15
|
+
const useSessionState = ({ client, reactions, reloadCooldownMs, probing }) => {
|
|
16
|
+
const [state, dispatch] = useReducer(sessionReducer, INITIAL_SESSION_STATE);
|
|
17
|
+
const policy = useCallback(
|
|
18
|
+
() => ({ reactions, reloadStamp: readReloadStamp(), now: Date.now(), reloadCooldownMs }),
|
|
19
|
+
[reactions, reloadCooldownMs],
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!client) return undefined;
|
|
24
|
+
return client.session.subscribe(({ expired }) =>
|
|
25
|
+
dispatch({
|
|
26
|
+
type: SESSION_EVENT.REPORTED,
|
|
27
|
+
client,
|
|
28
|
+
expired,
|
|
29
|
+
visible: isVisible(),
|
|
30
|
+
probing: probing(),
|
|
31
|
+
policy: policy(),
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}, [client, policy, probing]);
|
|
35
|
+
|
|
36
|
+
const returned = useCallback(
|
|
37
|
+
() => dispatch({ type: SESSION_EVENT.VISIBLE, client, visible: true, policy: policy() }),
|
|
38
|
+
[client, policy],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return { state: state.client === client ? state : INITIAL_SESSION_STATE, returned };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const useAutomaticReload = (reaction) =>
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (reaction !== REACTION.RELOAD) return;
|
|
47
|
+
writeReloadStamp(Date.now());
|
|
48
|
+
reloadPage();
|
|
49
|
+
}, [reaction]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Follows the session behind `client` and reacts when it expires:
|
|
53
|
+
* `expiredOnReturn` when the user comes back to the tab, `expiredOnRequest`
|
|
54
|
+
* when a request fails while the user works. Each is `reload`, `dialog` or
|
|
55
|
+
* `none`. After an automatic reload, none runs for `reloadCooldownMs`; an
|
|
56
|
+
* expiry then shows the dialog. Children read it through useSession.
|
|
57
|
+
* @param {object} params
|
|
58
|
+
* @param {object} params.client faybl-client 4.1 or later; nothing happens until it is set
|
|
59
|
+
* @param {'reload' | 'dialog' | 'none'} [params.expiredOnReturn] Default `reload`
|
|
60
|
+
* @param {'reload' | 'dialog' | 'none'} [params.expiredOnRequest] Default `dialog`
|
|
61
|
+
* @param {number} [params.reloadCooldownMs] Default 30 000
|
|
62
|
+
* @param {string} [params.signOutUrl] Where signOut goes; through the login proxy by default
|
|
63
|
+
* @param {{ title?: string, action?: string }} [params.dialogLabels]
|
|
64
|
+
* @param {import('react').ReactNode} params.children
|
|
65
|
+
*/
|
|
66
|
+
export const SessionProvider = ({
|
|
67
|
+
client,
|
|
68
|
+
expiredOnReturn = REACTION.RELOAD,
|
|
69
|
+
expiredOnRequest = REACTION.DIALOG,
|
|
70
|
+
reloadCooldownMs = DEFAULT_RELOAD_COOLDOWN_MS,
|
|
71
|
+
signOutUrl,
|
|
72
|
+
dialogLabels,
|
|
73
|
+
children,
|
|
74
|
+
}) => {
|
|
75
|
+
const reactions = useMemo(
|
|
76
|
+
() => ({ [CAUSE.RETURNED]: expiredOnReturn, [CAUSE.REQUEST]: expiredOnRequest }),
|
|
77
|
+
[expiredOnReturn, expiredOnRequest],
|
|
78
|
+
);
|
|
79
|
+
const { user, loadUser, fetchUser } = useUser(client);
|
|
80
|
+
const { probing, check } = useReturnCheck(fetchUser);
|
|
81
|
+
const { state, returned } = useSessionState({ client, reactions, reloadCooldownMs, probing });
|
|
82
|
+
const { expired, cause, reaction } = state;
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!client) return undefined;
|
|
86
|
+
const onVisibilityChange = () => {
|
|
87
|
+
if (!isVisible()) return;
|
|
88
|
+
returned();
|
|
89
|
+
check();
|
|
90
|
+
};
|
|
91
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
92
|
+
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
93
|
+
}, [client, returned, check]);
|
|
94
|
+
|
|
95
|
+
useAutomaticReload(reaction);
|
|
96
|
+
|
|
97
|
+
const signOut = useCallback(
|
|
98
|
+
() => navigateTo(signOutUrl ?? defaultSignOutUrl(window.location)),
|
|
99
|
+
[signOutUrl],
|
|
100
|
+
);
|
|
101
|
+
const session = useMemo(
|
|
102
|
+
() => ({ expired, cause, user, signIn: reloadPage, signOut }),
|
|
103
|
+
[expired, cause, user, signOut],
|
|
104
|
+
);
|
|
105
|
+
const value = useMemo(() => ({ session, loadUser }), [session, loadUser]);
|
|
106
|
+
|
|
107
|
+
return createElement(
|
|
108
|
+
SessionContext,
|
|
109
|
+
{ value },
|
|
110
|
+
children,
|
|
111
|
+
createElement(SessionExpiredDialog, {
|
|
112
|
+
open: reaction === REACTION.DIALOG,
|
|
113
|
+
onSignIn: reloadPage,
|
|
114
|
+
labels: dialogLabels,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
};
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export { CAUSE, REACTION } from './sessionReaction.js';
|
|
2
|
+
export { SessionExpiredDialog } from './SessionExpiredDialog.js';
|
|
3
|
+
export { SessionProvider } from './SessionProvider.js';
|
|
1
4
|
export { useChannelsLatest } from './useChannelsLatest.js';
|
|
2
5
|
export { useChannelsSeries } from './useChannelsSeries.js';
|
|
6
|
+
export { useConnection } from './useConnection.js';
|
|
3
7
|
export { usePresence } from './usePresence.js';
|
|
8
|
+
export { useSession } from './useSession.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const RELOAD_STAMP_KEY = 'faybl.session.reloadedAt';
|
|
2
|
+
|
|
3
|
+
// sessionStorage is per tab and survives the reload it records. Reaching it
|
|
4
|
+
// throws where the browser blocks storage, so it is reached inside the guard.
|
|
5
|
+
const tabStorage = () => globalThis.sessionStorage;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* When this tab last reloaded on its own: `{ ok: true, value }`, with `value`
|
|
9
|
+
* undefined for never, or `{ ok: false }` when storage cannot be read.
|
|
10
|
+
*/
|
|
11
|
+
export const readReloadStamp = (storage = tabStorage) => {
|
|
12
|
+
try {
|
|
13
|
+
const stamp = Number(storage().getItem(RELOAD_STAMP_KEY));
|
|
14
|
+
return { ok: true, value: Number.isFinite(stamp) && stamp > 0 ? stamp : undefined };
|
|
15
|
+
} catch {
|
|
16
|
+
return { ok: false };
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const writeReloadStamp = (now, storage = tabStorage) => {
|
|
21
|
+
try {
|
|
22
|
+
storage().setItem(RELOAD_STAMP_KEY, String(now));
|
|
23
|
+
} catch {
|
|
24
|
+
// A failed write leaves the earlier stamp, if any.
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const REACTION = { RELOAD: 'reload', DIALOG: 'dialog', NONE: 'none' };
|
|
2
|
+
|
|
3
|
+
export const CAUSE = { RETURNED: 'returned', REQUEST: 'request' };
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_RELOAD_COOLDOWN_MS = 30_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether an automatic reload may run now: none until `reloadCooldownMs` have
|
|
9
|
+
* passed since the last one, so a 401 that a reload does not cure cannot loop.
|
|
10
|
+
* Without a readable stamp the last reload is unknown, and none runs.
|
|
11
|
+
* @param {object} params
|
|
12
|
+
* @param {{ ok: boolean, value?: number }} params.reloadStamp Epoch ms of the last automatic reload
|
|
13
|
+
* @param {number} params.now Epoch ms
|
|
14
|
+
* @param {number} params.reloadCooldownMs
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export const reloadAllowed = ({ reloadStamp, now, reloadCooldownMs }) =>
|
|
18
|
+
reloadStamp.ok &&
|
|
19
|
+
(reloadStamp.value === undefined || now - reloadStamp.value >= reloadCooldownMs);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The reaction to an expired session: the one configured for its cause, with
|
|
23
|
+
* a reload turned into the dialog during the reload cooldown.
|
|
24
|
+
* @param {object} params
|
|
25
|
+
* @param {string} params.cause One of CAUSE
|
|
26
|
+
* @param {{ returned: string, request: string }} params.reactions One of REACTION per cause
|
|
27
|
+
* @param {{ ok: boolean, value?: number }} params.reloadStamp
|
|
28
|
+
* @param {number} params.now
|
|
29
|
+
* @param {number} params.reloadCooldownMs
|
|
30
|
+
* @returns {string} One of REACTION
|
|
31
|
+
*/
|
|
32
|
+
export const chooseReaction = ({ cause, reactions, ...guard }) => {
|
|
33
|
+
const reaction = Object.values(REACTION).includes(reactions[cause])
|
|
34
|
+
? reactions[cause]
|
|
35
|
+
: REACTION.DIALOG;
|
|
36
|
+
return reaction === REACTION.RELOAD && !reloadAllowed(guard) ? REACTION.DIALOG : reaction;
|
|
37
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CAUSE, chooseReaction } from './sessionReaction.js';
|
|
2
|
+
|
|
3
|
+
export const SESSION_EVENT = { REPORTED: 'reported', VISIBLE: 'visible' };
|
|
4
|
+
|
|
5
|
+
export const INITIAL_SESSION_STATE = {
|
|
6
|
+
client: undefined,
|
|
7
|
+
expired: undefined,
|
|
8
|
+
cause: undefined,
|
|
9
|
+
reaction: undefined,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// The reaction is chosen only while the user can see the page; an expiry found
|
|
13
|
+
// in a hidden tab waits for the user to come back.
|
|
14
|
+
const decide = (state, { visible, policy }) =>
|
|
15
|
+
state.expired && visible && !state.reaction
|
|
16
|
+
? { ...state, reaction: chooseReaction({ cause: state.cause, ...policy }) }
|
|
17
|
+
: state;
|
|
18
|
+
|
|
19
|
+
const reported = (state, { client, expired, visible, probing }) => {
|
|
20
|
+
const current = state.client === client ? state : { ...INITIAL_SESSION_STATE, client };
|
|
21
|
+
if (!expired) return { ...INITIAL_SESSION_STATE, client, expired };
|
|
22
|
+
if (current.expired) return current;
|
|
23
|
+
const cause = probing || !visible ? CAUSE.RETURNED : CAUSE.REQUEST;
|
|
24
|
+
return { client, expired, cause, reaction: undefined };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The provider's session state, for one client at a time. REPORTED is the
|
|
29
|
+
* client's `{ expired }`; VISIBLE is the tab becoming visible again. Both carry
|
|
30
|
+
* `client`, `visible` and `policy`, the input to chooseReaction apart from the
|
|
31
|
+
* cause, and REPORTED whether a check started by the user's return is running.
|
|
32
|
+
*/
|
|
33
|
+
export const sessionReducer = (state, event) => {
|
|
34
|
+
switch (event.type) {
|
|
35
|
+
case SESSION_EVENT.REPORTED:
|
|
36
|
+
return decide(reported(state, event), event);
|
|
37
|
+
case SESSION_EVENT.VISIBLE:
|
|
38
|
+
return state.client === event.client ? decide(state, event) : state;
|
|
39
|
+
default:
|
|
40
|
+
return state;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where sign-out goes by default: the login proxy on the tenant's host, which
|
|
3
|
+
* clears its cookie, ends the identity provider's session and sends the user
|
|
4
|
+
* back to the page they left. That page has no session, so it asks the user to
|
|
5
|
+
* sign in.
|
|
6
|
+
* @param {{ host: string, origin: string, pathname: string }} location
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export const defaultSignOutUrl = ({ host, origin, pathname }) =>
|
|
10
|
+
`https://oauth2.${host}/oauth2/sign_out?rd=${encodeURIComponent(`${origin}${pathname}`)}`;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the client's MQTT connection is up: true or false once the client
|
|
5
|
+
* has reported it, undefined before that and without a client.
|
|
6
|
+
* @param {object} params
|
|
7
|
+
* @param {object} params.client faybl-client with MQTT
|
|
8
|
+
* @returns {boolean | undefined}
|
|
9
|
+
*/
|
|
10
|
+
export const useConnection = ({ client }) => {
|
|
11
|
+
const [state, setState] = useState({ client: undefined, connected: undefined });
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!client) return undefined;
|
|
15
|
+
return client.connection.subscribe(({ connected }) => setState({ client, connected }));
|
|
16
|
+
}, [client]);
|
|
17
|
+
|
|
18
|
+
return state.client === client ? state.connected : undefined;
|
|
19
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The request sent when the user returns to the tab, so a lost session is
|
|
5
|
+
* found at once. `probing()` is true while one is running, which tells an
|
|
6
|
+
* expiry found by it from one found while the user works.
|
|
7
|
+
* @param {() => Promise<void>} request A cheap request through the client that never rejects
|
|
8
|
+
*/
|
|
9
|
+
export const useReturnCheck = (request) => {
|
|
10
|
+
const running = useRef(0);
|
|
11
|
+
|
|
12
|
+
const probing = useCallback(() => running.current > 0, []);
|
|
13
|
+
|
|
14
|
+
const check = useCallback(async () => {
|
|
15
|
+
/* eslint-disable functional/immutable-data -- Counts checks in flight across visibility events; it is read synchronously by the session callback the request triggers, before any render. */
|
|
16
|
+
running.current += 1;
|
|
17
|
+
try {
|
|
18
|
+
await request();
|
|
19
|
+
} finally {
|
|
20
|
+
running.current -= 1;
|
|
21
|
+
}
|
|
22
|
+
/* eslint-enable functional/immutable-data */
|
|
23
|
+
}, [request]);
|
|
24
|
+
|
|
25
|
+
return { probing, check };
|
|
26
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useContext, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
import { navigateTo, reloadPage } from './pageNavigation.js';
|
|
4
|
+
import { SessionContext } from './sessionContext.js';
|
|
5
|
+
import { defaultSignOutUrl } from './signOutUrl.js';
|
|
6
|
+
|
|
7
|
+
const OUTSIDE_PROVIDER = {
|
|
8
|
+
expired: undefined,
|
|
9
|
+
cause: undefined,
|
|
10
|
+
user: undefined,
|
|
11
|
+
signIn: reloadPage,
|
|
12
|
+
signOut: () => navigateTo(defaultSignOutUrl(window.location)),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The session from the nearest SessionProvider. `expired` is undefined
|
|
17
|
+
* until the client has reported it, and `cause` says how an expiry was found:
|
|
18
|
+
* `returned` or `request`. `user` is fetched the first time a component asks
|
|
19
|
+
* and is undefined until it arrives.
|
|
20
|
+
* @returns {{
|
|
21
|
+
* expired: boolean | undefined,
|
|
22
|
+
* cause: 'returned' | 'request' | undefined,
|
|
23
|
+
* user: { id: string, firstName?: string, lastName?: string, email?: string } | undefined,
|
|
24
|
+
* signIn: () => void,
|
|
25
|
+
* signOut: () => void,
|
|
26
|
+
* }}
|
|
27
|
+
*/
|
|
28
|
+
export const useSession = () => {
|
|
29
|
+
const context = useContext(SessionContext);
|
|
30
|
+
const loadUser = context?.loadUser;
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
loadUser?.();
|
|
34
|
+
}, [loadUser]);
|
|
35
|
+
|
|
36
|
+
return context?.session ?? OUTSIDE_PROVIDER;
|
|
37
|
+
};
|
package/src/useUser.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who is signed in, from the client's `auth.getUserInfo`. `loadUser` fetches
|
|
5
|
+
* once per client and keeps the answer; `fetchUser` fetches again. A failed
|
|
6
|
+
* fetch keeps the last answer: a 401 already reaches the session, and the next
|
|
7
|
+
* return to the tab fetches again.
|
|
8
|
+
*/
|
|
9
|
+
export const useUser = (client) => {
|
|
10
|
+
const [cache, setCache] = useState({ client: undefined, user: undefined });
|
|
11
|
+
const loadedFor = useRef(undefined);
|
|
12
|
+
|
|
13
|
+
const fetchUser = useCallback(async () => {
|
|
14
|
+
if (!client) return;
|
|
15
|
+
try {
|
|
16
|
+
const user = await client.auth.getUserInfo();
|
|
17
|
+
setCache({ client, user });
|
|
18
|
+
} catch {
|
|
19
|
+
// Reported by the client: a 401 as `session`, anything else to its onError.
|
|
20
|
+
}
|
|
21
|
+
}, [client]);
|
|
22
|
+
|
|
23
|
+
const loadUser = useCallback(() => {
|
|
24
|
+
if (!client || loadedFor.current === client) return;
|
|
25
|
+
// eslint-disable-next-line functional/immutable-data -- Marks the client whose user is fetched, across the renders of every component that asks; state would refetch until the first answer lands.
|
|
26
|
+
loadedFor.current = client;
|
|
27
|
+
void fetchUser();
|
|
28
|
+
}, [client, fetchUser]);
|
|
29
|
+
|
|
30
|
+
return { user: cache.client === client ? cache.user : undefined, loadUser, fetchUser };
|
|
31
|
+
};
|