@opengeni/react 4.0.2-canary.0 → 5.0.2-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +42 -0
  2. package/dist/artifacts.js +24 -285
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/chunk-BRFMGY4L.js +274 -0
  5. package/dist/chunk-BRFMGY4L.js.map +1 -0
  6. package/dist/chunk-IWIDV7SB.js +1 -0
  7. package/dist/chunk-IWIDV7SB.js.map +1 -0
  8. package/dist/{chunk-CQG7YUJB.js → chunk-WRVKY242.js} +1169 -1017
  9. package/dist/chunk-WRVKY242.js.map +1 -0
  10. package/dist/clipboard.js +1 -0
  11. package/dist/components/message-timeline.d.ts +7 -3
  12. package/dist/connect-accounts.d.ts +10 -0
  13. package/dist/connect-chooser.d.ts +11 -0
  14. package/dist/connect-panel.d.ts +10 -0
  15. package/dist/connect-setup.d.ts +12 -0
  16. package/dist/connect.d.ts +30 -0
  17. package/dist/connect.js +831 -0
  18. package/dist/connect.js.map +1 -0
  19. package/dist/device-authorization.d.ts +15 -0
  20. package/dist/identity-link-accounts.d.ts +11 -0
  21. package/dist/identity-link-consent.d.ts +18 -0
  22. package/dist/index.js +523 -663
  23. package/dist/index.js.map +1 -1
  24. package/dist/session-ui.d.ts +2 -0
  25. package/dist/session-ui.js +3 -1
  26. package/dist/sites-ui.d.ts +33 -0
  27. package/dist/sites.d.ts +3 -0
  28. package/dist/sites.js +288 -0
  29. package/dist/sites.js.map +1 -0
  30. package/package.json +16 -2
  31. package/src/components/message-timeline.tsx +22 -5
  32. package/src/components/sandbox-workspace.tsx +9 -2
  33. package/src/connect-accounts.tsx +171 -0
  34. package/src/connect-chooser.tsx +144 -0
  35. package/src/connect-panel.tsx +29 -0
  36. package/src/connect-setup.tsx +260 -0
  37. package/src/connect.ts +48 -0
  38. package/src/device-authorization.tsx +90 -0
  39. package/src/hooks/use-workspace-capture.ts +3 -2
  40. package/src/identity-link-accounts.tsx +144 -0
  41. package/src/identity-link-consent.tsx +192 -0
  42. package/src/session-ui.ts +2 -0
  43. package/src/sites-ui.tsx +385 -0
  44. package/src/sites.ts +3 -0
  45. package/src/timeline/tool-renderers.tsx +1 -1
  46. package/styles/connect.css +48 -0
  47. package/styles/connect.d.ts +1 -0
  48. package/dist/chunk-CQG7YUJB.js.map +0 -1
