| // Copyright 2026 The Chromium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import '../../ui/kit/kit.js'; | |
| import '../../ui/legacy/components/data_grid/data_grid.js'; | |
| import * as Common from '../../core/common/common.js'; | |
| import * as i18n from '../../core/i18n/i18n.js'; | |
| import * as Platform from '../../core/platform/platform.js'; | |
| import * as SDK from '../../core/sdk/sdk.js'; | |
| import type * as Protocol from '../../generated/protocol.js'; | |
| import * as Buttons from '../../ui/components/buttons/buttons.js'; | |
| import * as UI from '../../ui/legacy/legacy.js'; | |
| import {Directives, html, render, type TemplateResult} from '../../ui/lit/lit.js'; | |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; | |
| import privateVerificationTokensViewStyles from './privateVerificationTokensView.css.js'; | |
| const {repeat} = Directives; | |
| const UIStrings = { | |
| /** | |
| * @description Text for the issuer of an item. | |
| */ | |
| issuer: 'Issuer', | |
| /** | |
| * @description Column header indicating whether the current origin is the issuer of the token. | |
| */ | |
| isIssuer: 'Is issuer', | |
| /** | |
| * @description Column header indicating whether the current origin is authorized to redeem the token. | |
| */ | |
| isRedeemer: 'Is redeemer', | |
| /** | |
| * @description Empty state message when no private verification tokens match the current origin. | |
| */ | |
| noPrivateVerificationTokens: 'No private verification tokens for this origin', | |
| /** | |
| * @description Text to clear everything. | |
| */ | |
| clearAll: 'Clear all', | |
| /** | |
| * @description Tooltip text that appears when hovering over the delete button in the toolbar. | |
| */ | |
| deleteSelected: 'Delete selected', | |
| /** | |
| * @description Column header for Token ID. | |
| */ | |
| tokenId: 'Token ID', | |
| /** | |
| * @description Column header for Public Key ID. | |
| */ | |
| keyId: 'Key ID', | |
| /** | |
| * @description Column header for token expiration date. | |
| */ | |
| expiration: 'Expiration', | |
| /** | |
| * @description Column header for token creation time. | |
| */ | |
| creationTime: 'Created', | |
| /** | |
| * @description Column header for token protocol version. | |
| */ | |
| version: 'Version', | |
| /** | |
| * @description Column header for base64 encoded token payload. | |
| */ | |
| tokenPayload: 'Token (Base64)', | |
| } as const; | |
| const str_ = i18n.i18n.registerUIStrings('panels/application/PrivateVerificationTokensView.ts', UIStrings); | |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); | |
| /** | |
| * Presentation model for a single token row. All values are pre-formatted by | |
| * the presenter so that the view is a pure projection of its input. | |
| */ | |
| export interface TokenRow { | |
| id: string; | |
| issuerOrigin: Platform.DevToolsPath.UrlString; | |
| isIssuer: boolean; | |
| isRedeemer: boolean; | |
| keyId: number; | |
| expiration: string; | |
| creationTime: string; | |
| version: number; | |
| token: string; | |
| } | |
| export interface ViewInput { | |
| tokens: TokenRow[]; | |
| selectedTokenId: string|null; | |
| onSelect: (tokenId: string) => void; | |
| onDeselect: () => void; | |
| onDeleteAll: () => void; | |
| onDeleteToken: (tokenId: string) => void; | |
| } | |
| function renderView(input: ViewInput): TemplateResult { | |
| const hasTokens = input.tokens.length > 0; | |
| // clang-format off | |
| return html` | |
| <div class="pvt-view" jslog=${VisualLogging.pane('private-verification-tokens')}> | |
| <devtools-toolbar class="pvt-toolbar" jslog=${VisualLogging.toolbar()}> | |
| <devtools-button class="clear-all-button" | |
| .disabled=${!hasTokens} | |
| .iconName=${'clear'} | |
| .jslogContext=${'clear-all'} | |
| .title=${i18nString(UIStrings.clearAll)} | |
| .variant=${Buttons.Button.Variant.TOOLBAR} | |
| @click=${input.onDeleteAll}></devtools-button> | |
| <devtools-button class="delete-selected-button" | |
| .disabled=${!input.selectedTokenId} | |
| .iconName=${'bin'} | |
| .jslogContext=${'delete-selected'} | |
| .title=${i18nString(UIStrings.deleteSelected)} | |
| .variant=${Buttons.Button.Variant.TOOLBAR} | |
| @click=${() => input.selectedTokenId && input.onDeleteToken(input.selectedTokenId)}> | |
| </devtools-button> | |
| </devtools-toolbar> | |
| <div class="table-container"> | |
| ${hasTokens ? html` | |
| <devtools-data-grid striped inline deletable | |
| @deselect=${input.onDeselect}> | |
| <table> | |
| <tr> | |
| <th id="issuer" weight="6" sortable>${i18nString(UIStrings.issuer)}</th> | |
| <th id="is-issuer" type="boolean" align="center" weight="3" sortable>${i18nString(UIStrings.isIssuer)}</th> | |
| <th id="is-redeemer" type="boolean" align="center" weight="3" sortable>${i18nString(UIStrings.isRedeemer)}</th> | |
| <th id="id" weight="3" sortable>${i18nString(UIStrings.tokenId)}</th> | |
| <th id="key-id" weight="2" sortable>${i18nString(UIStrings.keyId)}</th> | |
| <th id="expiration" weight="5" sortable>${i18nString(UIStrings.expiration)}</th> | |
| <th id="creation-time" weight="5" sortable>${i18nString(UIStrings.creationTime)}</th> | |
| <th id="version" weight="2" sortable>${i18nString(UIStrings.version)}</th> | |
| <th id="token" weight="2">${i18nString(UIStrings.tokenPayload)}</th> | |
| </tr> | |
| ${repeat(input.tokens, token => token.id, token => html` | |
| <tr ?selected=${input.selectedTokenId === token.id} | |
| @select=${() => input.onSelect(token.id)} | |
| @delete=${() => input.onDeleteToken(token.id)}> | |
| <td>${token.issuerOrigin}</td> | |
| <td>${token.isIssuer}</td> | |
| <td>${token.isRedeemer}</td> | |
| <td>${token.id}</td> | |
| <td>${token.keyId}</td> | |
| <td>${token.expiration}</td> | |
| <td>${token.creationTime}</td> | |
| <td>${token.version}</td> | |
| <td class="monospace" title=${token.token}>${token.token}</td> | |
| </tr> | |
| `)} | |
| </table> | |
| </devtools-data-grid> | |
| ` : html` | |
| ${UI.Widget.widget(UI.EmptyWidget.EmptyWidget, {header: i18nString(UIStrings.noPrivateVerificationTokens)})} | |
| `} | |
| </div> | |
| </div> | |
| `; | |
| // clang-format on | |
| } | |
| type View = (input: ViewInput, output: undefined, target: HTMLElement) => void; | |
| export const DEFAULT_VIEW: View = (input, _output, target) => { | |
| // clang-format off | |
| render(html` | |
| <style>${privateVerificationTokensViewStyles}</style> | |
| <style>${UI.inspectorCommonStyles}</style> | |
| ${renderView(input)} | |
| `, target); | |
| // clang-format on | |
| }; | |
| export class PrivateVerificationTokensView extends UI.Widget.VBox implements | |
| SDK.TargetManager.SDKModelObserver<SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel> { | |
| #model: SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel|null = null; | |
| #tokens: TokenRow[] = []; | |
| #fetchGeneration = 0; | |
| #selectedTokenId: string|null = null; | |
| #view: View; | |
| constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) { | |
| super(element, {useShadowDom: true}); | |
| this.#view = view; | |
| } | |
| override wasShown(): void { | |
| super.wasShown(); | |
| const targetManager = SDK.TargetManager.TargetManager.instance(); | |
| targetManager.observeModels(SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel, this, | |
| {scoped: true}); | |
| targetManager.addEventListener(SDK.TargetManager.Events.INSPECTED_URL_CHANGED, this.#onTokensChanged, this); | |
| targetManager.addModelListener(SDK.SecurityOriginManager.SecurityOriginManager, | |
| SDK.SecurityOriginManager.Events.MainSecurityOriginChanged, this.#onTokensChanged, | |
| this, {scoped: true}); | |
| // modelAdded() already triggers a refresh when a model is available. | |
| if (!this.#model) { | |
| this.#onTokensChanged(); | |
| } | |
| } | |
| override willHide(): void { | |
| super.willHide(); | |
| const targetManager = SDK.TargetManager.TargetManager.instance(); | |
| targetManager.unobserveModels(SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel, this); | |
| targetManager.removeEventListener(SDK.TargetManager.Events.INSPECTED_URL_CHANGED, this.#onTokensChanged, this); | |
| targetManager.removeModelListener(SDK.SecurityOriginManager.SecurityOriginManager, | |
| SDK.SecurityOriginManager.Events.MainSecurityOriginChanged, this.#onTokensChanged, | |
| this); | |
| if (this.#model) { | |
| this.modelRemoved(this.#model); | |
| } | |
| } | |
| modelAdded(model: SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel): void { | |
| if (model.target() !== SDK.TargetManager.TargetManager.instance().primaryPageTarget() || model === this.#model) { | |
| return; | |
| } | |
| if (this.#model) { | |
| this.modelRemoved(this.#model); | |
| } | |
| this.#model = model; | |
| model.setTracking(true); | |
| model.addEventListener(SDK.PrivateVerificationTokensModel.Events.TOKENS_UPDATED, this.#onTokensChanged, this); | |
| this.#onTokensChanged(); | |
| } | |
| modelRemoved(model: SDK.PrivateVerificationTokensModel.PrivateVerificationTokensModel): void { | |
| if (model !== this.#model) { | |
| return; | |
| } | |
| model.setTracking(false); | |
| model.removeEventListener(SDK.PrivateVerificationTokensModel.Events.TOKENS_UPDATED, this.#onTokensChanged, this); | |
| this.#model = null; | |
| this.#onTokensChanged(); | |
| } | |
| #onTokensChanged(): void { | |
| void this.#refreshTokens(); | |
| } | |
| /** | |
| * Fetches tokens from the backend and caches them, so that local view state | |
| * changes (such as selection) re-render without additional CDP round-trips. | |
| * Responses from superseded fetches are discarded. | |
| */ | |
| async #refreshTokens(): Promise<void> { | |
| const generation = ++this.#fetchGeneration; | |
| const tokens = await this.#fetchTokens(); | |
| if (generation !== this.#fetchGeneration) { | |
| return; | |
| } | |
| this.#tokens = tokens; | |
| if (this.#selectedTokenId && !tokens.some(token => token.id === this.#selectedTokenId)) { | |
| this.#selectedTokenId = null; | |
| } | |
| this.requestUpdate(); | |
| } | |
| async #fetchTokens(): Promise<TokenRow[]> { | |
| const model = this.#model; | |
| if (!model) { | |
| return []; | |
| } | |
| const currentOrigin = getInspectedOrigin(model.target()); | |
| if (!currentOrigin) { | |
| return []; | |
| } | |
| const [rawTokens, configs] = await Promise.all([model.getTokens(), model.getIssuerConfigs()]); | |
| return toTokenRows(rawTokens, configs, currentOrigin); | |
| } | |
| override performUpdate(): void { | |
| this.#view({ | |
| tokens: this.#tokens, | |
| selectedTokenId: this.#selectedTokenId, | |
| onSelect: tokenId => { | |
| this.#selectedTokenId = tokenId; | |
| this.requestUpdate(); | |
| }, | |
| onDeselect: () => { | |
| this.#selectedTokenId = null; | |
| this.requestUpdate(); | |
| }, | |
| onDeleteAll: () => void this.#clearAllTokensHandler(), | |
| onDeleteToken: tokenId => void this.#deleteTokenHandler(tokenId), | |
| }, | |
| undefined, this.contentElement); | |
| } | |
| async #clearAllTokensHandler(): Promise<void> { | |
| const model = this.#model; | |
| if (!model) { | |
| return; | |
| } | |
| const issuers = new Set(this.#tokens.map(token => token.issuerOrigin)); | |
| await Promise.all([...issuers].map(issuerOrigin => model.clearTokens(issuerOrigin))); | |
| await this.#refreshTokens(); | |
| } | |
| async #deleteTokenHandler(tokenId: string): Promise<void> { | |
| await this.#model?.deleteToken(tokenId); | |
| await this.#refreshTokens(); | |
| } | |
| } | |
| function toTokenRows(rawTokens: Protocol.Storage.PrivateVerificationToken[], | |
| configs: Protocol.Storage.PrivateVerificationTokensIssuerConfig[], | |
| currentOrigin: Platform.DevToolsPath.UrlString): TokenRow[] { | |
| const redeemersByIssuer = | |
| new Map(configs.map(config => [extractOrigin(config.issuerOrigin), config.redeemerOrigins.map(extractOrigin)])); | |
| return rawTokens | |
| .map(token => { | |
| const issuerOrigin = extractOrigin(token.issuerOrigin); | |
| return { | |
| id: token.id, | |
| issuerOrigin, | |
| isIssuer: issuerOrigin === currentOrigin, | |
| isRedeemer: Boolean(redeemersByIssuer.get(issuerOrigin)?.includes(currentOrigin)), | |
| keyId: token.keyId, | |
| expiration: formatEpochTime(token.expiration), | |
| creationTime: formatEpochTime(token.creationTime), | |
| version: token.version, | |
| token: token.token, | |
| }; | |
| }) | |
| .filter(token => token.isIssuer || token.isRedeemer) | |
| .sort((a, b) => a.issuerOrigin.localeCompare(b.issuerOrigin) || | |
| Platform.StringUtilities.naturalOrderComparator(a.id, b.id)); | |
| } | |
| function getInspectedOrigin(target: SDK.Target.Target): Platform.DevToolsPath.UrlString { | |
| const securityOriginManager = target.model(SDK.SecurityOriginManager.SecurityOriginManager); | |
| return extractOrigin(securityOriginManager?.mainSecurityOrigin() || target.inspectedURL()); | |
| } | |
| function extractOrigin(url: string): Platform.DevToolsPath.UrlString { | |
| return Common.ParsedURL.ParsedURL.extractOrigin(url as Platform.DevToolsPath.UrlString); | |
| } | |
| function formatEpochTime(seconds: number): string { | |
| if (!Number.isFinite(seconds) || seconds <= 0) { | |
| return '—'; | |
| } | |
| const date = new Date(seconds * 1000); | |
| return Number.isNaN(date.getTime()) ? '—' : date.toISOString(); | |
| } |