@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.
@@ -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"})}));
@@ -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";
@@ -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.