diff --git a/clients/remote/src/user-api/controller.ts b/clients/remote/src/user-api/controller.ts index 054b612a9..a2852457a 100644 --- a/clients/remote/src/user-api/controller.ts +++ b/clients/remote/src/user-api/controller.ts @@ -14,10 +14,14 @@ import { AddSessionResult, ListSessionsResult, SetPrimaryWalletParams, - ListWalletsResult, SyncWalletsResult, } from './rpc-gen/typings.js' -import { Store, Auth, Transaction } from '@canton-network/core-wallet-store' +import { + Store, + Auth, + Transaction, + Network, +} from '@canton-network/core-wallet-store' import { Logger } from 'pino' import { NotificationService } from '../notification/NotificationService.js' import { @@ -51,9 +55,11 @@ export const userController = ( const logger = _logger.child({ component: 'user-controller' }) return buildController({ - addNetwork: async (network: AddNetworkParams) => { + addNetwork: async (params: AddNetworkParams) => { + const { network } = params + const ledgerApi = { - baseUrl: network.ledgerApiUrl ?? '', + baseUrl: network.ledgerApi ?? '', } let auth: Auth @@ -81,16 +87,25 @@ export const userController = ( } } - const newNetwork = { - name: network.network.name, - chainId: network.network.chainId, - description: network.network.description, - synchronizerId: network.network.synchronizerId, + const newNetwork: Network = { + name: network.name, + chainId: network.chainId, + description: network.description, + synchronizerId: network.synchronizerId, auth, ledgerApi, } - await store.addNetwork(newNetwork) + // TODO: Add an explicit updateNetwork method to the User API spec and controller + const existingNetworks = await store.listNetworks() + if ( + existingNetworks.find((n) => n.chainId === newNetwork.chainId) + ) { + await store.updateNetwork(newNetwork) + } else { + await store.addNetwork(newNetwork) + } + return null }, removeNetwork: async (params: RemoveNetworkParams) => { diff --git a/clients/remote/src/web/frontend/networks/index.ts b/clients/remote/src/web/frontend/networks/index.ts index 40c0f0119..ba840334a 100644 --- a/clients/remote/src/web/frontend/networks/index.ts +++ b/clients/remote/src/web/frontend/networks/index.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import '@canton-network/core-wallet-ui-components' -import { Auth } from '@canton-network/core-wallet-store' import { LitElement, html, css } from 'lit' import { customElement, state } from 'lit/decorators.js' import { @@ -15,6 +14,10 @@ import '/index.css' import { stateManager } from '../state-manager' import { createUserClient } from '../rpc-client' import { handleErrorToast } from '../handle-errors' +import { + NetworkCardDeleteEvent, + NetworkEditSaveEvent, +} from '@canton-network/core-wallet-ui-components' @customElement('user-ui-networks') export class UserUiNetworks extends LitElement { @@ -86,6 +89,8 @@ export class UserUiNetworks extends LitElement { border-radius: 8px; min-width: 300px; max-width: 95vw; + max-height: 75vh; + overflow-y: scroll; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); } @media (max-width: 600px) { @@ -195,11 +200,17 @@ export class UserUiNetworks extends LitElement { this.listNetworks() } - private async handleDelete(net: Network) { - if (!confirm(`Delete network "${net.name}"?`)) return + private async handleDelete(e: NetworkCardDeleteEvent) { + const network: Network = { + ...e.network, + ledgerApi: e.network.ledgerApi.baseUrl, + } + + if (!confirm(`Delete network "${network.name}"?`)) return try { - const params: RemoveNetworkParams = { networkName: net.name } + // TODO: rename parameter to chainId in User API + const params: RemoveNetworkParams = { networkName: network.chainId } const userClient = createUserClient(stateManager.accessToken.get()) await userClient.request('removeNetwork', params) await this.listNetworks() @@ -208,52 +219,17 @@ export class UserUiNetworks extends LitElement { } } - handleSubmit = async (e: CustomEvent) => { + private handleSubmit = async (e: NetworkEditSaveEvent) => { e.preventDefault() - const formData = e.detail - const authType = formData.get('authType') as string - try { - let auth: Auth - if (authType === 'implicit') { - auth = { - type: 'implicit', - identityProviderId: formData.get( - 'identityProviderId' - ) as string, - issuer: formData.get('issuer') as string, - configUrl: formData.get('configUrl') as string, - audience: formData.get('audience') as string, - scope: formData.get('scope') as string, - clientId: formData.get('clientId') as string, - } - } else { - auth = { - type: 'password', - identityProviderId: formData.get( - 'identityProviderId' - ) as string, - issuer: formData.get('issuer') as string, - configUrl: formData.get('configUrl') as string, - tokenUrl: formData.get('tokenUrl') as string, - grantType: formData.get('grantType') as string, - scope: formData.get('scope') as string, - clientId: formData.get('clientId') as string, - audience: formData.get('audience') as string, - } - } - - const networkParam: Network = { - chainId: formData.get('chainId') as string, - synchronizerId: formData.get('synchronizerId') as string, - name: formData.get('name') as string, - description: formData.get('description') as string, - auth: auth, - ledgerApi: formData.get('ledgerApi.baseurl') as string, - } + const network: Network = { + ...e.network, + ledgerApi: e.network.ledgerApi.baseUrl, + } + try { const userClient = createUserClient(stateManager.accessToken.get()) - await userClient.request('addNetwork', { network: networkParam }) + await userClient.request('addNetwork', { network }) await this.listNetworks() } catch (e) { handleErrorToast(e) @@ -341,7 +317,8 @@ export class UserUiNetworks extends LitElement { this.handleDelete(e.detail)} + @network-edit-save=${this.handleSubmit} + @delete=${this.handleDelete} > ${this.isModalOpen @@ -359,8 +336,8 @@ export class UserUiNetworks extends LitElement { diff --git a/core/wallet-store/src/config/schema.ts b/core/wallet-store/src/config/schema.ts index 300507815..1ac36225c 100644 --- a/core/wallet-store/src/config/schema.ts +++ b/core/wallet-store/src/config/schema.ts @@ -89,5 +89,6 @@ export type Auth = z.infer export type Network = z.infer export type ImplicitAuth = z.infer export type PasswordAuth = z.infer -export type clientCredentialAuth = z.infer +export type ClientCredentials = z.infer +export type ClientCredentialAuth = z.infer export type LedgerApi = z.infer diff --git a/core/wallet-ui-components/src/components/NetworkCard.ts b/core/wallet-ui-components/src/components/NetworkCard.ts new file mode 100644 index 000000000..26fa6bde3 --- /dev/null +++ b/core/wallet-ui-components/src/components/NetworkCard.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { customElement, property, state } from 'lit/decorators.js' +import { BaseElement } from '../internal/BaseElement' +import { css, html } from 'lit' +import { Network } from '@canton-network/core-wallet-store' + +/** Emitted when the user clicks the "Delete" button on a network card */ +export class NetworkCardDeleteEvent extends Event { + constructor(public network: Network) { + super('delete', { bubbles: true, composed: true }) + } +} + +/** Emitted when the user clicks the "Update" button on a network card */ +export class NetworkCardUpdateEvent extends Event { + constructor() { + super('update', { bubbles: true, composed: true }) + } +} + +@customElement('network-card') +export class NetworkCard extends BaseElement { + @property({ type: Object }) network: Network | null = null + + @state() private _editing = false + + static styles = [ + BaseElement.styles, + css` + .network-card { + background: #fff; + border: none; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; + } + .network-meta { + color: var(--bs-gray-600); + margin-bottom: 0.5rem; + word-break: break-all; + } + .network-desc { + color: var(--bs-gray-700); + margin-bottom: 0.5rem; + word-break: break-all; + } + `, + ] + + render() { + let body = html`

no network supplied

` + + if (this.network !== null) { + if (this._editing) { + body = html` (this._editing = false)} + @network-edit-save=${() => (this._editing = false)} + >` + } else { + body = html`
+ ${this.network.name} +
+
+ ID: + ${this.network.chainId}
+ Auth: ${this.network.auth.type}
+ Synchronizer: + ${this.network.synchronizerId} +
+
${this.network.description}
+
+ + +
` + } + } + + return html`
+
${body}
+
` + } +} diff --git a/core/wallet-ui-components/src/components/NetworkForm.stories.ts b/core/wallet-ui-components/src/components/NetworkForm.stories.ts index 3db7f5cf9..e18bb9a93 100644 --- a/core/wallet-ui-components/src/components/NetworkForm.stories.ts +++ b/core/wallet-ui-components/src/components/NetworkForm.stories.ts @@ -4,12 +4,93 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite' import { html } from 'lit' +import { Network } from '@canton-network/core-wallet-store' + const meta: Meta = { title: 'NetworkForm', } export default meta +function onSaved() { + document.getElementById('output')!.textContent = 'saved successfully!' +} + export const Default: StoryObj = { - render: () => html``, + render: () => { + return html` +
` + }, +} + +const sampleNetworkImplicit: Network = { + name: 'Local (OAuth IDP)', + chainId: 'canton:local-oauth', + synchronizerId: + 'wallet::1220e7b23ea52eb5c672fb0b1cdbc916922ffed3dd7676c223a605664315e2d43edd', + description: 'Mock OAuth IDP', + ledgerApi: { + baseUrl: 'http://127.0.0.1:5003', + }, + auth: { + identityProviderId: 'idp2', + type: 'implicit', + issuer: 'http://127.0.0.1:8889', + configUrl: 'http://127.0.0.1:8889/.well-known/openid-configuration', + audience: + 'https://daml.com/jwt/aud/participant/participant1::1220d44fc1c3ba0b5bdf7b956ee71bc94ebe2d23258dc268fdf0824fbaeff2c61424', + scope: 'openid daml_ledger_api offline_access', + clientId: 'operator', + admin: { + clientId: 'participant_admin', + clientSecret: 'admin-client-secret', + }, + }, +} + +export const PopulatedImplicitAuth: StoryObj = { + render: () => { + return html` +
` + }, +} + +const sampleNetworkPassword: Network = { + name: 'Local (password IDP)', + chainId: 'canton:local-password', + synchronizerId: + 'wallet::1220e7b23ea52eb5c672fb0b1cdbc916922ffed3dd7676c223a605664315e2d43edd', + description: 'Unimplemented Password Auth', + ledgerApi: { + baseUrl: 'https://test', + }, + auth: { + identityProviderId: 'idp1', + type: 'password', + issuer: 'http://127.0.0.1:8889', + configUrl: 'http://127.0.0.1:8889/.well-known/openid-configuration', + audience: + 'https://daml.com/jwt/aud/participant/participant1::1220d44fc1c3ba0b5bdf7b956ee71bc94ebe2d23258dc268fdf0824fbaeff2c61424', + tokenUrl: 'tokenUrl', + grantType: 'password', + scope: 'openid', + clientId: 'wk-service-account', + admin: { + clientId: 'participant_admin', + clientSecret: 'admin-client-secret', + }, + }, +} + +export const PopulatedPasswordAuth: StoryObj = { + render: () => { + return html` +
` + }, } diff --git a/core/wallet-ui-components/src/components/NetworkForm.ts b/core/wallet-ui-components/src/components/NetworkForm.ts index 6bb1d649f..0065c5d9c 100644 --- a/core/wallet-ui-components/src/components/NetworkForm.ts +++ b/core/wallet-ui-components/src/components/NetworkForm.ts @@ -1,23 +1,62 @@ // Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Network } from '@canton-network/core-wallet-store' +import { + ClientCredentials, + Network, + networkSchema, + PasswordAuth, +} from '@canton-network/core-wallet-store' import { html } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { customElement, property, state } from 'lit/decorators.js' import { BaseElement } from '../internal/BaseElement.js' +import { NetworkInputChangedEvent } from './NetworkFormInput.js' + +/** + * Emitted when the user clicks the Cancel button on the form + */ +export class NetworkEditCancelEvent extends Event { + constructor() { + super('network-edit-cancel', { bubbles: true, composed: true }) + } +} + +/** + * Emitted when the user clicks the Save button on the form + */ +export class NetworkEditSaveEvent extends Event { + network: Network + + constructor(network: Network) { + super('network-edit-save', { bubbles: true, composed: true }) + this.network = network + } +} + +type NetworkKeys = Exclude +type LedgerApiKeys = keyof Network['ledgerApi'] + +type CommonAuth = Exclude +type AdminAuth = keyof ClientCredentials +type PasswordAuthKeys = Exclude @customElement('network-form') export class NetworkForm extends BaseElement { - @property({ type: Object }) editingNetwork: Network | null = null + @property({ type: Object }) network: Network = { + ledgerApi: {}, + auth: {}, + } as Network @property({ type: String }) authType: string = 'implicit' + @state() private _error = '' + static styles = [BaseElement.styles] - private getAuthField(field: string): string { - if (!this.editingNetwork) return '' - const auth = this.editingNetwork.auth - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (auth as any)?.[field] ?? '' + connectedCallback(): void { + super.connectedCallback() + if (this.network.auth.type) { + this.authType = this.network.auth.type + } } onAuthTypeChange(e: Event) { @@ -28,120 +67,259 @@ export class NetworkForm extends BaseElement { handleSubmit(e: Event) { e.preventDefault() - const form = e.target as HTMLFormElement - const formData = new FormData(form) + const parsedData = networkSchema.safeParse(this.network) + + if (!parsedData.success) { + this._error = + 'Invalid network data, please ensure all fields are set correctly' + console.error('Error parsing network data: ', parsedData.error) + return + } else { + this.dispatchEvent(new NetworkEditSaveEvent(this.network)) + } + } + + setNetwork(field: NetworkKeys) { + return (ev: NetworkInputChangedEvent) => { + this.network[field] = ev.value + } + } + + setLedgerApi(field: LedgerApiKeys) { + return (ev: NetworkInputChangedEvent) => { + if (!this.network.ledgerApi) { + this.network.ledgerApi = { + baseUrl: '', + } + } + this.network.ledgerApi[field] = ev.value + } + } + + setAuth(field: CommonAuth) { + return (ev: NetworkInputChangedEvent) => { + this.network.auth[field] = ev.value + } + } + + setAdminAuth(field: AdminAuth) { + return (ev: NetworkInputChangedEvent) => { + if (this.network.auth.admin) { + this.network.auth.admin[field] = ev.value + } + } + } + + setPasswordAuth(field: PasswordAuthKeys) { + return (ev: NetworkInputChangedEvent) => { + if (this.network.auth.type !== 'password') { + return + } - this.dispatchEvent( - new CustomEvent('form-submit', { - detail: formData, - bubbles: true, - composed: true, - }) - ) + if (!this.network.auth) { + this.network.auth = { + type: 'password', + clientId: '', + identityProviderId: '', + issuer: '', + configUrl: '', + audience: '', + tokenUrl: '', + grantType: '', + scope: '', + } + } + this.network.auth[field] = ev.value + } + } + + renderAuthForm() { + console.log('calling render auth') + const commonFields = html` + + + + + + + + ` + + const adminFields = html` +
+
+ Admin auth fields (optional) +
+ + +
+ ` + + if (this.authType === 'implicit') { + let auth = this.network.auth + if (auth.type !== 'implicit') { + auth = { + type: 'implicit', + identityProviderId: '', + configUrl: '', + clientId: '', + issuer: '', + audience: '', + scope: '', + } + this.network.auth = auth + } + + return html`${commonFields}${adminFields}` + } else if (this.authType === 'password') { + let auth = this.network.auth + if (auth.type !== 'password') { + auth = { + type: 'password', + identityProviderId: '', + configUrl: '', + clientId: '', + issuer: '', + audience: '', + scope: '', + tokenUrl: '', + grantType: '', + } + this.network.auth = auth + } + + const netauth = this.network.auth as PasswordAuth + + return html` + ${commonFields} + + + ${adminFields} + ` + } else { + throw new Error(`Unsupported auth type: ${this.authType}`) + } } render() { return html`
- - + + - + + - + + - + + - - - - ${this.authType === 'implicit' - ? html` - - - - - ` - : html` - - - - - `} + label="Ledger API Base Url" + .value=${this.network.ledgerApi.baseUrl ?? ''} + @network-input-change=${this.setLedgerApi('baseUrl')} + > + +
+
+ + +
+
+ +
+
+ Configuring ${this.authType} auth +
+ ${this.renderAuthForm()} +
+ +
${this._error}
- + diff --git a/core/wallet-ui-components/src/components/NetworkFormInput.ts b/core/wallet-ui-components/src/components/NetworkFormInput.ts new file mode 100644 index 000000000..813aca80b --- /dev/null +++ b/core/wallet-ui-components/src/components/NetworkFormInput.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { customElement, property } from 'lit/decorators.js' +import { BaseElement } from '../internal/BaseElement' +import { html } from 'lit' +import { eyeFillIcon, eyeSlashIcon } from '../icons' + +/** + * Emitted when the value of an individual form input changes + */ +export class NetworkInputChangedEvent extends Event { + value: string + + constructor(value: string) { + super('network-input-change', { bubbles: true, composed: true }) + this.value = value + } +} + +/** + * An individual input field in the network form + */ +@customElement('network-form-input') +export class NetworkFormInput extends BaseElement { + @property({ type: String }) label = '' + @property({ type: String }) value = '' + @property({ type: String }) text = '' + @property({ type: Boolean }) required = false + @property({ type: Boolean }) hideable = false + + /** Only takes effect if hideable is true */ + @property({ type: Boolean }) hidden = true + + static styles = [BaseElement.styles] + + render() { + return html` +
+ +
+ ${this.hideable + ? html`` + : null} + { + const input = e.target as HTMLInputElement + this.value = input.value + + this.dispatchEvent( + new NetworkInputChangedEvent(this.value) + ) + }} + type="text" + class="form-control" + name=${this.label} + /> +
+ ${this.text + ? html`
${this.text}
` + : null} +
+ ` + } +} diff --git a/core/wallet-ui-components/src/components/NetworkTable.stories.ts b/core/wallet-ui-components/src/components/NetworkTable.stories.ts index 8af9f182c..8d5ca7b1b 100644 --- a/core/wallet-ui-components/src/components/NetworkTable.stories.ts +++ b/core/wallet-ui-components/src/components/NetworkTable.stories.ts @@ -5,6 +5,8 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite' import { html } from 'lit' import { Network } from '@canton-network/core-wallet-store' +import { NetworkEditSaveEvent } from './NetworkForm' + const meta: Meta = { title: 'NetworkTable', } @@ -41,7 +43,12 @@ const networks: Network[] = [ ] export const Default: StoryObj = { - render: () => html``, + render: () => + html` + console.log('saved!', e.network)} + >`, } export const Multiple: StoryObj = { diff --git a/core/wallet-ui-components/src/components/NetworkTable.ts b/core/wallet-ui-components/src/components/NetworkTable.ts index 2bc0a5fc8..d89676f7f 100644 --- a/core/wallet-ui-components/src/components/NetworkTable.ts +++ b/core/wallet-ui-components/src/components/NetworkTable.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Network } from '@canton-network/core-wallet-store' -import { html, css } from 'lit' +import { html } from 'lit' import { customElement, property } from 'lit/decorators.js' import { BaseElement } from '../internal/BaseElement.js' @@ -11,31 +11,7 @@ import { BaseElement } from '../internal/BaseElement.js' export class NetworkTable extends BaseElement { @property({ type: Array }) networks: Network[] = [] - static styles = [ - BaseElement.styles, - css` - .network-card { - background: #fff; - border: none; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - gap: 0.5rem; - min-width: 0; - } - .network-meta { - color: var(--bs-gray-600); - margin-bottom: 0.5rem; - word-break: break-all; - } - .network-desc { - color: var(--bs-gray-700); - margin-bottom: 0.5rem; - word-break: break-all; - } - `, - ] + static styles = [BaseElement.styles] render() { return html` @@ -45,40 +21,7 @@ export class NetworkTable extends BaseElement { > ${this.networks.map( (net) => html` -
-
-
- ${net.name} -
-
- ID: - ${net.chainId}
- Auth: ${net.auth - .type}
- Synchronizer: - ${net.synchronizerId} -
-
- ${net.description} -
-
- - -
-
-
+ ` )}
diff --git a/core/wallet-ui-components/src/icons/index.ts b/core/wallet-ui-components/src/icons/index.ts new file mode 100644 index 000000000..5d9b4eb2e --- /dev/null +++ b/core/wallet-ui-components/src/icons/index.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit' + +export const eyeFillIcon = html` + + + + +` + +export const eyeSlashIcon = html` + + + + + +` diff --git a/core/wallet-ui-components/src/index.ts b/core/wallet-ui-components/src/index.ts index 2cbd2ce61..f48464c90 100644 --- a/core/wallet-ui-components/src/index.ts +++ b/core/wallet-ui-components/src/index.ts @@ -6,7 +6,9 @@ export * from './components/AppLayout.js' export * from './components/CustomToast.js' export * from './components/Discovery.js' export * from './components/NetworkTable.js' +export * from './components/NetworkCard.js' export * from './components/NetworkForm.js' +export * from './components/NetworkFormInput.js' export * from './components/NotFound.js' export * from './windows/discovery.js'