@specbook/ui 0.1.0 → 0.2.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.
Files changed (38) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
  3. package/dist-viewer/assets/index-ez9c20v3.js +1 -0
  4. package/dist-viewer/assets/main-38QGGpNT.js +14 -0
  5. package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
  6. package/dist-viewer/assets/{panel-Coi9H1nv.css → panel-C-vBZrep.css} +1 -1
  7. package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
  8. package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
  9. package/dist-viewer/assets/{worker-Dw3Ps3hk.js → worker-ClGb0tE1.js} +1 -1
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +5 -4
  13. package/src/app.tsx +7 -2
  14. package/src/components/EnvBadge.tsx +4 -2
  15. package/src/embed/panel.tsx +2 -1
  16. package/src/engine.ts +143 -1
  17. package/src/main.tsx +9 -1
  18. package/src/oauth-callback.ts +87 -0
  19. package/src/oauth.ts +194 -0
  20. package/src/panel/CodeSample.tsx +4 -3
  21. package/src/panel/OAuthSignIn.tsx +163 -0
  22. package/src/panel/Panel.tsx +8 -4
  23. package/src/panel/RequestPanel.tsx +2 -0
  24. package/src/panel/SecretsBar.tsx +35 -2
  25. package/src/store.ts +2 -0
  26. package/src/styles/panel.css +44 -2
  27. package/src/viewer/embed.ts +5 -0
  28. package/src/viewer/main.tsx +5 -1
  29. package/src/viewer/protocol.ts +3 -0
  30. package/src/viewer/registry-data.ts +4 -0
  31. package/src/viewer/registry.ts +11 -2
  32. package/src/viewer/worker.ts +12 -7
  33. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  34. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  35. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  36. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  37. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  38. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
