diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug index 1184eec8..b1987a65 100644 --- a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug @@ -2,7 +2,15 @@ strong(translate) Keyboard-interactive auth .ms-2 {{prompt.name}} -.prompt-text {{prompt.prompts[step].prompt}} +.prompt-text + span(*ngFor='let part of getPromptParts()') + a( + *ngIf='part.url', + [href]='part.url', + rel='noreferrer noopener', + (click)='openPromptLink(part.url, $event)' + ) {{part.text}} + span(*ngIf='!part.url') {{part.text}} input.form-control.mt-2( #input, diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts index bf6e22e9..b9e16d8c 100644 --- a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts @@ -1,8 +1,17 @@ import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy, OnInit, ChangeDetectorRef } from '@angular/core' +import { PlatformService } from 'tabby-core' import { KeyboardInteractivePrompt } from '../session/ssh' import { SSHProfile } from '../api' import { PasswordStorageService } from '../services/passwordStorage.service' +const PROMPT_URL_REGEX = /https?:\/\/[^\s<>"']+/g +const TRAILING_PROMPT_URL_PUNCTUATION = /[),.;:!?]+$/ + +interface PromptPart { + text: string + url?: string +} + @Component({ selector: 'keyboard-interactive-auth-panel', templateUrl: './keyboardInteractiveAuthPanel.component.pug', @@ -19,6 +28,7 @@ export class KeyboardInteractiveAuthComponent implements OnInit { constructor ( private passwordStorage: PasswordStorageService, + private platform: PlatformService, private cdr: ChangeDetectorRef, ) {} @@ -42,6 +52,52 @@ export class KeyboardInteractiveAuthComponent implements OnInit { return this.prompt.prompts[this.step].echo ?? false } + getPromptParts (): PromptPart[] { + return this.parsePromptText(this.prompt.prompts[this.step].prompt) + } + + parsePromptText (text: string): PromptPart[] { + const parts: PromptPart[] = [] + let lastIndex = 0 + + for (const match of text.matchAll(PROMPT_URL_REGEX)) { + const matchedText = match[0] + const matchIndex = match.index ?? 0 + const punctuationMatch = TRAILING_PROMPT_URL_PUNCTUATION.exec(matchedText) + const trailingPunctuation = punctuationMatch?.[0] ?? '' + const url = trailingPunctuation ? matchedText.slice(0, -trailingPunctuation.length) : matchedText + + if (matchIndex > lastIndex) { + parts.push({ text: text.slice(lastIndex, matchIndex) }) + } + + if (this.isPromptUrl(url)) { + parts.push({ text: url, url }) + if (trailingPunctuation) { + parts.push({ text: trailingPunctuation }) + } + } else { + parts.push({ text: matchedText }) + } + + lastIndex = matchIndex + matchedText.length + } + + if (lastIndex < text.length) { + parts.push({ text: text.slice(lastIndex) }) + } + + return parts.length ? parts : [{ text }] + } + + openPromptLink (url: string|undefined, event: Event): void { + event.preventDefault() + + if (url) { + this.platform.openExternal(url) + } + } + previous (): void { if (this.step > 0) { this.step-- @@ -62,4 +118,13 @@ export class KeyboardInteractiveAuthComponent implements OnInit { this.step++ this.input.nativeElement.focus() } + + private isPromptUrl (url: string): boolean { + try { + const parsedUrl = new URL(url) + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' + } catch { + return false + } + } }