@opengeni/react 4.0.2 → 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.
- package/README.md +42 -0
- package/dist/artifacts.js +24 -285
- package/dist/artifacts.js.map +1 -1
- package/dist/chunk-BRFMGY4L.js +274 -0
- package/dist/chunk-BRFMGY4L.js.map +1 -0
- package/dist/chunk-IWIDV7SB.js +1 -0
- package/dist/chunk-IWIDV7SB.js.map +1 -0
- package/dist/{chunk-CQG7YUJB.js → chunk-WRVKY242.js} +1169 -1017
- package/dist/chunk-WRVKY242.js.map +1 -0
- package/dist/clipboard.js +1 -0
- package/dist/components/message-timeline.d.ts +7 -3
- package/dist/connect-accounts.d.ts +10 -0
- package/dist/connect-chooser.d.ts +11 -0
- package/dist/connect-panel.d.ts +10 -0
- package/dist/connect-setup.d.ts +12 -0
- package/dist/connect.d.ts +30 -0
- package/dist/connect.js +831 -0
- package/dist/connect.js.map +1 -0
- package/dist/device-authorization.d.ts +15 -0
- package/dist/identity-link-accounts.d.ts +11 -0
- package/dist/identity-link-consent.d.ts +18 -0
- package/dist/index.js +523 -663
- package/dist/index.js.map +1 -1
- package/dist/session-ui.d.ts +2 -0
- package/dist/session-ui.js +3 -1
- package/dist/sites-ui.d.ts +33 -0
- package/dist/sites.d.ts +3 -0
- package/dist/sites.js +288 -0
- package/dist/sites.js.map +1 -0
- package/package.json +16 -2
- package/src/components/message-timeline.tsx +22 -5
- package/src/components/sandbox-workspace.tsx +9 -2
- package/src/connect-accounts.tsx +171 -0
- package/src/connect-chooser.tsx +144 -0
- package/src/connect-panel.tsx +29 -0
- package/src/connect-setup.tsx +260 -0
- package/src/connect.ts +48 -0
- package/src/device-authorization.tsx +90 -0
- package/src/hooks/use-workspace-capture.ts +3 -2
- package/src/identity-link-accounts.tsx +144 -0
- package/src/identity-link-consent.tsx +192 -0
- package/src/session-ui.ts +2 -0
- package/src/sites-ui.tsx +385 -0
- package/src/sites.ts +3 -0
- package/src/timeline/tool-renderers.tsx +1 -1
- package/styles/connect.css +48 -0
- package/styles/connect.d.ts +1 -0
- 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
|
+
}
|