@@ -0,0 +1,163 @@
1
+ import { isDiscovery, type OAuthSettings } from '@specbook/core'
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import { noteExpiry, recallClientId, redirectUri, rememberClientId, signIn } from '../oauth'
4
+ import { engine } from '../runtime'
5
+ import { secretStore } from '../secrets'
6
+ import { env, version } from '../store'
7
+
8
+ type Status = { kind: 'idle' } | { kind: 'busy' } | { kind: 'error'; message: string }
9
+
10
+ interface Endpoints {
11
+ authorizationUrl: string
12
+ scopes: Record<string, string>
13
+ }
14
+
15
+ /**
16
+ * "Sign in" for a scheme with an authorization code flow, or an OpenID Connect provider. The token lands
17
+ * in the same tab-scoped store, bound to the API's origin, that a pasted value would, so everything
18
+ * downstream is unchanged. A failure or a cancel only reports itself: the paste field next to this stays
19
+ * usable.
20
+ */
21
+ export function OAuthSignIn({
22
+ service,
23
+ profile,
24
+ origin,
25
+ secret,
26
+ oauth,
27
+ onSignedIn,
28
+ }: {
29
+ service: string
30
+ profile: string
31
+ origin: string
32
+ /** the secret the profile's `apply` reads; the token is stored under it */
33
+ secret: string
34
+ oauth: OAuthSettings
35
+ onSignedIn(): void
36
+ }) {
37
+ // one provider, one remembered client id: keyed by the address that identifies the provider
38
+ const providerKey = isDiscovery(oauth) ? oauth.openIdConnectUrl : oauth.authorizationUrl
39
+ const [clientId, setClientId] = useState(
40
+ () => oauth.clientId ?? recallClientId(providerKey) ?? '',
41
+ )
42
+ const [endpoints, setEndpoints] = useState<Endpoints | undefined>(undefined)
43
+ const [picked, setPicked] = useState<string[]>([])
44
+ const [status, setStatus] = useState<Status>({ kind: 'idle' })
45
+ const abort = useRef<AbortController | undefined>(undefined)
46
+ const busy = status.kind === 'busy'
47
+ const [attempt, setAttempt] = useState(0)
48
+
49
+ // The provider's endpoints are settled before a click: the popup has to open from the click itself,
50
+ // and an OpenID Connect provider has to be asked where it is first.
51
+ useEffect(() => {
52
+ let on = true
53
+ setEndpoints(undefined)
54
+ setStatus((s) => (s.kind === 'busy' ? s : { kind: 'idle' }))
55
+ engine
56
+ .oauthEndpoints({ env: env.value, version: version.value, profile })
57
+ .then((found) => {
58
+ if (!on) return
59
+ setEndpoints(found)
60
+ // OpenID Connect asks for `openid` alone until more is ticked; a plain OAuth scheme lists what
61
+ // its operations need, so those are all ticked
62
+ setPicked(isDiscovery(oauth) ? ['openid'] : Object.keys(found.scopes))
63
+ })
64
+ .catch((e: unknown) => {
65
+ if (on) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
66
+ })
67
+ return () => {
68
+ on = false
69
+ }
70
+ }, [profile, env.value, version.value, attempt])
71
+
72
+ const scopeNames = Object.keys(endpoints?.scopes ?? {})
73
+
74
+ // called from the click itself: the popup must open before anything is awaited
75
+ const start = () => {
76
+ if (!endpoints) return setAttempt((n) => n + 1)
77
+ const id = clientId.trim()
78
+ if (!id)
79
+ return setStatus({ kind: 'error', message: 'Enter the client id registered for this page.' })
80
+ const ctl = new AbortController()
81
+ abort.current = ctl
82
+ setStatus({ kind: 'busy' })
83
+ rememberClientId(providerKey, id)
84
+ signIn({
85
+ authorizationUrl: endpoints.authorizationUrl,
86
+ clientId: id,
87
+ scopes: picked,
88
+ signal: ctl.signal,
89
+ exchange: (fields, signal) =>
90
+ engine.oauthExchange({ env: env.value, version: version.value, profile, fields, signal }),
91
+ })
92
+ .then((token) => {
93
+ secretStore.set(origin, secret, token.accessToken)
94
+ noteExpiry(origin, secret, token.expiresIn)
95
+ setStatus({ kind: 'idle' })
96
+ onSignedIn()
97
+ })
98
+ .catch((e: unknown) =>
99
+ setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) }),
100
+ )
101
+ }
102
+
103
+ const failedLookup = !endpoints && status.kind === 'error'
104
+ // the idle line (the address to register) is the only one a phone can do without
105
+ const hint = !busy && status.kind !== 'error' && !!endpoints
106
+ return (
107
+ <span class="needs-var oauth-signin">
108
+ <input
109
+ type="text"
110
+ autocomplete="off"
111
+ aria-label={`Client id for ${service}`}
112
+ placeholder="client id"
113
+ value={clientId}
114
+ disabled={busy}
115
+ onInput={(e) => setClientId((e.target as HTMLInputElement).value)}
116
+ />
117
+ {busy ? (
118
+ <button type="button" class="link" onClick={() => abort.current?.abort()}>
119
+ Cancel
120
+ </button>
121
+ ) : (
122
+ <button type="button" class="btn" disabled={!endpoints && !failedLookup} onClick={start}>
123
+ {failedLookup ? 'Try again' : 'Sign in'}
124
+ </button>
125
+ )}
126
+ {scopeNames.length > 0 && (
127
+ <details>
128
+ <summary class="muted small">
129
+ Scopes ({picked.length} of {scopeNames.length})
130
+ </summary>
131
+ {scopeNames.map((s) => (
132
+ <label class="small" title={endpoints?.scopes[s] || undefined}>
133
+ <input
134
+ type="checkbox"
135
+ checked={picked.includes(s)}
136
+ disabled={busy}
137
+ onChange={(e) =>
138
+ setPicked((p) =>
139
+ (e.target as HTMLInputElement).checked ? [...p, s] : p.filter((x) => x !== s),
140
+ )
141
+ }
142
+ />{' '}
143
+ <code>{s}</code>
144
+ </label>
145
+ ))}
146
+ </details>
147
+ )}
148
+ <span
149
+ class={`muted small oauth-status ${hint ? 'oauth-hint' : ''}`}
150
+ role="status"
151
+ aria-live="polite"
152
+ >
153
+ {busy
154
+ ? 'Waiting for the sign-in window…'
155
+ : status.kind === 'error'
156
+ ? status.message
157
+ : !endpoints
158
+ ? 'Looking up the provider…'
159
+ : `Register this redirect URI with the provider: ${redirectUri()}`}
160
+ </span>
161
+ </span>
162
+ )
163
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ComponentChildren } from 'preact'
2
- import { useEffect, useRef } from 'preact/hooks'
3
- import { env, environments } from '../store'
2
+ import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'
3
+ import { env, environmentNote, environments } from '../store'
4
4
 
5
5
  interface Props {
6
6
  title: string
@@ -99,7 +99,9 @@ export function Panel({
99
99
  }
100
100
  }, [])
101
101
 
