-
Notifications
You must be signed in to change notification settings - Fork 707
Expand file tree
/
Copy pathObserverConfigModal.tsx
More file actions
497 lines (465 loc) · 21.9 KB
/
Copy pathObserverConfigModal.tsx
File metadata and controls
497 lines (465 loc) · 21.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
import { withDoResetRetry } from './rpcErrors'
import { useState, useEffect, useRef } from 'react'
import { Dialog, Select, Loader, Text, useKumoToastManager } from '@cloudflare/kumo'
import { Warning, Plus, ArrowClockwise, CheckCircle } from '@phosphor-icons/react'
import { RpcStub, RpcTarget } from 'capnweb'
import {
AuthenticatedApi,
ConnectedAccountsSubscriber,
GatekeeperVendorInfo,
ObserverBindingNeed,
ObserverAccountChoice,
} from '@gadgets/workshop-shared/api'
import {
AccountDescription,
VendorDescription,
SupportedResource,
resolveRequestedResource,
} from '@gadgets/workshop-shared/gatekeeper'
import { WorkshopButton } from './components/WorkshopControls'
import Avatar from './components/Avatar'
// Shown when a non-owner opens a shared Gadget that reads data through one or more gatekeeper
// bindings, and they haven't yet chosen which of their own connected accounts to use for each one.
// Each chosen account's owner is verified (server-side, via the gatekeeper) to actually have access
// to the data the Gadget read — that's how we uphold the "observers can't see data they couldn't
// otherwise read" invariant. See observers-implementation-plan.md §5 Step 4.
//
// The overseer invokes ObserverConfigCallback.configure(needs) during openGadget(); this modal is
// what fulfills that call. Resolving with one ObserverAccountChoice per need lets the open proceed;
// cancelling rejects it (the open is denied and the caller shows an access-denied page).
interface AccountInfo {
id: number
description: AccountDescription
vendor: VendorDescription
vendorId: string
supportedResources: SupportedResource[]
credentialsValid: boolean
}
// How to name one of the user's accounts in the UI. Falls back to the id, which is all we can show
// for an account that has since been disconnected (so `accounts` no longer has it).
function accountLabel(account: AccountInfo | undefined, accountId: number): string {
return account?.description.uniqueName || account?.description.displayName || `Account ${accountId}`
}
// Return the grantable resource type needed to verify one observer binding. Account metadata is
// preferred because it reflects the resources currently available to that account; vendor metadata
// is the fallback used before an account has been connected. Non-grantable resources require no
// OAuth scope expansion.
function requiredResourceUrlPatterns(
need: ObserverBindingNeed,
vendor: GatekeeperVendorInfo | undefined,
account?: AccountInfo,
): string[] {
const supportedResources = account?.supportedResources.length
? account.supportedResources
: vendor?.supportedResources
if (!supportedResources) return []
const resolved = resolveRequestedResource(supportedResources, need.resourceUrl)
return resolved.ok && resolved.resource.grantable ? [resolved.resource.urlPattern] : []
}
// Filter required resource types down to those the account has not granted yet. An omitted
// granted-resource list denotes a legacy or full-scope account and therefore satisfies every
// requirement.
function missingResourceUrlPatterns(account: AccountInfo, required: string[]): string[] {
const granted = account.description.grantedResourceUrlPatterns
return granted === undefined ? [] : required.filter(pattern => !granted.includes(pattern))
}
interface ObserverConfigModalProps {
needs: ObserverBindingNeed[]
authenticatedApi: RpcStub<AuthenticatedApi>
onConfirm: (choices: ObserverAccountChoice[]) => void
onCancel: () => void
}
export default function ObserverConfigModal({
needs,
authenticatedApi,
onConfirm,
onCancel,
}: ObserverConfigModalProps) {
const toasts = useKumoToastManager()
const [accounts, setAccounts] = useState<Map<number, AccountInfo>>(new Map())
const [ready, setReady] = useState(false)
// gatekeeperId -> chosen accountId (undefined = not yet chosen).
const [choices, setChoices] = useState<Record<number, number | undefined>>({})
// Vendor metadata keyed by vendorId, used both for display and to resolve the resource scopes each
// observer binding needs.
const [vendorsById, setVendorsById] = useState<Map<string, GatekeeperVendorInfo>>(new Map())
const [vendorsReady, setVendorsReady] = useState(false)
const [connecting, setConnecting] = useState<string | null>(null)
const [reconnecting, setReconnecting] = useState<number | null>(null)
const [granting, setGranting] = useState<number | null>(null)
// The subscriber closure (created once) reads the in-flight connect target through this ref so it
// can clear it when the freshly-connected account arrives.
const connectingRef = useRef<string | null>(null)
// ── subscribe to the user's connected accounts ────────────────────────────────
useEffect(() => {
let subStub: { [Symbol.dispose](): void } | null = null
let cancelled = false
class Subscriber extends RpcTarget implements ConnectedAccountsSubscriber {
add(
id: number,
description: AccountDescription,
vendor: VendorDescription,
supportedResources: SupportedResource[] = [],
credentialsValid: boolean = true,
vendorId: string = '',
) {
setAccounts(prev => {
const next = new Map(prev)
next.set(id, { id, description, vendor, vendorId, supportedResources, credentialsValid })
return next
})
if (credentialsValid) {
setReconnecting(r => (r === id ? null : r))
setGranting(g => (g === id ? null : g))
// If we were waiting on a connect for this vendor, it's done.
if (connectingRef.current === vendorId) {
connectingRef.current = null
setConnecting(null)
}
}
}
remove(id: number) {
setAccounts(prev => {
if (!prev.has(id)) return prev
const next = new Map(prev)
next.delete(id)
return next
})
}
ready() {
setReady(true)
}
}
withDoResetRetry(() => authenticatedApi
.subscribeConnectedAccounts(new Subscriber(), { includeForcedAutoProvisionedAccounts: true }))
.then(stub => {
if (cancelled) { stub[Symbol.dispose](); return }
subStub = stub
})
.catch(err => {
// Loud on purpose: the modal has no retry path, so a quieted transient failure would
// strand the user on a permanent loader.
console.error('Failed to subscribe to connected accounts:', err)
toasts.add({ title: 'Failed to load your connected accounts', variant: 'error' })
})
return () => {
cancelled = true
subStub?.[Symbol.dispose]()
}
}, [authenticatedApi])
// ── load vendor metadata for display and resource-scope resolution ─────────────
useEffect(() => {
let cancelled = false
withDoResetRetry(() => Promise.all([
authenticatedApi.listGatekeeperVendors(),
authenticatedApi.listAddableGatekeepers(),
]))
.then(([vendors, addable]) => {
if (cancelled) return
const map = new Map<string, GatekeeperVendorInfo>()
for (const vendor of [...vendors, ...addable]) map.set(vendor.id, vendor)
setVendorsById(map)
setVendorsReady(true)
})
.catch(err => {
console.error('Failed to load vendors:', err)
if (!cancelled) setVendorsReady(true)
})
return () => { cancelled = true }
}, [authenticatedApi])
// ── keep choices in sync with the available accounts ──────────────────────────
// Default each binding to its first matching account, and drop a choice whose account has
// disappeared (e.g. disconnected in another tab). When a binding is being re-prompted because it
// just failed, prefer the account that failed: re-authenticating it in place is usually the fix,
// so it should be what the re-authenticate affordance is aimed at.
useEffect(() => {
setChoices(prev => {
let changed = false
const next = { ...prev }
for (const need of needs) {
const matching = [...accounts.values()].filter(a => a.vendorId === need.vendorId)
const failed = need.failure && accounts.has(need.failure.accountId)
? need.failure.accountId
: undefined
const preferred = failed ?? matching[0]?.id
const current = next[need.gatekeeperId]
if (current !== undefined && !accounts.has(current)) {
next[need.gatekeeperId] = preferred
changed = true
} else if (current === undefined && preferred !== undefined) {
next[need.gatekeeperId] = preferred
changed = true
}
}
return changed ? next : prev
})
}, [accounts, needs])
// ── connect / reconnect handlers ──────────────────────────────────────────────
const handleConnect = async (need: ObserverBindingNeed) => {
const { vendorId } = need
connectingRef.current = vendorId
setConnecting(vendorId)
try {
const vendor = vendorsById.get(vendorId)
if (vendor?.description.autoProvisionsAccount) {
await authenticatedApi.provisionAmbientAccount(vendorId)
} else {
const required = requiredResourceUrlPatterns(need, vendor)
const { url } = await authenticatedApi.connectAccount(
vendorId,
required.length > 0 ? required : undefined,
)
window.open(url, '_blank', 'noopener,noreferrer')
}
} catch (err) {
console.error('Failed to initiate connection:', err)
toasts.add({ title: 'Failed to start connection flow', variant: 'error' })
connectingRef.current = null
setConnecting(null)
}
}
const handleReconnect = async (accountId: number) => {
setReconnecting(accountId)
try {
const { url } = await authenticatedApi.reconnectAccount(accountId)
window.open(url, '_blank', 'noopener,noreferrer')
// Subscription fires add() with credentialsValid:true on completion, clearing `reconnecting`.
} catch (err) {
console.error('Failed to initiate reconnection:', err)
toasts.add({ title: 'Failed to start re-authentication flow', variant: 'error' })
setReconnecting(null)
}
}
const handleGrantResourceAccess = async (need: ObserverBindingNeed, account: AccountInfo) => {
const required = requiredResourceUrlPatterns(
need,
vendorsById.get(need.vendorId),
account,
)
const missing = missingResourceUrlPatterns(account, required)
if (missing.length === 0) return
setGranting(account.id)
try {
const { url } = await authenticatedApi.ensureAccountResources(account.id, missing)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
else setGranting(null)
} catch (err) {
console.error('Failed to request additional access:', err)
toasts.add({ title: 'Failed to request additional access', variant: 'error' })
setGranting(null)
}
}
// A binding is satisfied only when its chosen account has valid credentials and every grantable
// resource type needed for observer verification.
const accountFor = (gatekeeperId: number): AccountInfo | undefined => {
const id = choices[gatekeeperId]
return id === undefined ? undefined : accounts.get(id)
}
const accountSatisfies = (need: ObserverBindingNeed, account: AccountInfo | undefined) => {
if (!account?.credentialsValid) return false
const required = requiredResourceUrlPatterns(
need,
vendorsById.get(need.vendorId),
account,
)
return missingResourceUrlPatterns(account, required).length === 0
}
const allSatisfied = needs.every(need => accountSatisfies(need, accountFor(need.gatekeeperId)))
const handleConfirm = () => {
const result: ObserverAccountChoice[] = []
for (const need of needs) {
const accountId = choices[need.gatekeeperId]
if (accountId === undefined) return
result.push({ gatekeeperId: need.gatekeeperId, accountId })
}
onConfirm(result)
}
// The overseer re-prompts with `failure` set when an already-configured binding failed
// verification on this open (typically expired credentials).
const isRetry = needs.some(n => n.failure)
return (
<Dialog.Root open disablePointerDismissal onOpenChange={open => { if (!open) onCancel() }}>
<Dialog className="p-6" size="lg">
<Dialog.Title className="mb-2 text-lg font-semibold">
{isRetry ? 'Verify your access again' : 'Verify your access'}
</Dialog.Title>
<Text variant="secondary" size="sm" as="p">
{isRetry
? 'We couldn’t confirm your access to everything this workspace has read. Re-authenticate ' +
'the account below, or choose a different one, then try again.'
: 'Before opening this workspace, confirm that your own accounts can access the connected ' +
'data it uses.'}
</Text>
{!ready || !vendorsReady ? (
<div className="text-center py-10">
<Loader />
</div>
) : (
<div className="flex flex-col gap-4 mt-5">
{needs.map(need => {
const matching = [...accounts.values()].filter(a => a.vendorId === need.vendorId)
const vendorInfo = vendorsById.get(need.vendorId)
const vendor = matching[0]?.vendor ?? vendorInfo?.description
const vendorName = vendor?.displayName || need.vendorId || 'service'
const chosen = accountFor(need.gatekeeperId)
const required = requiredResourceUrlPatterns(need, vendorInfo, chosen)
const missing = chosen ? missingResourceUrlPatterns(chosen, required) : []
return (
<div key={need.gatekeeperId} className="rounded-xl border border-kumo-line bg-kumo-base p-4">
<div className={`flex items-center gap-3${matching.length === 0 && !need.failure ? '' : ' mb-3'}`}>
<Avatar
src={vendor?.logo?.url}
background={vendor?.color}
size={32}
fallback={<Plus size={16} />}
/>
<div className="min-w-0 flex-1">
<div className="text-[14px] font-medium text-kumo-default truncate">
{need.resourceTitle}
</div>
{need.resourceUrl && (
<div className="text-xs font-mono text-kumo-subtle truncate">
{need.resourceUrl.replace(/^https?:\/\//, '')}
</div>
)}
</div>
{matching.length === 0 && vendor && (
<WorkshopButton
tone="primary"
onClick={() => handleConnect(need)}
disabled={connecting === need.vendorId}
>
{connecting === need.vendorId ? 'Waiting for connection…' : 'Connect'}
</WorkshopButton>
)}
</div>
{/* Name the account that was refused and why. The reason is free text, either from
the gatekeeper or authored by the overseer, and must not be parsed. */}
{need.failure && (
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-xs text-kumo-warning bg-kumo-warning-tint border border-kumo-warning/20${matching.length === 0 ? '' : ' mb-3'}`}>
<Warning size={14} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="font-medium">
{accountLabel(accounts.get(need.failure.accountId), need.failure.accountId)}
</span>
{' — '}
{need.failure.reason}
</div>
</div>
)}
{matching.length > 0 && (
<div className="flex flex-col gap-2">
{matching.length === 1 ? (
<div className="flex min-h-10 items-center gap-3 rounded-lg border border-kumo-line bg-kumo-elevated/50 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] leading-4 text-kumo-subtle">Using your account</div>
<div className="truncate text-sm font-medium text-kumo-default">
{accountLabel(matching[0], matching[0].id)}
</div>
</div>
{accountSatisfies(need, matching[0]) && (
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-kumo-success">
<CheckCircle size={15} weight="fill" /> Ready
</span>
)}
</div>
) : (
<Select
className="w-full text-sm"
value={
choices[need.gatekeeperId] !== undefined
? String(choices[need.gatekeeperId])
: undefined
}
placeholder={`Choose a ${vendorName} account…`}
onValueChange={v =>
setChoices(prev => ({ ...prev, [need.gatekeeperId]: Number(v) }))
}
renderValue={v => accountLabel(accounts.get(Number(v)), Number(v))}
>
{matching.map(acct => (
<Select.Option key={acct.id} value={String(acct.id)}>
{accountLabel(acct, acct.id)}
{!acct.credentialsValid ? ' (expired)' : ''}
</Select.Option>
))}
</Select>
)}
{/* Scope expansion also replaces expired credentials, so prefer this over the
plain re-authentication path when both apply. */}
{chosen && missing.length > 0 && (
<button
type="button"
onClick={() => handleGrantResourceAccess(need, chosen)}
disabled={granting === chosen.id}
className="flex items-center gap-1.5 text-xs text-kumo-warning hover:underline disabled:opacity-60"
>
{granting === chosen.id ? (
<ArrowClockwise size={12} className="animate-spin" />
) : (
<Warning size={12} />
)}
{granting === chosen.id
? 'Waiting for access…'
: 'Grant the access needed to verify this resource'}
</button>
)}
{/* Offer re-authentication when we know the credentials are stale, and also
when this is the account that just failed verification: a gatekeeper that
rejects an observer on an auth error doesn't always tell the Workshop, so
`credentialsValid` can still read true. reconnectAccount() is documented as
safe for an account that merely *may* be expiring. */}
{chosen && missing.length === 0 &&
(!chosen.credentialsValid || chosen.id === need.failure?.accountId) && (
<button
type="button"
onClick={() => handleReconnect(chosen.id)}
disabled={reconnecting === chosen.id}
className="flex items-center gap-1.5 text-xs text-kumo-warning hover:underline disabled:opacity-60"
>
{reconnecting === chosen.id ? (
<ArrowClockwise size={12} className="animate-spin" />
) : (
<Warning size={12} />
)}
{reconnecting === chosen.id
? 'Re-authenticating…'
: chosen.credentialsValid
? 'Click to re-authenticate this account'
: 'This account has expired — click to re-authenticate'}
</button>
)}
{!vendor?.autoProvisionsAccount && (
<button
type="button"
onClick={() => handleConnect(need)}
disabled={connecting === need.vendorId}
className="flex items-center gap-1 text-xs text-kumo-subtle hover:text-kumo-default disabled:opacity-60 self-start"
>
<Plus size={11} />
{connecting === need.vendorId ? 'Waiting for connection…' : 'Connect a different account'}
</button>
)}
</div>
)}
</div>
)
})}
</div>
)}
<div className="flex justify-end gap-2 mt-6">
<WorkshopButton tone="secondary" onClick={onCancel}>
Cancel
</WorkshopButton>
<WorkshopButton
tone="primary"
onClick={handleConfirm}
disabled={!ready || !vendorsReady || !allSatisfied}
>
{isRetry ? 'Verify again' : 'Verify and open'}
</WorkshopButton>
</div>
</Dialog>
</Dialog.Root>
)
}