@onaio/akuko-embed-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +204 -0
- package/dist/AkukoDashboard.d.ts +116 -0
- package/dist/PostMessageBridge.d.ts +18 -0
- package/dist/akuko-embed-sdk.js +210 -0
- package/dist/akuko-embed-sdk.umd.cjs +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/types.d.ts +205 -0
- package/docs/host-recipes.md +134 -0
- package/docs/sso-path-d-brokering.md +156 -0
- package/examples/keycloak-path-d-setup.sh +173 -0
- package/package.json +43 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
class d {
|
|
2
|
+
constructor(t, e) {
|
|
3
|
+
this.iframe = t, this.expectedOrigin = e, this.handlers = /* @__PURE__ */ new Map(), this.onMessage = (i) => {
|
|
4
|
+
if (i.origin !== this.expectedOrigin || i.source && i.source !== this.iframe.contentWindow) return;
|
|
5
|
+
const s = i.data;
|
|
6
|
+
if (!s || s.version !== 1 || !s.type?.startsWith("akuko:")) return;
|
|
7
|
+
const a = this.handlers.get(s.type);
|
|
8
|
+
a && a.forEach((o) => o(s.payload));
|
|
9
|
+
}, window.addEventListener("message", this.onMessage);
|
|
10
|
+
}
|
|
11
|
+
on(t, e) {
|
|
12
|
+
this.handlers.has(t) || this.handlers.set(t, /* @__PURE__ */ new Set()), this.handlers.get(t).add(e);
|
|
13
|
+
}
|
|
14
|
+
off(t, e) {
|
|
15
|
+
this.handlers.get(t)?.delete(e);
|
|
16
|
+
}
|
|
17
|
+
send(t, e) {
|
|
18
|
+
if (!t.startsWith("akuko:")) throw new Error("Type must start with 'akuko:'");
|
|
19
|
+
const i = {
|
|
20
|
+
type: t,
|
|
21
|
+
payload: e,
|
|
22
|
+
messageId: crypto.randomUUID(),
|
|
23
|
+
version: 1
|
|
24
|
+
};
|
|
25
|
+
this.iframe.contentWindow?.postMessage(i, this.expectedOrigin);
|
|
26
|
+
}
|
|
27
|
+
destroy() {
|
|
28
|
+
window.removeEventListener("message", this.onMessage), this.handlers.clear();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
class n {
|
|
32
|
+
constructor(t) {
|
|
33
|
+
if (this.opts = t, this.localListeners = /* @__PURE__ */ new Map(), this.loadWatchdog = null, this.container = typeof t.container == "string" ? document.querySelector(t.container) : t.container, !this.container)
|
|
34
|
+
throw new Error(`Container not found: ${String(t.container)}`);
|
|
35
|
+
if ([
|
|
36
|
+
t.onadataOrgId,
|
|
37
|
+
t.onadataProjectId,
|
|
38
|
+
t.onadataFormId,
|
|
39
|
+
t.spaceUuid,
|
|
40
|
+
t.dashboardId,
|
|
41
|
+
t.linkProjectsOrgId
|
|
42
|
+
].filter(Boolean).length !== 1)
|
|
43
|
+
throw new Error(
|
|
44
|
+
"AkukoDashboard: must pass exactly one of onadataOrgId, onadataProjectId, onadataFormId, spaceUuid, dashboardId, linkProjectsOrgId"
|
|
45
|
+
);
|
|
46
|
+
this.baseUrl = t.baseUrl ?? "https://akuko.io", this.lastHostUser = this.opts.hostUser, this.iframe = this.createIframe(), this.container.appendChild(this.iframe);
|
|
47
|
+
const i = this.opts.allowedOrigin ?? new URL(this.baseUrl).origin;
|
|
48
|
+
this.bridge = new d(this.iframe, i), this.bridge.on("akuko:resize", (s) => {
|
|
49
|
+
if (this.opts.layout === "fill") return;
|
|
50
|
+
const a = s;
|
|
51
|
+
typeof a.height == "number" && (this.iframe.style.height = `${a.height}px`);
|
|
52
|
+
}), this.bridge.on("akuko:ready", () => this.replayState()), this.startLoadWatchdog();
|
|
53
|
+
}
|
|
54
|
+
startLoadWatchdog() {
|
|
55
|
+
const t = this.opts.iframeLoadTimeoutMs ?? 3e4;
|
|
56
|
+
t <= 0 || (this.loadWatchdog = setTimeout(() => {
|
|
57
|
+
this.loadWatchdog = null, this.emitLocal("akuko:error", { code: "IFRAME_LOAD_FAILED" });
|
|
58
|
+
}, t), this.iframe.addEventListener(
|
|
59
|
+
"load",
|
|
60
|
+
() => {
|
|
61
|
+
this.loadWatchdog && (clearTimeout(this.loadWatchdog), this.loadWatchdog = null);
|
|
62
|
+
},
|
|
63
|
+
{ once: !0 }
|
|
64
|
+
));
|
|
65
|
+
}
|
|
66
|
+
createIframe() {
|
|
67
|
+
const t = document.createElement("iframe");
|
|
68
|
+
return t.style.width = "100%", t.style.border = "0", this.opts.layout === "fill" ? (t.style.flex = "1 1 0%", t.style.minHeight = "0") : (t.style.height = "600px", this.opts.minHeight != null && (t.style.minHeight = typeof this.opts.minHeight == "number" ? `${this.opts.minHeight}px` : this.opts.minHeight)), t.setAttribute(
|
|
69
|
+
"sandbox",
|
|
70
|
+
this.opts.sandbox ?? // allow-downloads: the export feature triggers PNG (blob) and PDF
|
|
71
|
+
// (render-URL) downloads from inside the iframe; a sandbox without it
|
|
72
|
+
// silently blocks them.
|
|
73
|
+
"allow-scripts allow-same-origin allow-popups allow-forms allow-downloads"
|
|
74
|
+
), t.setAttribute("allow", "fullscreen; clipboard-write"), t.src = this.computeInitialUrl(), t;
|
|
75
|
+
}
|
|
76
|
+
computeInitialUrl() {
|
|
77
|
+
const t = this.opts;
|
|
78
|
+
return t.dashboardId ? `${this.baseUrl}/embed/post/${t.dashboardId}${this.queryString()}` : t.spaceUuid ? `${this.baseUrl}/embed/space/${t.spaceUuid}${this.queryString()}` : t.onadataOrgId ? `${this.baseUrl}/embed/resolve/onadata-org/${t.onadataOrgId}${this.queryString()}` : t.onadataProjectId ? `${this.baseUrl}/embed/resolve/onadata-project/${t.onadataProjectId}${this.queryString()}` : t.onadataFormId ? `${this.baseUrl}/embed/resolve/onadata-form/${t.onadataFormId}${this.queryString()}` : t.linkProjectsOrgId ? `${this.baseUrl}/embed/link-projects/onadata-org/${t.linkProjectsOrgId}${this.queryString()}` : `${this.baseUrl}/embed/loading${this.queryString()}`;
|
|
79
|
+
}
|
|
80
|
+
queryString() {
|
|
81
|
+
const t = new URLSearchParams();
|
|
82
|
+
return this.opts.idpHint && t.set("idp_hint", this.opts.idpHint), this.opts.layout && t.set("layout", this.opts.layout), this.opts.hostIsAdmin && t.set("host_admin", "1"), this.opts.initialPostId && t.set("post", this.opts.initialPostId), this.opts.resolveOnadataOrgId && t.set("onadata_org_id", this.opts.resolveOnadataOrgId), this.opts.resolveOnadataProjectId && t.set("onadata_project_id", this.opts.resolveOnadataProjectId), this.opts.theme?.primary && t.set("theme_primary", this.opts.theme.primary), t.toString() ? `?${t.toString()}` : "";
|
|
83
|
+
}
|
|
84
|
+
on(t, e) {
|
|
85
|
+
this.bridge.on(t, e);
|
|
86
|
+
let i = this.localListeners.get(t);
|
|
87
|
+
i || (i = /* @__PURE__ */ new Set(), this.localListeners.set(t, i)), i.add(e);
|
|
88
|
+
}
|
|
89
|
+
off(t, e) {
|
|
90
|
+
this.bridge.off(t, e), this.localListeners.get(t)?.delete(e);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Dispatch an event originated host-side (e.g. the iframe load watchdog) to
|
|
94
|
+
* any subscribers registered via on(). The bridge is unaffected — its
|
|
95
|
+
* handlers only fire on iframe → host messages. Note: akuko:resolved now
|
|
96
|
+
* arrives from the iframe over the bridge (the iframe owns resolve), so
|
|
97
|
+
* on('akuko:resolved') keeps working without a host-side emit.
|
|
98
|
+
*/
|
|
99
|
+
emitLocal(t, e) {
|
|
100
|
+
this.localListeners.get(t)?.forEach((i) => i(e));
|
|
101
|
+
}
|
|
102
|
+
replayState() {
|
|
103
|
+
this.lastTheme && this.bridge.send("akuko:setTheme", this.lastTheme), this.lastVisibility && this.bridge.send("akuko:setVisibility", this.lastVisibility), this.lastFilters && this.bridge.send("akuko:setFilters", this.lastFilters), this.lastHostUser && this.bridge.send("akuko:setHostIdentity", this.lastHostUser), this.lastViewport && this.bridge.send("akuko:viewport", this.lastViewport);
|
|
104
|
+
}
|
|
105
|
+
/** Apply a theme to the embedded dashboard. */
|
|
106
|
+
setTheme(t) {
|
|
107
|
+
this.lastTheme = t, this.bridge.send("akuko:setTheme", t);
|
|
108
|
+
}
|
|
109
|
+
/** Show or hide dashboard UI chrome (header, footer, filter bar, etc.). */
|
|
110
|
+
setVisibility(t) {
|
|
111
|
+
this.lastVisibility = t, this.bridge.send("akuko:setVisibility", t);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Report the iframe's currently-visible vertical band (host→iframe), so
|
|
115
|
+
* in-iframe floating UI can center itself in view even when the host page owns
|
|
116
|
+
* the scrollbar (`layout: "content"`). The React wrapper computes and pushes
|
|
117
|
+
* this automatically on scroll/resize; hosts using the vanilla SDK can call it
|
|
118
|
+
* directly.
|
|
119
|
+
*/
|
|
120
|
+
setViewport(t) {
|
|
121
|
+
this.lastViewport = t, this.bridge.send("akuko:viewport", t);
|
|
122
|
+
}
|
|
123
|
+
/** Push filter values into the embedded dashboard. */
|
|
124
|
+
setFilters(t) {
|
|
125
|
+
this.lastFilters = t, this.bridge.send("akuko:setFilters", t);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Clear specific filter keys, or all filters when called with no arguments.
|
|
129
|
+
* @param keys - Optional list of filter keys to clear. Omit to clear all.
|
|
130
|
+
*/
|
|
131
|
+
clearFilters(t) {
|
|
132
|
+
this.bridge.send("akuko:clearFilters", { keys: t });
|
|
133
|
+
}
|
|
134
|
+
/** Ask the embedded dashboard to reload its data. */
|
|
135
|
+
refresh() {
|
|
136
|
+
this.bridge.send("akuko:refresh", {});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Navigate the iframe to the org's onboarding / project-picker URL so
|
|
140
|
+
* an admin can link more projects to an already-linked org. Throws when
|
|
141
|
+
* the SDK was constructed without `onadataOrgId` — the call has no
|
|
142
|
+
* unambiguous org context to act on.
|
|
143
|
+
*/
|
|
144
|
+
linkProjects() {
|
|
145
|
+
if (!this.opts.onadataOrgId)
|
|
146
|
+
throw new Error(
|
|
147
|
+
"linkProjects() requires the SDK to be constructed with onadataOrgId"
|
|
148
|
+
);
|
|
149
|
+
this.bridge.send("akuko:linkProjects", {
|
|
150
|
+
onadataOrgId: this.opts.onadataOrgId
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Request the current state of the embedded dashboard.
|
|
155
|
+
* The response arrives via the `akuko:stateResponse` event on the bridge.
|
|
156
|
+
*/
|
|
157
|
+
getState() {
|
|
158
|
+
this.bridge.send("akuko:getState", {});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Trigger a background re-sync of Onadata members into the linked Akuko
|
|
162
|
+
* space or folder.
|
|
163
|
+
*
|
|
164
|
+
* The backend derives `account_id`/`space_id`/`onadata_org_slug` (and
|
|
165
|
+
* `folder_id` for project scope) from the authenticated user's session
|
|
166
|
+
* when those fields aren't passed explicitly. Hosts that need to override
|
|
167
|
+
* the derivation may supply them on `opts`. Progress events arrive via
|
|
168
|
+
* `akuko:provisioningStatus` on the bridge.
|
|
169
|
+
*/
|
|
170
|
+
async resyncMembers(t) {
|
|
171
|
+
const e = t.scope === "org" ? {
|
|
172
|
+
scope: "org",
|
|
173
|
+
onadata_org_id: this.opts.onadataOrgId,
|
|
174
|
+
...t.account_id ? { account_id: t.account_id } : {},
|
|
175
|
+
...t.space_id ? { space_id: t.space_id } : {},
|
|
176
|
+
...t.onadata_org_slug ? { onadata_org_slug: t.onadata_org_slug } : {}
|
|
177
|
+
} : {
|
|
178
|
+
scope: "project",
|
|
179
|
+
onadata_project_id: t.onadata_project_id,
|
|
180
|
+
...t.account_id ? { account_id: t.account_id } : {},
|
|
181
|
+
...t.space_id ? { space_id: t.space_id } : {},
|
|
182
|
+
...t.folder_id ? { folder_id: t.folder_id } : {}
|
|
183
|
+
};
|
|
184
|
+
return this.bridge.send("akuko:resyncMembers", e), Promise.resolve({ workflow_id: "" });
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Tear down the embedded akuko session: asks the in-iframe akuko app to run
|
|
188
|
+
* an RP-initiated end-session (clears akuko.io localStorage tokens AND ends
|
|
189
|
+
* the akuko-keycloak SSO session), then resolves on the iframe's
|
|
190
|
+
* `akuko:logoutComplete` acknowledgement — or after `logoutTimeoutMs`
|
|
191
|
+
* (default 2000ms), whichever comes first. Never rejects; never hangs, so a
|
|
192
|
+
* host can `await dashboard.logout()` in its own logout path safely.
|
|
193
|
+
*/
|
|
194
|
+
logout() {
|
|
195
|
+
return new Promise((t) => {
|
|
196
|
+
let e = !1;
|
|
197
|
+
const i = () => {
|
|
198
|
+
e || (e = !0, clearTimeout(a), this.bridge.off("akuko:logoutComplete", s), t());
|
|
199
|
+
}, s = () => i(), a = setTimeout(i, this.opts.logoutTimeoutMs ?? 2e3);
|
|
200
|
+
this.bridge.on("akuko:logoutComplete", s), this.bridge.send("akuko:logout", {});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
destroy() {
|
|
204
|
+
this.loadWatchdog && (clearTimeout(this.loadWatchdog), this.loadWatchdog = null), this.bridge.destroy(), this.iframe.remove();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export {
|
|
208
|
+
n as AkukoDashboard,
|
|
209
|
+
d as PostMessageBridge
|
|
210
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(a,r){typeof exports=="object"&&typeof module<"u"?r(exports):typeof define=="function"&&define.amd?define(["exports"],r):(a=typeof globalThis<"u"?globalThis:a||self,r(a.AkukoSDK={}))})(this,(function(a){"use strict";class r{constructor(t,e){this.iframe=t,this.expectedOrigin=e,this.handlers=new Map,this.onMessage=i=>{if(i.origin!==this.expectedOrigin||i.source&&i.source!==this.iframe.contentWindow)return;const s=i.data;if(!s||s.version!==1||!s.type?.startsWith("akuko:"))return;const o=this.handlers.get(s.type);o&&o.forEach(h=>h(s.payload))},window.addEventListener("message",this.onMessage)}on(t,e){this.handlers.has(t)||this.handlers.set(t,new Set),this.handlers.get(t).add(e)}off(t,e){this.handlers.get(t)?.delete(e)}send(t,e){if(!t.startsWith("akuko:"))throw new Error("Type must start with 'akuko:'");const i={type:t,payload:e,messageId:crypto.randomUUID(),version:1};this.iframe.contentWindow?.postMessage(i,this.expectedOrigin)}destroy(){window.removeEventListener("message",this.onMessage),this.handlers.clear()}}class d{constructor(t){if(this.opts=t,this.localListeners=new Map,this.loadWatchdog=null,this.container=typeof t.container=="string"?document.querySelector(t.container):t.container,!this.container)throw new Error(`Container not found: ${String(t.container)}`);if([t.onadataOrgId,t.onadataProjectId,t.onadataFormId,t.spaceUuid,t.dashboardId,t.linkProjectsOrgId].filter(Boolean).length!==1)throw new Error("AkukoDashboard: must pass exactly one of onadataOrgId, onadataProjectId, onadataFormId, spaceUuid, dashboardId, linkProjectsOrgId");this.baseUrl=t.baseUrl??"https://akuko.io",this.lastHostUser=this.opts.hostUser,this.iframe=this.createIframe(),this.container.appendChild(this.iframe);const i=this.opts.allowedOrigin??new URL(this.baseUrl).origin;this.bridge=new r(this.iframe,i),this.bridge.on("akuko:resize",s=>{if(this.opts.layout==="fill")return;const o=s;typeof o.height=="number"&&(this.iframe.style.height=`${o.height}px`)}),this.bridge.on("akuko:ready",()=>this.replayState()),this.startLoadWatchdog()}startLoadWatchdog(){const t=this.opts.iframeLoadTimeoutMs??3e4;t<=0||(this.loadWatchdog=setTimeout(()=>{this.loadWatchdog=null,this.emitLocal("akuko:error",{code:"IFRAME_LOAD_FAILED"})},t),this.iframe.addEventListener("load",()=>{this.loadWatchdog&&(clearTimeout(this.loadWatchdog),this.loadWatchdog=null)},{once:!0}))}createIframe(){const t=document.createElement("iframe");return t.style.width="100%",t.style.border="0",this.opts.layout==="fill"?(t.style.flex="1 1 0%",t.style.minHeight="0"):(t.style.height="600px",this.opts.minHeight!=null&&(t.style.minHeight=typeof this.opts.minHeight=="number"?`${this.opts.minHeight}px`:this.opts.minHeight)),t.setAttribute("sandbox",this.opts.sandbox??"allow-scripts allow-same-origin allow-popups allow-forms allow-downloads"),t.setAttribute("allow","fullscreen; clipboard-write"),t.src=this.computeInitialUrl(),t}computeInitialUrl(){const t=this.opts;return t.dashboardId?`${this.baseUrl}/embed/post/${t.dashboardId}${this.queryString()}`:t.spaceUuid?`${this.baseUrl}/embed/space/${t.spaceUuid}${this.queryString()}`:t.onadataOrgId?`${this.baseUrl}/embed/resolve/onadata-org/${t.onadataOrgId}${this.queryString()}`:t.onadataProjectId?`${this.baseUrl}/embed/resolve/onadata-project/${t.onadataProjectId}${this.queryString()}`:t.onadataFormId?`${this.baseUrl}/embed/resolve/onadata-form/${t.onadataFormId}${this.queryString()}`:t.linkProjectsOrgId?`${this.baseUrl}/embed/link-projects/onadata-org/${t.linkProjectsOrgId}${this.queryString()}`:`${this.baseUrl}/embed/loading${this.queryString()}`}queryString(){const t=new URLSearchParams;return this.opts.idpHint&&t.set("idp_hint",this.opts.idpHint),this.opts.layout&&t.set("layout",this.opts.layout),this.opts.hostIsAdmin&&t.set("host_admin","1"),this.opts.initialPostId&&t.set("post",this.opts.initialPostId),this.opts.resolveOnadataOrgId&&t.set("onadata_org_id",this.opts.resolveOnadataOrgId),this.opts.resolveOnadataProjectId&&t.set("onadata_project_id",this.opts.resolveOnadataProjectId),this.opts.theme?.primary&&t.set("theme_primary",this.opts.theme.primary),t.toString()?`?${t.toString()}`:""}on(t,e){this.bridge.on(t,e);let i=this.localListeners.get(t);i||(i=new Set,this.localListeners.set(t,i)),i.add(e)}off(t,e){this.bridge.off(t,e),this.localListeners.get(t)?.delete(e)}emitLocal(t,e){this.localListeners.get(t)?.forEach(i=>i(e))}replayState(){this.lastTheme&&this.bridge.send("akuko:setTheme",this.lastTheme),this.lastVisibility&&this.bridge.send("akuko:setVisibility",this.lastVisibility),this.lastFilters&&this.bridge.send("akuko:setFilters",this.lastFilters),this.lastHostUser&&this.bridge.send("akuko:setHostIdentity",this.lastHostUser),this.lastViewport&&this.bridge.send("akuko:viewport",this.lastViewport)}setTheme(t){this.lastTheme=t,this.bridge.send("akuko:setTheme",t)}setVisibility(t){this.lastVisibility=t,this.bridge.send("akuko:setVisibility",t)}setViewport(t){this.lastViewport=t,this.bridge.send("akuko:viewport",t)}setFilters(t){this.lastFilters=t,this.bridge.send("akuko:setFilters",t)}clearFilters(t){this.bridge.send("akuko:clearFilters",{keys:t})}refresh(){this.bridge.send("akuko:refresh",{})}linkProjects(){if(!this.opts.onadataOrgId)throw new Error("linkProjects() requires the SDK to be constructed with onadataOrgId");this.bridge.send("akuko:linkProjects",{onadataOrgId:this.opts.onadataOrgId})}getState(){this.bridge.send("akuko:getState",{})}async resyncMembers(t){const e=t.scope==="org"?{scope:"org",onadata_org_id:this.opts.onadataOrgId,...t.account_id?{account_id:t.account_id}:{},...t.space_id?{space_id:t.space_id}:{},...t.onadata_org_slug?{onadata_org_slug:t.onadata_org_slug}:{}}:{scope:"project",onadata_project_id:t.onadata_project_id,...t.account_id?{account_id:t.account_id}:{},...t.space_id?{space_id:t.space_id}:{},...t.folder_id?{folder_id:t.folder_id}:{}};return this.bridge.send("akuko:resyncMembers",e),Promise.resolve({workflow_id:""})}logout(){return new Promise(t=>{let e=!1;const i=()=>{e||(e=!0,clearTimeout(o),this.bridge.off("akuko:logoutComplete",s),t())},s=()=>i(),o=setTimeout(i,this.opts.logoutTimeoutMs??2e3);this.bridge.on("akuko:logoutComplete",s),this.bridge.send("akuko:logout",{})})}destroy(){this.loadWatchdog&&(clearTimeout(this.loadWatchdog),this.loadWatchdog=null),this.bridge.destroy(),this.iframe.remove()}}a.AkukoDashboard=d,a.PostMessageBridge=r,Object.defineProperty(a,Symbol.toStringTag,{value:"Module"})}));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AkukoDashboard } from "./AkukoDashboard";
|
|
2
|
+
export { PostMessageBridge } from "./PostMessageBridge";
|
|
3
|
+
export type { AkukoSDKOptions, AkukoTheme, AkukoViewport, AkukoVisibility, ResolveResponse, } from "./types";
|
|
4
|
+
export type { AkukoEventType, AkukoMessage } from "./PostMessageBridge";
|
|
5
|
+
export type { ResyncMembersOpts } from "./AkukoDashboard";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
export interface AkukoSDKOptions {
|
|
2
|
+
container: string | HTMLElement;
|
|
3
|
+
onadataOrgId?: string;
|
|
4
|
+
onadataProjectId?: string;
|
|
5
|
+
/**
|
|
6
|
+
* OnaData form id for a single-form generated dashboard. The iframe owns
|
|
7
|
+
* resolve: it loads `/embed/resolve/onadata-form/:id`, which looks up (or
|
|
8
|
+
* triggers generation of) the form's deterministic dashboard. Mutually
|
|
9
|
+
* exclusive with the other entry props.
|
|
10
|
+
*/
|
|
11
|
+
onadataFormId?: string;
|
|
12
|
+
spaceUuid?: string;
|
|
13
|
+
dashboardId?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Org slug for the standalone "link projects" picker surface, loaded in its
|
|
16
|
+
* own iframe (host renders it in a modal). Mutually exclusive with the other
|
|
17
|
+
* entry props. Maps to /embed/link-projects/onadata-org/:id.
|
|
18
|
+
*/
|
|
19
|
+
linkProjectsOrgId?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Grant-INDEPENDENT resolve context — companion fields, NOT entry selectors.
|
|
22
|
+
* When the host knows the owning org (and, for a form, its project), it passes
|
|
23
|
+
* them so akuko-api can derive the embed's state (org-provisioned / project-
|
|
24
|
+
* linked / dashboard) from akuko's own data without the caller's personal
|
|
25
|
+
* OnaData grant. This lets every authenticated org user — not just the admin
|
|
26
|
+
* who provisioned analytics — see role-appropriate content.
|
|
27
|
+
*
|
|
28
|
+
* - `resolveOnadataOrgId`: owning org slug. Companion to `onadataProjectId`
|
|
29
|
+
* (project embed) and `onadataFormId` (form embed). Forwarded as
|
|
30
|
+
* `?onadata_org_id=`.
|
|
31
|
+
* - `resolveOnadataProjectId`: the form's project id. Companion to
|
|
32
|
+
* `onadataFormId` (form embed). Forwarded as `?onadata_project_id=`.
|
|
33
|
+
*
|
|
34
|
+
* Unlike the entry props above, these do NOT count toward the "exactly one
|
|
35
|
+
* entry prop" rule — they augment whichever entry prop is set.
|
|
36
|
+
*/
|
|
37
|
+
resolveOnadataOrgId?: string;
|
|
38
|
+
resolveOnadataProjectId?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Origin that serves the embed *frontend* routes (`/embed/*`) loaded into
|
|
41
|
+
* the iframe. Defaults to `https://akuko.io`.
|
|
42
|
+
*/
|
|
43
|
+
baseUrl?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Origin that serves the akuko *API* (`/v1/*`). Accepted for forward/backward
|
|
46
|
+
* compatibility but no longer used by the SDK: resolve and resync now run
|
|
47
|
+
* inside the iframe (same-origin, with the akuko cookie/Bearer), so the host
|
|
48
|
+
* never makes a cross-origin `/v1/*` call. Safe to keep passing.
|
|
49
|
+
*/
|
|
50
|
+
apiBaseUrl?: string;
|
|
51
|
+
idpHint?: string;
|
|
52
|
+
theme?: AkukoTheme;
|
|
53
|
+
visibility?: AkukoVisibility;
|
|
54
|
+
filters?: Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Host-settable initial post (dashboard) to open on mount, overriding the
|
|
57
|
+
* folder/space configured default. Used for deep-linking/sharing: the host
|
|
58
|
+
* reflects the open post in its own URL (?post=) and passes it back here so a
|
|
59
|
+
* shared link reproduces the exact view. Forwarded to the iframe as `?post=`.
|
|
60
|
+
*/
|
|
61
|
+
initialPostId?: string;
|
|
62
|
+
allowedOrigin?: string;
|
|
63
|
+
sandbox?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Iframe sizing strategy:
|
|
66
|
+
* - `content` (default): the iframe auto-resizes to the embed's content
|
|
67
|
+
* height (akuko:resize). Right for inline/single-dashboard embeds.
|
|
68
|
+
* - `fill`: the host owns the height — the embed fills the iframe (100%) and
|
|
69
|
+
* auto-resize is ignored. Right for a full-page analytics surface, so it
|
|
70
|
+
* extends down to the host's footer instead of floating in a short box.
|
|
71
|
+
* The host must give the iframe's container a real height (e.g. viewport).
|
|
72
|
+
*/
|
|
73
|
+
layout?: "content" | "fill";
|
|
74
|
+
/**
|
|
75
|
+
* Content layout only: a minimum height floor for the iframe. A short
|
|
76
|
+
* dashboard — or the empty "generate"/"no dashboard yet" state — still fills
|
|
77
|
+
* down to the host's footer instead of leaving a gap, while the iframe still
|
|
78
|
+
* grows beyond this floor as the content height grows (the page scrolls).
|
|
79
|
+
* Accepts a number (px) or any CSS length string (e.g. "calc(100vh - 8.5rem)").
|
|
80
|
+
* Ignored in `fill` layout (there the host owns the height).
|
|
81
|
+
*/
|
|
82
|
+
minHeight?: number | string;
|
|
83
|
+
/**
|
|
84
|
+
* Host-provided hint that the current user can administer (own/manage) the
|
|
85
|
+
* embedded org or project. Used ONLY to gate the in-iframe onboarding consent
|
|
86
|
+
* screen for an unlinked org: admin status can't be computed server-side until
|
|
87
|
+
* the caller completes the OnaData OAuth grant, so before that the embed trusts
|
|
88
|
+
* this hint to decide whether to show "Authorize & enable analytics" or "ask
|
|
89
|
+
* your owner". UX-only — the backend re-verifies ownership at provisioning, so
|
|
90
|
+
* a spoofed value cannot actually enable an org. Forwarded as `?host_admin=1`.
|
|
91
|
+
*/
|
|
92
|
+
hostIsAdmin?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The host's CURRENT signed-in OnaData user identity. Forwarded to the iframe
|
|
95
|
+
* (postMessage, replayed on akuko:ready) so the embed can detect a host
|
|
96
|
+
* user-switch and tear down a stale akuko session. UNTRUSTED: used ONLY to force
|
|
97
|
+
* a re-auth on mismatch, never to grant access or select an identity — the akuko
|
|
98
|
+
* session identity always derives from the verified OIDC Bearer.
|
|
99
|
+
*/
|
|
100
|
+
hostUser?: {
|
|
101
|
+
email: string;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Milliseconds to wait for iframe.onload before emitting
|
|
105
|
+
* akuko:error{code:'IFRAME_LOAD_FAILED'}. Defaults to 30_000. Set to 0
|
|
106
|
+
* to disable the watchdog. Values <5_000 are not recommended — the iframe
|
|
107
|
+
* may still be completing its brokered resolve + redirect.
|
|
108
|
+
*/
|
|
109
|
+
iframeLoadTimeoutMs?: number;
|
|
110
|
+
/**
|
|
111
|
+
* Milliseconds `logout()` waits for the iframe's `akuko:logoutComplete`
|
|
112
|
+
* acknowledgement before resolving anyway. Defaults to 2000. The host
|
|
113
|
+
* should use a slightly larger ceiling so the SDK's own timeout wins first.
|
|
114
|
+
*/
|
|
115
|
+
logoutTimeoutMs?: number;
|
|
116
|
+
}
|
|
117
|
+
export interface AkukoTheme {
|
|
118
|
+
primary?: string;
|
|
119
|
+
background?: string;
|
|
120
|
+
foreground?: string;
|
|
121
|
+
card?: string;
|
|
122
|
+
border?: string;
|
|
123
|
+
radius?: string;
|
|
124
|
+
fontFamily?: string;
|
|
125
|
+
darkMode?: boolean;
|
|
126
|
+
chart1?: string;
|
|
127
|
+
chart2?: string;
|
|
128
|
+
chart3?: string;
|
|
129
|
+
chart4?: string;
|
|
130
|
+
chart5?: string;
|
|
131
|
+
}
|
|
132
|
+
export interface AkukoVisibility {
|
|
133
|
+
header?: boolean;
|
|
134
|
+
breadcrumbs?: boolean;
|
|
135
|
+
footer?: boolean;
|
|
136
|
+
undoRedo?: boolean;
|
|
137
|
+
chartActions?: boolean;
|
|
138
|
+
filterBar?: boolean;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The slice of the embed iframe currently visible in the host's browser
|
|
142
|
+
* viewport, in iframe-local CSS pixels. Sent host→iframe (`akuko:viewport`) so
|
|
143
|
+
* in-iframe floating UI (e.g. an action panel) can center itself in the visible
|
|
144
|
+
* band even when the HOST page — not the iframe — owns the scrollbar
|
|
145
|
+
* (`layout: "content"`, where the iframe auto-grows to full content height).
|
|
146
|
+
* `top` is the iframe-local Y of the first visible row; `height` is the visible
|
|
147
|
+
* band's height. In `layout: "fill"` the iframe is itself the scroll viewport,
|
|
148
|
+
* so `top` is 0 and `height` is the iframe height.
|
|
149
|
+
*/
|
|
150
|
+
export interface AkukoViewport {
|
|
151
|
+
top: number;
|
|
152
|
+
height: number;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Response shape from GET /v1/embed/resolve. Surfaced to the host via the
|
|
156
|
+
* `akuko:resolved` event so the host can read `links.akuko_*_url` for
|
|
157
|
+
* "Open in new tab" deep-links without making its own HTTP call.
|
|
158
|
+
*/
|
|
159
|
+
export type ResolveResponse = {
|
|
160
|
+
status: "linked";
|
|
161
|
+
space_id: string;
|
|
162
|
+
folder_id?: string;
|
|
163
|
+
/**
|
|
164
|
+
* Project (folder) default dashboard, present on project lookups. The
|
|
165
|
+
* embed renders it by default; hosts may read it for display/analytics.
|
|
166
|
+
*/
|
|
167
|
+
default_post_id?: string | null;
|
|
168
|
+
/**
|
|
169
|
+
* Org (space) default dashboard, present on org lookups. The embed
|
|
170
|
+
* renders it by default on the org-level analytics tab.
|
|
171
|
+
*/
|
|
172
|
+
space_default_post_id?: string | null;
|
|
173
|
+
links: {
|
|
174
|
+
akuko_space_url?: string;
|
|
175
|
+
akuko_space_posts_url?: string;
|
|
176
|
+
akuko_folder_url?: string;
|
|
177
|
+
};
|
|
178
|
+
/** Caller is an OnaData org owner/manager (UX gate; re-verified at provisioning). */
|
|
179
|
+
is_org_admin?: boolean;
|
|
180
|
+
} | {
|
|
181
|
+
status: "unlinked";
|
|
182
|
+
onboarding_url: string;
|
|
183
|
+
/** Caller is an OnaData org owner/manager (UX gate). */
|
|
184
|
+
is_org_admin?: boolean;
|
|
185
|
+
/** OnaData org slug owning the resource; lets the host link to org analytics. */
|
|
186
|
+
onadata_org_slug?: string;
|
|
187
|
+
/**
|
|
188
|
+
* Project scope only: the org IS linked but this project isn't. The embed
|
|
189
|
+
* renders its own "link this project" / "ask an admin" state, so the host
|
|
190
|
+
* can keep the iframe rather than showing an org-not-enabled card.
|
|
191
|
+
*/
|
|
192
|
+
org_linked?: boolean;
|
|
193
|
+
} | {
|
|
194
|
+
status: "provisioning";
|
|
195
|
+
/** JIT member-sync workflow id to poll while the caller is provisioned. */
|
|
196
|
+
workflow_id: string;
|
|
197
|
+
} | {
|
|
198
|
+
/**
|
|
199
|
+
* The akuko session belongs to a different person than the current
|
|
200
|
+
* OnaData user. The embed clears its akuko session and re-brokers; hosts
|
|
201
|
+
* generally need not react (the iframe self-heals).
|
|
202
|
+
*/
|
|
203
|
+
status: "reauth_required";
|
|
204
|
+
reason: "identity_mismatch";
|
|
205
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Host recipes
|
|
2
|
+
|
|
3
|
+
Concrete integration recipes for embedding Akuko dashboards with
|
|
4
|
+
`@onaio/akuko-embed-sdk`. All examples use the current API
|
|
5
|
+
(`new AkukoDashboard({ … })`).
|
|
6
|
+
|
|
7
|
+
For federated/private dashboards, see
|
|
8
|
+
[`./sso-path-d-brokering.md`](./sso-path-d-brokering.md) — wherever a recipe
|
|
9
|
+
passes `idpHint`, that alias must first be wired up as a brokered IdP in Akuko's
|
|
10
|
+
Keycloak.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## WordPress (public dashboard)
|
|
15
|
+
|
|
16
|
+
The simplest case: embedding a **public** Akuko dashboard (a published post that
|
|
17
|
+
needs no login). Drop a container and a module script into a page, a Custom HTML
|
|
18
|
+
block, or a small plugin/shortcode that prints this markup.
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<div id="akuko-dashboard"></div>
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { AkukoDashboard } from "https://esm.sh/@onaio/akuko-embed-sdk";
|
|
24
|
+
|
|
25
|
+
new AkukoDashboard({
|
|
26
|
+
container: "#akuko-dashboard",
|
|
27
|
+
dashboardId: "your-public-dashboard-id",
|
|
28
|
+
baseUrl: "https://akuko.io",
|
|
29
|
+
// No idpHint — public posts skip auth entirely.
|
|
30
|
+
});
|
|
31
|
+
</script>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If you bundle your theme's JS, import from the npm package instead of the CDN:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { AkukoDashboard } from "@onaio/akuko-embed-sdk";
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Private / federated WordPress:** a public post needs no auth, so it "just
|
|
41
|
+
works". To embed a *private* dashboard for logged-in WP users, those users must
|
|
42
|
+
exist in an IdP that Akuko's Keycloak can broker (Path D), and you pass the
|
|
43
|
+
matching `idpHint`. WordPress's own cookie session is not an OIDC IdP — you'd
|
|
44
|
+
front WP with (or federate it to) a brokerable OIDC provider first.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Directus
|
|
49
|
+
|
|
50
|
+
Two distinct shapes depending on *where* the embed lives.
|
|
51
|
+
|
|
52
|
+
### Variant A — Directus Module extension (inside the admin app)
|
|
53
|
+
|
|
54
|
+
A custom [Module extension](https://docs.directus.io/extensions/modules.html)
|
|
55
|
+
that renders an Akuko dashboard inside the Directus admin UI. The extension is a
|
|
56
|
+
Vue SFC; mount the SDK in `onMounted` and tear it down in `onBeforeUnmount`:
|
|
57
|
+
|
|
58
|
+
```vue
|
|
59
|
+
<template>
|
|
60
|
+
<private-view title="Analytics">
|
|
61
|
+
<div ref="mount" style="height: calc(100vh - 60px)"></div>
|
|
62
|
+
</private-view>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<script setup>
|
|
66
|
+
import { ref, onMounted, onBeforeUnmount } from "vue";
|
|
67
|
+
import { AkukoDashboard } from "@onaio/akuko-embed-sdk";
|
|
68
|
+
|
|
69
|
+
const mount = ref(null);
|
|
70
|
+
let dashboard = null;
|
|
71
|
+
let themeObserver = null;
|
|
72
|
+
|
|
73
|
+
// Map Directus theme CSS vars → AkukoTheme.
|
|
74
|
+
function readDirectusTheme() {
|
|
75
|
+
const s = getComputedStyle(document.documentElement);
|
|
76
|
+
const v = (name) => s.getPropertyValue(name).trim() || undefined;
|
|
77
|
+
return {
|
|
78
|
+
primary: v("--theme--primary"),
|
|
79
|
+
background: v("--theme--background"),
|
|
80
|
+
foreground: v("--theme--foreground"),
|
|
81
|
+
border: v("--theme--border-color"),
|
|
82
|
+
darkMode: document.documentElement.classList.contains("dark"),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onMounted(() => {
|
|
87
|
+
dashboard = new AkukoDashboard({
|
|
88
|
+
container: mount.value,
|
|
89
|
+
dashboardId: import.meta.env.AKUKO_DASHBOARD_ID,
|
|
90
|
+
baseUrl: import.meta.env.AKUKO_BASE_URL,
|
|
91
|
+
idpHint: import.meta.env.AKUKO_IDP_HINT, // your brokered IdP alias
|
|
92
|
+
layout: "fill",
|
|
93
|
+
theme: readDirectusTheme(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Re-push the theme when Directus toggles light/dark.
|
|
97
|
+
themeObserver = new MutationObserver(() => dashboard?.setTheme(readDirectusTheme()));
|
|
98
|
+
themeObserver.observe(document.documentElement, {
|
|
99
|
+
attributes: true,
|
|
100
|
+
attributeFilter: ["class"],
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
onBeforeUnmount(() => {
|
|
105
|
+
themeObserver?.disconnect();
|
|
106
|
+
dashboard?.destroy();
|
|
107
|
+
});
|
|
108
|
+
</script>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Directus extensions are built with Vite. To expose `AKUKO_*` build-time env
|
|
112
|
+
vars to the extension, widen the env prefix in the extension's `vite.config.ts`:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
export default defineConfig({
|
|
116
|
+
envPrefix: ["VITE_", "DIRECTUS_", "AKUKO_"],
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
> An `@onaio/akuko-directus-module` drop-in package is a **future** deliverable.
|
|
121
|
+
> Until then, this is the build-your-own recipe.
|
|
122
|
+
|
|
123
|
+
### Variant B — A Directus-powered site (separate front-end)
|
|
124
|
+
|
|
125
|
+
If your site is a separate app (Next.js, Nuxt, Astro, or plain HTML) that reads
|
|
126
|
+
content from Directus, embed Akuko there like any other host — exactly as in the
|
|
127
|
+
WordPress example above. If that site authenticates users through a brokerable
|
|
128
|
+
OIDC IdP, Path D applies and you pass `idpHint`; if it only renders public
|
|
129
|
+
dashboards, omit it.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
See [`./sso-path-d-brokering.md`](./sso-path-d-brokering.md) for the federation
|
|
134
|
+
setup any `idpHint`-based recipe depends on.
|