From 8f39f0fc5ea321fe18a896932751cb8b29346455 Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Thu, 11 Jan 2024 07:00:18 +0800 Subject: [PATCH] Add a modal confirmation before installing WaveShell (#212) * init * integrate showShellPrompt flag * renive debugging code * remove debugging code * run gofmt. add migration files. * remove debugging code * remove migrations and adjust code. show prompt on import ssh configs as well. * fix show/hide logic * reset mmap.go * use resolveBool and utilfn.ContainsStr * make AlertModal take a generic 'confirmkey' instead of hard coding hideShellPrompt * rename confirmkey to confirmflag (to be consistent). move confirmflag checking into the alertmodal. short circuit with Promise.resolve(true) if noConfirm checked. * disable buttons while status is 'connecting' * minor refactor --- src/app/appconst.ts | 2 + src/app/common/common.less | 8 +--- src/app/common/common.tsx | 52 ++++++++++++++++---- src/app/common/modals/modals.less | 6 ++- src/app/common/modals/modals.tsx | 73 +++++++++++++++++++++++++---- src/app/connections/connections.tsx | 28 +++++++++-- src/model/model.ts | 13 +++++ src/types/types.ts | 6 +++ wavesrv/pkg/cmdrunner/cmdrunner.go | 53 +++++++++++++++++++++ wavesrv/pkg/cmdrunner/resolver.go | 2 +- wavesrv/pkg/shparse/tokenize.go | 3 +- wavesrv/pkg/sstore/sstore.go | 7 +-- 12 files changed, 219 insertions(+), 34 deletions(-) diff --git a/src/app/appconst.ts b/src/app/appconst.ts index aa4c29a0..69bcf604 100644 --- a/src/app/appconst.ts +++ b/src/app/appconst.ts @@ -13,4 +13,6 @@ export const LineContainer_Main = "main"; export const LineContainer_History = "history"; export const LineContainer_Sidebar = "sidebar"; +export const ConfirmKey_HideShellPrompt = "hideshellprompt"; + export const NoStrPos = -1; diff --git a/src/app/common/common.less b/src/app/common/common.less index 8b4b832a..8552e15f 100644 --- a/src/app/common/common.less +++ b/src/app/common/common.less @@ -188,14 +188,14 @@ position: relative; display: flex; align-items: center; - color: #9e9e9e; + color: @term-bright-white; transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1); } input[type="checkbox"] + label > span { display: flex; justify-content: center; align-items: center; - margin-right: 16px; + margin-right: 10px; width: 20px; height: 20px; background: transparent; @@ -205,10 +205,6 @@ transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1); } - input[type="checkbox"] + label:hover, - input[type="checkbox"]:focus + label { - color: #fff; - } input[type="checkbox"] + label:hover > span, input[type="checkbox"]:focus + label > span { background: rgba(255, 255, 255, 0.1); diff --git a/src/app/common/common.tsx b/src/app/common/common.tsx index 3e271b57..69b8b52b 100644 --- a/src/app/common/common.tsx +++ b/src/app/common/common.tsx @@ -99,23 +99,57 @@ class Toggle extends React.Component<{ checked: boolean; onChange: (value: boole } class Checkbox extends React.Component< - { checked: boolean; onChange: (value: boolean) => void; label: React.ReactNode; id: string }, - {} + { + checked?: boolean; + defaultChecked?: boolean; + onChange: (value: boolean) => void; + label: React.ReactNode; + className?: string; + id?: string; + }, + { checkedInternal: boolean } > { + generatedId; + static idCounter = 0; + + constructor(props) { + super(props); + this.state = { + checkedInternal: this.props.checked !== undefined ? this.props.checked : Boolean(this.props.defaultChecked), + }; + this.generatedId = `checkbox-${Checkbox.idCounter++}`; + } + + componentDidUpdate(prevProps) { + if (this.props.checked !== undefined && this.props.checked !== prevProps.checked) { + this.setState({ checkedInternal: this.props.checked }); + } + } + + handleChange = (e) => { + const newChecked = e.target.checked; + if (this.props.checked === undefined) { + this.setState({ checkedInternal: newChecked }); + } + this.props.onChange(newChecked); + }; + render() { - const { checked, onChange, label, id } = this.props; + const { label, className, id } = this.props; + const { checkedInternal } = this.state; + const checkboxId = id || this.generatedId; return ( -
+
onChange(e.target.checked)} - aria-checked={checked} + id={checkboxId} + checked={checkedInternal} + onChange={this.handleChange} + aria-checked={checkedInternal} role="checkbox" /> -
- + - +
@@ -503,6 +525,10 @@ class CreateRemoteConnModal extends React.Component<{}, {}> { this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" }); } + componentDidMount(): void { + GlobalModel.getClientData(); + } + remoteCName(): string { let hostName = this.tempHostName.get(); if (hostName == "") { @@ -521,6 +547,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> { return this.remoteEdit?.errorstr ?? null; } + @boundMethod + handleOk(): void { + this.showShellPrompt(this.submitRemote); + } + + @boundMethod + showShellPrompt(cb: () => void): void { + let prtn = GlobalModel.showAlert({ + message: + "You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.", + confirm: true, + confirmflag: appconst.ConfirmKey_HideShellPrompt, + }); + prtn.then((confirm) => { + if (!confirm) { + return; + } + cb(); + }); + } + @boundMethod submitRemote(): void { mobx.action(() => { @@ -581,12 +628,6 @@ class CreateRemoteConnModal extends React.Component<{}, {}> { }); } - @boundMethod - handleClose(): void { - this.model.closeModal(); - this.model.setRecentConnAdded(false); - } - @boundMethod handleChangeKeyFile(value: string): void { mobx.action(() => { @@ -802,7 +843,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
Error: {this.getErrorStr()}
- + ); } @@ -1097,6 +1138,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> { let termFontSize = GlobalModel.termFontSize.get(); let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize); let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias; + let selectedRemoteStatus = this.getSelectedRemote().status; return ( @@ -1175,7 +1217,18 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> { - +
+ + +
); } diff --git a/src/app/connections/connections.tsx b/src/app/connections/connections.tsx index e9b1e03e..56017b75 100644 --- a/src/app/connections/connections.tsx +++ b/src/app/connections/connections.tsx @@ -11,6 +11,7 @@ import { GlobalModel, RemotesModel, GlobalCommandRunner } from "../../model/mode import { Button, IconButton, Status } from "../common/common"; import * as T from "../../types/types"; import * as util from "../../util/util"; +import * as appconst from "../appconst"; import "./connections.less"; @@ -74,10 +75,31 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered } @boundMethod - handleImportSshConfig(): void { + importSshConfig(): void { GlobalCommandRunner.importSshConfig(); } + @boundMethod + handleImportSshConfig(): void { + this.showShellPrompt(this.importSshConfig); + } + + @boundMethod + showShellPrompt(cb: () => void): void { + let prtn = GlobalModel.showAlert({ + message: + "You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.", + confirm: true, + confirmflag: appconst.ConfirmKey_HideShellPrompt, + }); + prtn.then((confirm) => { + if (!confirm) { + return; + } + cb(); + }); + } + @boundMethod handleRead(remoteId: string): void { GlobalModel.remotesModel.openReadModal(remoteId); @@ -163,8 +185,8 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered onClick={() => this.handleRead(item.remoteid)} // Moved onClick here > - - {this.getName(item)} {this.getImportSymbol(item)} + + {this.getName(item)} {this.getImportSymbol(item)}
{item.remotetype}
diff --git a/src/model/model.ts b/src/model/model.ts index 9ba1051d..7934fbe7 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -3287,6 +3287,13 @@ class Model { } showAlert(alertMessage: AlertMessageType): Promise { + if (alertMessage.confirmflag != null) { + let cdata = GlobalModel.clientData.get(); + let noConfirm = cdata.clientopts?.confirmflags?.[alertMessage.confirmflag]; + if (noConfirm) { + return Promise.resolve(true); + } + } mobx.action(() => { this.alertMessage.set(alertMessage); GlobalModel.modalsModel.pushModal(appconst.ALERT); @@ -4652,6 +4659,12 @@ class CommandRunner { GlobalModel.submitCommand("client", "accepttos", null, { nohist: "1" }, true); } + clientSetConfirmFlag(flag: string, value: boolean): Promise { + let kwargs = { nohist: "1" }; + let valueStr = value ? "1" : "0"; + return GlobalModel.submitCommand("client", "setconfirmflag", [flag, valueStr], kwargs, false); + } + editBookmark(bookmarkId: string, desc: string, cmdstr: string) { let kwargs = { nohist: "1", diff --git a/src/types/types.ts b/src/types/types.ts index 758392ff..29b6fb07 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -473,10 +473,15 @@ type FeOptsType = { termfontsize: number; }; +type ConfirmFlagsType = { + [k: string]: boolean; +}; + type ClientOptsType = { notelemetry: boolean; noreleasecheck: boolean; acceptedtos: number; + confirmflags: ConfirmFlagsType; }; type ReleaseInfoType = { @@ -525,6 +530,7 @@ type AlertMessageType = { message: string; confirm?: boolean; markdown?: boolean; + confirmflag?: string; }; type HistorySearchParams = { diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index 96472a81..810771a5 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -89,6 +89,7 @@ var ColorNames = []string{"yellow", "blue", "pink", "mint", "cyan", "violet", "o var TabIcons = []string{"square", "sparkle", "fire", "ghost", "cloud", "compass", "crown", "droplet", "graduation-cap", "heart", "file"} var RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"} var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"} +var ConfirmFlags = []string{"hideshellprompt"} var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"} var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"} @@ -213,6 +214,7 @@ func init() { registerCmdFn("client:set", ClientSetCommand) registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand) registerCmdFn("client:accepttos", ClientAcceptTosCommand) + registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand) registerCmdFn("sidebar:open", SidebarOpenCommand) registerCmdFn("sidebar:close", SidebarCloseCommand) @@ -4089,6 +4091,57 @@ func ClientAcceptTosCommand(ctx context.Context, pk *scpacket.FeCommandPacketTyp return update, nil } +var confirmKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + +// confirm flags must be all lowercase and only contain letters, numbers, and underscores (and start with letter) +func ClientConfirmFlagCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { + // Check for valid arguments length + if len(pk.Args) < 2 { + return nil, fmt.Errorf("invalid arguments: expected at least 2, got %d", len(pk.Args)) + } + + // Extract confirmKey and value from pk.Args + confirmKey := pk.Args[0] + if !confirmKeyRe.MatchString(confirmKey) { + return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey) + } + value := resolveBool(pk.Args[1], true) + validKey := utilfn.ContainsStr(ConfirmFlags, confirmKey) + if !validKey { + return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey) + } + + clientData, err := sstore.EnsureClientData(ctx) + if err != nil { + return nil, fmt.Errorf("cannot retrieve client data: %v", err) + } + + // Initialize ConfirmFlags if it's nil + if clientData.ClientOpts.ConfirmFlags == nil { + clientData.ClientOpts.ConfirmFlags = make(map[string]bool) + } + + // Set the confirm flag + clientData.ClientOpts.ConfirmFlags[confirmKey] = value + + err = sstore.SetClientOpts(ctx, clientData.ClientOpts) + if err != nil { + return nil, fmt.Errorf("error updating client data: %v", err) + } + + // Retrieve updated client data + clientData, err = sstore.EnsureClientData(ctx) + if err != nil { + return nil, fmt.Errorf("cannot retrieve updated client data: %v", err) + } + + update := &sstore.ModelUpdate{ + ClientData: clientData, + } + + return update, nil +} + func validateOpenAIAPIToken(key string) error { if len(key) > MaxOpenAIAPITokenLen { return fmt.Errorf("invalid openai token, too long") diff --git a/wavesrv/pkg/cmdrunner/resolver.go b/wavesrv/pkg/cmdrunner/resolver.go index 43ed6c43..d429cc9c 100644 --- a/wavesrv/pkg/cmdrunner/resolver.go +++ b/wavesrv/pkg/cmdrunner/resolver.go @@ -11,10 +11,10 @@ import ( "strconv" "strings" + "github.com/google/uuid" "github.com/wavetermdev/waveterm/wavesrv/pkg/remote" "github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket" "github.com/wavetermdev/waveterm/wavesrv/pkg/sstore" - "github.com/google/uuid" ) const ( diff --git a/wavesrv/pkg/shparse/tokenize.go b/wavesrv/pkg/shparse/tokenize.go index 32ae75b0..7d0cc1b0 100644 --- a/wavesrv/pkg/shparse/tokenize.go +++ b/wavesrv/pkg/shparse/tokenize.go @@ -240,7 +240,8 @@ func (c *parseContext) tokenizeDQ() ([]*WordType, bool) { // returns (words, eofexit) // backticks (WordTypeBQ) handle backslash in a special way, but that seems to mainly effect execution (not completion) -// de_backslash => removes initial backslash in \`, \\, and \$ before execution +// +// de_backslash => removes initial backslash in \`, \\, and \$ before execution func (c *parseContext) tokenizeRaw() ([]*WordType, bool) { state := &tokenizeOutputState{} isExpSubShell := c.QC.cur() == WordTypeDP diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index 4f25dbdc..9e4f646d 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -267,9 +267,10 @@ func (tdata *TelemetryData) Scan(val interface{}) error { } type ClientOptsType struct { - NoTelemetry bool `json:"notelemetry,omitempty"` - NoReleaseCheck bool `json:"noreleasecheck,omitempty"` - AcceptedTos int64 `json:"acceptedtos,omitempty"` + NoTelemetry bool `json:"notelemetry,omitempty"` + NoReleaseCheck bool `json:"noreleasecheck,omitempty"` + AcceptedTos int64 `json:"acceptedtos,omitempty"` + ConfirmFlags map[string]bool `json:"confirmflags,omitempty"` } type FeOptsType struct {