@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.9
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/package.json +89 -5
- package/src/agents/mcpServerPresets.test.ts +105 -0
- package/src/agents/mcpServerPresets.ts +380 -0
- package/src/agents/runtime/index.ts +7 -0
- package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
- package/src/agents/runtime/pendingAsks.test.ts +117 -0
- package/src/agents/runtime/pendingAsks.ts +110 -0
- package/src/bridge/agentScopeDefaults.ts +65 -0
- package/src/bridge/agentTypes.ts +57 -3
- package/src/bridge/agents.d.mts +6 -0
- package/src/bridge/agents.mjs +10 -0
- package/src/bridge/agents.ts +4 -0
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/credentials.d.mts +62 -0
- package/src/bridge/credentials.mjs +53 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/events.d.mts +1 -0
- package/src/bridge/events.mjs +1 -0
- package/src/bridge/googleAuth.ts +7 -152
- package/src/bridge/mcpClient.d.mts +74 -0
- package/src/bridge/mcpClient.mjs +79 -0
- package/src/bridge/methods.d.mts +45 -0
- package/src/bridge/methods.mjs +46 -0
- package/src/bridge/notifications.d.mts +12 -0
- package/src/bridge/notifications.mjs +36 -0
- package/src/bridge/operations.d.mts +44 -0
- package/src/bridge/operations.mjs +40 -0
- package/src/bridge/pureRender/base.css +44 -0
- package/src/bridge/pureRender/document.ts +29 -2
- package/src/bridge/pureRender/index.ts +3 -0
- package/src/bridge/pureRender/normalize.ts +167 -0
- package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
- package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
- package/src/bridge/pureRender/styles.ts +125 -5
- package/src/bridge/pureRender/theme.test.ts +259 -0
- package/src/bridge/pureRender/theme.ts +19 -1
- package/src/bridge/pureRender/types.ts +33 -0
- package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
- package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
- package/src/bridge/react/useDocumentHotkeys.ts +41 -0
- package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
- package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
- package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
- package/src/bridge/react/usePlatformOperations.tsx +75 -0
- package/src/bridge/storage.d.mts +23 -10
- package/src/bridge/storage.mjs +6 -3
- package/src/bridge/storage.test.ts +6 -6
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentComposer.tsx +10 -6
- package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
- package/src/components/agents/AgentDrawerPanel.tsx +23 -62
- package/src/components/agents/AgentMessageBubble.tsx +59 -33
- package/src/components/agents/AgentMessageList.tsx +4 -4
- package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
- package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/AgentToolPendingList.tsx +6 -6
- package/src/components/agents/AgentToolPendingReview.tsx +6 -6
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/QuestionRequestList.tsx +278 -0
- package/src/components/agents/agentPanelStyles.ts +8 -31
- package/src/components/agents/agentTypes.ts +25 -41
- package/src/components/agents/index.ts +4 -0
- package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
- package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/chat/ChatBox.tsx +9 -9
- package/src/components/common/chat/ChatModelMenu.tsx +34 -4
- package/src/components/common/chat/ChatThread.tsx +23 -7
- package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
- package/src/components/common/connections/ProviderConnection.tsx +277 -0
- package/src/components/common/connections/index.ts +5 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +26 -10
- package/src/components/common/containers/AppHeader.tsx +37 -15
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
- package/src/components/common/dropdown/MenuPortal.tsx +3 -1
- package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
- package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
- package/src/components/common/overlays/Modal.tsx +13 -2
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
- package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
- package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
- package/src/components/print-design/index.ts +2 -0
- package/src/components/print-design/previewContent.test.ts +32 -0
- package/src/components/print-design/previewContent.ts +31 -0
- package/src/context/buildContextDocument.test.ts +240 -0
- package/src/context/buildContextDocument.ts +309 -0
- package/src/context/contextAccess.ts +66 -0
- package/src/context/contextConfig.ts +72 -0
- package/src/context/contextDocument.test.ts +155 -0
- package/src/context/contextDocument.ts +300 -0
- package/src/context/contextSections.test.ts +88 -0
- package/src/context/contextSections.ts +84 -0
- package/src/context/htmlToContextMarkdown.test.ts +134 -0
- package/src/context/htmlToContextMarkdown.ts +369 -0
- package/src/ics/generateIcs.test.ts +153 -0
- package/src/ics/generateIcs.ts +197 -0
- package/src/index.ts +3 -0
- package/src/net/httpRetry.test.ts +117 -0
- package/src/net/httpRetry.ts +111 -0
- package/src/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +93 -104
- package/src/theme/appIdentityCss.mjs +178 -229
- package/src/theme/appIdentityCss.ts +222 -231
- package/src/theme/themes/dark.ts +3 -1
- package/src/theme/themes/light.ts +3 -1
- package/src/bridge/googleProviderConfig.ts +0 -73
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
-
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
|
|
3
3
|
import { styled } from 'styled-components'
|
|
4
4
|
|
|
5
5
|
export interface ChatThreadProps {
|
|
@@ -17,21 +17,36 @@ export function ChatThread({
|
|
|
17
17
|
scrollKey,
|
|
18
18
|
className,
|
|
19
19
|
}: ChatThreadProps): React.ReactElement {
|
|
20
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
20
21
|
const endRef = useRef<HTMLDivElement>(null)
|
|
21
22
|
const rafRef = useRef<number>(0)
|
|
22
|
-
|
|
23
|
-
useEffect(() => {
|
|
23
|
+
const scrollToEnd = useCallback(() => {
|
|
24
24
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
|
25
25
|
rafRef.current = requestAnimationFrame(() => {
|
|
26
|
-
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
26
|
+
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
|
27
27
|
})
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
scrollToEnd()
|
|
28
32
|
return () => {
|
|
29
33
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
|
30
34
|
}
|
|
31
|
-
}, [children, scrollKey,
|
|
35
|
+
}, [children, footer, scrollKey, scrollToEnd])
|
|
36
|
+
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
const node = containerRef.current
|
|
39
|
+
if (!node || typeof ResizeObserver === 'undefined') return
|
|
40
|
+
|
|
41
|
+
const observer = new ResizeObserver(() => {
|
|
42
|
+
scrollToEnd()
|
|
43
|
+
})
|
|
44
|
+
observer.observe(node)
|
|
45
|
+
return () => observer.disconnect()
|
|
46
|
+
}, [scrollToEnd])
|
|
32
47
|
|
|
33
48
|
return (
|
|
34
|
-
<Container className={className}>
|
|
49
|
+
<Container ref={containerRef} className={className} data-agent-thread-scroll>
|
|
35
50
|
{emptyMessage}
|
|
36
51
|
{children}
|
|
37
52
|
{footer}
|
|
@@ -48,7 +63,8 @@ const Container = styled.div`
|
|
|
48
63
|
min-height: 0;
|
|
49
64
|
overflow-y: auto;
|
|
50
65
|
padding: calc(16px + var(--platform-agent-thread-header-height, 0px)) 24px
|
|
51
|
-
16px;
|
|
66
|
+
calc(16px + var(--platform-agent-composer-reserve-height, 0px));
|
|
67
|
+
scroll-padding-bottom: var(--platform-agent-composer-reserve-height, 0px);
|
|
52
68
|
`
|
|
53
69
|
|
|
54
70
|
export const ChatThreadEmpty = styled.div`
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { act } from 'react'
|
|
2
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { OAuthCredentialStatus } from '../../../bridge/credentials.mjs'
|
|
5
|
+
import {
|
|
6
|
+
ProviderConnection,
|
|
7
|
+
type ProviderConnectionClient,
|
|
8
|
+
} from './ProviderConnection.js'
|
|
9
|
+
|
|
10
|
+
function statusOf(
|
|
11
|
+
overrides: Partial<OAuthCredentialStatus> = {},
|
|
12
|
+
): OAuthCredentialStatus {
|
|
13
|
+
return {
|
|
14
|
+
id: 'google.oauth',
|
|
15
|
+
kind: 'oauth',
|
|
16
|
+
provider: 'google',
|
|
17
|
+
configured: false,
|
|
18
|
+
connected: false,
|
|
19
|
+
scopes: [],
|
|
20
|
+
needsReconnect: false,
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mockClient(
|
|
26
|
+
status: OAuthCredentialStatus,
|
|
27
|
+
): ProviderConnectionClient & {
|
|
28
|
+
setOAuthConfig: ReturnType<typeof vi.fn>
|
|
29
|
+
connectOAuth: ReturnType<typeof vi.fn>
|
|
30
|
+
disconnect: ReturnType<typeof vi.fn>
|
|
31
|
+
} {
|
|
32
|
+
let current = status
|
|
33
|
+
return {
|
|
34
|
+
status: vi.fn(async () => current),
|
|
35
|
+
setOAuthConfig: vi.fn(async () => {
|
|
36
|
+
current = statusOf({ configured: true })
|
|
37
|
+
return { ok: true }
|
|
38
|
+
}),
|
|
39
|
+
connectOAuth: vi.fn(async () => {
|
|
40
|
+
current = statusOf({
|
|
41
|
+
configured: true,
|
|
42
|
+
connected: true,
|
|
43
|
+
email: 'adam@pure.science',
|
|
44
|
+
})
|
|
45
|
+
return { email: 'adam@pure.science' }
|
|
46
|
+
}),
|
|
47
|
+
disconnect: vi.fn(async () => {
|
|
48
|
+
current = statusOf({ configured: true })
|
|
49
|
+
return { ok: true }
|
|
50
|
+
}),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let host: HTMLDivElement | null = null
|
|
55
|
+
let root: Root | null = null
|
|
56
|
+
|
|
57
|
+
async function render(element: React.ReactElement): Promise<void> {
|
|
58
|
+
host = document.createElement('div')
|
|
59
|
+
document.body.appendChild(host)
|
|
60
|
+
root = createRoot(host)
|
|
61
|
+
await act(async () => {
|
|
62
|
+
root?.render(element)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await act(async () => {
|
|
68
|
+
root?.unmount()
|
|
69
|
+
})
|
|
70
|
+
host?.remove()
|
|
71
|
+
host = null
|
|
72
|
+
root = null
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('ProviderConnection', () => {
|
|
76
|
+
it('shows config fields when unconfigured and clears the secret after save', async () => {
|
|
77
|
+
const client = mockClient(statusOf())
|
|
78
|
+
await render(
|
|
79
|
+
<ProviderConnection
|
|
80
|
+
credentialId="google.oauth"
|
|
81
|
+
title="Google account"
|
|
82
|
+
client={client}
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const clientIdInput = host?.querySelector<HTMLInputElement>(
|
|
87
|
+
'#google\\.oauth-client-id',
|
|
88
|
+
)
|
|
89
|
+
const secretInput = host?.querySelector<HTMLInputElement>(
|
|
90
|
+
'#google\\.oauth-client-secret',
|
|
91
|
+
)
|
|
92
|
+
expect(clientIdInput).toBeTruthy()
|
|
93
|
+
expect(secretInput?.type).toBe('password')
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
clientIdInput?.focus()
|
|
97
|
+
})
|
|
98
|
+
// Drive the controlled inputs through React's onChange.
|
|
99
|
+
const setNativeValue = (input: HTMLInputElement, value: string) => {
|
|
100
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
101
|
+
HTMLInputElement.prototype,
|
|
102
|
+
'value',
|
|
103
|
+
)?.set
|
|
104
|
+
setter?.call(input, value)
|
|
105
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
106
|
+
}
|
|
107
|
+
await act(async () => {
|
|
108
|
+
if (clientIdInput) setNativeValue(clientIdInput, 'client-id')
|
|
109
|
+
if (secretInput) setNativeValue(secretInput, 'client-secret')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const save = Array.from(host?.querySelectorAll('button') ?? []).find(button =>
|
|
113
|
+
button.textContent?.includes('Save'),
|
|
114
|
+
)
|
|
115
|
+
expect(save?.disabled).toBe(false)
|
|
116
|
+
await act(async () => {
|
|
117
|
+
save?.click()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(client.setOAuthConfig).toHaveBeenCalledWith({
|
|
121
|
+
id: 'google.oauth',
|
|
122
|
+
clientId: 'client-id',
|
|
123
|
+
clientSecret: 'client-secret',
|
|
124
|
+
})
|
|
125
|
+
// Write-only: after save the secret is gone from the DOM entirely.
|
|
126
|
+
expect(host?.innerHTML).not.toContain('client-secret')
|
|
127
|
+
// Now configured: the sign-in button is offered.
|
|
128
|
+
expect(host?.textContent).toContain('Sign in with Google')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('shows the connected identity and never any secret material', async () => {
|
|
132
|
+
const client = mockClient(
|
|
133
|
+
statusOf({
|
|
134
|
+
configured: true,
|
|
135
|
+
connected: true,
|
|
136
|
+
email: 'adam@pure.science',
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
await render(
|
|
140
|
+
<ProviderConnection
|
|
141
|
+
credentialId="google.oauth"
|
|
142
|
+
title="Google account"
|
|
143
|
+
client={client}
|
|
144
|
+
/>,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
expect(host?.textContent).toContain('Connected as adam@pure.science')
|
|
148
|
+
expect(host?.textContent).toContain('Disconnect')
|
|
149
|
+
expect(host?.querySelector('input')).toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('surfaces reconnect prominently when the grant is revoked', async () => {
|
|
153
|
+
const client = mockClient(
|
|
154
|
+
statusOf({
|
|
155
|
+
configured: true,
|
|
156
|
+
connected: false,
|
|
157
|
+
needsReconnect: true,
|
|
158
|
+
email: 'adam@pure.science',
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
await render(
|
|
162
|
+
<ProviderConnection
|
|
163
|
+
credentialId="google.oauth"
|
|
164
|
+
title="Google account"
|
|
165
|
+
client={client}
|
|
166
|
+
/>,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(host?.textContent).toContain('Reconnect required')
|
|
170
|
+
const reconnect = Array.from(host?.querySelectorAll('button') ?? []).find(
|
|
171
|
+
button => button.textContent === 'Reconnect',
|
|
172
|
+
)
|
|
173
|
+
expect(reconnect).toBeTruthy()
|
|
174
|
+
|
|
175
|
+
await act(async () => {
|
|
176
|
+
reconnect?.click()
|
|
177
|
+
})
|
|
178
|
+
expect(client.connectOAuth).toHaveBeenCalledWith({ id: 'google.oauth' })
|
|
179
|
+
expect(host?.textContent).toContain('Connected as adam@pure.science')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('notifies the host app on status changes', async () => {
|
|
183
|
+
const client = mockClient(statusOf({ configured: true }))
|
|
184
|
+
const onStatusChange = vi.fn()
|
|
185
|
+
await render(
|
|
186
|
+
<ProviderConnection
|
|
187
|
+
credentialId="google.oauth"
|
|
188
|
+
title="Google account"
|
|
189
|
+
client={client}
|
|
190
|
+
onStatusChange={onStatusChange}
|
|
191
|
+
/>,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(onStatusChange).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({ configured: true, connected: false }),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const signIn = Array.from(host?.querySelectorAll('button') ?? []).find(button =>
|
|
199
|
+
button.textContent?.includes('Sign in with Google'),
|
|
200
|
+
)
|
|
201
|
+
await act(async () => {
|
|
202
|
+
signIn?.click()
|
|
203
|
+
})
|
|
204
|
+
expect(onStatusChange).toHaveBeenLastCalledWith(
|
|
205
|
+
expect.objectContaining({ connected: true }),
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
connectCredentialOAuth,
|
|
4
|
+
disconnectCredential,
|
|
5
|
+
readCredentialStatus,
|
|
6
|
+
setCredentialOAuthConfig,
|
|
7
|
+
type OAuthCredentialStatus,
|
|
8
|
+
} from '../../../bridge/credentials.mjs'
|
|
9
|
+
import { Button } from '../buttons/Button.js'
|
|
10
|
+
import { FormField } from '../inputs/FormField.js'
|
|
11
|
+
import { TextField } from '../inputs/TextField.js'
|
|
12
|
+
import {
|
|
13
|
+
ConnectionButtonRow,
|
|
14
|
+
ConnectionFieldGrid,
|
|
15
|
+
ConnectionPanel,
|
|
16
|
+
ConnectionPanelText,
|
|
17
|
+
ConnectionPanelTitle,
|
|
18
|
+
ConnectionTestResult,
|
|
19
|
+
} from './ConnectionSetup.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bridge surface the component talks to. Overridable for tests; production
|
|
23
|
+
* defaults to the shell credentials bridge. The shell owns all state — this
|
|
24
|
+
* component never caches connection state and never sees secret material.
|
|
25
|
+
*/
|
|
26
|
+
export interface ProviderConnectionClient {
|
|
27
|
+
status(request: { id: string }): Promise<OAuthCredentialStatus>
|
|
28
|
+
setOAuthConfig(request: {
|
|
29
|
+
id: string
|
|
30
|
+
clientId: string
|
|
31
|
+
clientSecret: string
|
|
32
|
+
scopes?: string[]
|
|
33
|
+
}): Promise<unknown>
|
|
34
|
+
connectOAuth(request: {
|
|
35
|
+
id: string
|
|
36
|
+
scopes?: string[]
|
|
37
|
+
}): Promise<{ email?: string }>
|
|
38
|
+
disconnect(request: { id: string; forget?: boolean }): Promise<unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const bridgeClient: ProviderConnectionClient = {
|
|
42
|
+
status: readCredentialStatus,
|
|
43
|
+
setOAuthConfig: setCredentialOAuthConfig,
|
|
44
|
+
connectOAuth: connectCredentialOAuth,
|
|
45
|
+
disconnect: disconnectCredential,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProviderConnectionProps {
|
|
49
|
+
/** Shell credential id, e.g. `google.oauth`. */
|
|
50
|
+
credentialId: string
|
|
51
|
+
title: string
|
|
52
|
+
description?: string
|
|
53
|
+
/** Scopes this app needs; merged into the connection request. */
|
|
54
|
+
scopes?: string[]
|
|
55
|
+
/** "How to create an OAuth client" walkthrough for the unconfigured state. */
|
|
56
|
+
helpUrl?: string
|
|
57
|
+
/** Called after connect/disconnect so the host app can rebuild providers. */
|
|
58
|
+
onStatusChange?: (status: OAuthCredentialStatus) => void
|
|
59
|
+
client?: ProviderConnectionClient
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Busy = 'idle' | 'saving' | 'connecting' | 'disconnecting'
|
|
63
|
+
|
|
64
|
+
export function ProviderConnection({
|
|
65
|
+
credentialId,
|
|
66
|
+
title,
|
|
67
|
+
description,
|
|
68
|
+
scopes,
|
|
69
|
+
helpUrl,
|
|
70
|
+
onStatusChange,
|
|
71
|
+
client = bridgeClient,
|
|
72
|
+
}: ProviderConnectionProps): React.ReactElement {
|
|
73
|
+
const [status, setStatus] = useState<OAuthCredentialStatus | null>(null)
|
|
74
|
+
const [clientIdDraft, setClientIdDraft] = useState('')
|
|
75
|
+
const [clientSecretDraft, setClientSecretDraft] = useState('')
|
|
76
|
+
const [busy, setBusy] = useState<Busy>('idle')
|
|
77
|
+
const [error, setError] = useState<string | null>(null)
|
|
78
|
+
|
|
79
|
+
const refresh = useCallback(async () => {
|
|
80
|
+
const next = await client.status({ id: credentialId })
|
|
81
|
+
setStatus(next)
|
|
82
|
+
onStatusChange?.(next)
|
|
83
|
+
return next
|
|
84
|
+
}, [client, credentialId, onStatusChange])
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
refresh().catch(cause =>
|
|
88
|
+
setError(cause instanceof Error ? cause.message : String(cause)),
|
|
89
|
+
)
|
|
90
|
+
}, [refresh])
|
|
91
|
+
|
|
92
|
+
const scopesRequest = useMemo(
|
|
93
|
+
() => (scopes?.length ? { scopes } : {}),
|
|
94
|
+
[scopes],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const saveConfig = useCallback(async () => {
|
|
98
|
+
setBusy('saving')
|
|
99
|
+
setError(null)
|
|
100
|
+
try {
|
|
101
|
+
await client.setOAuthConfig({
|
|
102
|
+
id: credentialId,
|
|
103
|
+
clientId: clientIdDraft,
|
|
104
|
+
clientSecret: clientSecretDraft,
|
|
105
|
+
...scopesRequest,
|
|
106
|
+
})
|
|
107
|
+
// Write-only: the secret leaves the DOM the moment it is saved.
|
|
108
|
+
setClientSecretDraft('')
|
|
109
|
+
setClientIdDraft('')
|
|
110
|
+
await refresh()
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
113
|
+
} finally {
|
|
114
|
+
setBusy('idle')
|
|
115
|
+
}
|
|
116
|
+
}, [client, credentialId, clientIdDraft, clientSecretDraft, refresh, scopesRequest])
|
|
117
|
+
|
|
118
|
+
const connect = useCallback(async () => {
|
|
119
|
+
setBusy('connecting')
|
|
120
|
+
setError(null)
|
|
121
|
+
try {
|
|
122
|
+
await client.connectOAuth({ id: credentialId, ...scopesRequest })
|
|
123
|
+
await refresh()
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
126
|
+
} finally {
|
|
127
|
+
setBusy('idle')
|
|
128
|
+
}
|
|
129
|
+
}, [client, credentialId, refresh, scopesRequest])
|
|
130
|
+
|
|
131
|
+
const disconnect = useCallback(async () => {
|
|
132
|
+
setBusy('disconnecting')
|
|
133
|
+
setError(null)
|
|
134
|
+
try {
|
|
135
|
+
await client.disconnect({ id: credentialId })
|
|
136
|
+
await refresh()
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
139
|
+
} finally {
|
|
140
|
+
setBusy('idle')
|
|
141
|
+
}
|
|
142
|
+
}, [client, credentialId, refresh])
|
|
143
|
+
|
|
144
|
+
const configured = status?.configured === true
|
|
145
|
+
const connected = status?.connected === true
|
|
146
|
+
const needsReconnect = status?.needsReconnect === true
|
|
147
|
+
const working = busy !== 'idle'
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<ConnectionPanel aria-busy={working}>
|
|
151
|
+
<ConnectionPanelTitle>{title}</ConnectionPanelTitle>
|
|
152
|
+
{description ? (
|
|
153
|
+
<ConnectionPanelText>{description}</ConnectionPanelText>
|
|
154
|
+
) : null}
|
|
155
|
+
|
|
156
|
+
{!configured ? (
|
|
157
|
+
<>
|
|
158
|
+
<ConnectionPanelText>
|
|
159
|
+
Paste the OAuth client id and secret from your Google Cloud
|
|
160
|
+
console.{' '}
|
|
161
|
+
{helpUrl ? (
|
|
162
|
+
<a href={helpUrl} target="_blank" rel="noreferrer">
|
|
163
|
+
How to create an OAuth client
|
|
164
|
+
</a>
|
|
165
|
+
) : null}
|
|
166
|
+
</ConnectionPanelText>
|
|
167
|
+
<ConnectionFieldGrid>
|
|
168
|
+
<FormField label="Client id" htmlFor={`${credentialId}-client-id`}>
|
|
169
|
+
<TextField
|
|
170
|
+
id={`${credentialId}-client-id`}
|
|
171
|
+
value={clientIdDraft}
|
|
172
|
+
autoComplete="off"
|
|
173
|
+
spellCheck={false}
|
|
174
|
+
disabled={working}
|
|
175
|
+
onChange={event => setClientIdDraft(event.target.value)}
|
|
176
|
+
/>
|
|
177
|
+
</FormField>
|
|
178
|
+
<FormField
|
|
179
|
+
label="Client secret"
|
|
180
|
+
htmlFor={`${credentialId}-client-secret`}
|
|
181
|
+
hint="Stored in the encrypted vault; never shown again."
|
|
182
|
+
>
|
|
183
|
+
<TextField
|
|
184
|
+
id={`${credentialId}-client-secret`}
|
|
185
|
+
type="password"
|
|
186
|
+
value={clientSecretDraft}
|
|
187
|
+
autoComplete="off"
|
|
188
|
+
spellCheck={false}
|
|
189
|
+
disabled={working}
|
|
190
|
+
onChange={event => setClientSecretDraft(event.target.value)}
|
|
191
|
+
/>
|
|
192
|
+
</FormField>
|
|
193
|
+
</ConnectionFieldGrid>
|
|
194
|
+
<ConnectionButtonRow>
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
disabled={
|
|
198
|
+
working || !clientIdDraft.trim() || !clientSecretDraft.trim()
|
|
199
|
+
}
|
|
200
|
+
onClick={() => void saveConfig()}
|
|
201
|
+
>
|
|
202
|
+
{busy === 'saving' ? 'Saving…' : 'Save'}
|
|
203
|
+
</Button>
|
|
204
|
+
</ConnectionButtonRow>
|
|
205
|
+
</>
|
|
206
|
+
) : null}
|
|
207
|
+
|
|
208
|
+
{configured && !connected && !needsReconnect ? (
|
|
209
|
+
<ConnectionButtonRow>
|
|
210
|
+
<Button
|
|
211
|
+
type="button"
|
|
212
|
+
variant="subtle"
|
|
213
|
+
disabled={working}
|
|
214
|
+
onClick={() => void disconnect()}
|
|
215
|
+
>
|
|
216
|
+
Remove
|
|
217
|
+
</Button>
|
|
218
|
+
<Button
|
|
219
|
+
type="button"
|
|
220
|
+
disabled={working}
|
|
221
|
+
onClick={() => void connect()}
|
|
222
|
+
>
|
|
223
|
+
{busy === 'connecting' ? 'Waiting for browser…' : 'Sign in with Google'}
|
|
224
|
+
</Button>
|
|
225
|
+
</ConnectionButtonRow>
|
|
226
|
+
) : null}
|
|
227
|
+
|
|
228
|
+
{needsReconnect ? (
|
|
229
|
+
<ConnectionTestResult
|
|
230
|
+
tone="warning"
|
|
231
|
+
title="Reconnect required"
|
|
232
|
+
message={
|
|
233
|
+
status?.email
|
|
234
|
+
? `Access for ${status.email} has expired or was revoked.`
|
|
235
|
+
: 'Access has expired or was revoked.'
|
|
236
|
+
}
|
|
237
|
+
/>
|
|
238
|
+
) : null}
|
|
239
|
+
|
|
240
|
+
{configured && (connected || needsReconnect) ? (
|
|
241
|
+
<ConnectionButtonRow>
|
|
242
|
+
<Button
|
|
243
|
+
type="button"
|
|
244
|
+
variant="subtle"
|
|
245
|
+
disabled={working}
|
|
246
|
+
onClick={() => void disconnect()}
|
|
247
|
+
>
|
|
248
|
+
Disconnect
|
|
249
|
+
</Button>
|
|
250
|
+
<Button
|
|
251
|
+
type="button"
|
|
252
|
+
disabled={working}
|
|
253
|
+
onClick={() => void connect()}
|
|
254
|
+
>
|
|
255
|
+
{busy === 'connecting'
|
|
256
|
+
? 'Waiting for browser…'
|
|
257
|
+
: needsReconnect
|
|
258
|
+
? 'Reconnect'
|
|
259
|
+
: 'Reconnect account'}
|
|
260
|
+
</Button>
|
|
261
|
+
</ConnectionButtonRow>
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
{connected ? (
|
|
265
|
+
<ConnectionTestResult
|
|
266
|
+
tone="success"
|
|
267
|
+
title={status?.email ? `Connected as ${status.email}` : 'Connected'}
|
|
268
|
+
message="Mail and Calendar share this connection."
|
|
269
|
+
/>
|
|
270
|
+
) : null}
|
|
271
|
+
|
|
272
|
+
{error ? (
|
|
273
|
+
<ConnectionTestResult tone="error" title="Something went wrong" message={error} />
|
|
274
|
+
) : null}
|
|
275
|
+
</ConnectionPanel>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
@@ -62,10 +62,10 @@ describe('AppFrame', () => {
|
|
|
62
62
|
|
|
63
63
|
expect(document.getElementById(MENU_PORTAL_ID)).not.toBeNull()
|
|
64
64
|
const baseStyles = document.getElementById(PLATFORM_APP_FRAME_BASE_STYLE_ID)
|
|
65
|
-
expect(baseStyles).not.toBeNull()
|
|
66
|
-
expect(baseStyles?.textContent).toContain('box-sizing: border-box')
|
|
67
|
-
expect(baseStyles?.textContent).toContain('scrollbar-color')
|
|
68
|
-
expect(document.getElementById(PLATFORM_FONT_STYLESHEET_ID)).not.toBeNull()
|
|
65
|
+
expect(baseStyles).not.toBeNull()
|
|
66
|
+
expect(baseStyles?.textContent).toContain('box-sizing: border-box')
|
|
67
|
+
expect(baseStyles?.textContent).toContain('scrollbar-color')
|
|
68
|
+
expect(document.getElementById(PLATFORM_FONT_STYLESHEET_ID)).not.toBeNull()
|
|
69
69
|
expect(container.querySelector('[data-app="tasks"]')).not.toBeNull()
|
|
70
70
|
expect(document.documentElement.dataset.app).toBe('tasks')
|
|
71
71
|
expect(container.textContent).toContain('App content')
|
|
@@ -15,12 +15,18 @@ export interface AppFrameProps
|
|
|
15
15
|
themeFromBridge?: boolean
|
|
16
16
|
/** Optional actions rendered in the app header's action slot. */
|
|
17
17
|
headerActions?: ReactNode
|
|
18
|
-
|
|
18
|
+
/** Open document's file name, shown beside the wordmark. */
|
|
19
|
+
headerDocumentName?: ReactNode
|
|
20
|
+
/** Hide the app wordmark when identity is already provided by host chrome. */
|
|
21
|
+
hideWordmark?: boolean
|
|
22
|
+
/** Optional app identity slug used before the shell bridge is ready. */
|
|
23
|
+
identityAppSlug?: string
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
const StyledRoot = styled.div`
|
|
21
27
|
display: flex;
|
|
22
28
|
flex-direction: column;
|
|
23
|
-
|
|
29
|
+
width: 100%;
|
|
24
30
|
height: 100%;
|
|
25
31
|
min-width: 0;
|
|
26
32
|
min-height: 0;
|
|
@@ -36,9 +42,12 @@ export function AppFrame({
|
|
|
36
42
|
children,
|
|
37
43
|
className,
|
|
38
44
|
themeFromBridge = true,
|
|
39
|
-
headerActions,
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
headerActions,
|
|
46
|
+
headerDocumentName,
|
|
47
|
+
hideWordmark = false,
|
|
48
|
+
identityAppSlug,
|
|
49
|
+
...rest
|
|
50
|
+
}: AppFrameProps): React.ReactElement {
|
|
42
51
|
const { meta, ready } = usePlatformBridge()
|
|
43
52
|
const { manifest } = usePlatformAppManifest(meta?.appSlug ?? null, ready)
|
|
44
53
|
|
|
@@ -48,17 +57,18 @@ export function AppFrame({
|
|
|
48
57
|
|
|
49
58
|
useEffect(() => {
|
|
50
59
|
const root = document.documentElement
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
const appSlug = meta?.appSlug ?? identityAppSlug
|
|
61
|
+
if (appSlug) {
|
|
62
|
+
root.dataset.app = appSlug
|
|
53
63
|
} else {
|
|
54
64
|
delete root.dataset.app
|
|
55
65
|
}
|
|
56
66
|
return () => {
|
|
57
|
-
if (root.dataset.app ===
|
|
67
|
+
if (root.dataset.app === appSlug) {
|
|
58
68
|
delete root.dataset.app
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
|
-
}, [meta?.appSlug])
|
|
71
|
+
}, [identityAppSlug, meta?.appSlug])
|
|
62
72
|
|
|
63
73
|
usePlatformThemeFromBridge(themeFromBridge)
|
|
64
74
|
const themeAttrs = usePlatformThemeDocumentAttrs(themeFromBridge)
|
|
@@ -73,7 +83,13 @@ export function AppFrame({
|
|
|
73
83
|
data-platform-surface-palette={themeAttrs.surfacePalette || undefined}
|
|
74
84
|
{...rest}
|
|
75
85
|
>
|
|
76
|
-
{manifest &&
|
|
86
|
+
{manifest && !hideWordmark && (
|
|
87
|
+
<AppHeader
|
|
88
|
+
manifest={manifest}
|
|
89
|
+
actions={headerActions}
|
|
90
|
+
documentName={headerDocumentName}
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
77
93
|
{children}
|
|
78
94
|
</StyledRoot>
|
|
79
95
|
</>
|