diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 6d7402ca9d..e5b1745fea 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -171,7 +171,6 @@ "src/renderer/settings/relay-profile-draft.ts", "src/renderer/settings/relay-thinking-bulk.ts", "src/renderer/settings/request-customization-editor.tsx", - "src/renderer/settings/runtime-host-connection-code-dialog.tsx", "src/renderer/settings/runtime-host-interaction-boundary.tsx", "src/renderer/settings/runtime-host-management-dialog.tsx", "src/renderer/settings/runtime-host-onboarding-dialog.tsx", @@ -3723,30 +3722,6 @@ "@maka/ui/icons": 1 } }, - "src/renderer/settings/runtime-host-connection-code-dialog.tsx": { - "bridgePaths": { - "window.maka.runtimeHostProfiles.importConnectionCode": 1 - }, - "environmentCapabilities": { - "navigator.clipboard.writeText": 1 - }, - "hookCalls": { - "useState": 2, - "useToast": 1, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../locales/settings-projects-copy.js": 1, - "./settings-error-copy.js": 1, - "@astryxdesign/core/Dialog": 1, - "@astryxdesign/core/Layout": 1, - "@maka/ui": 1, - "react": 1 - } - }, "src/renderer/settings/runtime-host-interaction-boundary.tsx": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3788,13 +3763,12 @@ "dependencyPaths": { "../../preload/bridge-contract.js": 1, "../../shared/runtime-host-project-directory-policy.js": 1, + "../features/runtime-host-management": 1, "../locales/settings-projects-copy.js": 1, "./runtime-host-project-directory-editor.js": 1, "./settings-error-copy.js": 1, "@astryxdesign/core/Dialog": 1, "@astryxdesign/core/Layout": 1, - "@astryxdesign/core/Switch": 1, - "@astryxdesign/core/Text": 1, "@astryxdesign/core/Tooltip": 1, "@maka/core/ui-locale": 1, "@maka/ui": 1, @@ -3811,13 +3785,11 @@ "window.maka.runtimeHostOnboarding.start": 1, "window.maka.runtimeHostOnboarding.subscribe": 1 }, - "environmentCapabilities": { - "navigator.userAgent.includes": 2 - }, + "environmentCapabilities": {}, "hookCalls": { "useEffect": 2, "useRef": 2, - "useState": 8, + "useState": 7, "useUiLocale": 1 }, "lifecycleMethods": {}, @@ -3828,7 +3800,6 @@ "../../shared/runtime-host-project-directory-policy.js": 1, "../locales/settings-projects-copy.js": 1, "./runtime-host-project-directory-editor.js": 1, - "@astryxdesign/core": 1, "@astryxdesign/core/Dialog": 1, "@astryxdesign/core/Layout": 1, "@astryxdesign/core/Text": 1, @@ -3870,7 +3841,6 @@ "../locales/session-collaboration-copy.js": 1, "../locales/settings-projects-copy.js": 1, "./password-input.js": 1, - "./runtime-host-connection-code-dialog.js": 1, "./runtime-host-management-dialog.js": 1, "./runtime-host-onboarding-dialog.js": 1, "./settings-error-copy.js": 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 7b1725a66b..c3fb08c973 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -19,7 +19,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { + encodeRuntimeHostOwnerConnectionCode, + RuntimeHostOperationError, +} from '@maka/runtime-host/client'; import { RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, RUNTIME_HOST_OPERATOR_PEER_WEBRTC_STUN_CAPABILITY, @@ -299,6 +302,17 @@ test('identifies, rotates, and revokes managed credentials without exposing secr const replacement = 'maka_rh_replacement-secret'; let profileEnabled = true; let prepareCalls = 0; + let issuedConnectionCode = encodeRuntimeHostOwnerConnectionCode({ + name: profile.name, + rootId: profile.rootId, + transport: { + kind: 'libp2p-direct', + peerId: '12D3KooWoffice', + routeHints: ['/ip4/192.0.2.8/udp/44001/quic-v1'], + coordinationRelays: [], + }, + credential: 'pending-credential', + }); const currentFingerprint = runtimeHostAccessCredentialFingerprint('maka_rh_current-secret'); const credentials = [ accessCredential('current', principalId, currentFingerprint), @@ -317,6 +331,11 @@ test('identifies, rotates, and revokes managed credentials without exposing secr }, profiles: { ...unusedDirectPeerProfileDependencies(), + resolveManagedDirectPeerProfile: async () => ({ + exists: true, + enabled: false, + peerId: '12D3KooWoffice', + }), resolveManagedService: async () => managedBinding(profile, service, 'active'), resolveManagedAccess: async () => ({ ...managedBinding(profile, service, 'active'), @@ -346,6 +365,15 @@ test('identifies, rotates, and revokes managed credentials without exposing secr if (input.action === 'list') { return { schemaVersion: 1, kind: 'result', action: 'list', credentials }; } + if (input.action === 'connection-code') { + assert.equal(input.name, profile.name); + return { + schemaVersion: 1, + kind: 'result', + action: 'connection-code', + connectionCode: issuedConnectionCode, + }; + } if (input.action === 'prepare') { prepareCalls += 1; assert.equal(input.currentCredentialFingerprint, currentFingerprint); @@ -391,9 +419,46 @@ test('identifies, rotates, and revokes managed credentials without exposing secr }); const list = handlers.get('runtime-host-management:list-credentials'); + const connectionCode = handlers.get('runtime-host-management:create-connection-code'); const rotate = handlers.get('runtime-host-management:rotate-credential'); const revoke = handlers.get('runtime-host-management:revoke-credential'); - assert.ok(list && rotate && revoke); + assert.ok(list && connectionCode && rotate && revoke); + assert.equal(await connectionCode({}, profile.id), issuedConnectionCode); + issuedConnectionCode = encodeRuntimeHostOwnerConnectionCode({ + name: profile.name, + rootId: 'c'.repeat(64), + transport: { + kind: 'libp2p-direct', + peerId: '12D3KooWoffice', + routeHints: ['/ip4/192.0.2.8/udp/44001/quic-v1'], + coordinationRelays: [], + }, + credential: 'pending-credential', + }); + await assert.rejects( + connectionCode({}, profile.id) as Promise, + /different Host/u, + ); + issuedConnectionCode = encodeRuntimeHostOwnerConnectionCode({ + name: profile.name, + rootId: profile.rootId, + transport: { + kind: 'libp2p-direct', + peerId: '12D3KooWunexpected', + routeHints: ['/ip4/192.0.2.8/udp/44001/quic-v1'], + coordinationRelays: [], + }, + credential: 'pending-credential', + }); + await assert.rejects( + connectionCode({}, profile.id) as Promise, + /different Direct peer/u, + ); + issuedConnectionCode = 'not-a-connection-code'; + await assert.rejects( + connectionCode({}, profile.id) as Promise, + /connection code is invalid/u, + ); const initial = await list({}, profile.id); assert.equal((initial as { canRotate: boolean }).canRotate, true); assert.deepEqual( diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 30c750dec0..88413a47a4 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -641,6 +641,41 @@ test('keeps a prepared access credential out of the SSH terminal projection', as await harness.terminal.close(); }); +test('creates an owner connection code through the framed SSH operator channel', async () => { + const harness = createHarness('pending'); + const connectionCode = 'maka-runtime-host:connect:v1:secret-code'; + const management = harness.terminal.runAccessManagement({ + destination: 'operator@example.com', + operatorPath: '/home/operator/.local/share/maka/operator', + rootPath: '/srv/maka', + expectedRootId: 'a'.repeat(64), + action: 'connection-code', + name: "Owner's Linux", + }); + await waitFor(() => harness.pty.hasDataListener()); + harness.pty.emitData(encodeRuntimeHostAccessManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'connection-code', + connectionCode, + })); + harness.pty.exit(0); + + const result = await management; + assert.equal( + result.kind === 'result' && result.action === 'connection-code' + ? result.connectionCode + : undefined, + connectionCode, + ); + const command = harness.launchArgs.at(-1)?.at(-1) ?? ''; + assert.match(command, /access.*connection-code/u); + assert.match(command, /--name/u); + assert.match(command, /Owner/u); + assert.doesNotMatch(JSON.stringify(harness.events), /secret-code|MAKA_RUNTIME/u); + await harness.terminal.close(); +}); + test('requests adaptive-connectivity status only on the peer-management frame', async () => { const harness = createHarness('pending'); const management = harness.terminal.runPeerManagement({ diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index f30d31475b..54e5932172 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -285,6 +285,10 @@ export class DesktopRuntimeHostClient { } get hostId(): string { + return this.rootId; + } + + get rootId(): string { return this.connection.rootId; } diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 942e245c82..387527418e 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -23,11 +23,11 @@ import { hostname } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; import type { IpcMain } from 'electron'; import { - consumeAccessCredentialDelivery, encodeRuntimeHostOwnerConnectionCode, + issueRuntimeHostOwnerConnectionCode, } from '@maka/runtime-host/client'; import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator'; -import { REMOTE_OWNER_OPERATION_GRANTS, type HostRegistration } from '@maka/runtime-host/protocol'; +import type { HostRegistration } from '@maka/runtime-host/protocol'; import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, DesktopLocalRuntimeHostRemoteAccessSnapshot, @@ -328,7 +328,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { changed.response.restarted ? previousHostEpoch : undefined, ); return enabledResult( - await issueConnectionCode(input.rootPath, desired.rootId, peer, localClient(input.manager)), + await issueConnectionCode(input.rootPath, peer, localClient(input.manager)), ); } @@ -511,7 +511,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); const peer = await readPeer(input.operator, managed); if (!peer) throw new Error('Remote access is not enabled on this computer'); - return issueConnectionCode(input.rootPath, managed.rootId, peer, localClient(input.manager)); + return issueConnectionCode(input.rootPath, peer, localClient(input.manager)); }); const createCollaborationConnectionTarget = () => @@ -978,29 +978,15 @@ function enabledResult( async function issueConnectionCode( rootPath: string, - rootId: string, peer: LocalPeerDescriptor, client: DesktopRuntimeHostClient, ): Promise { - const livePeer = await readLivePeer(client, peer); - const prepared = await client.request('access.credential.prepare', { - principalKind: 'remote_owner', - principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, - operationGrants: REMOTE_OWNER_OPERATION_GRANTS, - canPublishClientCapabilities: true, - canUseHostPaths: false, - bindClientInstance: true, - }); - const credential = await consumeAccessCredentialDelivery( + return issueRuntimeHostOwnerConnectionCode({ rootPath, - prepared.deliveryId, - prepared.credentialId, - ); - return encodeRuntimeHostOwnerConnectionCode({ name: hostName(), - rootId, - transport: { kind: 'libp2p-direct', ...livePeer }, - credential, + principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID, + expectedPeerId: peer.peerId, + client, }); } @@ -1032,6 +1018,9 @@ async function hasSharedAccess( target, }); if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'list') { + throw new Error('Runtime Host operator returned an unrelated access result'); + } return response.credentials.some( (credential) => credential.principalKind === 'remote_owner' && diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index f97b0253e7..3b07068d7f 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -18,6 +18,7 @@ */ import type { IpcMain } from 'electron'; +import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client'; import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, @@ -900,6 +901,30 @@ export function createDesktopRuntimeHostManagement(input: { ); }; + const createConnectionCode = async (profileId: unknown): Promise => { + const access = await resolveAccess(profileId); + const response = await input.runAccessManagement({ + ...access.target, + action: 'connection-code', + name: access.managed.profile.name, + }); + if (response.kind === 'error') throw new Error(response.error.message); + if (response.action !== 'connection-code') { + throw new Error('Remote Runtime Host did not return a connection code'); + } + const decoded = decodeRuntimeHostOwnerConnectionCode(response.connectionCode); + if (decoded.rootId !== access.managed.profile.rootId) { + throw new Error('Remote Runtime Host returned a connection code for a different Host'); + } + const peerProfile = await input.profiles.resolveManagedDirectPeerProfile( + access.managed.profile.id, + ); + if (peerProfile.peerId && decoded.transport.peerId !== peerProfile.peerId) { + throw new Error('Remote Runtime Host returned a connection code for a different Direct peer'); + } + return response.connectionCode; + }; + const rotateCredential = async ( profileId: unknown, ): Promise => { @@ -980,6 +1005,7 @@ export function createDesktopRuntimeHostManagement(input: { run: 'runtime-host-management:run', update: 'runtime-host-management:update', configureProjectDirectories: 'runtime-host-management:configure-project-directories', + createConnectionCode: 'runtime-host-management:create-connection-code', listCredentials: 'runtime-host-management:list-credentials', rotateCredential: 'runtime-host-management:rotate-credential', revokeCredential: 'runtime-host-management:revoke-credential', @@ -1019,6 +1045,8 @@ export function createDesktopRuntimeHostManagement(input: { allowInterruptActiveTasks, ), ); + input.ipcMain.handle(channels.createConnectionCode, (_event, profileId: unknown) => + createConnectionCode(profileId)); input.ipcMain.handle(channels.listCredentials, (_event, profileId: unknown) => listCredentials(profileId)); input.ipcMain.handle(channels.rotateCredential, (_event, profileId: unknown) => diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index a310141439..48c62a9271 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -124,6 +124,7 @@ export interface DesktopRuntimeHostProfileService { resolveManagedDirectPeerProfile(profileId: string): Promise<{ readonly exists: boolean; readonly enabled: boolean; + readonly peerId?: string; }>; upsertManagedDirectPeerProfile( profileId: string, @@ -993,9 +994,15 @@ export function createDesktopRuntimeHostProfileService(input: { resolveManagedDirectPeerProfile(profileId) { return mutate(async () => { const peerProfileId = managedDirectPeerProfileId(profileId); + const profile = (await catalog.read()).profiles.find( + (candidate) => candidate.id === peerProfileId, + ); return { - exists: (await catalog.read()).profiles.some((profile) => profile.id === peerProfileId), + exists: profile !== undefined, enabled: preferences.enabledRemoteProfileIds.includes(peerProfileId), + ...(profile?.kind === 'remote' && profile.transport.kind === 'libp2p-direct' + ? { peerId: profile.transport.peerId } + : {}), }; }); }, diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 535379e60a..bf8fe7ae9f 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -220,6 +220,7 @@ interface DesktopRuntimeHostSshAccessTarget { export type DesktopRuntimeHostSshAccessInput = DesktopRuntimeHostSshAccessTarget & ( | { readonly action: 'list' } + | { readonly action: 'connection-code'; readonly name: string } | { readonly action: 'prepare'; readonly currentCredentialFingerprint: string } | { readonly action: 'revoke'; @@ -1326,7 +1327,9 @@ function runtimeHostAccessManagementRemoteCommand( '--credential', input.credentialId, '--current-fingerprint', input.currentCredentialFingerprint, ] - : []; + : input.action === 'connection-code' + ? ['--name', input.name] + : []; const command = [ input.operatorPath, 'access', diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1b0183f1c4..f825aaf1cb 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -868,6 +868,7 @@ export interface MakaBridge { automaticRelayDiscovery: boolean, webRtcStunPolicy?: import('@maka/runtime-host/operator').RuntimeHostWebRtcStunPolicy, ): Promise; + createConnectionCode(profileId: string): Promise; listCredentials(profileId: string): Promise; rotateCredential(profileId: string): Promise; revokeCredential( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 4c59db252b..10949441a2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1587,6 +1587,9 @@ const makaBridge = { webRtcStunPolicy, ); }, + createConnectionCode(profileId: string): Promise { + return ipcRenderer.invoke('runtime-host-management:create-connection-code', profileId); + }, listCredentials(profileId: string): Promise { return ipcRenderer.invoke('runtime-host-management:list-credentials', profileId); }, diff --git a/apps/desktop/src/renderer/features/runtime-host-management/index.ts b/apps/desktop/src/renderer/features/runtime-host-management/index.ts index f8a658bc7a..005423d585 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/index.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/index.ts @@ -19,6 +19,9 @@ export { RuntimeHostPeerMeshDialog } from './ui/runtime-host-peer-mesh-dialog'; export { PeerMeshPeerIdButton } from './ui/peer-mesh-peer-id-button'; +export { RuntimeHostAddComputerMenu } from './ui/runtime-host-add-computer-menu'; +export { RuntimeHostConnectionCodeButton } from './ui/runtime-host-connection-code-button'; +export { RuntimeHostConnectionCodeDialog } from './ui/runtime-host-connection-code-dialog'; export { RuntimeHostPairingRecoveryButton, RuntimeHostProfileMoreMenu, diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts index b09ab17d15..606d21dd91 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts @@ -84,7 +84,28 @@ export interface RuntimeHostProfilePairingServices { discard(profileId: string): Promise; } +export type RuntimeHostConnectionCodeImportResult = + | { readonly kind: 'connected'; readonly profileId: string } + | { + readonly kind: 'error'; + readonly reason: + | 'invalid_code' + | 'code_unavailable' + | 'host_unreachable' + | 'host_mismatch' + | 'unknown'; + }; + +export interface RuntimeHostConnectionCodeServices { + create(profileId: string): Promise; + importCode(code: string): Promise; + readClipboardText(): Promise; + writeClipboardText(value: string): Promise; +} + export interface RuntimeHostManagementServices { readonly peerMesh: PeerMeshServices; readonly profilePairing: RuntimeHostProfilePairingServices; + readonly connectionCodes: RuntimeHostConnectionCodeServices; + readonly supportsWsl: boolean; } diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-add-computer-menu.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-add-computer-menu.tsx new file mode 100644 index 0000000000..1d48424651 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-add-computer-menu.tsx @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core'; +import { useRuntimeHostManagementServices } from '../services-context.js'; + +export interface RuntimeHostAddComputerCopy { + readonly addComputer: string; + readonly useConnectionCode: string; + readonly useConnectionCodeDescription: string; + readonly addSshComputer: string; + readonly addSshComputerDescription: string; + readonly addWslEnvironment: string; + readonly addWslEnvironmentDescription: string; + readonly configureManually: string; + readonly configureManuallyDescription: string; + readonly cancel: string; +} + +export function RuntimeHostAddComputerMenu(props: { + readonly copy: RuntimeHostAddComputerCopy; + readonly isDisabled: boolean; + readonly isManualConfigurationOpen: boolean; + readonly onUseConnectionCode: () => void; + readonly onSetupSsh: () => void; + readonly onSetupWsl: () => void; + readonly onConfigureManually: () => void; +}) { + const services = useRuntimeHostManagementServices(); + return ( + + + + {services.supportsWsl ? ( + + ) : null} + + + ); +} diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-connection-code-button.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-connection-code-button.tsx new file mode 100644 index 0000000000..c239cb6824 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-connection-code-button.tsx @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from 'react'; +import { Button, useToast } from '@maka/ui'; +import { useRuntimeHostManagementServices } from '../services-context.js'; + +export function RuntimeHostConnectionCodeButton(props: { + readonly profileId: string; + readonly label: string; + readonly failureTitle: string; + readonly isDisabled: boolean; + readonly errorMessage: (error: unknown) => string; + readonly onCreated: (connectionCode: string) => void; + readonly onWorkingChange: (working: boolean) => void; +}) { + const services = useRuntimeHostManagementServices(); + const toast = useToast(); + const [working, setWorking] = useState(false); + + async function create(): Promise { + setWorking(true); + props.onWorkingChange(true); + let connectionCode: string | undefined; + try { + connectionCode = await services.connectionCodes.create(props.profileId); + } catch (error) { + toast.error(props.failureTitle, props.errorMessage(error)); + } finally { + setWorking(false); + props.onWorkingChange(false); + } + if (connectionCode) props.onCreated(connectionCode); + } + + return ( +