@clearly-hub/browser 0.1.1 → 0.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 CHANGED
@@ -11,13 +11,13 @@ hub) and each hub's subscription to your app. Tokens stay on your server.
11
11
 
12
12
  **1. Register your app in Clearly.HUB** (on your app's page, no code):
13
13
 
14
- | Setting | Value |
15
- | --- | --- |
16
- | Go to app | `https://app.example.com/clearly-hub/sign-in` |
14
+ | Setting | Value |
15
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
16
+ | Go to app | `https://app.example.com/clearly-hub/sign-in` |
17
17
  | Sign-in client (H2M): callback URLs | `https://app.example.com/clearly-hub/callback`, `https://app.example.com/clearly-hub/answer`, `https://hub.clearly.app/components` |
18
- | Sign-in client: sign-out URLs | `https://app.example.com`, `https://hub.clearly.app/components` |
19
- | M2M client | create it, keep the secret on your server |
20
- | Plans | give each your own plan id as its *External reference*, for example `pro` |
18
+ | Sign-in client: sign-out URLs | `https://app.example.com`, `https://hub.clearly.app/components` |
19
+ | M2M client | create it, keep the secret on your server |
20
+ | Plans | give each your own plan id as its _External reference_, for example `pro` |
21
21
 
22
22
  **2. Mount the server handler** (Node 20+):
23
23
 
@@ -55,18 +55,18 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
55
55
  </script>
56
56
  ```
57
57
 
58
- Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-hub/browser'`.
58
+ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-hub/browser'`. With React and TypeScript, add `import '@clearly-hub/browser/react'` once, so JSX knows `<clearly-hub-button>`.
59
59
 
60
60
  ## The browser package
61
61
 
62
- | | |
63
- | --- | --- |
64
- | `clearlyHub.session()` | `{ signedIn: false }`, or the signed-in user (`sub`, `email`, `name`, `appUserId`) |
65
- | `clearlyHub.signIn()`, `signOut()` | Go through Clearly.HUB and come back to this page |
66
- | `clearlyHub.open(action, { data, display })` | Opens a hosted screen; resolves with its checked answer. Call it from a click: browsers only allow popups there |
67
- | `clearlyHub.result()` | After a redirect screen: its answer, on the page it came back to |
68
- | `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen |
69
- | `clearly-hub-answer` event | Every answer, from a popup or after a redirect |
62
+ | | |
63
+ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
64
+ | `clearlyHub.session()` | `{ signedIn: false }`, or the signed-in user (`sub`, `email`, `name`, `appUserId`) |
65
+ | `clearlyHub.signIn()`, `signOut()` | Go through Clearly.HUB and come back to this page |
66
+ | `clearlyHub.open(action, { data, display })` | Opens a hosted screen; resolves with its checked answer. Call it from a click: browsers only allow popups there |
67
+ | `clearlyHub.result()` | After a redirect screen: its answer, on the page it came back to |
68
+ | `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen |
69
+ | `clearly-hub-answer` event | Every answer, from a popup or after a redirect |
70
70
 
71
71
  **Styling the button.** It uses your page's font. Attributes for the common cases:
72
72
  `variant="outline"`, `size="small"` or `size="large"`. CSS custom properties for your look:
@@ -74,32 +74,51 @@ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-
74
74
  `--clearly-hub-font`. For anything else, style the real button through `::part(button)`:
75
75
 
76
76
  ```css
77
- clearly-hub-button { --clearly-hub-color: #0b7471; --clearly-hub-radius: 999px; }
78
- clearly-hub-button::part(button) { text-transform: uppercase; }
77
+ clearly-hub-button {
78
+ --clearly-hub-color: #0b7471;
79
+ --clearly-hub-radius: 999px;
80
+ }
81
+ clearly-hub-button::part(button) {
82
+ text-transform: uppercase;
83
+ }
79
84
  ```
80
85
 
81
86
  Screens open in a popup by default (`data-display="redirect"` on the script tag changes that). A
82
87
  blocked popup falls back to a redirect.
83
88
 
84
- | Action | Send in `data` | `answer` when `result` is `SUCCESS` |
85
- | --- | --- | --- |
86
- | `SELECT_SUBSCRIPTION` | | `subscription` (`_id`, `status`, `externalReference`), `subscribedHub` (`_id`, `name`, `role`) |
87
- | `DATA_PICKER` | | `activeHub`, `resources` (`url`, `format`, `accessibility`, `requiresToken`, `hasAccess`) |
88
- | `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
89
- | `SELECT_HUB` | | `hub` (`_id`, `name`) |
89
+ | Action | Send in `data` | `answer` when `result` is `SUCCESS` |
90
+ | --------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
91
+ | `SELECT_SUBSCRIPTION` | | `subscription` (`_id`, `status`, `externalReference`), `subscribedHub` (`_id`, `name`, `role`) |
92
+ | `DATA_PICKER` | | `activeHub`, `resources` (`url`, `format`, `accessibility`, `requiresToken`, `hasAccess`) |
93
+ | `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
94
+ | `SELECT_HUB` | | `hub` (`_id`, `name`) |
90
95
 
91
96
  ## The Node package
92
97
 
93
- | | |
94
- | --- | --- |
95
- | `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result` |
96
- | `onSignIn(person)` | Called after each sign-in; return your own user id to keep it in the session |
97
- | `onAnswer(result, { user })` | Called with each checked answer before it reaches the browser, for example to set up a workspace |
98
- | `hub.getUser(req)` | Who's signed in, in your own routes |
99
- | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key |
100
- | `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
101
- | `hub.reportUsage({ request, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub |
102
- | `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
98
+ | | |
99
+ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result` |
101
+ | `onSignIn(person, { request, req })` | Called after each sign-in. Return your own user id to keep it in the session, or `{ appUserId, redirectTo }` to also choose the next page |
102
+ | `onAnswer(result, { user, data, request, req })` | Called with each checked answer before it reaches the browser, for example to set up a workspace. `data` is what the screen was sent; what you return reaches the page as `result.app` |
103
+ | `hub.getUser(req)` | Who's signed in, in your own routes |
104
+ | `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key |
105
+ | `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
106
+ | `hub.reportUsage({ request or sub, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub. Pass `sub` for a Clearly.HUB account you stored, without a live sign-in |
107
+ | `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
108
+
109
+ **Your own session in the hooks.** With `hub.express()`, the hooks get Express's `req` as
110
+ `context.req`, so they can use your own session (for example express-session) alongside Clearly.HUB's:
111
+
112
+ ```ts
113
+ const hub = clearlyHub<express.Request>({
114
+ // ...
115
+ onSignIn: (person, { req }) => {
116
+ const user = findOrCreateUser(person);
117
+ req!.session.userId = user.id;
118
+ return { appUserId: user.id };
119
+ },
120
+ });
121
+ ```
103
122
 
104
123
  **What it checks for you:** every screen answer's Ed25519 signature, and that it answers a screen
105
124
  opened in this browser, once (`state`). The session is encrypted in cookies, so there's nothing to
@@ -112,7 +131,6 @@ store; it's split over several cookies because tokens are large.
112
131
 
113
132
  - Setting up your own workspaces or tenants: do it by hand, with plans that need approval, or in
114
133
  `onAnswer` for `SELECT_SUBSCRIPTION`.
115
- - Child hubs in `session().hubs`: Clearly.HUB only returns root hubs today.
116
134
  - Other server languages: follow the Build checklist.
117
135
 
118
136
  ## Working on this repo
@@ -1,4 +1,4 @@
1
- "use strict";(()=>{var C=Object.defineProperty;var x=(r,e,t)=>e in r?C(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t;var l=(r,e,t)=>x(r,typeof e!="symbol"?e+"":e,t);var T=["SELECT_SUBSCRIPTION","DATA_PICKER","REGISTER_RESOURCE","SELECT_HUB"];function E(r){return T.includes(r)}function S(r,e){if(r!=="REGISTER_RESOURCE")return;let{url:t,format:s}=e??{};if(typeof t!="string"||!t||typeof s!="string"||!s)return'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';if(!t.startsWith("https://"))return"REGISTER_RESOURCE needs a public https url: Clearly.HUB refuses other addresses"}var b="/clearly-hub";var u={signIn:"/sign-in",callback:"/callback",signOut:"/sign-out",session:"/session",open:"/open",answer:"/answer",result:"/result"},g="clearly-hub-answers";var p="clearly-hub-answer",y=480,f=class{constructor(e={}){l(this,"basePath",b);l(this,"display","popup");l(this,"redirectResult");this.configure(e)}configure(e){return e.basePath&&(this.basePath=e.basePath.replace(/\/+$/,"")),e.display&&(this.display=e.display),this}async session(){let e=await fetch(this.url(u.session),{credentials:"same-origin"});if(!e.ok)throw new Error(`Clearly.HUB session failed (${e.status})`);return e.json()}signIn({returnTo:e=A()}={}){window.location.assign(this.url(u.signIn,{returnTo:e}))}signOut(){window.location.assign(this.url(u.signOut))}open(e,t={}){let s=S(e,t.data);if(s)return Promise.reject(new Error(`Clearly.HUB: ${s}`));let o=crypto.randomUUID(),n=t.display??this.display,i={action:e,ref:o,returnTo:t.returnTo??A(),data:t.data?JSON.stringify(t.data):void 0};if(n==="popup"){let c=window.open(this.url(u.open,{...i,display:"popup"}),"clearly-hub-screen",`width=${y},height=${screen.availHeight},left=${screen.availWidth-y}`);if(c)return this.waitForPopup(c,o,e)}return window.location.assign(this.url(u.open,{...i,display:"redirect"})),new Promise(()=>{})}result(){return this.redirectResult??(this.redirectResult=fetch(this.url(u.result),{credentials:"same-origin"}).then(e=>e.ok?e.json():null).then(e=>e?.result??null).catch(()=>null)),this.redirectResult}waitForPopup(e,t,s){return new Promise(o=>{let n=new BroadcastChannel(g),i=!1,c=a=>{i||(i=!0,n.close(),window.clearInterval(d),window.dispatchEvent(new CustomEvent(p,{detail:a})),o(a))};n.onmessage=a=>{a.data?.ref===t&&c(a.data.result)};let d=window.setInterval(()=>{e.closed&&(window.clearInterval(d),window.setTimeout(()=>c({action:s,result:"CANCELLED"}),500))},400)})}url(e,t={}){let s=new URLSearchParams;for(let[n,i]of Object.entries(t))i!==void 0&&s.set(n,i);let o=s.toString();return`${this.basePath}${e}${o?`?${o}`:""}`}};function A(){return`${window.location.pathname}${window.location.search}${window.location.hash}`}var R={"sign-in":"Sign in with Clearly.HUB","sign-out":"Sign out",SELECT_SUBSCRIPTION:"Choose a plan in Clearly.HUB",DATA_PICKER:"Pick data from Clearly.HUB",REGISTER_RESOURCE:"Save to Clearly.HUB",SELECT_HUB:"Choose a hub"},v=`
1
+ "use strict";(()=>{var C=Object.defineProperty;var T=(r,t,n)=>t in r?C(r,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):r[t]=n;var l=(r,t,n)=>T(r,typeof t!="symbol"?t+"":t,n);var x=["SELECT_SUBSCRIPTION","DATA_PICKER","REGISTER_RESOURCE","SELECT_HUB"];function b(r){return x.includes(r)}function E(r,t){if(r!=="REGISTER_RESOURCE")return;let{url:n,format:i}=t??{};if(typeof n!="string"||!n||typeof i!="string"||!i)return'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';let o;try{o=new URL(n).hostname}catch{return`REGISTER_RESOURCE: "${n}" is not a URL`}if(!o.includes("."))return"REGISTER_RESOURCE needs a url with a domain Clearly.HUB accepts, not localhost"}var S="/clearly-hub";var c={signIn:"/sign-in",callback:"/callback",signOut:"/sign-out",session:"/session",open:"/open",answer:"/answer",result:"/result"},y="clearly-hub-answers";var d="clearly-hub-answer",R=480,h=class{constructor(t={}){l(this,"basePath",S);l(this,"display","popup");l(this,"redirectResult");this.configure(t)}configure(t){return t.basePath&&(this.basePath=t.basePath.replace(/\/+$/,"")),t.display&&(this.display=t.display),this}async session(){let t=await fetch(this.url(c.session),{credentials:"same-origin"});if(!t.ok)throw new Error(`Clearly.HUB session failed (${t.status})`);return t.json()}signIn({returnTo:t=m()}={}){window.location.assign(this.url(c.signIn,{returnTo:t}))}signOut(){window.location.assign(this.url(c.signOut))}open(t,n={}){let i=E(t,n.data);if(i)return Promise.reject(new Error(`Clearly.HUB: ${i}`));let o=crypto.randomUUID(),e=n.display??this.display,s={action:t,ref:o,returnTo:n.returnTo??m(),data:n.data?JSON.stringify(n.data):void 0};if(e==="popup"){let a=window.open(this.url(c.open,{...s,display:"popup"}),"clearly-hub-screen",`width=${R},height=${screen.availHeight},left=${screen.availWidth-R}`);if(a)return this.waitForPopup(a,o,t,n.eventTarget??window)}return window.location.assign(this.url(c.open,{...s,display:"redirect"})),new Promise(()=>{})}result(){return this.redirectResult??(this.redirectResult=fetch(this.url(c.result),{credentials:"same-origin"}).then(t=>t.ok?t.json():null).then(t=>t?.result??null).catch(()=>null)),this.redirectResult}waitForPopup(t,n,i,o){return new Promise(e=>{let s=new BroadcastChannel(y),a=!1,f=u=>{a||(a=!0,s.close(),window.clearInterval(g),o.dispatchEvent(new CustomEvent(d,{detail:u,bubbles:!0,composed:!0})),e(u))};s.onmessage=u=>{u.data?.ref===n&&f(u.data.result)};let g=window.setInterval(()=>{t.closed&&(window.clearInterval(g),window.setTimeout(()=>f({action:i,result:"CANCELLED"}),500))},400)})}url(t,n={}){let i=new URLSearchParams;for(let[e,s]of Object.entries(n))s!==void 0&&i.set(e,s);let o=i.toString();return`${this.basePath}${t}${o?`?${o}`:""}`}};function m(){return`${window.location.pathname}${window.location.search}${window.location.hash}`}var A={"sign-in":"Sign in with Clearly.HUB","sign-out":"Sign out",SELECT_SUBSCRIPTION:"Choose a plan in Clearly.HUB",DATA_PICKER:"Pick data from Clearly.HUB",REGISTER_RESOURCE:"Save to Clearly.HUB",SELECT_HUB:"Choose a hub"},O=`
2
2
  :host { display: inline-block; }
3
3
  button {
4
4
  --color: var(--clearly-hub-color, #1fa69d);
@@ -20,4 +20,4 @@
20
20
  button:focus-visible { outline: 2px solid var(--color); outline-offset: 2px; }
21
21
  button:disabled { opacity: 0.6; cursor: default; }
22
22
  svg { width: 1.25em; height: 1.25em; flex: none; fill: currentColor; }
23
- `,U='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 6.5c-1.66 0-3 1.34-3 3 0 .07 0 .14.01.21l-2.03.68c-.64-1.21-1.82-2.09-3.22-2.32V5.91C14.04 5.57 15 4.4 15 3c0-1.66-1.34-3-3-3S9 1.34 9 3c0 1.4.96 2.57 2.25 2.91v2.16c-1.4.23-2.58 1.11-3.22 2.32l-2.04-.68C6 9.64 6 9.57 6 9.5c0-1.66-1.34-3-3-3s-3 1.34-3 3 1.34 3 3 3c1.06 0 1.98-.55 2.52-1.37l2.03.68c-.2 1.29.17 2.66 1.09 3.69l-1.41 1.77Q6.66 17 6 17c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3c0-.68-.22-1.3-.6-1.8l1.41-1.77c1.36.76 3.02.75 4.37 0l1.41 1.77c-.37.5-.59 1.12-.59 1.8 0 1.66 1.34 3 3 3s3-1.34 3-3-1.34-3-3-3c-.44 0-.85.09-1.23.26l-1.41-1.77c.93-1.04 1.29-2.4 1.09-3.69l2.03-.68c.53.82 1.46 1.37 2.52 1.37 1.66 0 3-1.34 3-3S22.66 6.5 21 6.5m-18 4c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1M6 21c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1m5-18c0-.55.45-1 1-1s1 .45 1 1-.45 1-1 1-1-.45-1-1m1 12c-1.38 0-2.5-1.12-2.5-2.5S10.62 10 12 10s2.5 1.12 2.5 2.5S13.38 15 12 15m6 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1m3-8.5c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1"/></svg>';function m(r,e="clearly-hub-button"){if(typeof customElements>"u"||customElements.get(e))return;class t extends HTMLElement{constructor(){super();l(this,"button");let n=this.attachShadow({mode:"open"});n.innerHTML=`<style>${v}</style><button part="button" type="button"></button>`,this.button=n.querySelector("button"),this.button.addEventListener("click",()=>this.run())}connectedCallback(){this.render()}attributeChangedCallback(){this.render()}get action(){let n=this.getAttribute("action")??"sign-in";return n in R?n:"sign-in"}render(){let n=this.getAttribute("label")??R[this.action];this.button.innerHTML=`${U}<span></span>`,this.button.querySelector("span").textContent=n}async run(){let n=this.action;if(n==="sign-in")return r.signIn();if(n==="sign-out")return r.signOut();if(!E(n))return;let i=this.getAttribute("data"),c=this.getAttribute("display")==="redirect"?"redirect":void 0;this.button.disabled=!0;let d;try{d=await r.open(n,{display:c,data:i?JSON.parse(i):void 0})}catch(a){console.error(a),d={action:n,result:"FAILURE",error:a.message}}finally{this.button.disabled=!1}this.dispatchEvent(new CustomEvent(p,{detail:d,bubbles:!0,composed:!0}))}}l(t,"observedAttributes",["action","label"]),customElements.define(e,t)}var h=new f;m(h);typeof window<"u"&&queueMicrotask(()=>{h.result().then(r=>{r&&window.dispatchEvent(new CustomEvent(p,{detail:r}))})});var w=document.currentScript;h.configure({basePath:w?.dataset.basePath,display:w?.dataset.display==="redirect"?"redirect":void 0});window.clearlyHub=h;})();
23
+ `,U='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 6.5c-1.66 0-3 1.34-3 3 0 .07 0 .14.01.21l-2.03.68c-.64-1.21-1.82-2.09-3.22-2.32V5.91C14.04 5.57 15 4.4 15 3c0-1.66-1.34-3-3-3S9 1.34 9 3c0 1.4.96 2.57 2.25 2.91v2.16c-1.4.23-2.58 1.11-3.22 2.32l-2.04-.68C6 9.64 6 9.57 6 9.5c0-1.66-1.34-3-3-3s-3 1.34-3 3 1.34 3 3 3c1.06 0 1.98-.55 2.52-1.37l2.03.68c-.2 1.29.17 2.66 1.09 3.69l-1.41 1.77Q6.66 17 6 17c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3c0-.68-.22-1.3-.6-1.8l1.41-1.77c1.36.76 3.02.75 4.37 0l1.41 1.77c-.37.5-.59 1.12-.59 1.8 0 1.66 1.34 3 3 3s3-1.34 3-3-1.34-3-3-3c-.44 0-.85.09-1.23.26l-1.41-1.77c.93-1.04 1.29-2.4 1.09-3.69l2.03-.68c.53.82 1.46 1.37 2.52 1.37 1.66 0 3-1.34 3-3S22.66 6.5 21 6.5m-18 4c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1M6 21c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1m5-18c0-.55.45-1 1-1s1 .45 1 1-.45 1-1 1-1-.45-1-1m1 12c-1.38 0-2.5-1.12-2.5-2.5S10.62 10 12 10s2.5 1.12 2.5 2.5S13.38 15 12 15m6 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1m3-8.5c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1"/></svg>';function w(r,t="clearly-hub-button"){if(typeof customElements>"u"||customElements.get(t))return;class n extends HTMLElement{constructor(){super();l(this,"button");let e=this.attachShadow({mode:"open"});e.innerHTML=`<style>${O}</style><button part="button" type="button"></button>`,this.button=e.querySelector("button"),this.button.addEventListener("click",()=>this.run())}connectedCallback(){for(let e of["action","display","label","variant","size","data"])if(Object.prototype.hasOwnProperty.call(this,e)){let s=this[e];delete this[e],this[e]=s}this.render()}attributeChangedCallback(){this.render()}get action(){let e=this.getAttribute("action")??"sign-in";return e in A?e:"sign-in"}set action(e){this.setOrRemove("action",e)}get display(){return this.getAttribute("display")==="redirect"?"redirect":void 0}set display(e){this.setOrRemove("display",e)}get label(){return this.getAttribute("label")}set label(e){this.setOrRemove("label",e)}get variant(){return this.getAttribute("variant")}set variant(e){this.setOrRemove("variant",e)}get size(){return this.getAttribute("size")}set size(e){this.setOrRemove("size",e)}get data(){let e=this.getAttribute("data");return e?JSON.parse(e):void 0}set data(e){this.setOrRemove("data",typeof e=="string"||e==null?e:JSON.stringify(e))}setOrRemove(e,s){s==null?this.removeAttribute(e):this.setAttribute(e,s)}render(){let e=this.label??A[this.action];this.button.innerHTML=`${U}<span></span>`,this.button.querySelector("span").textContent=e}async run(){let e=this.action;if(e==="sign-in")return r.signIn();if(e==="sign-out")return r.signOut();if(b(e)){this.button.disabled=!0;try{await r.open(e,{display:this.display,data:this.data,eventTarget:this})}catch(s){console.error(s);let a={action:e,result:"FAILURE",error:s.message};this.dispatchEvent(new CustomEvent(d,{detail:a,bubbles:!0,composed:!0}))}finally{this.button.disabled=!1}}}}l(n,"observedAttributes",["action","label"]),customElements.define(t,n)}var p=new h;w(p);typeof window<"u"&&queueMicrotask(()=>{p.result().then(r=>{r&&window.dispatchEvent(new CustomEvent(d,{detail:r}))})});var v=document.currentScript;p.configure({basePath:v?.dataset.basePath,display:v?.dataset.display==="redirect"?"redirect":void 0});window.clearlyHub=p;})();
package/dist/index.d.ts CHANGED
@@ -1,71 +1,5 @@
1
- declare const SCREEN_ACTIONS: readonly ["SELECT_SUBSCRIPTION", "DATA_PICKER", "REGISTER_RESOURCE", "SELECT_HUB"];
2
- type ScreenAction = (typeof SCREEN_ACTIONS)[number];
3
- declare function isScreenAction(value: unknown): value is ScreenAction;
4
- type ScreenDisplay = 'redirect' | 'popup';
5
- interface ScreenData {
6
- REGISTER_RESOURCE: {
7
- url: string;
8
- format: string;
9
- };
10
- }
11
- declare function checkScreenData(action: ScreenAction, data: unknown): string | undefined;
12
- interface HubRef {
13
- _id: string;
14
- name: string;
15
- }
16
- interface PickedResource {
17
- datasetTitle: string;
18
- resourceName: string;
19
- format: string;
20
- accessibility: 'OPEN' | 'PROTECTED' | 'PAID';
21
- url: string;
22
- requiresToken: boolean;
23
- hasAccess: boolean;
24
- }
25
- interface ScreenAnswers {
26
- SELECT_SUBSCRIPTION: {
27
- subscription: {
28
- _id: string;
29
- status: 'ACTIVE' | 'PENDING';
30
- externalReference: string;
31
- };
32
- subscribedHub: HubRef & {
33
- role?: 'ADMIN' | 'MEMBER';
34
- };
35
- user?: {
36
- _id: string;
37
- email: string;
38
- };
39
- hasMultipleSubscriptions?: boolean;
40
- };
41
- DATA_PICKER: {
42
- activeHub: HubRef;
43
- resources: PickedResource[];
44
- };
45
- REGISTER_RESOURCE: {
46
- activeHub: string;
47
- dataset: {
48
- datasetId: string;
49
- datasetTitle?: string;
50
- };
51
- resource: {
52
- resourceId: string;
53
- };
54
- };
55
- SELECT_HUB: {
56
- hub: HubRef;
57
- };
58
- }
59
- type ScreenResult<A extends ScreenAction = ScreenAction> = {
60
- action: A;
61
- result: 'SUCCESS';
62
- answer: ScreenAnswers[A];
63
- } | {
64
- action: A;
65
- result: 'CANCELLED' | 'FAILURE';
66
- answer?: undefined;
67
- error?: string;
68
- };
1
+ import { S as ScreenResult, a as ScreenDisplay, b as ScreenAction, c as ScreenData } from './screens-CZiYNLZI.js';
2
+ export { H as HubRef, P as PickedResource, d as SCREEN_ACTIONS, e as ScreenAnswers, f as checkScreenData, i as isScreenAction } from './screens-CZiYNLZI.js';
69
3
 
70
4
  interface SessionUser {
71
5
  sub: string;
@@ -113,6 +47,7 @@ interface OpenOptions<A extends ScreenAction> {
113
47
  display?: ScreenDisplay;
114
48
  data?: A extends keyof ScreenData ? ScreenData[A] : never;
115
49
  returnTo?: string;
50
+ eventTarget?: EventTarget;
116
51
  }
117
52
  declare const ANSWER_EVENT = "clearly-hub-answer";
118
53
  declare class ClearlyHubClient {
@@ -136,5 +71,5 @@ declare function defineButton(client: ClearlyHubClient, tagName?: string): void;
136
71
 
137
72
  declare const clearlyHub: ClearlyHubClient;
138
73
 
139
- export { ANSWER_CHANNEL, ANSWER_EVENT, ClearlyHubClient, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, defineButton, isScreenAction };
140
- export type { AnswerMessage, ClientOptions, HubRef, HubSubscription, OpenOptions, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser };
74
+ export { ANSWER_CHANNEL, ANSWER_EVENT, ClearlyHubClient, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, ScreenAction, ScreenData, ScreenDisplay, ScreenResult, clearlyHub, defineButton };
75
+ export type { AnswerMessage, ClientOptions, HubSubscription, OpenOptions, Session, SessionUser };
package/dist/index.js CHANGED
@@ -18,8 +18,14 @@ function checkScreenData(action, data) {
18
18
  if (typeof url !== "string" || !url || typeof format !== "string" || !format) {
19
19
  return 'REGISTER_RESOURCE needs data with a url and a format, for example { url, format: "geojson" }';
20
20
  }
21
- if (!url.startsWith("https://")) {
22
- return "REGISTER_RESOURCE needs a public https url: Clearly.HUB refuses other addresses";
21
+ let hostname;
22
+ try {
23
+ hostname = new URL(url).hostname;
24
+ } catch {
25
+ return `REGISTER_RESOURCE: "${url}" is not a URL`;
26
+ }
27
+ if (!hostname.includes(".")) {
28
+ return "REGISTER_RESOURCE needs a url with a domain Clearly.HUB accepts, not localhost";
23
29
  }
24
30
  return void 0;
25
31
  }
@@ -84,7 +90,7 @@ var ClearlyHubClient = class {
84
90
  "clearly-hub-screen",
85
91
  `width=${POPUP_WIDTH},height=${screen.availHeight},left=${screen.availWidth - POPUP_WIDTH}`
86
92
  );
87
- if (popup) return this.waitForPopup(popup, ref, action);
93
+ if (popup) return this.waitForPopup(popup, ref, action, options.eventTarget ?? window);
88
94
  }
89
95
  window.location.assign(this.url(ROUTES.open, { ...query, display: "redirect" }));
90
96
  return new Promise(() => {
@@ -96,7 +102,7 @@ var ClearlyHubClient = class {
96
102
  this.redirectResult ?? (this.redirectResult = fetch(this.url(ROUTES.result), { credentials: "same-origin" }).then((response) => response.ok ? response.json() : null).then((message) => message?.result ?? null).catch(() => null));
97
103
  return this.redirectResult;
98
104
  }
99
- waitForPopup(popup, ref, action) {
105
+ waitForPopup(popup, ref, action, eventTarget) {
100
106
  return new Promise((resolve) => {
101
107
  const channel = new BroadcastChannel(ANSWER_CHANNEL);
102
108
  let isDone = false;
@@ -105,7 +111,9 @@ var ClearlyHubClient = class {
105
111
  isDone = true;
106
112
  channel.close();
107
113
  window.clearInterval(closedCheck);
108
- window.dispatchEvent(new CustomEvent(ANSWER_EVENT, { detail: result }));
114
+ eventTarget.dispatchEvent(
115
+ new CustomEvent(ANSWER_EVENT, { detail: result, bubbles: true, composed: true })
116
+ );
109
117
  resolve(result);
110
118
  };
111
119
  channel.onmessage = (event) => {
@@ -176,17 +184,69 @@ function defineButton(client, tagName = "clearly-hub-button") {
176
184
  this.button.addEventListener("click", () => this.run());
177
185
  }
178
186
  connectedCallback() {
187
+ for (const name of ["action", "display", "label", "variant", "size", "data"]) {
188
+ if (Object.prototype.hasOwnProperty.call(this, name)) {
189
+ const value = this[name];
190
+ delete this[name];
191
+ this[name] = value;
192
+ }
193
+ }
179
194
  this.render();
180
195
  }
181
196
  attributeChangedCallback() {
182
197
  this.render();
183
198
  }
199
+ // Every attribute is also a property that writes the attribute, as web
200
+ // components do. React 19, for one, sets props as properties when the
201
+ // element has them.
184
202
  get action() {
185
203
  const action = this.getAttribute("action") ?? "sign-in";
186
204
  return action in LABELS ? action : "sign-in";
187
205
  }
206
+ set action(value) {
207
+ this.setOrRemove("action", value);
208
+ }
209
+ get display() {
210
+ return this.getAttribute("display") === "redirect" ? "redirect" : void 0;
211
+ }
212
+ set display(value) {
213
+ this.setOrRemove("display", value);
214
+ }
215
+ get label() {
216
+ return this.getAttribute("label");
217
+ }
218
+ set label(value) {
219
+ this.setOrRemove("label", value);
220
+ }
221
+ get variant() {
222
+ return this.getAttribute("variant");
223
+ }
224
+ set variant(value) {
225
+ this.setOrRemove("variant", value);
226
+ }
227
+ get size() {
228
+ return this.getAttribute("size");
229
+ }
230
+ set size(value) {
231
+ this.setOrRemove("size", value);
232
+ }
233
+ // As an attribute: JSON text. As a property: also an object
234
+ get data() {
235
+ const data = this.getAttribute("data");
236
+ return data ? JSON.parse(data) : void 0;
237
+ }
238
+ set data(value) {
239
+ this.setOrRemove(
240
+ "data",
241
+ typeof value === "string" || value == null ? value : JSON.stringify(value)
242
+ );
243
+ }
244
+ setOrRemove(name, value) {
245
+ if (value == null) this.removeAttribute(name);
246
+ else this.setAttribute(name, value);
247
+ }
188
248
  render() {
189
- const label = this.getAttribute("label") ?? LABELS[this.action];
249
+ const label = this.label ?? LABELS[this.action];
190
250
  this.button.innerHTML = `${ICON}<span></span>`;
191
251
  this.button.querySelector("span").textContent = label;
192
252
  }
@@ -195,24 +255,22 @@ function defineButton(client, tagName = "clearly-hub-button") {
195
255
  if (action === "sign-in") return client.signIn();
196
256
  if (action === "sign-out") return client.signOut();
197
257
  if (!isScreenAction(action)) return;
198
- const data = this.getAttribute("data");
199
- const display = this.getAttribute("display") === "redirect" ? "redirect" : void 0;
200
258
  this.button.disabled = true;
201
- let result;
202
259
  try {
203
- result = await client.open(action, {
204
- display,
205
- data: data ? JSON.parse(data) : void 0
260
+ await client.open(action, {
261
+ display: this.display,
262
+ data: this.data,
263
+ eventTarget: this
206
264
  });
207
265
  } catch (error) {
208
266
  console.error(error);
209
- result = { action, result: "FAILURE", error: error.message };
267
+ const result = { action, result: "FAILURE", error: error.message };
268
+ this.dispatchEvent(
269
+ new CustomEvent(ANSWER_EVENT, { detail: result, bubbles: true, composed: true })
270
+ );
210
271
  } finally {
211
272
  this.button.disabled = false;
212
273
  }
213
- this.dispatchEvent(
214
- new CustomEvent(ANSWER_EVENT, { detail: result, bubbles: true, composed: true })
215
- );
216
274
  }
217
275
  }
218
276
  __publicField(ClearlyHubButton, "observedAttributes", ["action", "label"]);
@@ -0,0 +1,24 @@
1
+ import { DetailedHTMLProps, HTMLAttributes } from 'react';
2
+ import { ScreenAction, ScreenDisplay } from './index.js';
3
+
4
+ interface ClearlyHubButtonAttributes {
5
+ action?: ScreenAction | 'sign-in' | 'sign-out';
6
+ display?: ScreenDisplay;
7
+ data?: string | {
8
+ url: string;
9
+ format: string;
10
+ };
11
+ label?: string;
12
+ variant?: 'solid' | 'outline';
13
+ size?: 'small' | 'large';
14
+ }
15
+ type ClearlyHubButtonProps = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & ClearlyHubButtonAttributes;
16
+ declare module 'react' {
17
+ namespace JSX {
18
+ interface IntrinsicElements {
19
+ 'clearly-hub-button': ClearlyHubButtonProps;
20
+ }
21
+ }
22
+ }
23
+
24
+ export type { ClearlyHubButtonAttributes };
package/dist/react.js ADDED
File without changes
@@ -0,0 +1,73 @@
1
+ declare const SCREEN_ACTIONS: readonly ["SELECT_SUBSCRIPTION", "DATA_PICKER", "REGISTER_RESOURCE", "SELECT_HUB"];
2
+ type ScreenAction = (typeof SCREEN_ACTIONS)[number];
3
+ declare function isScreenAction(value: unknown): value is ScreenAction;
4
+ type ScreenDisplay = 'redirect' | 'popup';
5
+ interface ScreenData {
6
+ REGISTER_RESOURCE: {
7
+ url: string;
8
+ format: string;
9
+ };
10
+ }
11
+ declare function checkScreenData(action: ScreenAction, data: unknown): string | undefined;
12
+ interface HubRef {
13
+ _id: string;
14
+ name: string;
15
+ }
16
+ interface PickedResource {
17
+ datasetTitle: string;
18
+ resourceName: string;
19
+ format: string;
20
+ accessibility: 'OPEN' | 'PROTECTED' | 'PAID';
21
+ url: string;
22
+ requiresToken: boolean;
23
+ hasAccess: boolean;
24
+ }
25
+ interface ScreenAnswers {
26
+ SELECT_SUBSCRIPTION: {
27
+ subscription: {
28
+ _id: string;
29
+ status: 'ACTIVE' | 'PENDING';
30
+ externalReference: string;
31
+ };
32
+ subscribedHub: HubRef & {
33
+ role?: 'ADMIN' | 'MEMBER';
34
+ };
35
+ user?: {
36
+ _id: string;
37
+ email: string;
38
+ };
39
+ hasMultipleSubscriptions?: boolean;
40
+ };
41
+ DATA_PICKER: {
42
+ activeHub: HubRef;
43
+ resources: PickedResource[];
44
+ };
45
+ REGISTER_RESOURCE: {
46
+ activeHub: string;
47
+ dataset: {
48
+ datasetId: string;
49
+ datasetTitle?: string;
50
+ };
51
+ resource: {
52
+ resourceId: string;
53
+ };
54
+ };
55
+ SELECT_HUB: {
56
+ hub: HubRef;
57
+ };
58
+ }
59
+ type ScreenResult<A extends ScreenAction = ScreenAction> = {
60
+ action: A;
61
+ result: 'SUCCESS';
62
+ answer: ScreenAnswers[A];
63
+ app?: unknown;
64
+ } | {
65
+ action: A;
66
+ result: 'CANCELLED' | 'FAILURE';
67
+ answer?: undefined;
68
+ error?: string;
69
+ app?: unknown;
70
+ };
71
+
72
+ export { SCREEN_ACTIONS as d, checkScreenData as f, isScreenAction as i };
73
+ export type { HubRef as H, PickedResource as P, ScreenResult as S, ScreenDisplay as a, ScreenAction as b, ScreenData as c, ScreenAnswers as e };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearly-hub/browser",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Clearly.HUB in the browser: sign-in, hosted screens and the session, talking to @clearly-hub/node",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Future Insight Group BV",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/index.d.ts",
20
20
  "import": "./dist/index.js"
21
21
  },
22
+ "./react": {
23
+ "types": "./dist/react.d.ts",
24
+ "import": "./dist/react.js"
25
+ },
22
26
  "./clearly-hub.js": "./dist/clearly-hub.js"
23
27
  },
24
28
  "unpkg": "./dist/clearly-hub.js",
@@ -36,7 +40,16 @@
36
40
  "typecheck": "tsc -p .",
37
41
  "prepack": "node ../../scripts/copy-license.mjs && npm run build"
38
42
  },
43
+ "peerDependencies": {
44
+ "@types/react": ">=18"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@types/react": {
48
+ "optional": true
49
+ }
50
+ },
39
51
  "devDependencies": {
40
- "@clearly-hub/shared": "*"
52
+ "@clearly-hub/shared": "*",
53
+ "@types/react": "^19.3.0"
41
54
  }
42
55
  }