@uniweb/api 1.1.1 → 1.2.1
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 +200 -21
- package/package.json +2 -2
- package/src/client.js +275 -8
- package/src/elevation.js +268 -0
- package/src/hooks/useChoices.js +102 -0
- package/src/hooks/useDefinition.js +78 -0
- package/src/hooks/useElevation.js +147 -0
- package/src/hooks/useRecords.js +2 -2
- package/src/hooks/useSession.js +5 -0
- package/src/index.js +11 -0
- package/src/mock/definition.js +117 -0
- package/src/mock/index.js +74 -7
- package/src/mock/schema-shape.js +139 -6
- package/src/mock/store.js +442 -61
- package/src/wire.js +247 -34
package/src/elevation.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elevation — a site's session reads through its roles until it is elevated.
|
|
3
|
+
*
|
|
4
|
+
* On a site's backend, a session signed in on the site holds each of the account's roles
|
|
5
|
+
* as a reader until it is elevated: an administrator reads what their role reaches, and
|
|
6
|
+
* changes nothing through it. Their own content they edit, delete and share as always.
|
|
7
|
+
* To act through the role — remove a member's post, grade a submission — the session
|
|
8
|
+
* elevates, with the password again (and the second factor, when the account has one),
|
|
9
|
+
* for a while the backend decides (`ELEVATION` in `./wire.js`). No React in this module;
|
|
10
|
+
* `useElevation` is the hook over it.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ **Functions of a client, not methods** — the page's client may have been created by
|
|
13
|
+
* an older copy of this package (`./mailed-links.js` says why). Everything here reaches
|
|
14
|
+
* the wire through `request` and keeps its state on the instance.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ApiError } from './errors.js'
|
|
18
|
+
import { composeUrl } from './http.js'
|
|
19
|
+
import { AUTH, ELEVATION } from './wire.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `/auth/me`'s `elevation` as the viewer carries it — `{ available, until }`, `until` an
|
|
23
|
+
* ISO time or `null` — or `null` when the backend sends none: a backend no site reaches,
|
|
24
|
+
* whose roles answer in full.
|
|
25
|
+
*
|
|
26
|
+
* @param {*} raw
|
|
27
|
+
* @returns {{ available: boolean, until: string|null } | null}
|
|
28
|
+
*/
|
|
29
|
+
export function viewerElevation(raw) {
|
|
30
|
+
if (!raw || typeof raw !== 'object') return null
|
|
31
|
+
const until = raw[ELEVATION.until]
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
available: raw[ELEVATION.available] === true,
|
|
34
|
+
until: typeof until === 'string' && until ? until : null,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a viewer's session is elevated — `until` still ahead, by this clock.
|
|
40
|
+
*
|
|
41
|
+
* @param {object|null} viewer - `useSession().viewer`
|
|
42
|
+
* @param {number} [now]
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isElevated(viewer, now = Date.now()) {
|
|
46
|
+
const until = viewer?.elevation?.until
|
|
47
|
+
return typeof until === 'string' && Date.parse(until) > now
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a viewer's session acts through its roles now: elevated — or on a backend that
|
|
52
|
+
* asks for no elevation, whose `/auth/me` carries none and whose roles always answer in
|
|
53
|
+
* full. False for nobody signed in, and for a viewer read by a copy of this package
|
|
54
|
+
* older than elevation, which cannot say.
|
|
55
|
+
*
|
|
56
|
+
* @param {object|null} viewer
|
|
57
|
+
* @param {number} [now]
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function rolesInFull(viewer, now = Date.now()) {
|
|
61
|
+
if (!viewer || !('elevation' in viewer)) return false
|
|
62
|
+
return viewer.elevation === null || isElevated(viewer, now)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether a cached read is one entity's — whose `can_edit` is the session's answer. */
|
|
66
|
+
const isEntityRead = (spec) => spec?.uuid != null
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ask the backend again who the viewer is, after anything already asking: a probe that
|
|
70
|
+
* left before the change would answer for the session as it was.
|
|
71
|
+
*/
|
|
72
|
+
async function reprobe(client) {
|
|
73
|
+
const earlier = client._pending
|
|
74
|
+
if (earlier) await earlier.catch(() => {})
|
|
75
|
+
return client.refresh?.()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Elevate the session — the password again, and `code` when the account has a second
|
|
80
|
+
* factor. Answers `{ until }`, an ISO time. The session is asked again, so `roles` name
|
|
81
|
+
* what it holds now, and every entity read is read again, since what the viewer may do
|
|
82
|
+
* to an entity is its own answer (`can_edit`).
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ **A wrong password, a wrong code, a missing code and a session that has ended are
|
|
85
|
+
* one answer, `401`** (`kind: 'auth'`). The session is asked again before the refusal is
|
|
86
|
+
* thrown, so a session that ended shows as signed out, and one still signed in means
|
|
87
|
+
* the credential was refused. Nothing on the wire says whether the account has a second
|
|
88
|
+
* factor: offer the code field to whoever might have one.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} client
|
|
91
|
+
* @param {{ password: string, code?: string }} credentials
|
|
92
|
+
* @returns {Promise<{ until: string|null }>}
|
|
93
|
+
* @throws {ApiError} `auth` — refused, as above · `invalid` — no password given
|
|
94
|
+
*/
|
|
95
|
+
export async function elevateWith(client, { password, code } = {}) {
|
|
96
|
+
if (!password) {
|
|
97
|
+
throw ApiError.invalid(
|
|
98
|
+
'No Password',
|
|
99
|
+
'elevate needs the account’s password — an account with none elevates through its provider (elevateWithProvider)',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const body = { [ELEVATION.password]: password }
|
|
103
|
+
if (code != null && code !== '') body[ELEVATION.code] = code
|
|
104
|
+
let answer
|
|
105
|
+
try {
|
|
106
|
+
answer = await client.request('POST', AUTH.elevate, { body, onUnauthorized: 'ignore' })
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.status === 401) await reprobe(client)
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
client.invalidate?.(isEntityRead)
|
|
112
|
+
await reprobe(client)
|
|
113
|
+
return { until: answer?.[ELEVATION.answer] ?? null }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Lower the session before its time runs out. The session is asked again, and every
|
|
118
|
+
* entity read is read again.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} client
|
|
121
|
+
* @returns {Promise<{ until: null }>}
|
|
122
|
+
* @throws {ApiError} `auth` — nobody signed in
|
|
123
|
+
*/
|
|
124
|
+
export async function lowerWith(client) {
|
|
125
|
+
await client.request('DELETE', AUTH.elevate)
|
|
126
|
+
client.invalidate?.(isEntityRead)
|
|
127
|
+
await reprobe(client)
|
|
128
|
+
return { until: null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Marks a `state` as ours, so its echo can be taken off the address bar on the way back. */
|
|
132
|
+
const STATE_PREFIX = 'uw-elevate-'
|
|
133
|
+
|
|
134
|
+
/** The page's own address, without its query or fragment — where the provider returns to. */
|
|
135
|
+
function pageAddress() {
|
|
136
|
+
const location = globalThis.location
|
|
137
|
+
if (!location?.origin || location.origin === 'null') return null
|
|
138
|
+
return `${location.origin}${location.pathname}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The provider's elevation for this attempt — for an account with no password — or
|
|
143
|
+
* `null`: no backend, nobody signed in, no provider named, or no page to return to.
|
|
144
|
+
*
|
|
145
|
+
* ⛔ **A navigation, never a fetch**, and one address per attempt: each carries a fresh
|
|
146
|
+
* `state`, so make it when the viewer asks, never at render.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} client
|
|
149
|
+
* @param {string} provider - the provider's id, as the backend names it (`google`)
|
|
150
|
+
* @param {{ returnTo?: string }} [options] - the absolute address to come back to; the
|
|
151
|
+
* page the viewer is on, without its query or fragment, by default
|
|
152
|
+
* @returns {string|null}
|
|
153
|
+
*/
|
|
154
|
+
export function elevationAddressOf(client, provider, { returnTo } = {}) {
|
|
155
|
+
const base = client?.base ?? null
|
|
156
|
+
if (base === null || !provider || client.session?.status !== 'authenticated') return null
|
|
157
|
+
const redirect = returnTo || pageAddress()
|
|
158
|
+
if (!redirect) return null
|
|
159
|
+
const state = `${STATE_PREFIX}${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`
|
|
160
|
+
return composeUrl(base, ELEVATION.byProvider(provider), { [ELEVATION.redirect]: redirect, [ELEVATION.state]: state })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Elevate through the account's provider — go to its sign-in, and come back elevated.
|
|
165
|
+
* Nothing happens outside a browser, or when there is no address to go to
|
|
166
|
+
* (`elevationAddressOf`).
|
|
167
|
+
*
|
|
168
|
+
* @param {object} client
|
|
169
|
+
* @param {string} provider
|
|
170
|
+
* @param {{ returnTo?: string }} [options]
|
|
171
|
+
* @returns {string|null} the address navigated to
|
|
172
|
+
*/
|
|
173
|
+
export function elevateWithProviderOf(client, provider, options) {
|
|
174
|
+
const address = elevationAddressOf(client, provider, options)
|
|
175
|
+
if (address && typeof globalThis.location?.assign === 'function') globalThis.location.assign(address)
|
|
176
|
+
return address
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What a provider's elevation came back with, on the page it returned to:
|
|
181
|
+
* `{ ok: true }`, `{ ok: false, error: 'oauth_step_up_not_linked' }` — the identity that
|
|
182
|
+
* came back is not this account's — or `null` when the URL carries neither.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} [search] - a query string; the page's own by default
|
|
185
|
+
* @returns {{ ok: boolean, error?: string } | null}
|
|
186
|
+
*/
|
|
187
|
+
export function elevationReturn(search = globalThis.location?.search ?? '') {
|
|
188
|
+
let params
|
|
189
|
+
try {
|
|
190
|
+
params = new URLSearchParams(search)
|
|
191
|
+
} catch {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
if (params.get(ELEVATION.returned) === ELEVATION.ok) return Object.freeze({ ok: true })
|
|
195
|
+
if (params.get(ELEVATION.error) === ELEVATION.notLinked) return Object.freeze({ ok: false, error: ELEVATION.notLinked })
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Take a provider's answer, and our own `state`, off the address bar — `replaceState`,
|
|
201
|
+
* no navigation. The rest of the URL stays, another `state` or `error` included.
|
|
202
|
+
*/
|
|
203
|
+
export function forgetElevationReturn() {
|
|
204
|
+
const location = globalThis.location
|
|
205
|
+
const history = globalThis.history
|
|
206
|
+
if (!location || typeof history?.replaceState !== 'function') return
|
|
207
|
+
const params = new URLSearchParams(location.search)
|
|
208
|
+
let changed = false
|
|
209
|
+
if (params.get(ELEVATION.returned) === ELEVATION.ok) {
|
|
210
|
+
params.delete(ELEVATION.returned)
|
|
211
|
+
changed = true
|
|
212
|
+
}
|
|
213
|
+
if (params.get(ELEVATION.error) === ELEVATION.notLinked) {
|
|
214
|
+
params.delete(ELEVATION.error)
|
|
215
|
+
changed = true
|
|
216
|
+
}
|
|
217
|
+
if (params.get(ELEVATION.state)?.startsWith(STATE_PREFIX)) {
|
|
218
|
+
params.delete(ELEVATION.state)
|
|
219
|
+
changed = true
|
|
220
|
+
}
|
|
221
|
+
if (!changed) return
|
|
222
|
+
const query = params.toString()
|
|
223
|
+
history.replaceState(history.state, '', `${location.pathname}${query ? `?${query}` : ''}${location.hash}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A provider's answer, read ONCE per page and taken off the address bar — the same
|
|
228
|
+
* answer to every component that asks.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} client
|
|
231
|
+
* @returns {{ ok: boolean, error?: string } | null}
|
|
232
|
+
*/
|
|
233
|
+
export function takeElevationReturn(client) {
|
|
234
|
+
if (!client) return null
|
|
235
|
+
if (client._elevationReturn === undefined) {
|
|
236
|
+
client._elevationReturn = elevationReturn()
|
|
237
|
+
if (client._elevationReturn) forgetElevationReturn()
|
|
238
|
+
}
|
|
239
|
+
return client._elevationReturn
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ask the backend again when the session's elevation runs out, so `roles` stop naming
|
|
244
|
+
* what it no longer holds — one timer per client, armed for the `until` the session
|
|
245
|
+
* carries, and let go when that changes or the session ends.
|
|
246
|
+
*
|
|
247
|
+
* ⚠️ `until` is the backend's clock and the timer is this one's. Behind, the probe comes
|
|
248
|
+
* late. Ahead, the probe finds the session still elevated at an `until` this clock has
|
|
249
|
+
* passed, and arms nothing: the page's next probe tells.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} client
|
|
252
|
+
*/
|
|
253
|
+
export function watchLapse(client) {
|
|
254
|
+
const until = client?.session?.viewer?.elevation?.until ?? null
|
|
255
|
+
if (client._lapse && client._lapse.until === until) return
|
|
256
|
+
if (client._lapse) clearTimeout(client._lapse.timer)
|
|
257
|
+
client._lapse = null
|
|
258
|
+
if (!until || typeof setTimeout !== 'function') return
|
|
259
|
+
const wait = Date.parse(until) - Date.now()
|
|
260
|
+
if (!(wait > 0)) return
|
|
261
|
+
// A second past `until`, and never longer than a timer can wait.
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
client._lapse = null
|
|
264
|
+
client.refresh?.()
|
|
265
|
+
}, Math.min(wait + 1000, 2 ** 31 - 1))
|
|
266
|
+
timer?.unref?.()
|
|
267
|
+
client._lapse = { until, timer }
|
|
268
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
const noSubscribe = () => () => {}
|
|
5
|
+
const noSnapshot = () => null
|
|
6
|
+
const NONE = Object.freeze([])
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The choices a field offers — a page of its list, for a dropdown or a type-ahead.
|
|
10
|
+
*
|
|
11
|
+
* ```jsx
|
|
12
|
+
* const [q, setQ] = useState('')
|
|
13
|
+
* const { status, items } = useChoices({ schema: '@acme/cv', field: 'country', q, searchIn: 'label' })
|
|
14
|
+
* // items[i].uuid is the value to store in `country`; items[i].data.label what to show
|
|
15
|
+
*
|
|
16
|
+
* // a dependent list: the provinces of the country chosen above
|
|
17
|
+
* const provinces = useChoices(country ? { schema: '@acme/cv', field: 'province', parent: country } : null)
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `field` is a field of kind `item_ref` (`options:` in a schema); `listChoices` says
|
|
21
|
+
* what each parameter does. A page is `limit` items (50 by default): pass `nextCursor`
|
|
22
|
+
* back as `cursor` for the next. ⚠️ A searched page can be short while more follow —
|
|
23
|
+
* `hasMore` is the end signal, never the page's length.
|
|
24
|
+
*
|
|
25
|
+
* `absent` — no `backend` service, or nobody signed in. Cached under a key scoped to the
|
|
26
|
+
* viewer; a list edited elsewhere is read again by `refresh`.
|
|
27
|
+
*
|
|
28
|
+
* @param {{ schema: string, field: string, section?: string, parent?: string, q?: string, searchIn?: string, cursor?: string, limit?: number } | null} query
|
|
29
|
+
* pass null to skip
|
|
30
|
+
* @returns {{ status: string, items: object[], nextCursor: string|null, hasMore: boolean, error: Error|null, refresh: Function }}
|
|
31
|
+
*/
|
|
32
|
+
export function useChoices(query) {
|
|
33
|
+
const client = getClient()
|
|
34
|
+
const store = client?.website?.dataStore ?? null
|
|
35
|
+
|
|
36
|
+
const session = useSyncExternalStore(
|
|
37
|
+
client ? client.subscribe : noSubscribe,
|
|
38
|
+
client ? () => client.session : noSnapshot,
|
|
39
|
+
client ? () => client.session : noSnapshot,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const active = !!(
|
|
43
|
+
client &&
|
|
44
|
+
client.enabled &&
|
|
45
|
+
store &&
|
|
46
|
+
query &&
|
|
47
|
+
query.schema &&
|
|
48
|
+
query.field &&
|
|
49
|
+
session?.status !== 'anonymous'
|
|
50
|
+
)
|
|
51
|
+
const spec = active
|
|
52
|
+
? {
|
|
53
|
+
endpoint: '/sections',
|
|
54
|
+
schema: query.schema,
|
|
55
|
+
field: query.field,
|
|
56
|
+
section: query.section,
|
|
57
|
+
parent: query.parent,
|
|
58
|
+
q: query.q || undefined,
|
|
59
|
+
searchIn: query.searchIn,
|
|
60
|
+
cursor: query.cursor,
|
|
61
|
+
limit: query.limit,
|
|
62
|
+
}
|
|
63
|
+
: null
|
|
64
|
+
const key = spec ? client.cacheKey(spec) : null
|
|
65
|
+
|
|
66
|
+
const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
|
|
67
|
+
const entry = useSyncExternalStore(
|
|
68
|
+
subscribe,
|
|
69
|
+
() => (key ? store.get(key) : null),
|
|
70
|
+
() => (key ? store.get(key) : null),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const [error, setError] = useState(null)
|
|
74
|
+
const [attempt, setAttempt] = useState(0)
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!key || entry) return undefined
|
|
78
|
+
let live = true
|
|
79
|
+
setError(null)
|
|
80
|
+
client.load(key, () => client.listChoices(query), spec).catch((err) => {
|
|
81
|
+
if (live) setError(err)
|
|
82
|
+
})
|
|
83
|
+
return () => {
|
|
84
|
+
live = false
|
|
85
|
+
}
|
|
86
|
+
// `query` is read through `key`, which encodes every part of it.
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, [client, key, entry, attempt])
|
|
89
|
+
|
|
90
|
+
const refresh = useCallback(() => {
|
|
91
|
+
if (key && store) store.delete(key)
|
|
92
|
+
setAttempt((n) => n + 1)
|
|
93
|
+
}, [store, key])
|
|
94
|
+
|
|
95
|
+
const empty = { items: NONE, nextCursor: null, hasMore: false }
|
|
96
|
+
if (!active) return { status: 'absent', ...empty, error: null, refresh }
|
|
97
|
+
if (entry) return { status: 'ready', ...entry.data, error: null, refresh }
|
|
98
|
+
if (error) return { status: 'error', ...empty, error, refresh }
|
|
99
|
+
return { status: 'loading', ...empty, error: null, refresh }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default useChoices
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
const noSubscribe = () => () => {}
|
|
5
|
+
const noSnapshot = () => null
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A Model's definition — the sections and fields a form is built from.
|
|
9
|
+
*
|
|
10
|
+
* ```jsx
|
|
11
|
+
* const { status, definition } = useDefinition('@acme/cv')
|
|
12
|
+
* if (status !== 'ready') return null
|
|
13
|
+
* const brief = definition.sections.find((s) => s.brief)
|
|
14
|
+
* // brief.fields → [{ key: 'title', kind: 'string', required: true, max_length: 250 }, …]
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Each field is its declaration as the backend stores it, with `enum` folded in from its
|
|
18
|
+
* section's rules (`readDefinition`). The rules a definition states are the rules every
|
|
19
|
+
* write is checked against.
|
|
20
|
+
*
|
|
21
|
+
* `absent` — no `backend` service, or nobody signed in: a definition is read by someone
|
|
22
|
+
* signed in. Cached under a key scoped to the viewer.
|
|
23
|
+
*
|
|
24
|
+
* @param {string|null} schema - the Model, e.g. `@acme/cv`; null to skip
|
|
25
|
+
* @returns {{ status: string, definition: object|null, error: Error|null, refresh: Function }}
|
|
26
|
+
*/
|
|
27
|
+
export function useDefinition(schema) {
|
|
28
|
+
const client = getClient()
|
|
29
|
+
const store = client?.website?.dataStore ?? null
|
|
30
|
+
|
|
31
|
+
const session = useSyncExternalStore(
|
|
32
|
+
client ? client.subscribe : noSubscribe,
|
|
33
|
+
client ? () => client.session : noSnapshot,
|
|
34
|
+
client ? () => client.session : noSnapshot,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const active = !!(client && client.enabled && store && schema && session?.status !== 'anonymous')
|
|
38
|
+
const spec = active ? { endpoint: '/models', schema } : null
|
|
39
|
+
const key = spec ? client.cacheKey(spec) : null
|
|
40
|
+
|
|
41
|
+
const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
|
|
42
|
+
const entry = useSyncExternalStore(
|
|
43
|
+
subscribe,
|
|
44
|
+
() => (key ? store.get(key) : null),
|
|
45
|
+
() => (key ? store.get(key) : null),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const [error, setError] = useState(null)
|
|
49
|
+
const [attempt, setAttempt] = useState(0)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!key || entry) return undefined
|
|
53
|
+
let live = true
|
|
54
|
+
setError(null)
|
|
55
|
+
client.load(key, () => client.readDefinition({ schema }), spec).catch((err) => {
|
|
56
|
+
if (live) setError(err)
|
|
57
|
+
})
|
|
58
|
+
return () => {
|
|
59
|
+
live = false
|
|
60
|
+
}
|
|
61
|
+
// `schema` is read through `key`, which encodes it.
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
}, [client, key, entry, attempt])
|
|
64
|
+
|
|
65
|
+
const refresh = useCallback(() => {
|
|
66
|
+
if (key && store) store.delete(key)
|
|
67
|
+
// The client keeps the definition too; a refresh reads it from the backend again.
|
|
68
|
+
if (schema) client?.forgetDefinition(schema)
|
|
69
|
+
setAttempt((n) => n + 1)
|
|
70
|
+
}, [store, key, client, schema])
|
|
71
|
+
|
|
72
|
+
if (!active) return { status: 'absent', definition: null, error: null, refresh }
|
|
73
|
+
if (entry) return { status: 'ready', definition: entry.data, error: null, refresh }
|
|
74
|
+
if (error) return { status: 'error', definition: null, error, refresh }
|
|
75
|
+
return { status: 'loading', definition: null, error: null, refresh }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default useDefinition
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
import { ApiError } from '../errors.js'
|
|
4
|
+
import { elevateWith, lowerWith, elevateWithProviderOf, takeElevationReturn, rolesInFull } from '../elevation.js'
|
|
5
|
+
import { useAction } from './useAction.js'
|
|
6
|
+
|
|
7
|
+
const NONE = Object.freeze({ status: 'anonymous', viewer: null, error: null })
|
|
8
|
+
const noSubscribe = () => () => {}
|
|
9
|
+
const noSnapshot = () => NONE
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Elevation — whether the viewer's session acts through their roles, and the calls that
|
|
13
|
+
* change it.
|
|
14
|
+
*
|
|
15
|
+
* On a site's backend a session holds each of the account's roles as a reader until it
|
|
16
|
+
* is elevated: an administrator reads what their role reaches and changes nothing
|
|
17
|
+
* through it — their own content they edit, delete and share as always. To act through
|
|
18
|
+
* the role, they confirm it is them, and the session is elevated for a while.
|
|
19
|
+
*
|
|
20
|
+
* ```jsx
|
|
21
|
+
* const { available, elevated, until, elevate, lower, status, error } = useElevation()
|
|
22
|
+
* if (available && !elevated) return <ConfirmPassword onSubmit={(password, code) => elevate({ password, code })} />
|
|
23
|
+
* if (elevated) return <p>Acting as administrator until {new Date(until).toLocaleTimeString()}. <button onClick={lower}>Stop</button></p>
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* - **`available`** — the account holds a role that answers more than reading: offer
|
|
27
|
+
* elevation only then.
|
|
28
|
+
* - **`elevated`** — true while it lasts; `until` is when it ends, an ISO time. It turns
|
|
29
|
+
* false by itself when the time passes, and the session is asked again then. Elevating
|
|
30
|
+
* again starts the time again; nothing extends it on its own.
|
|
31
|
+
* - **`rolesInFull`** — whether the session acts through its roles now: elevated, or on a
|
|
32
|
+
* backend that never asks for it. Draw controls that act through a role from this, and
|
|
33
|
+
* ask each entity what the viewer may do to it — `useEntity`'s `entity.can_edit`, read
|
|
34
|
+
* again after every change here.
|
|
35
|
+
* - **`elevate({ password, code })`** — `code` when the account has a second factor.
|
|
36
|
+
* Nothing says whether it has one, so offer the field to whoever might. A refusal is
|
|
37
|
+
* `kind: 'auth'`, one answer for a wrong password or code; a session that has ended
|
|
38
|
+
* shows as signed out first.
|
|
39
|
+
* - **`elevateWithProvider(provider)`** — for an account with no password: a navigation
|
|
40
|
+
* to the provider's sign-in, which comes back to this page. If the identity that comes
|
|
41
|
+
* back is not this account's, `error` says so — `kind: 'auth'`,
|
|
42
|
+
* `error.extensions.reason === 'oauth_step_up_not_linked'`.
|
|
43
|
+
* - **`lower()`** — ends it early.
|
|
44
|
+
*
|
|
45
|
+
* Elevating does not open the backend's administration — the Admin link does
|
|
46
|
+
* (`useSession().adminHref`). On a site with no backend, or with nobody signed in,
|
|
47
|
+
* nothing is available.
|
|
48
|
+
*
|
|
49
|
+
* @returns {{
|
|
50
|
+
* available: boolean,
|
|
51
|
+
* elevated: boolean,
|
|
52
|
+
* until: string | null,
|
|
53
|
+
* rolesInFull: boolean,
|
|
54
|
+
* elevate: (credentials: { password: string, code?: string }) => Promise<{ until: string|null }>,
|
|
55
|
+
* elevateWithProvider: (provider: string, options?: { returnTo?: string }) => string | null,
|
|
56
|
+
* lower: () => Promise<{ until: null }>,
|
|
57
|
+
* status: 'idle' | 'submitting' | 'success' | 'error',
|
|
58
|
+
* error: Error | null,
|
|
59
|
+
* reset: () => void,
|
|
60
|
+
* }}
|
|
61
|
+
*/
|
|
62
|
+
export function useElevation() {
|
|
63
|
+
const client = getClient()
|
|
64
|
+
const session = useSyncExternalStore(
|
|
65
|
+
client ? client.subscribe : noSubscribe,
|
|
66
|
+
client ? () => client.session : noSnapshot,
|
|
67
|
+
client ? () => client.session : noSnapshot,
|
|
68
|
+
)
|
|
69
|
+
const viewer = session.status === 'authenticated' ? session.viewer : null
|
|
70
|
+
const elevation = viewer?.elevation ?? null
|
|
71
|
+
const until = elevation?.until ?? null
|
|
72
|
+
|
|
73
|
+
// Ask who the viewer is, as `useSession` does — this hook may be the only one asking.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (client && client.enabled) client.ensureSession()
|
|
76
|
+
}, [client])
|
|
77
|
+
|
|
78
|
+
// Render again when the elevation runs out, so `elevated` turns false on time.
|
|
79
|
+
const [, setTick] = useState(0)
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!until) return undefined
|
|
82
|
+
const wait = Date.parse(until) - Date.now()
|
|
83
|
+
if (!(wait > 0)) return undefined
|
|
84
|
+
const timer = setTimeout(() => setTick((n) => n + 1), Math.min(wait + 50, 2 ** 31 - 1))
|
|
85
|
+
return () => clearTimeout(timer)
|
|
86
|
+
}, [until])
|
|
87
|
+
|
|
88
|
+
const { run, status, error, reset } = useAction(
|
|
89
|
+
useCallback(
|
|
90
|
+
async (step, arg) => {
|
|
91
|
+
if (!client) throw ApiError.disabled()
|
|
92
|
+
return step === 'lower' ? lowerWith(client) : elevateWith(client, arg)
|
|
93
|
+
},
|
|
94
|
+
[client],
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// A provider's elevation returns to the page: read once, in an effect — the URL is the
|
|
99
|
+
// browser's, never the server render's. Coming back elevated needs nothing here: the
|
|
100
|
+
// session says so.
|
|
101
|
+
const [returned, setReturned] = useState(null)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const answer = takeElevationReturn(client)
|
|
104
|
+
if (answer && !answer.ok) {
|
|
105
|
+
setReturned(
|
|
106
|
+
new ApiError({
|
|
107
|
+
status: 0,
|
|
108
|
+
title: 'Not Linked',
|
|
109
|
+
detail: 'the identity the provider returned is not this account’s',
|
|
110
|
+
kind: 'auth',
|
|
111
|
+
extensions: { reason: answer.error },
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}, [client])
|
|
116
|
+
|
|
117
|
+
const elevate = useCallback((credentials) => run('elevate', credentials), [run])
|
|
118
|
+
const lower = useCallback(() => run('lower'), [run])
|
|
119
|
+
const elevateWithProvider = useCallback(
|
|
120
|
+
(provider, options) => {
|
|
121
|
+
setReturned(null)
|
|
122
|
+
return client ? elevateWithProviderOf(client, provider, options) : null
|
|
123
|
+
},
|
|
124
|
+
[client],
|
|
125
|
+
)
|
|
126
|
+
const clear = useCallback(() => {
|
|
127
|
+
setReturned(null)
|
|
128
|
+
reset()
|
|
129
|
+
}, [reset])
|
|
130
|
+
|
|
131
|
+
const elevated = typeof until === 'string' && Date.parse(until) > Date.now()
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
available: elevation?.available === true,
|
|
135
|
+
elevated,
|
|
136
|
+
until: elevated ? until : null,
|
|
137
|
+
rolesInFull: rolesInFull(viewer),
|
|
138
|
+
elevate,
|
|
139
|
+
elevateWithProvider,
|
|
140
|
+
lower,
|
|
141
|
+
status,
|
|
142
|
+
error: error ?? (status === 'idle' ? returned : null),
|
|
143
|
+
reset: clear,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default useElevation
|
package/src/hooks/useRecords.js
CHANGED
|
@@ -35,8 +35,8 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
|
|
|
35
35
|
* `hasMore` is true when the page came back full.
|
|
36
36
|
*
|
|
37
37
|
* The default scope is everything the viewer may read — signed in on a site, their
|
|
38
|
-
* own entities
|
|
39
|
-
* `scope: 'mine'` is only the viewer's own.
|
|
38
|
+
* own entities, what was shared with them, and what a role of theirs reaches: the
|
|
39
|
+
* operator's, every member's. `scope: 'mine'` is only the viewer's own.
|
|
40
40
|
*
|
|
41
41
|
* Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
|
|
42
42
|
* is now looking, and a write through `useEntityWriter` drops it.
|
package/src/hooks/useSession.js
CHANGED
|
@@ -15,6 +15,11 @@ const noop = async () => NONE
|
|
|
15
15
|
* affordance on false. `error` is set when the backend could not be asked;
|
|
16
16
|
* `refresh()` asks again.
|
|
17
17
|
*
|
|
18
|
+
* `viewer.roles` describe the session, not the account: on a site's backend an
|
|
19
|
+
* administrator's session holds its roles as a reader until it is elevated, and
|
|
20
|
+
* `viewer.elevation` — `{ available, until }`, or `null` on a backend that never asks —
|
|
21
|
+
* says whether it can be, and until when it is. `useElevation` reads it and elevates.
|
|
22
|
+
*
|
|
18
23
|
* `adminHref` is the Admin link — set only for a signed-in account the backend's
|
|
19
24
|
* administration admits, `null` for everyone else, so draw the link only when it is
|
|
20
25
|
* set. ⛔ Navigate to it, never `fetch` it (`adminHrefOf` in `../client.js`).
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,11 @@ export {
|
|
|
18
18
|
signIn,
|
|
19
19
|
completeChallenge,
|
|
20
20
|
signOut,
|
|
21
|
+
elevate,
|
|
22
|
+
lower,
|
|
23
|
+
elevateWithProvider,
|
|
24
|
+
isElevated,
|
|
25
|
+
rolesInFull,
|
|
21
26
|
signUp,
|
|
22
27
|
requestPasswordReset,
|
|
23
28
|
confirmPasswordReset,
|
|
@@ -30,6 +35,9 @@ export {
|
|
|
30
35
|
forgetMailedLink,
|
|
31
36
|
readEntity,
|
|
32
37
|
listEntities,
|
|
38
|
+
readDefinition,
|
|
39
|
+
listSectionItems,
|
|
40
|
+
listChoices,
|
|
33
41
|
writeItems,
|
|
34
42
|
createEntity,
|
|
35
43
|
deleteEntity,
|
|
@@ -39,6 +47,7 @@ export {
|
|
|
39
47
|
} from './client.js'
|
|
40
48
|
|
|
41
49
|
export { useSession } from './hooks/useSession.js'
|
|
50
|
+
export { useElevation } from './hooks/useElevation.js'
|
|
42
51
|
export { useSignIn } from './hooks/useSignIn.js'
|
|
43
52
|
export { useSignUp } from './hooks/useSignUp.js'
|
|
44
53
|
export { usePasswordReset } from './hooks/usePasswordReset.js'
|
|
@@ -46,5 +55,7 @@ export { useMailedLink } from './hooks/useMailedLink.js'
|
|
|
46
55
|
export { useInvitations } from './hooks/useInvitations.js'
|
|
47
56
|
export { useEntity } from './hooks/useEntity.js'
|
|
48
57
|
export { useRecords } from './hooks/useRecords.js'
|
|
58
|
+
export { useDefinition } from './hooks/useDefinition.js'
|
|
59
|
+
export { useChoices } from './hooks/useChoices.js'
|
|
49
60
|
export { useEntityWriter } from './hooks/useEntityWriter.js'
|
|
50
61
|
export { SignedIn, SignedOut } from './components/gates.js'
|