@opengeni/react 4.0.2 → 5.0.4-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-SCZ6SUBW.js} +1177 -1021
- package/dist/chunk-SCZ6SUBW.js.map +1 -0
- package/dist/clipboard.js +1 -0
- package/dist/components/markdown-table-layout.d.ts +4 -1
- 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/{markdown-table-layout-3Z3AWBY7.js → markdown-table-layout-PFUEZ2U4.js} +7 -4
- package/dist/{markdown-table-layout-3Z3AWBY7.js.map → markdown-table-layout-PFUEZ2U4.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/markdown-table-layout.ts +6 -3
- package/src/components/markdown.tsx +11 -3
- 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,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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { copyTextToClipboard } from "./clipboard";
|
|
3
|
+
|
|
4
|
+
export type DeviceAuthorizationProps = {
|
|
5
|
+
userCode: string;
|
|
6
|
+
verificationUri: string;
|
|
7
|
+
providerLabel?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
codeAttributes?: Record<`data-${string}`, string>;
|
|
11
|
+
loadClipboard?: () => Promise<{ copyTextToClipboard(text: string): Promise<boolean> }>;
|
|
12
|
+
onCopyResult?: (copied: boolean) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Optional presentation for the existing model-account device APIs. It neither
|
|
16
|
+
* owns provider state nor assumes that opening a verification page completed it. */
|
|
17
|
+
export function DeviceAuthorization(props: DeviceAuthorizationProps) {
|
|
18
|
+
const [copied, setCopied] = useState(false);
|
|
19
|
+
const [copyError, setCopyError] = useState(false);
|
|
20
|
+
const generation = useRef(0);
|
|
21
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
generation.current++;
|
|
24
|
+
setCopied(false);
|
|
25
|
+
setCopyError(false);
|
|
26
|
+
return () => {
|
|
27
|
+
// This is a live async-operation counter, not a captured DOM ref.
|
|
28
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
29
|
+
generation.current++;
|
|
30
|
+
if (timer.current) clearTimeout(timer.current);
|
|
31
|
+
};
|
|
32
|
+
}, [props.userCode]);
|
|
33
|
+
async function copy() {
|
|
34
|
+
const revision = ++generation.current;
|
|
35
|
+
if (timer.current) clearTimeout(timer.current);
|
|
36
|
+
setCopied(false);
|
|
37
|
+
setCopyError(false);
|
|
38
|
+
try {
|
|
39
|
+
const clipboard = props.loadClipboard ? await props.loadClipboard() : { copyTextToClipboard };
|
|
40
|
+
if (revision !== generation.current) return;
|
|
41
|
+
const success = await clipboard.copyTextToClipboard(props.userCode);
|
|
42
|
+
if (revision !== generation.current) return;
|
|
43
|
+
setCopied(success);
|
|
44
|
+
setCopyError(!success);
|
|
45
|
+
props.onCopyResult?.(success);
|
|
46
|
+
if (success)
|
|
47
|
+
timer.current = setTimeout(() => {
|
|
48
|
+
if (revision === generation.current) setCopied(false);
|
|
49
|
+
}, 1600);
|
|
50
|
+
} catch {
|
|
51
|
+
if (revision !== generation.current) return;
|
|
52
|
+
setCopyError(true);
|
|
53
|
+
props.onCopyResult?.(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
let href: string | undefined;
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(props.verificationUri);
|
|
59
|
+
if (["https:", "http:"].includes(url.protocol) && !url.username && !url.password)
|
|
60
|
+
href = props.verificationUri;
|
|
61
|
+
} catch {
|
|
62
|
+
/* A malformed provider URL must never become an executable link. */
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<section className={`og-connect ${props.className ?? ""}`} aria-label="Device authorization">
|
|
66
|
+
<p>{props.description ?? `Enter this code at ${props.providerLabel ?? "the provider"}.`}</p>
|
|
67
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
|
|
68
|
+
<code {...props.codeAttributes} style={{ overflowWrap: "anywhere" }}>
|
|
69
|
+
{props.userCode}
|
|
70
|
+
</code>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
aria-label={copied ? "Code copied" : "Copy code"}
|
|
74
|
+
onClick={() => void copy()}
|
|
75
|
+
>
|
|
76
|
+
{copied ? "Copied" : "Copy code"}
|
|
77
|
+
</button>
|
|
78
|
+
{href ? (
|
|
79
|
+
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
80
|
+
Open {props.providerLabel ?? "auth page"}
|
|
81
|
+
</a>
|
|
82
|
+
) : (
|
|
83
|
+
<span role="alert">Authorization address unavailable.</span>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
{copyError ? <p role="alert">Couldn't copy the code. Copy it manually instead.</p> : null}
|
|
87
|
+
<p role="status">Waiting for authorization…</p>
|
|
88
|
+
</section>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -194,8 +194,9 @@ export function useWorkspaceCapture(
|
|
|
194
194
|
return;
|
|
195
195
|
}
|
|
196
196
|
setDegradedReason(null);
|
|
197
|
-
//
|
|
198
|
-
//
|
|
197
|
+
// Expose metadata while a large signed manifest downloads. Consumers
|
|
198
|
+
// must distinguish this pending count from a resolved capture; zero
|
|
199
|
+
// working-tree files can still have committed-only branch changes.
|
|
199
200
|
setFileCount(res.stats.fileCount);
|
|
200
201
|
// Exactly one of manifest / manifestUrl is non-null (M2 contract). The inline
|
|
201
202
|
// manifest is the <200ms common case; a >2MB manifest is a signed URL hop.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ExternalIdentityLink, ExternalIdentityLinkPage } from "@opengeni/sdk";
|
|
3
|
+
|
|
4
|
+
export type IdentityLinkAccountsClient = {
|
|
5
|
+
listIdentityLinks(workspaceId: string, cursor?: string): Promise<ExternalIdentityLinkPage>;
|
|
6
|
+
revokeIdentityLink(
|
|
7
|
+
workspaceId: string,
|
|
8
|
+
linkId: string,
|
|
9
|
+
expectedRevision: number,
|
|
10
|
+
): Promise<ExternalIdentityLink>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Participant-scoped inventory. Revocation never deletes native work or merges
|
|
14
|
+
* identities. The caller remounts this component when the signed-in user changes. */
|
|
15
|
+
export function IdentityLinkAccounts({
|
|
16
|
+
client,
|
|
17
|
+
workspaceId,
|
|
18
|
+
}: {
|
|
19
|
+
client: IdentityLinkAccountsClient;
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
}) {
|
|
22
|
+
const [links, setLinks] = useState<ExternalIdentityLink[]>([]);
|
|
23
|
+
const [cursor, setCursor] = useState<string | null>(null);
|
|
24
|
+
const [busy, setBusy] = useState(true);
|
|
25
|
+
const [error, setError] = useState(false);
|
|
26
|
+
const [revision, setRevision] = useState(0);
|
|
27
|
+
const generation = useRef(0);
|
|
28
|
+
const inFlight = useRef(false);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const current = ++generation.current;
|
|
31
|
+
inFlight.current = true;
|
|
32
|
+
setLinks([]);
|
|
33
|
+
setCursor(null);
|
|
34
|
+
setBusy(true);
|
|
35
|
+
setError(false);
|
|
36
|
+
void client
|
|
37
|
+
.listIdentityLinks(workspaceId)
|
|
38
|
+
.then(
|
|
39
|
+
(page) => {
|
|
40
|
+
if (current !== generation.current) return;
|
|
41
|
+
setLinks(page.links);
|
|
42
|
+
setCursor(page.nextCursor);
|
|
43
|
+
},
|
|
44
|
+
() => {
|
|
45
|
+
if (current === generation.current) setError(true);
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
.finally(() => {
|
|
49
|
+
if (current === generation.current) {
|
|
50
|
+
inFlight.current = false;
|
|
51
|
+
setBusy(false);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
// Invalidate live async work, rather than cleaning up a captured DOM node.
|
|
56
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
57
|
+
generation.current++;
|
|
58
|
+
};
|
|
59
|
+
}, [client, workspaceId, revision]);
|
|
60
|
+
|
|
61
|
+
async function act(link?: ExternalIdentityLink) {
|
|
62
|
+
if (inFlight.current || (!link && !cursor)) return;
|
|
63
|
+
const current = generation.current;
|
|
64
|
+
inFlight.current = true;
|
|
65
|
+
setBusy(true);
|
|
66
|
+
setError(false);
|
|
67
|
+
try {
|
|
68
|
+
if (link) {
|
|
69
|
+
const updated = await client.revokeIdentityLink(workspaceId, link.id, link.revision);
|
|
70
|
+
if (current === generation.current)
|
|
71
|
+
setLinks((values) =>
|
|
72
|
+
values.map((value) => (value.id === updated.id ? { ...value, ...updated } : value)),
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
const page = await client.listIdentityLinks(workspaceId, cursor!);
|
|
76
|
+
if (current === generation.current) {
|
|
77
|
+
setLinks((values) => [
|
|
78
|
+
...new Map([...values, ...page.links].map((value) => [value.id, value])).values(),
|
|
79
|
+
]);
|
|
80
|
+
setCursor(page.nextCursor);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
if (current === generation.current) setError(true);
|
|
85
|
+
} finally {
|
|
86
|
+
if (current === generation.current) {
|
|
87
|
+
inFlight.current = false;
|
|
88
|
+
setBusy(false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<section
|
|
94
|
+
className="og-connect og-identity-link"
|
|
95
|
+
aria-label="Linked product access"
|
|
96
|
+
aria-busy={busy}
|
|
97
|
+
>
|
|
98
|
+
<h2>Linked product access</h2>
|
|
99
|
+
<p>
|
|
100
|
+
Manage products allowed to act as your account in this organization. Revoking a link stops
|
|
101
|
+
future linked access; it does not delete your work or undo an operation already started.
|
|
102
|
+
</p>
|
|
103
|
+
{error && (
|
|
104
|
+
<div role="alert">
|
|
105
|
+
Could not update account links. Reload to see current access.
|
|
106
|
+
<button type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>
|
|
107
|
+
Reload links
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
{busy && <p role="status">Loading account links…</p>}
|
|
112
|
+
{!busy && !error && links.length === 0 && (
|
|
113
|
+
<p>No products have linked access to this account.</p>
|
|
114
|
+
)}
|
|
115
|
+
<ul>
|
|
116
|
+
{links.map((link) => (
|
|
117
|
+
<li key={link.id}>
|
|
118
|
+
<p>
|
|
119
|
+
{link.externalIdentity?.source || "Product identity"}{" "}
|
|
120
|
+
<code>{link.externalIdentity?.externalId ?? link.externalIdentityId}</code>
|
|
121
|
+
</p>
|
|
122
|
+
<p>
|
|
123
|
+
Status: {link.status}.{" "}
|
|
124
|
+
{link.expiresAt
|
|
125
|
+
? `Expires ${new Date(link.expiresAt).toLocaleString()}.`
|
|
126
|
+
: "No automatic expiry."}
|
|
127
|
+
</p>
|
|
128
|
+
<p>{link.permissions.join(", ")}</p>
|
|
129
|
+
{(link.status === "active" || link.status === "pending") && (
|
|
130
|
+
<button type="button" disabled={busy} onClick={() => void act(link)}>
|
|
131
|
+
Revoke access
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
{cursor && (
|
|
138
|
+
<button type="button" disabled={busy} onClick={() => void act()}>
|
|
139
|
+
Load more links
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
</section>
|
|
143
|
+
);
|
|
144
|
+
}
|