102
- useEffect(() => {
102
+ // A layout effect, so the keys work from the moment the panel is on screen: an effect waits for the next
103
+ // frame, and a key pressed (or sent by a test, or a host page) before it was ignored
104
+ useLayoutEffect(() => {
103
105
  const key = (e: KeyboardEvent) => {
104
106
  if (e.key === 'Escape') onClose()
105
107
  if (e.key !== 'Tab' || !modal || !aside.current) return
@@ -153,7 +155,9 @@ export function Panel({
153
155
  onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
154
156
  >
155
157
  {environments.value.map((e) => (
156
- <option value={e}>{e}</option>
158
+ <option value={e} title={environmentNote.value[e]}>
159
+ {e}
160
+ </option>
157
161
  ))}
158
162
  </select>
159
163
  </label>
@@ -243,6 +243,8 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
243
243
  setError(e instanceof Error ? e.message : String(e))
244
244
  } finally {
245
245
  setRunning(false)
246
+ // what you sent a request to see is the response, so show it (the tab stays one click away)
247
+ setTab('response')
246
248
  }
247
249
  }
248
250
 
@@ -1,16 +1,25 @@
1
+ import type { AuthProfile, OAuthSettings } from '@specbook/core'
1
2
  import { useEffect, useState } from 'preact/hooks'
3
+ import { expiryOf, noteExpiry } from '../oauth'
2
4
  import { absoluteBase } from '../routing'
3
5
  import { engine } from '../runtime'
4
6
  import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
5
7
  import { env, version } from '../store'
8
+ import { OAuthSignIn } from './OAuthSignIn'
6
9
 
7
10
  interface Need {
8
11
  service: string
9
12
  origin: string
10
13
  title: string
11
14
  names: string[]
15
+ /** the config's name for the profile (the key a sign-in's token exchange is addressed by) */
16
+ profile: string
17
+ oauth?: OAuthSettings
12
18
  }
13
19
 
20
+ const minutesLeft = (at: number | undefined) =>
21
+ at === undefined ? undefined : Math.max(0, Math.round((at - Date.now()) / 60_000))
22
+
14
23
  const SOURCE: Record<string, string> = {
15
24
  session: 'this session',
16
25
  process: 'environment variable',
@@ -67,7 +76,15 @@ export function SecretsBar({
67
76
  ),
68
77
  ),
69
78
  ]
70
- if (names.length) out.push({ service, origin, title: profile.title ?? s.auth ?? '', names })
79
+ if (names.length)
80
+ out.push({
81
+ service,
82
+ origin,
83
+ title: profile.title ?? s.auth ?? '',
84
+ names,
85
+ profile: s.auth as string,
86
+ oauth: (profile as AuthProfile).oauth,
87
+ })
71
88
  }
72
89
  setNeeds(out)
73
90
  })
@@ -109,12 +126,18 @@ export function SecretsBar({
109
126
  <code>{name}</code>
110
127
  {has ? (
111
128
  <>
112
- <span class="muted">✓ set</span>
129
+ <span class="muted">
130
+ ✓ set
131
+ {n.oauth && minutesLeft(expiryOf(n.origin, name)) !== undefined
132
+ ? ` · expires in ${minutesLeft(expiryOf(n.origin, name))} min`
133
+ : ''}
134
+ </span>
113
135
  <button
114
136
  type="button"
115
137
  class="link"
116
138
  onClick={() => {
117
139
  secretStore.remove(n.origin, name)
140
+ noteExpiry(n.origin, name, undefined)
118
141
  bump((x) => x + 1)
119
142
  }}
120
143
  >
@@ -124,6 +147,16 @@ export function SecretsBar({
124
147
  ) : (
125
148
  <>
126
149
  {from && <span class="muted">from {SOURCE[from] ?? from}</span>}
150
+ {n.oauth && (
151
+ <OAuthSignIn
152
+ service={n.service}
153
+ profile={n.profile}
154
+ origin={n.origin}
155
+ secret={name}
156
+ oauth={n.oauth}
157
+ onSignedIn={() => bump((x) => x + 1)}
158
+ />
159
+ )}
127
160
  <input
128
161
  type="password"
129
162
  autocomplete="off"
package/src/store.ts CHANGED
@@ -6,5 +6,7 @@ export const env = signal('mock')
6
6
  export const environments = signal<string[]>(['mock'])
7
7
  /** Environments the config marks `production` or `sandbox`; others are judged by their name. */
8
8
  export const environmentRisk = signal<Record<string, 'production' | 'sandbox'>>({})
9
+ /** What the config says each environment is, for the tooltip of its entry in a picker. */
10
+ export const environmentNote = signal<Record<string, string>>({})
9
11
  export const locale = signal<string | undefined>(undefined)
10
12
  export const seed = signal<number | undefined>(undefined)
@@ -715,13 +715,53 @@
715
715
  }
716
716
  .needs-var {
717
717
  display: inline-flex;
718
+ flex-wrap: wrap;
718
719
  align-items: center;
719
720
  gap: 6px;
721
+ min-width: 0;
722
+ max-width: 100%;
720
723
  }
721
724
  .needs-var input {
722
725
  width: 130px;
723
726
  min-width: 0;
724
727
  }
728
+ /* the pasted value looks like the client id field beside it */
729
+ .needs-var input[type="password"] {
730
+ padding: 5px 8px;
731
+ border: 1px solid var(--sb-border);
732
+ border-radius: var(--sb-radius-control);
733
+ background: var(--sb-surface);
734
+ }
735
+ /* Sign in: its own row inside the credential, so the client id, the button, the scopes and the status
736
+ wrap under each other on a narrow screen instead of running off the edge */
737
+ .oauth-signin {
738
+ flex: 1 1 100%;
739
+ }
740
+ .oauth-signin input[type="text"] {
741
+ flex: 1 1 140px;
742
+ width: auto;
743
+ max-width: 360px;
744
+ }
745
+ .oauth-signin details {
746
+ flex: 1 1 100%;
747
+ min-width: 0;
748
+ }
749
+ .oauth-signin input[type="checkbox"] {
750
+ flex: 0 0 auto;
751
+ width: auto;
752
+ }
753
+ .oauth-signin details label {
754
+ display: flex;
755
+ align-items: center;
756
+ gap: 6px;
757
+ min-height: 28px;
758
+ overflow-wrap: anywhere;
759
+ }
760
+ .oauth-status {
761
+ flex: 1 1 100%;
762
+ min-width: 0;
763
+ overflow-wrap: anywhere;
764
+ }
725
765
  .rq-meta {
726
766
  display: flex;
727
767
  align-items: center;
@@ -1787,8 +1827,10 @@
1787
1827
  flex-wrap: wrap;
1788
1828
  padding-inline: var(--sb-space-3);
1789
1829
  }
1790
- /* the credentials note is in the chip's tooltip; not worth a line of a phone screen */
1791
- .secrets-note {
1830
+ /* the credentials note is in the chip's tooltip; not worth a line of a phone screen. The redirect
1831
+ address to register is a desktop job too; a problem or a wait is still shown */
1832
+ .secrets-note,
1833
+ .oauth-hint {
1792
1834
  display: none;
1793
1835
  }
1794
1836
  .secrets-folded,
@@ -9,6 +9,7 @@
9
9
  * The script takes the page over (it owns the document, as `/docs` does). `spec` may be relative.
10
10
  * `baseUrl` gives the API address when the document has none, `target` names another container.
11
11
  */
12
+ import { handleOAuthCallback } from '../oauth-callback'
12
13
  import { SpecWorker } from './client'
13
14
  import { mountOpened } from './mount'
14
15
 
@@ -46,6 +47,10 @@ const say = (el: HTMLElement, text: string, error = false) => {
46
47
  }
47
48
 
48
49
  async function start() {
50
+ // A sign-in's popup comes back to this page, so the viewer is what has to hand the code on. The address
51
+ // is left alone unless a viewer tab claims it: the page is the host's, and its `?code=&state=` may be
52
+ // the host application's own sign-in.
53
+ if (await handleOAuthCallback({ stripWhenUnclaimed: false })) return
49
54
  const cfg = readConfig()
50
55
  const target = container(cfg.target)
51
56
  if (!cfg.spec)
@@ -1,5 +1,6 @@
1
1
  import { render } from 'preact'
2
2
  import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import { handleOAuthCallback } from '../oauth-callback'
3
4
  import { applyTheme } from '../theme'
4
5
  import { type OpenFailure, SpecWorker } from './client'
5
6
  import { mountOpened } from './mount'
@@ -282,4 +283,7 @@ function BasePrompt({
282
283
  )
283
284
  }
284
285
 
285
- render(<Picker initial={{ kind: 'idle' }} />, root)
286
+ // a sign-in's return leg lands on this page too: hand it to the tab that started it, draw nothing here
287
+ void handleOAuthCallback().then((handled) => {
288
+ if (!handled) render(<Picker initial={{ kind: 'idle' }} />, root)
289
+ })
@@ -1,3 +1,4 @@
1
+ import type { ServerEntry } from '@specbook/core'
1
2
  import type { AuthProfileLike } from './registry-data'
2
3
 
3
4
  /** Where a spec comes from. */
@@ -26,6 +27,8 @@ export interface OpenResult {
26
27
  defaultProfile?: string
27
28
  /** absolute API address, from `servers` (resolved against the spec URL) or `host` */
28
29
  baseUrl?: string
30
+ /** every usable server the document lists, as environments; the first is `live` */
31
+ servers: ServerEntry[]
29
32
  /** a relative `servers` entry the person has to give a base URL for */
30
33
  needsBaseUrl: boolean
31
34
  warnings: string[]
@@ -1,4 +1,8 @@
1
+ import type { OAuthSettings } from '@specbook/core'
2
+
1
3
  export interface AuthProfileLike {
2
4
  title: string
3
5
  apply: { headers?: Record<string, string>; query?: Record<string, string> }
6
+ /** the scheme's authorization code flow, when it has one the page can sign in to */
7
+ oauth?: OAuthSettings
4
8
  }
@@ -134,19 +134,28 @@ export const loadFaker = () =>
134
134
  },
135
135
  }))
136
136
 
137
- const baseUrl = v.baseUrl ?? open.baseUrl
137
+ // `live` is the document's first server, or the address the person typed for it; the rest keep their own names
138
+ const live = v.baseUrl ?? open.baseUrl
139
+ const baseUrl: Record<string, string> = {
140
+ ...(live ? { live } : {}),
141
+ ...Object.fromEntries(open.servers.filter((s) => s.name !== 'live').map((s) => [s.name, s.url])),
142
+ }
143
+ const described = Object.fromEntries(
144
+ open.servers.filter((s) => s.description).map((s) => [s.name, { description: s.description }]),
145
+ )
138
146
  export const loadConfig = () =>
139
147
  Promise.resolve({
140
148
  default: {
141
149
  project: service,
142
150
  secrets: open.secrets,
143
151
  auth: open.auth,
152
+ ...(Object.keys(described).length ? { environments: described } : {}),
144
153
  // one locale, so the header offers no locale choice
145
154
  faker: { default: 'en', locales: { en: ['en', 'base'] } },
146
155
  services: {
147
156
  [service]: {
148
157
  spec: '',
149
- baseUrl: baseUrl ? { live: baseUrl } : {},
158
+ baseUrl,
150
159
  ...(open.defaultProfile ? { auth: open.defaultProfile } : {}),
151
160
  },
152
161
  },
@@ -1,6 +1,6 @@
1
1
  /// <reference lib="webworker" />
2
2
  import { generateData, type OperationMeta, openSpec } from '@specbook/codegen/browser'
3
- import { authFromSpec, liveBaseUrl } from '@specbook/core'
3
+ import { authFromSpec, MAX_SERVERS, openApiServers } from '@specbook/core'
4
4
  import { bundleJson, type Parts, sortFiles } from './bundles'
5
5
  import type { OpenResult, Request, Response, Source } from './protocol'
6
6
 
@@ -112,11 +112,10 @@ async function open(id: number, source: Source, signal: AbortSignal): Promise<Op
112
112
  (o.examples as { name: string }[]).map((e) => e.name),
113
113
  ]),
114
114
  )
115
- const { auth, secrets, defaultProfile } = authFromSpec(doc)
116
115
  const sourceUrl = source.kind === 'url' ? source.url : undefined
117
- const baseUrl = liveBaseUrl(doc, sourceUrl ?? name ?? '')
118
- const relativeServer =
119
- typeof doc.servers?.[0]?.url === 'string' && !/^https?:\/\//i.test(doc.servers[0].url)
116
+ const { auth, secrets, defaultProfile } = authFromSpec(doc, sourceUrl)
117
+ const { servers, needsBaseUrl, omitted } = openApiServers(doc, sourceUrl ?? name ?? '')
118
+ const baseUrl = servers.find((s) => s.name === 'live')?.url
120
119
  return {
121
120
  service,
122
121
  title,
@@ -127,8 +126,14 @@ async function open(id: number, source: Source, signal: AbortSignal): Promise<Op
127
126
  secrets,
128
127
  defaultProfile,
129
128
  baseUrl,
130
- needsBaseUrl: !baseUrl && relativeServer,
131
- warnings,
129
+ servers,
130
+ needsBaseUrl,
131
+ warnings: omitted
132
+ ? [
133
+ ...warnings,
134
+ `This document lists ${servers.length + omitted} servers. The first ${MAX_SERVERS} are offered as environments.`,
135
+ ]
136
+ : warnings,
132
137
  timings: {
133
138
  ...(fetched !== undefined ? { fetch: Math.round(fetched) } : {}),
134
139
  parse: Math.round(parse),