@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.
@@ -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
@@ -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 and what was shared with them, the operator included.
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.
@@ -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'