@@ -0,0 +1,171 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ConnectAccount, ConnectController } from "@opengeni/connect";
3
+ import { useConnect } from "./connect";
4
+
5
+ export type ConnectAccountsProps = {
6
+ controller: ConnectController;
7
+ className?: string;
8
+ /** Enables explicit account-bound reconnect. Host supplies the exact destination. */
9
+ returnUrl?: string;
10
+ };
11
+
12
+ /** Local credential revocation only. Unknown or changed versions must be
13
+ * refreshed; this component never retries a destructive operation implicitly. */
14
+ export function ConnectAccounts(props: ConnectAccountsProps) {
15
+ const [scope, setScope] = useState(props.controller);
16
+ const [generation, setGeneration] = useState(0);
17
+ if (scope !== props.controller) {
18
+ setScope(props.controller);
19
+ setGeneration(generation + 1);
20
+ }
21
+ return <ScopedAccounts key={generation} {...props} />;
22
+ }
23
+
24
+ function ScopedAccounts({ controller, className, returnUrl }: ConnectAccountsProps) {
25
+ const view = useConnect(controller);
26
+ const [accounts, setAccounts] = useState<ConnectAccount[] | null>(null);
27
+ const [selected, setSelected] = useState<string | null>(null);
28
+ const [busy, setBusy] = useState(false);
29
+ const [error, setError] = useState(false);
30
+ const [reload, setReload] = useState(0);
31
+ const mutation = useRef<AbortController | null>(null);
32
+ useEffect(() => () => mutation.current?.abort(), []);
33
+ useEffect(() => {
34
+ const abort = new AbortController();
35
+ setAccounts(null);
36
+ setSelected(null);
37
+ setError(false);
38
+ void Promise.resolve()
39
+ .then(() => {
40
+ abort.signal.throwIfAborted();
41
+ return controller.transport.accounts(controller.workspaceId, { signal: abort.signal });
42
+ })
43
+ .then((result) => {
44
+ if (!abort.signal.aborted) setAccounts(structuredClone(result));
45
+ })
46
+ .catch(() => {
47
+ if (!abort.signal.aborted) setError(true);
48
+ });
49
+ return () => abort.abort();
50
+ }, [controller, reload]);
51
+
52
+ const disconnect = async (account: ConnectAccount) => {
53
+ const expectedVersion = account.version;
54
+ if (
55
+ mutation.current ||
56
+ view.busy ||
57
+ expectedVersion === undefined ||
58
+ !Number.isSafeInteger(expectedVersion) ||
59
+ expectedVersion < 1
60
+ )
61
+ return;
62
+ const abort = new AbortController();
63
+ mutation.current = abort;
64
+ setBusy(true);
65
+ setError(false);
66
+ try {
67
+ await controller.transport.disconnect(controller.workspaceId, account.id, {
68
+ expectedVersion,
69
+ signal: abort.signal,
70
+ });
71
+ if (!abort.signal.aborted) setReload((value) => value + 1);
72
+ } catch {
73
+ if (!abort.signal.aborted) {
74
+ // Outcome may be unknown. Drop the inventory so a second click cannot
75
+ // replay against the stale selection; let the host reload live state.
76
+ setAccounts(null);
77
+ setError(true);
78
+ }
79
+ } finally {
80
+ if (!abort.signal.aborted) {
81
+ mutation.current = null;
82
+ setBusy(false);
83
+ setSelected(null);
84
+ }
85
+ }
86
+ };
87
+ const reconnect = async (account: ConnectAccount) => {
88
+ if (!returnUrl || mutation.current || view.busy) return;
89
+ setError(false);
90
+ try {
91
+ await controller.begin({
92
+ providerId: account.providerId,
93
+ ownership: account.ownership,
94
+ reconnectAccountId: account.id,
95
+ returnUrl,
96
+ idempotencyKey: crypto.randomUUID(),
97
+ });
98
+ } catch {
99
+ setError(true);
100
+ }
101
+ };
102
+
103
+ return (
104
+ <section
105
+ className={className}
106
+ aria-label="Connected accounts"
107
+ aria-busy={busy || view.busy || (!accounts && !error)}
108
+ >
109
+ {error && (
110
+ <p role="alert">Account state could not be confirmed. Reload before trying again.</p>
111
+ )}
112
+ {!accounts && !error && <p role="status">Loading accounts…</p>}
113
+ {accounts?.length === 0 && <p role="status">No connected accounts.</p>}
114
+ {accounts && accounts.length > 0 && (
115
+ <ul>
116
+ {accounts.map((account) => (
117
+ <li key={`${account.providerId}:${account.id}`}>
118
+ <span>
119
+ {account.label} — {account.providerId} — {account.ownership} —{" "}
120
+ {account.status.replaceAll("_", " ")}
121
+ </span>
122
+ {returnUrl && (
123
+ <button
124
+ type="button"
125
+ disabled={busy || view.busy || account.status === "disabled"}
126
+ onClick={() => void reconnect(account)}
127
+ >
128
+ Reconnect {account.label}
129
+ </button>
130
+ )}
131
+ {selected === `${account.providerId}:${account.id}` ? (
132
+ <div role="group" aria-label={`Disconnect ${account.label}`}>
133
+ <p>
134
+ This removes local OpenGeni access. It does not revoke consent at the provider.
135
+ </p>
136
+ <button
137
+ type="button"
138
+ disabled={busy || view.busy}
139
+ onClick={() => void disconnect(account)}
140
+ >
141
+ Confirm disconnect
142
+ </button>
143
+ <button type="button" disabled={busy} onClick={() => setSelected(null)}>
144
+ Keep account
145
+ </button>
146
+ </div>
147
+ ) : (
148
+ <button
149
+ type="button"
150
+ disabled={
151
+ busy ||
152
+ view.busy ||
153
+ account.status === "disabled" ||
154
+ !Number.isSafeInteger(account.version) ||
155
+ account.version! < 1
156
+ }
157
+ onClick={() => setSelected(`${account.providerId}:${account.id}`)}
158
+ >
159
+ Disconnect {account.label}
160
+ </button>
161
+ )}
162
+ </li>
163
+ ))}
164
+ </ul>
165
+ )}
166
+ <button type="button" disabled={busy} onClick={() => setReload((value) => value + 1)}>
167
+ Reload accounts
168
+ </button>
169
+ </section>
170
+ );
171
+ }
@@ -0,0 +1,144 @@
1
+ import { useEffect, useState, type FormEvent } from "react";
2
+ import type { ConnectController, ConnectProvider } from "@opengeni/connect";
3
+ import { useConnect } from "./connect";
4
+
5
+ export type ConnectChooserProps = {
6
+ controller: ConnectController;
7
+ /** Exact host return string; no completion parameters are added. */
8
+ returnUrl: string;
9
+ className?: string;
10
+ };
11
+
12
+ /** Catalog readiness is supplied by the authenticated backend, never inferred
13
+ * from the existence of a provider definition. Host replaces the controller on
14
+ * actor/workspace change. This form does not open OAuth windows itself. */
15
+ export function ConnectChooser(props: ConnectChooserProps) {
16
+ // Remount scope-local selections synchronously when the controller changes,
17
+ // before effects can expose a previous actor's inventory for one frame.
18
+ const [scope, setScope] = useState(props.controller);
19
+ const [generation, setGeneration] = useState(0);
20
+ if (scope !== props.controller) {
21
+ setScope(props.controller);
22
+ setGeneration(generation + 1);
23
+ }
24
+ return <ScopedChooser key={generation} {...props} />;
25
+ }
26
+
27
+ function ScopedChooser({ controller, returnUrl, className }: ConnectChooserProps) {
28
+ const view = useConnect(controller);
29
+ const [catalog, setCatalog] = useState<ConnectProvider[] | null>(null);
30
+ const [failed, setFailed] = useState(false);
31
+ const [load, setLoad] = useState(0);
32
+ const [providerId, setProviderId] = useState("");
33
+ const [ownership, setOwnership] = useState("");
34
+ useEffect(() => {
35
+ const abort = new AbortController();
36
+ setCatalog(null);
37
+ setFailed(false);
38
+ setProviderId("");
39
+ setOwnership("");
40
+ // Promise boundary also handles a synchronously throwing injected transport.
41
+ void Promise.resolve()
42
+ .then(() => {
43
+ abort.signal.throwIfAborted();
44
+ return controller.transport.catalog(controller.workspaceId, { signal: abort.signal });
45
+ })
46
+ .then((providers) => {
47
+ if (!abort.signal.aborted) setCatalog(structuredClone(providers));
48
+ })
49
+ .catch(() => {
50
+ if (!abort.signal.aborted) setFailed(true);
51
+ });
52
+ return () => abort.abort();
53
+ }, [controller, load]);
54
+ const provider = catalog?.find((entry) => entry.id === providerId);
55
+ const canBegin =
56
+ provider?.readiness === "available" &&
57
+ (ownership === "personal" || ownership === "workspace") &&
58
+ provider.ownership.includes(ownership);
59
+ const submit = (event: FormEvent) => {
60
+ event.preventDefault();
61
+ if (!canBegin || view.busy || (ownership !== "personal" && ownership !== "workspace")) return;
62
+ setFailed(false);
63
+ try {
64
+ void controller
65
+ .begin({ providerId, ownership, returnUrl, idempotencyKey: crypto.randomUUID() })
66
+ .catch(() => setFailed(true));
67
+ } catch {
68
+ setFailed(true);
69
+ }
70
+ };
71
+ return (
72
+ <section
73
+ className={className}
74
+ aria-label="Choose a connection"
75
+ aria-busy={view.busy || (!catalog && !failed)}
76
+ >
77
+ {failed && (
78
+ <p role="alert">
79
+ Connections could not be loaded or started. Check setup status before retrying.
80
+ </p>
81
+ )}
82
+ {!catalog && !failed && <p role="status">Loading connections…</p>}
83
+ {catalog?.length === 0 && <p role="status">No connections are available.</p>}
84
+ {Boolean(catalog?.length) && (
85
+ <form onSubmit={submit}>
86
+ <fieldset disabled={view.busy}>
87
+ <legend>Provider and ownership</legend>
88
+ <label>
89
+ Provider
90
+ <select
91
+ required
92
+ value={providerId}
93
+ onChange={(event) => {
94
+ setProviderId(event.target.value);
95
+ setOwnership("");
96
+ }}
97
+ >
98
+ <option value="" disabled>
99
+ Choose a provider
100
+ </option>
101
+ {catalog!.map((entry) => (
102
+ <option
103
+ key={entry.id}
104
+ value={entry.id}
105
+ disabled={entry.readiness !== "available" || entry.ownership.length === 0}
106
+ >
107
+ {entry.label} — {entry.id} ({entry.readiness.replaceAll("_", " ")})
108
+ </option>
109
+ ))}
110
+ </select>
111
+ </label>
112
+ {provider && (
113
+ <label>
114
+ Ownership
115
+ <select
116
+ required
117
+ value={ownership}
118
+ onChange={(event) => setOwnership(event.target.value)}
119
+ >
120
+ <option value="" disabled>
121
+ Choose ownership
122
+ </option>
123
+ {provider.ownership.map((choice) => (
124
+ <option key={choice} value={choice}>
125
+ {choice === "personal"
126
+ ? "Personal — owned by you"
127
+ : "Workspace — shared connection"}
128
+ </option>
129
+ ))}
130
+ </select>
131
+ </label>
132
+ )}
133
+ <button type="submit" disabled={!canBegin}>
134
+ Start setup
135
+ </button>
136
+ </fieldset>
137
+ </form>
138
+ )}
139
+ <button type="button" disabled={view.busy} onClick={() => setLoad(load + 1)}>
140
+ Reload providers
141
+ </button>
142
+ </section>
143
+ );
144
+ }
@@ -0,0 +1,29 @@
1
+ import { ConnectAccounts } from "./connect-accounts";
2
+ import { ConnectChooser } from "./connect-chooser";
3
+ import { ConnectSetup, type ConnectSetupProps } from "./connect-setup";
4
+
5
+ export type ConnectPanelProps = ConnectSetupProps & {
6
+ returnUrl: string;
7
+ title?: string;
8
+ showAccounts?: boolean;
9
+ };
10
+
11
+ /** Optional composition over the same unstyled surfaces. Import
12
+ * @opengeni/react/connect.css for styling; the host retains navigation,
13
+ * transport, actor admission, and controller lifetime. */
14
+ export function ConnectPanel({
15
+ returnUrl,
16
+ title = "Connections",
17
+ showAccounts = true,
18
+ className,
19
+ ...setup
20
+ }: ConnectPanelProps) {
21
+ return (
22
+ <div className={["og-connect", className].filter(Boolean).join(" ")}>
23
+ <h2>{title}</h2>
24
+ <ConnectChooser controller={setup.controller} returnUrl={returnUrl} />
25
+ <ConnectSetup {...setup} />
26
+ {showAccounts && <ConnectAccounts controller={setup.controller} returnUrl={returnUrl} />}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,260 @@
1
+ import { useState, type FormEvent } from "react";
2
+ import type { ConnectAttempt, ConnectController } from "@opengeni/connect";
3
+ import { useConnect } from "./connect";
4
+
5
+ const setupStatus: Record<ConnectAttempt["state"], string> = {
6
+ ready: "Ready to connect",
7
+ requires_user_action: "Authorize your account",
8
+ credential_input: "Enter connection details",
9
+ provider_wait: "Waiting for authorization",
10
+ account_selection: "Choose an account",
11
+ resource_selection: "Choose resources",
12
+ preview: "Review available operations",
13
+ installing: "Installing selected operations…",
14
+ connected_but_incomplete: "Account connected — finish setup",
15
+ complete: "Connection ready",
16
+ cancelled: "Setup cancelled",
17
+ expired: "Setup expired",
18
+ failed: "Setup could not finish",
19
+ uncertain: "Setup outcome needs checking",
20
+ };
21
+
22
+ function deviceVerificationUrl(action: ConnectAttempt["nextAction"]): string | null {
23
+ if (action.type !== "wait" || !action.verificationUrl) return null;
24
+ try {
25
+ const url = new URL(action.verificationUrl);
26
+ return url.protocol === "https:" && !url.username && !url.password
27
+ ? action.verificationUrl
28
+ : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export type ConnectSetupProps = {
35
+ controller: ConnectController;
36
+ /** Called synchronously in the click handler so a host can open a popup. */
37
+ onAuthorize: (attempt: ConnectAttempt) => void | Promise<unknown>;
38
+ /** Host pagination must retain selections across pages and submit the final set. */
39
+ onBrowseResources?: (attempt: ConnectAttempt) => void | Promise<unknown>;
40
+ className?: string;
41
+ };
42
+
43
+ /** Unstyled setup surface. No provider secrets enter React state. Host owns
44
+ * chooser, controller lifetime, navigation and recovery after full redirect. */
45
+ export function ConnectSetup(props: ConnectSetupProps) {
46
+ // Attempt IDs/revisions are not a substitute for actor scope. A host can
47
+ // recover the same attempt through a replacement controller; never preserve
48
+ // a previous actor's unsent credential DOM or local errors across that swap.
49
+ const [scope, setScope] = useState(props.controller);
50
+ const [generation, setGeneration] = useState(0);
51
+ if (scope !== props.controller) {
52
+ setScope(props.controller);
53
+ setGeneration(generation + 1);
54
+ }
55
+ return <ScopedSetup key={generation} {...props} />;
56
+ }
57
+
58
+ function ScopedSetup({ controller, onAuthorize, onBrowseResources, className }: ConnectSetupProps) {
59
+ const view = useConnect(controller);
60
+ const [localError, setLocalError] = useState(false);
61
+ const invoke = (operation: () => unknown | Promise<unknown>) => {
62
+ setLocalError(false);
63
+ try {
64
+ void Promise.resolve(operation()).catch(() => setLocalError(true));
65
+ } catch {
66
+ setLocalError(true);
67
+ }
68
+ };
69
+ const attempt = view.attempt;
70
+ if (!attempt)
71
+ return (
72
+ <section className={className} aria-label="Connection setup">
73
+ <p role="status">Choose a connection to begin.</p>
74
+ </section>
75
+ );
76
+ const action = attempt.nextAction;
77
+ const verificationUrl = deviceVerificationUrl(action);
78
+ const terminal = ["complete", "cancelled", "expired"].includes(attempt.state);
79
+ const submit = (event: FormEvent<HTMLFormElement>) => {
80
+ event.preventDefault();
81
+ const form = event.currentTarget;
82
+ const data = new FormData(form);
83
+ if (view.busy) return;
84
+ const key = crypto.randomUUID();
85
+ if (action.type === "credentials") {
86
+ const values = Object.fromEntries(
87
+ action.fields.map((field, i) => [field.name, String(data.get(`field-${i}`) ?? "")]),
88
+ );
89
+ // Clear the DOM before awaiting remote work; controller never retains values.
90
+ form.reset();
91
+ invoke(() => view.advance({ type: "credentials", values }, key));
92
+ } else if (action.type === "select_account") {
93
+ const accountId = String(data.get("account") ?? "");
94
+ if (
95
+ action.accounts.some((account) => account.id === accountId && account.status !== "disabled")
96
+ )
97
+ invoke(() => view.advance({ type: "account", accountId }, key));
98
+ } else if (action.type === "select_resources") {
99
+ if (action.cursor) return;
100
+ const resourceIds = data.getAll("resource").map(String);
101
+ invoke(() => view.advance({ type: "resources", resourceIds }, key));
102
+ } else if (action.type === "preview") {
103
+ const operationIds = data.getAll("operation").map(String);
104
+ invoke(() =>
105
+ view.advance(
106
+ {
107
+ type: "install",
108
+ previewId: action.previewId,
109
+ contentHash: action.contentHash,
110
+ operationIds,
111
+ },
112
+ key,
113
+ ),
114
+ );
115
+ }
116
+ };
117
+ return (
118
+ <section className={className} aria-label="Connection setup" aria-busy={view.busy}>
119
+ <p>Ownership: {attempt.ownership === "personal" ? "Personal" : "Workspace"}</p>
120
+ <p role="status">{setupStatus[attempt.state]}</p>
121
+ {attempt.account && <p>Account: {attempt.account.label}</p>}
122
+ {(localError || view.error || attempt.error) && (
123
+ <p role="alert">
124
+ {attempt.error?.code === "source_changed"
125
+ ? "The integration source changed. Review the new operations before installing."
126
+ : "Connection setup could not continue. Refresh its status before trying again."}
127
+ </p>
128
+ )}
129
+ {!terminal && (
130
+ <form key={`${attempt.id}:${attempt.revision}`} onSubmit={submit} autoComplete="off">
131
+ <fieldset disabled={view.busy}>
132
+ <legend>Connection details</legend>
133
+ {attempt.state === "connected_but_incomplete" && action.type === "none" && (
134
+ <button
135
+ type="button"
136
+ onClick={() => invoke(() => view.advance({ type: "retry" }, crypto.randomUUID()))}
137
+ >
138
+ Review integration operations
139
+ </button>
140
+ )}
141
+ {action.type === "credentials" &&
142
+ action.fields.map((field, i) => (
143
+ <label key={field.name}>
144
+ {field.label}
145
+ {field.options ? (
146
+ <select name={`field-${i}`} required={field.required} defaultValue="">
147
+ <option value="">
148
+ {field.required ? "Choose an option" : "No account (public service)"}
149
+ </option>
150
+ {field.options.map((option) => (
151
+ <option key={option.value} value={option.value}>
152
+ {option.label}
153
+ </option>
154
+ ))}
155
+ </select>
156
+ ) : (
157
+ <input
158
+ name={`field-${i}`}
159
+ type={field.secret ? "password" : "text"}
160
+ required={field.required}
161
+ autoComplete="off"
162
+ />
163
+ )}
164
+ </label>
165
+ ))}
166
+ {action.type === "select_account" && (
167
+ <label>
168
+ Account
169
+ <select name="account" required defaultValue="">
170
+ <option value="" disabled>
171
+ Choose an account
172
+ </option>
173
+ {action.accounts.map((account) => (
174
+ <option
175
+ key={account.id}
176
+ value={account.id}
177
+ disabled={account.status === "disabled"}
178
+ >
179
+ {account.label} — {account.id} ({account.ownership})
180
+ </option>
181
+ ))}
182
+ </select>
183
+ </label>
184
+ )}
185
+ {action.type === "select_resources" &&
186
+ action.resources.map((resource) => (
187
+ <label key={resource.id}>
188
+ <input type="checkbox" name="resource" value={resource.id} />
189
+ {resource.label} ({resource.kind})
190
+ </label>
191
+ ))}
192
+ {action.type === "select_resources" && action.cursor && (
193
+ <>
194
+ <p>More resources are available. Review all pages before submitting a selection.</p>
195
+ {onBrowseResources && (
196
+ <button
197
+ type="button"
198
+ onClick={() => invoke(() => onBrowseResources(structuredClone(attempt)))}
199
+ >
200
+ Browse all resources
201
+ </button>
202
+ )}
203
+ </>
204
+ )}
205
+ {action.type === "preview" && (
206
+ <>
207
+ <p>Select the operations to install.</p>
208
+ {action.operations.map((operation) => (
209
+ <label key={operation.id}>
210
+ <input type="checkbox" name="operation" value={operation.id} />
211
+ {operation.label} ({operation.kind})
212
+ </label>
213
+ ))}
214
+ </>
215
+ )}
216
+ {["credentials", "select_account", "select_resources", "preview"].includes(
217
+ action.type,
218
+ ) && (
219
+ <button
220
+ type="submit"
221
+ disabled={action.type === "select_resources" && Boolean(action.cursor)}
222
+ >
223
+ {action.type === "preview" ? "Install selected operations" : "Continue"}
224
+ </button>
225
+ )}
226
+ {action.type === "authorize" && (
227
+ <button
228
+ type="button"
229
+ onClick={() => invoke(() => onAuthorize(structuredClone(attempt)))}
230
+ >
231
+ Authorize connection
232
+ </button>
233
+ )}
234
+ {action.type === "wait" && (
235
+ <>
236
+ {action.userCode && (
237
+ <p>
238
+ Verification code: <code>{action.userCode}</code>
239
+ </p>
240
+ )}
241
+ <p>Complete provider authorization, then check the connection status.</p>
242
+ {verificationUrl && (
243
+ <a href={verificationUrl} target="_blank" rel="noopener noreferrer">
244
+ Open provider verification page (new tab)
245
+ </a>
246
+ )}
247
+ </>
248
+ )}
249
+ <button type="button" onClick={() => invoke(() => view.refresh())}>
250
+ Check status
251
+ </button>
252
+ <button type="button" onClick={() => invoke(() => view.cancel(crypto.randomUUID()))}>
253
+ Cancel setup
254
+ </button>
255
+ </fieldset>
256
+ </form>
257
+ )}
258
+ </section>
259
+ );
260
+ }
package/src/connect.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Optional, unstyled React adapter over the shared headless controller.
2
+ // The host owns controller lifetime; unmounting one observer does not cancel an
3
+ // attempt another observer is displaying or a durable backend operation.
4
+ import { useMemo, useSyncExternalStore } from "react";
5
+ import type { ConnectController } from "@opengeni/connect";
6
+ export { ConnectSetup, type ConnectSetupProps } from "./connect-setup";
7
+ export { ConnectChooser, type ConnectChooserProps } from "./connect-chooser";
8
+ export { ConnectAccounts, type ConnectAccountsProps } from "./connect-accounts";
9
+ export { ConnectPanel, type ConnectPanelProps } from "./connect-panel";
10
+ export { DeviceAuthorization, type DeviceAuthorizationProps } from "./device-authorization";
11
+ export {
12
+ IdentityLinkConsent,
13
+ type IdentityLinkClient,
14
+ type IdentityLinkConsentProps,
15
+ } from "./identity-link-consent";
16
+ export { IdentityLinkAccounts, type IdentityLinkAccountsClient } from "./identity-link-accounts";
17
+
18
+ export type { ConnectSnapshot } from "@opengeni/connect";
19
+ export type {
20
+ ConnectAccount,
21
+ ConnectAdvance,
22
+ ConnectAttempt,
23
+ ConnectNextAction,
24
+ ConnectOwnership,
25
+ ConnectProvider,
26
+ ConnectResource,
27
+ ConnectTransport,
28
+ } from "@opengeni/connect";
29
+
30
+ export function useConnect(controller: ConnectController) {
31
+ const snapshot = useSyncExternalStore(
32
+ controller.subscribe,
33
+ controller.getSnapshot,
34
+ controller.getSnapshot,
35
+ );
36
+ const actions = useMemo(
37
+ () => ({
38
+ begin: controller.begin.bind(controller),
39
+ recover: controller.recover.bind(controller),
40
+ refresh: controller.refresh.bind(controller),
41
+ waitForAction: controller.waitForAction.bind(controller),
42
+ advance: controller.advance.bind(controller),
43
+ cancel: controller.cancel.bind(controller),
44
+ }),
45
+ [controller],
46
+ );
47
+ return useMemo(() => ({ ...snapshot, ...actions }), [snapshot, actions]);
48
+ }