From 36e0d3225b342c34f55f3ab2271376aae5215cac Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 25 Mar 2026 11:41:30 -0400 Subject: [PATCH] cleaner provision, more tests --- shared/constants/init/shared.tsx | 2 +- shared/devices/routes.tsx | 4 +- shared/provision/code-page/container.tsx | 8 +- shared/provision/code-page/qr-scan/hooks.tsx | 4 +- shared/provision/paper-key.tsx | 20 +- shared/provision/password.tsx | 12 +- .../select-other-device-connected.tsx | 12 +- shared/provision/set-public-name.tsx | 12 +- shared/provision/username-or-email.tsx | 11 +- shared/stores/provision.tsx | 501 ++++++++++-------- shared/stores/tests/provision.test.ts | 350 +++++++++++- 11 files changed, 659 insertions(+), 277 deletions(-) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index f1d101b3380c..aba36baa3e2e 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -343,7 +343,7 @@ export const initRecoverPasswordCallbacks = () => { defer: { ...currentState.dispatch.defer, onProvisionCancel: (ignoreWarning?: boolean) => { - useProvisionState.getState().dispatch.dynamic.cancel?.(ignoreWarning) + useProvisionState.getState().dispatch.cancel(ignoreWarning) }, onStartAccountReset: (skipPassword: boolean, username: string) => { useAutoResetState.getState().dispatch.startAccountReset(skipPassword, username) diff --git a/shared/devices/routes.tsx b/shared/devices/routes.tsx index 66d47742cd58..58df58e5cd74 100644 --- a/shared/devices/routes.tsx +++ b/shared/devices/routes.tsx @@ -7,13 +7,13 @@ import {HeaderTitle, HeaderRightActions} from './nav-header' import {useProvisionState} from '@/stores/provision' const AddDeviceCancelButton = () => { - const cancel = useProvisionState(s => s.dispatch.dynamic.cancel) + const cancel = useProvisionState(s => s.dispatch.cancel) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) return ( { - cancel?.() + cancel() navigateUp() }} > diff --git a/shared/provision/code-page/container.tsx b/shared/provision/code-page/container.tsx index 079c75ee60cc..c26749c9e497 100644 --- a/shared/provision/code-page/container.tsx +++ b/shared/provision/code-page/container.tsx @@ -19,11 +19,11 @@ const CodePageContainer = () => { const currentDeviceAlreadyProvisioned = !!storeDeviceName const provisionState = useProvisionState( C.useShallow(s => ({ - error: s.error, otherDevice: s.codePageOtherDevice, provisionDeviceName: s.deviceName, - submitTextCode: s.dispatch.dynamic.submitTextCode, - textCode: s.codePageIncomingTextCode, + submitTextCode: s.dispatch.submitTextCode, + textCode: s.session.prompt?.type === 'promptSecret' ? s.session.prompt.phrase : '', + error: s.session.prompt?.type === 'promptSecret' ? s.session.prompt.error : '', })) ) const {error, otherDevice, provisionDeviceName, submitTextCode, textCode} = provisionState @@ -36,7 +36,7 @@ const CodePageContainer = () => { const onBack = navigateUp const _onSubmitTextCode = (code: string) => { - !waiting && submitTextCode?.(code) + !waiting && submitTextCode(code) } const [code, setCode] = React.useState('') diff --git a/shared/provision/code-page/qr-scan/hooks.tsx b/shared/provision/code-page/qr-scan/hooks.tsx index c6515c36c5d4..872bd02dcedd 100644 --- a/shared/provision/code-page/qr-scan/hooks.tsx +++ b/shared/provision/code-page/qr-scan/hooks.tsx @@ -2,9 +2,9 @@ import * as C from '@/constants' import {useProvisionState} from '@/stores/provision' const useQR = () => { - const submitTextCode = useProvisionState(s => s.dispatch.dynamic.submitTextCode) + const submitTextCode = useProvisionState(s => s.dispatch.submitTextCode) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) - const onSubmitTextCode = (c: string) => submitTextCode?.(c) + const onSubmitTextCode = (c: string) => submitTextCode(c) return { onSubmitTextCode, waiting, diff --git a/shared/provision/paper-key.tsx b/shared/provision/paper-key.tsx index a1ef778c4bd8..432557e92014 100644 --- a/shared/provision/paper-key.tsx +++ b/shared/provision/paper-key.tsx @@ -5,20 +5,24 @@ import {SignupScreen, errorBanner} from '../signup/common' import {useProvisionState} from '@/stores/provision' const Container = () => { - const error = useProvisionState(s => s.error) - const hint = useProvisionState(s => `${s.codePageOtherDevice.name || ''}...`) + const {error, hint, submitPassphrase} = useProvisionState( + C.useShallow(s => ({ + error: s.session.prompt?.type === 'paperKey' ? s.session.prompt.error : '', + hint: `${s.codePageOtherDevice.name || ''}...`, + submitPassphrase: s.dispatch.submitPassphrase, + })) + ) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const onBack = () => { navigateUp() } - const onSubmit = useProvisionState(s => s.dispatch.dynamic.setPassphrase) const props = { - error: error, - hint: hint, - onBack: onBack, - onSubmit: (paperkey: string) => !waiting && onSubmit?.(paperkey), - waiting: waiting, + error, + hint, + onBack, + onSubmit: (paperkey: string) => !waiting && submitPassphrase(paperkey), + waiting, } return } diff --git a/shared/provision/password.tsx b/shared/provision/password.tsx index 879c2984e62a..352b5ef3b7e5 100644 --- a/shared/provision/password.tsx +++ b/shared/provision/password.tsx @@ -7,9 +7,14 @@ import {useState as useRecoverState} from '@/stores/recover-password' import {useProvisionState} from '@/stores/provision' const Password = () => { - const error = useProvisionState(s => s.error) + const {error, submitPassphrase, username} = useProvisionState( + C.useShallow(s => ({ + error: s.session.prompt?.type === 'passphrase' ? s.session.prompt.error : '', + submitPassphrase: s.dispatch.submitPassphrase, + username: s.username, + })) + ) const resetEmailSent = useRecoverState(s => s.resetEmailSent) - const username = useProvisionState(s => s.username) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const startRecoverPassword = useRecoverState(s => s.dispatch.startRecoverPassword) @@ -19,8 +24,7 @@ const Password = () => { const onBack = () => { navigateUp() } - const _onSubmit = useProvisionState(s => s.dispatch.dynamic.setPassphrase) - const onSubmit = (password: string) => !waiting && _onSubmit?.(password) + const onSubmit = (password: string) => !waiting && submitPassphrase(password) const [password, setPassword] = React.useState('') const _onSubmitClick = () => onSubmit(password) const resetState = useRecoverState(s => s.dispatch.resetState) diff --git a/shared/provision/select-other-device-connected.tsx b/shared/provision/select-other-device-connected.tsx index aa9e0929dcb0..c372bd3ffd32 100644 --- a/shared/provision/select-other-device-connected.tsx +++ b/shared/provision/select-other-device-connected.tsx @@ -5,9 +5,13 @@ import SelectOtherDevice from './select-other-device' import {useProvisionState} from '@/stores/provision' const SelectOtherDeviceContainer = () => { - const devices = useProvisionState(s => s.devices) - const submitDeviceSelect = useProvisionState(s => s.dispatch.dynamic.submitDeviceSelect) - const username = useProvisionState(s => s.username) + const {devices, submitDeviceSelect, username} = useProvisionState( + C.useShallow(s => ({ + devices: s.devices, + submitDeviceSelect: s.dispatch.submitDeviceSelect, + username: s.username, + })) + ) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const _onBack = navigateUp @@ -19,7 +23,7 @@ const SelectOtherDeviceContainer = () => { } const onSelect = (name: string) => { - if (!waiting) submitDeviceSelect?.(name) + if (!waiting) submitDeviceSelect(name) } return ( diff --git a/shared/provision/set-public-name.tsx b/shared/provision/set-public-name.tsx index 99c875ffb7dd..aa10aeb7875e 100644 --- a/shared/provision/set-public-name.tsx +++ b/shared/provision/set-public-name.tsx @@ -8,14 +8,18 @@ import {SignupScreen, errorBanner} from '../signup/common' import * as Provision from '@/stores/provision' const SetPublicName = () => { - const devices = Provision.useProvisionState(s => s.devices) - const error = Provision.useProvisionState(s => s.error) + const {devices, error, submitDeviceName} = Provision.useProvisionState( + C.useShallow(s => ({ + devices: s.devices, + error: s.session.prompt?.type === 'deviceName' ? s.session.prompt.error : '', + submitDeviceName: s.dispatch.submitDeviceName, + })) + ) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const ponBack = useSafeSubmit(navigateUp, !!error) - const psetDeviceName = Provision.useProvisionState(s => s.dispatch.dynamic.setDeviceName) const ponSubmit = (name: string) => { - !waiting && psetDeviceName?.(name) + !waiting && submitDeviceName(name) } const deviceNumbers = devices .filter(d => d.type === (C.isMobile ? 'mobile' : 'desktop')) diff --git a/shared/provision/username-or-email.tsx b/shared/provision/username-or-email.tsx index d09f5525349f..d633fcbfe600 100644 --- a/shared/provision/username-or-email.tsx +++ b/shared/provision/username-or-email.tsx @@ -36,11 +36,10 @@ const decodeInlineError = (inlineRPCError: RPCError | undefined) => { const UsernameOrEmailContainer = (op: OwnProps) => { const _resetBannerUser = AutoReset.useAutoResetState(s => s.username) const resetBannerUser = op.fromReset ? _resetBannerUser : undefined - const _error = useProvisionState(s => s.error) const {inlineError, inlineSignUpLink} = useProvisionState( C.useShallow(s => decodeInlineError(s.inlineError)) ) - const error = _error ? _error : inlineError && !inlineSignUpLink ? inlineError : '' + const error = inlineError && !inlineSignUpLink ? inlineError : '' // So we can clear the error if the name is changed const _username = useProvisionState(s => s.username) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvision) @@ -52,16 +51,16 @@ const UsernameOrEmailContainer = (op: OwnProps) => { const onForgotUsername = () => navigateAppend('forgotUsername') const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) const _onGoToSignup = requestAutoInvite - const _setUsername = useProvisionState(s => s.dispatch.dynamic.setUsername) + const submitUsername = useProvisionState(s => s.dispatch.submitUsername) const _onSubmit = (username: string) => { - !waiting && _setUsername?.(username) + !waiting && submitUsername(username) } const [username, setUsername] = React.useState(op.username ?? _username) React.useEffect(() => { if (op.username && op.username !== _username) { - _setUsername?.(op.username) + submitUsername(op.username) } - }, [op.username, _username, _setUsername]) + }, [op.username, _username, submitUsername]) const onSubmit = () => { _onSubmit(username) } diff --git a/shared/stores/provision.tsx b/shared/stores/provision.tsx index c63877ce07da..eebabd6f073b 100644 --- a/shared/stores/provision.tsx +++ b/shared/stores/provision.tsx @@ -5,7 +5,6 @@ import * as Z from '@/util/zustand' import {RPCError} from '@/util/errors' import {isMobile} from '@/constants/platform' import {type CommonResponseHandler} from '@/engine/types' -import isEqual from 'lodash/isEqual' import {rpcDeviceToDevice} from '@/constants/rpc-utils' import {invalidPasswordErrorString} from '@/constants/config' import {clearModals, navigateAppend} from '@/constants/router' @@ -18,6 +17,27 @@ export type Device = { type: T.Devices.DeviceType } +export type SessionPrompt = + | {error: string; phrase: string; type: 'promptSecret'} + | {error: string; existingDevices: Array; type: 'deviceName'} + | {devices: Array; type: 'chooseDevice'} + | {error: string; type: 'passphrase'} + | {error: string; type: 'paperKey'} + +export type Session = + | {kind: 'idle'} + | { + kind: 'addingDevice' | 'provisioning' + prompt?: SessionPrompt + requestID: number + } + +type ActivePrompt = + | {requestID: number; submit: (code: string) => void; type: 'promptSecret'} + | {requestID: number; submit: (name: string) => void; type: 'deviceName'} + | {devices: Array; requestID: number; submit: (name: string) => void; type: 'chooseDevice'} + | {requestID: number; submit: (passphrase: string) => void; type: 'passphrase' | 'paperKey'} + const decodeForgotUsernameError = (error: RPCError) => { switch (error.code) { case T.RPCGen.StatusCode.scnotfound: @@ -34,6 +54,7 @@ const errorCausedByUsCanceling = (e?: RPCError) => { const desc = e?.desc return desc === 'Input canceled' || desc === 'kex canceled by caller' } + const cancelOnCallback = (_: unknown, response: CommonResponseHandler) => { response.error({code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'}) } @@ -60,180 +81,180 @@ export const deviceNameInstructions = export const badDeviceChars = /[^a-zA-Z0-9-_' ]/g -type Step = - | {type: 'username'} - | {type: 'passphrase'} - | {type: 'deviceName'} - | {type: 'chooseDevice'; devices: Array} - | {type: 'promptSecret'} type Store = T.Immutable<{ - autoSubmit: Array - codePageIncomingTextCode: string codePageOtherDevice: Device deviceName: string devices: Array - error: string - existingDevices: Array finalError?: RPCError forgotUsernameResult: string inlineError?: RPCError passphrase: string + session: Session startProvisionTrigger: number username: string }> + +const idleSession = (): Session => ({kind: 'idle'}) + const initialStore: Store = { - autoSubmit: [], - codePageIncomingTextCode: '', codePageOtherDevice: makeDevice(), deviceName: '', devices: [], - error: '', - existingDevices: [], finalError: undefined, forgotUsernameResult: '', inlineError: undefined, passphrase: '', + session: idleSession(), startProvisionTrigger: 0, username: '', } export type State = Store & { dispatch: { - dynamic: { - cancel?: (ignoreWarning?: boolean) => void - setDeviceName?: (name: string) => void - setPassphrase?: (passphrase: string) => void - setUsername?: (username: string) => void - submitDeviceSelect?: (name: string) => void - submitTextCode?: (code: string) => void - } addNewDevice: (otherDeviceType: 'desktop' | 'mobile') => void + cancel: (ignoreWarning?: boolean) => void forgotUsername: (phone?: string, email?: string) => void resetState: () => void restartProvisioning: () => void startProvision: (name?: string, fromReset?: boolean) => void + submitDeviceName: (name: string) => void + submitDeviceSelect: (name: string) => void + submitPassphrase: (passphrase: string) => void + submitTextCode: (code: string) => void + submitUsername: (username: string) => void } } export const useProvisionState = Z.createZustand('provision', (set, get) => { - const _cancel = wrapErrors((ignoreWarning?: boolean) => { + const clearWaiting = (ignoreWarning?: boolean) => { useWaitingState.getState().dispatch.clear(waitingKeyProvision) if (!ignoreWarning) { console.log('Provision: cancel called while not overloaded') } - }) + } - // add a new value to submit and clear things behind - const _updateAutoSubmit = wrapErrors((step: Store['autoSubmit'][0]) => { - set(s => { - const idx = s.autoSubmit.findIndex(a => a.type === step.type) - if (idx !== -1) { - s.autoSubmit.splice(idx) - } - s.autoSubmit.push(T.castDraft(step)) - }) + const _cancel = wrapErrors((ignoreWarning?: boolean) => { + clearWaiting(ignoreWarning) }) - const _setPassphrase = wrapErrors((passphrase: string, restart: boolean = true) => { - set(s => { - s.passphrase = passphrase - }) - _updateAutoSubmit({type: 'passphrase'}) - if (restart) { - get().dispatch.restartProvisioning() - } - }) + let requestID = 0 + let activePrompt: ActivePrompt | undefined + let activeCancel: (ignoreWarning?: boolean) => void = _cancel - const _setDeviceName = wrapErrors((name: string, restart: boolean = true) => { + const setSession = (nextSession: Session) => { set(s => { - s.deviceName = name + s.session = T.castDraft(nextSession) }) - _updateAutoSubmit({type: 'deviceName'}) - if (restart) { - get().dispatch.restartProvisioning() - } - }) + } - const _submitDeviceSelect = wrapErrors((name: string, restart: boolean = true) => { - const devices = get().devices - const selectedDevice = devices.find(d => d.name === name) - if (!selectedDevice) { - throw new Error('Selected a non existant device?') - } + const updateSessionPrompt = (activeRequestID: number, prompt?: SessionPrompt) => { set(s => { - s.codePageOtherDevice = selectedDevice + if (s.session.kind !== 'idle' && s.session.requestID === activeRequestID) { + s.session.prompt = prompt ? T.castDraft(prompt) : undefined + } }) - _updateAutoSubmit({devices, type: 'chooseDevice'}) - if (restart) { - get().dispatch.restartProvisioning() + } + + const isActiveRequest = (activeRequestID: number) => { + const session = get().session + return session.kind !== 'idle' && session.requestID === activeRequestID + } + + const invalidateSession = () => { + requestID += 1 + activePrompt = undefined + activeCancel = _cancel + setSession(idleSession()) + } + + const finishPrompt = (activeRequestID: number) => { + if (activePrompt?.requestID === activeRequestID) { + activePrompt = undefined + activeCancel = _cancel + updateSessionPrompt(activeRequestID, undefined) } - }) + } + + const finishSession = (activeRequestID: number) => { + if (!isActiveRequest(activeRequestID)) return + finishPrompt(activeRequestID) + setSession(idleSession()) + } - const _submitTextCode = wrapErrors((_code: string) => { - console.log('Provision, unwatched submitTextCode called') - get().dispatch.restartProvisioning() + const cancelActiveSession = wrapErrors((ignoreWarning?: boolean) => { + const cancel = activeCancel + invalidateSession() + cancel(ignoreWarning) }) - const resetErrorAndCancel = () => { - set(s => { - s.error = '' - s.dispatch.dynamic.cancel = _cancel - }) + const isCanceled = (activeRequestID: number, response: CommonResponseHandler) => { + if (!isActiveRequest(activeRequestID)) { + cancelOnCallback(undefined, response) + return true + } + return false } - const makeCancelHelpers = () => { - let cancelled = false - const isCanceled = (response: CommonResponseHandler) => { - if (cancelled) { - cancelOnCallback(undefined, response) - return true - } + const setPrompt = (activeRequestID: number, response: CommonResponseHandler, prompt: SessionPrompt) => { + if (isCanceled(activeRequestID, response)) { return false } - const setupCancel = (response: CommonResponseHandler) => { - set(s => { - s.dispatch.dynamic.cancel = wrapErrors(() => { - set(s => { - s.dispatch.dynamic.cancel = _cancel - }) - cancelled = true - cancelOnCallback(undefined, response) - }) - }) - } - return {isCanceled, setupCancel} + activeCancel = wrapErrors((ignoreWarning?: boolean) => { + clearWaiting(ignoreWarning) + finishPrompt(activeRequestID) + updateSessionPrompt(activeRequestID, undefined) + cancelOnCallback(undefined, response) + }) + updateSessionPrompt(activeRequestID, prompt) + return true } + const beginSession = (kind: Exclude) => { + requestID += 1 + activePrompt = undefined + activeCancel = _cancel + const activeRequestID = requestID + setSession({kind, requestID: activeRequestID}) + return activeRequestID + } + + const normalizePromptSecret = (code: string) => code.replace(/\W+/g, ' ').trim() + const dispatch: State['dispatch'] = { addNewDevice: otherDeviceType => { - get().dispatch.dynamic.cancel?.() + cancelActiveSession() set(s => { s.codePageOtherDevice.type = otherDeviceType + s.finalError = undefined + s.inlineError = undefined }) - const {isCanceled, setupCancel} = makeCancelHelpers() + const activeRequestID = beginSession('addingDevice') const f = async () => { try { await T.RPCGen.deviceDeviceAddRpcListener({ customResponseIncomingCallMap: { 'keybase.1.provisionUi.DisplayAndPromptSecret': (params, response) => { - if (isCanceled(response)) return - const {phrase, previousErr} = params - setupCancel(response) - set(s => { - s.error = previousErr - s.codePageIncomingTextCode = phrase - s.dispatch.dynamic.submitTextCode = wrapErrors((code: string) => { - set(s => { - s.dispatch.dynamic.submitTextCode = _submitTextCode + const prompt: SessionPrompt = { + error: params.previousErr, + phrase: params.phrase, + type: 'promptSecret', + } + if (!setPrompt(activeRequestID, response, prompt)) return + activePrompt = { + requestID: activeRequestID, + submit: (code: string) => { + finishPrompt(activeRequestID) + response.result({ + phrase: normalizePromptSecret(code), + secret: null as unknown as Uint8Array, }) - resetErrorAndCancel() - const good = code.replace(/\W+/g, ' ').trim() - response.result({phrase: good, secret: null as unknown as Uint8Array}) - }) - }) + }, + type: 'promptSecret', + } navigateAppend('codePage') }, 'keybase.1.provisionUi.chooseDeviceType': (_params, response) => { + if (isCanceled(activeRequestID, response)) return const {type} = get().codePageOtherDevice switch (type) { case 'mobile': @@ -260,31 +281,13 @@ export const useProvisionState = Z.createZustand('provision', (set, get) }) } catch { } finally { - set(s => { - s.dispatch.dynamic.cancel = _cancel - s.dispatch.dynamic.submitTextCode = _submitTextCode - }) + finishSession(activeRequestID) } clearModals() } ignorePromise(f()) }, - dynamic: { - cancel: _cancel, - setDeviceName: _setDeviceName, - setPassphrase: _setPassphrase, - setUsername: wrapErrors((username: string, restart: boolean = true) => { - set(s => { - s.username = username - s.autoSubmit = [{type: 'username'}] - }) - if (restart) { - get().dispatch.restartProvisioning() - } - }), - submitDeviceSelect: _submitDeviceSelect, - submitTextCode: _submitTextCode, - }, + cancel: cancelActiveSession, forgotUsername: (phone, email) => { const f = async () => { if (email) { @@ -328,7 +331,7 @@ export const useProvisionState = Z.createZustand('provision', (set, get) ignorePromise(f()) }, resetState: () => { - get().dispatch.dynamic.cancel?.(true) + dispatch.cancel(true) set(s => ({ ...s, ...initialStore, @@ -338,100 +341,99 @@ export const useProvisionState = Z.createZustand('provision', (set, get) })) }, restartProvisioning: () => { - get().dispatch.dynamic.cancel?.() + cancelActiveSession() const {username} = get() if (!username) { return } - // freeze the autosubmit for this call so changes don't affect us - const {autoSubmit} = get() - console.log('Provision: startProvisioning starting with auto submit', autoSubmit) + console.log('Provision: startProvisioning starting for', username) + const activeRequestID = beginSession('provisioning') const f = async () => { - const {isCanceled, setupCancel} = makeCancelHelpers() - - let submitStep = 0 - const shouldAutoSubmit = (hadError: boolean, step: Step) => { - if (!hadError) { - ++submitStep - } - const auto = autoSubmit[submitStep] - return isEqual(auto, step) - } - try { await T.RPCGen.loginLoginRpcListener({ customResponseIncomingCallMap: { 'keybase.1.gpgUi.selectKey': cancelOnCallback, 'keybase.1.loginUi.getEmailOrUsername': cancelOnCallback, 'keybase.1.provisionUi.DisplayAndPromptSecret': (params, response) => { - if (isCanceled(response)) return - const {phrase, previousErr} = params - setupCancel(response) - set(s => { - s.error = previousErr - s.codePageIncomingTextCode = phrase - s.dispatch.dynamic.submitTextCode = wrapErrors((code: string) => { - set(s => { - s.dispatch.dynamic.submitTextCode = _submitTextCode + const prompt: SessionPrompt = { + error: params.previousErr, + phrase: params.phrase, + type: 'promptSecret', + } + if (!setPrompt(activeRequestID, response, prompt)) return + activePrompt = { + requestID: activeRequestID, + submit: (code: string) => { + finishPrompt(activeRequestID) + response.result({ + phrase: normalizePromptSecret(code), + secret: null as unknown as Uint8Array, }) - resetErrorAndCancel() - const good = code.replace(/\W+/g, ' ').trim() - response.result({phrase: good, secret: null as unknown as Uint8Array}) - }) - }) - - // we ignore the return as we never autosubmit, but we want things to increment - shouldAutoSubmit(!!previousErr, {type: 'promptSecret'}) + }, + type: 'promptSecret', + } navigateAppend('codePage') }, 'keybase.1.provisionUi.PromptNewDeviceName': (params, response) => { - if (isCanceled(response)) return - const {errorMessage, existingDevices} = params - setupCancel(response) - set(s => { - s.error = errorMessage - s.existingDevices = T.castDraft(existingDevices ?? []) - s.dispatch.dynamic.setDeviceName = wrapErrors((name: string) => { - set(s => { - s.dispatch.dynamic.setDeviceName = _setDeviceName - }) - _setDeviceName(name, false) - resetErrorAndCancel() + const prompt: SessionPrompt = { + error: params.errorMessage, + existingDevices: [...(params.existingDevices ?? [])], + type: 'deviceName', + } + if (!setPrompt(activeRequestID, response, prompt)) return + activePrompt = { + requestID: activeRequestID, + submit: (name: string) => { + finishPrompt(activeRequestID) response.result(name) - }) - }) + }, + type: 'deviceName', + } - if (shouldAutoSubmit(!!errorMessage, {type: 'deviceName'})) { + const {deviceName} = get() + if (deviceName && !params.errorMessage) { console.log('Provision: auto submit device name') - get().dispatch.dynamic.setDeviceName?.(get().deviceName) + dispatch.submitDeviceName(deviceName) } else { navigateAppend('setPublicName') } }, 'keybase.1.provisionUi.chooseDevice': (params, response) => { - if (isCanceled(response)) return - const {devices: _devices} = params - const devices = _devices?.map(d => rpcDeviceToDevice(d)) ?? [] - setupCancel(response) + const devices = params.devices?.map(d => rpcDeviceToDevice(d)) ?? [] + if ( + !setPrompt(activeRequestID, response, { + devices, + type: 'chooseDevice', + }) + ) { + return + } set(s => { - s.error = '' - s.devices = devices - s.dispatch.dynamic.submitDeviceSelect = wrapErrors((device: string) => { + s.devices = T.castDraft(devices) + }) + activePrompt = { + devices, + requestID: activeRequestID, + submit: (name: string) => { + const selectedDevice = devices.find(d => d.name === name) + if (!selectedDevice) { + throw new Error('Selected a non existant device?') + } set(s => { - s.dispatch.dynamic.submitDeviceSelect = _submitDeviceSelect + s.codePageOtherDevice = selectedDevice }) - _submitDeviceSelect(device, false) - const id = get().codePageOtherDevice.id - resetErrorAndCancel() - response.result(id) - }) - }) + finishPrompt(activeRequestID) + response.result(selectedDevice.id) + }, + type: 'chooseDevice', + } - if (shouldAutoSubmit(false, {devices, type: 'chooseDevice'})) { - console.log('Provision: auto submit passphrase') - get().dispatch.dynamic.submitDeviceSelect?.(get().codePageOtherDevice.name) + const selectedDeviceName = get().codePageOtherDevice.name + if (selectedDeviceName && devices.some(d => d.name === selectedDeviceName)) { + console.log('Provision: auto submit device selection') + dispatch.submitDeviceSelect(selectedDeviceName) } else { navigateAppend('selectOtherDevice') } @@ -439,38 +441,51 @@ export const useProvisionState = Z.createZustand('provision', (set, get) 'keybase.1.provisionUi.chooseGPGMethod': cancelOnCallback, 'keybase.1.provisionUi.switchToGPGSignOK': cancelOnCallback, 'keybase.1.secretUi.getPassphrase': (params, response) => { - if (isCanceled(response)) return const {pinentry} = params - const {retryLabel, type} = pinentry + const error = + pinentry.retryLabel === invalidPasswordErrorString ? 'Incorrect password.' : pinentry.retryLabel - setupCancel(response) - // Service asking us again due to an error? - set(s => { - s.error = retryLabel === invalidPasswordErrorString ? 'Incorrect password.' : retryLabel - s.dispatch.dynamic.setPassphrase = wrapErrors((passphrase: string) => { - set(s => { - s.dispatch.dynamic.setPassphrase = _setPassphrase - }) - _setPassphrase(passphrase, false) - resetErrorAndCancel() - response.result({passphrase, storeSecret: false}) - }) - }) - - if (shouldAutoSubmit(!!retryLabel, {type: 'passphrase'})) { - console.log('Provision: auto submit passphrase') - get().dispatch.dynamic.setPassphrase?.(get().passphrase) - } else { - switch (type) { - case T.RPCGen.PassphraseType.passPhrase: + switch (pinentry.type) { + case T.RPCGen.PassphraseType.passPhrase: { + if (!setPrompt(activeRequestID, response, {error, type: 'passphrase'})) return + activePrompt = { + requestID: activeRequestID, + submit: (passphrase: string) => { + finishPrompt(activeRequestID) + response.result({passphrase, storeSecret: false}) + }, + type: 'passphrase', + } + const {passphrase} = get() + if (passphrase && !error) { + console.log('Provision: auto submit passphrase') + dispatch.submitPassphrase(passphrase) + } else { navigateAppend('password') - break - case T.RPCGen.PassphraseType.paperKey: + } + break + } + case T.RPCGen.PassphraseType.paperKey: { + if (!setPrompt(activeRequestID, response, {error, type: 'paperKey'})) return + activePrompt = { + requestID: activeRequestID, + submit: (passphrase: string) => { + finishPrompt(activeRequestID) + response.result({passphrase, storeSecret: false}) + }, + type: 'paperKey', + } + const {passphrase} = get() + if (passphrase && !error) { + console.log('Provision: auto submit paper key') + dispatch.submitPassphrase(passphrase) + } else { navigateAppend('paperkey') - break - default: - throw new Error('Got confused about password entry. Please send a log to us!') + } + break } + default: + throw new Error('Got confused about password entry. Please send a log to us!') } }, }, @@ -498,8 +513,6 @@ export const useProvisionState = Z.createZustand('provision', (set, get) return } const finalError = _finalError - // If it's a non-existent username or invalid, allow the opportunity to - // correct it right there on the page. switch (finalError.code) { case T.RPCGen.StatusCode.scnotfound: case T.RPCGen.StatusCode.scbadusername: @@ -518,19 +531,63 @@ export const useProvisionState = Z.createZustand('provision', (set, get) break } } finally { - get().dispatch.resetState() + dispatch.resetState() } } ignorePromise(f()) }, startProvision: (name = '', fromReset = false) => { - get().dispatch.dynamic.cancel?.(true) + dispatch.cancel(true) set(s => { - s.startProvisionTrigger++ + s.finalError = undefined + s.inlineError = undefined s.username = name + s.startProvisionTrigger++ }) navigateAppend({name: 'username', params: {fromReset}}) }, + submitDeviceName: wrapErrors((name: string) => { + set(s => { + s.deviceName = name + }) + if (activePrompt?.type === 'deviceName') { + activePrompt.submit(name) + return + } + console.log('Provision: unwatched submitDeviceName called') + }), + submitDeviceSelect: wrapErrors((name: string) => { + if (activePrompt?.type === 'chooseDevice') { + activePrompt.submit(name) + return + } + console.log('Provision: unwatched submitDeviceSelect called') + }), + submitPassphrase: wrapErrors((passphrase: string) => { + set(s => { + s.passphrase = passphrase + }) + if (activePrompt?.type === 'passphrase' || activePrompt?.type === 'paperKey') { + activePrompt.submit(passphrase) + return + } + console.log('Provision: unwatched submitPassphrase called') + }), + submitTextCode: wrapErrors((code: string) => { + if (activePrompt?.type === 'promptSecret') { + activePrompt.submit(code) + return + } + console.log('Provision: unwatched submitTextCode called') + }), + submitUsername: wrapErrors((username: string) => { + set(s => { + s.finalError = undefined + s.inlineError = undefined + s.username = username + }) + dispatch.restartProvisioning() + }), } return { diff --git a/shared/stores/tests/provision.test.ts b/shared/stores/tests/provision.test.ts index b0d2edffba08..6cc144bc158a 100644 --- a/shared/stores/tests/provision.test.ts +++ b/shared/stores/tests/provision.test.ts @@ -1,6 +1,18 @@ /// +import * as T from '@/constants/types' +import {invalidPasswordErrorString} from '@/constants/config' import {RPCError} from '@/util/errors' import {resetAllStores} from '@/util/zustand' + +jest.mock('@/constants/router', () => { + const actual = jest.requireActual('@/constants/router') + return { + ...actual, + clearModals: jest.fn(), + navigateAppend: jest.fn(), + } +}) + import { badDeviceChars, badDeviceRE, @@ -10,11 +22,47 @@ import { useProvisionState, } from '../provision' +const {clearModals: mockClearModals, navigateAppend: mockNavigateAppend} = require('@/constants/router') as { + clearModals: jest.Mock + navigateAppend: jest.Mock +} + +const flush = async () => new Promise(resolve => setImmediate(resolve)) + +const deferred = () => { + let resolve = () => {} + const promise = new Promise(r => { + resolve = r + }) + return {promise, resolve} +} + +const makeResponse = () => ({ + error: jest.fn(), + result: jest.fn(), +}) + +const currentPrompt = () => { + const {session} = useProvisionState.getState() + return session.kind === 'idle' ? undefined : session.prompt +} + +const makeRpcDevice = (name: string, deviceID: string, type: 'mobile' | 'desktop' | 'backup') => + ({ + deviceID, + deviceNumberOfType: 1, + name, + type, + }) as any + beforeEach(() => { resetAllStores() }) afterEach(() => { + jest.restoreAllMocks() + mockClearModals.mockReset() + mockNavigateAppend.mockReset() resetAllStores() }) @@ -37,52 +85,45 @@ test('startProvision increments the trigger and stores the username', () => { }) test('restartProvisioning bails early when there is no username after canceling the current flow', () => { - const cancel = jest.fn() useProvisionState.setState(s => ({ ...s, - dispatch: { - ...s.dispatch, - dynamic: { - ...s.dispatch.dynamic, - cancel, - }, - }, + session: {kind: 'provisioning', requestID: 7}, })) useProvisionState.getState().dispatch.restartProvisioning() - expect(cancel).toHaveBeenCalledTimes(1) + expect(useProvisionState.getState().session).toEqual({kind: 'idle'}) expect(useProvisionState.getState().startProvisionTrigger).toBe(0) }) test('resetState preserves inline and final errors while clearing form state', () => { - const cancel = jest.fn() const finalError = new RPCError('final error', 1) const inlineError = new RPCError('inline error', 2) + const deviceID = T.Devices.stringToDeviceID('device1') useProvisionState.setState(s => ({ ...s, - autoSubmit: [{type: 'username'}], - deviceName: 'My Phone', - dispatch: { - ...s.dispatch, - dynamic: { - ...s.dispatch.dynamic, - cancel, - }, + codePageOtherDevice: { + deviceNumberOfType: 1, + id: deviceID, + name: 'old device', + type: 'desktop', }, + deviceName: 'My Phone', + devices: [{deviceNumberOfType: 1, id: deviceID, name: 'old device', type: 'desktop'}], finalError, forgotUsernameResult: 'success', inlineError, passphrase: 'hunter2', + session: {kind: 'provisioning', requestID: 9}, username: 'alice', })) useProvisionState.getState().dispatch.resetState() const state = useProvisionState.getState() - expect(cancel).toHaveBeenCalledWith(true) - expect(state.autoSubmit).toEqual([]) + expect(state.session).toEqual({kind: 'idle'}) + expect(state.devices).toEqual([]) expect(state.deviceName).toBe('') expect(state.forgotUsernameResult).toBe('') expect(state.passphrase).toBe('') @@ -90,3 +131,272 @@ test('resetState preserves inline and final errors while clearing form state', ( expect(state.finalError).toBe(finalError) expect(state.inlineError).toBe(inlineError) }) + +test('submitUsername starts provisioning and submits a passphrase prompt through stable actions', async () => { + const done = deferred() + const response = makeResponse() + + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async listener => { + const getPassphrase = listener.customResponseIncomingCallMap?.['keybase.1.secretUi.getPassphrase'] + if (!getPassphrase) { + throw new Error('getPassphrase handler missing') + } + getPassphrase( + {pinentry: {retryLabel: '', type: T.RPCGen.PassphraseType.passPhrase}} as any, + response as any + ) + await done.promise + return undefined as any + }) + + try { + useProvisionState.getState().dispatch.submitUsername('alice') + await flush() + + const state = useProvisionState.getState() + expect(state.username).toBe('alice') + expect(state.session.kind).toBe('provisioning') + expect(currentPrompt()).toEqual({error: '', type: 'passphrase'}) + expect(mockNavigateAppend).toHaveBeenCalledWith('password') + + state.dispatch.submitPassphrase('hunter2') + + expect(useProvisionState.getState().passphrase).toBe('hunter2') + expect(response.result).toHaveBeenCalledWith({passphrase: 'hunter2', storeSecret: false}) + expect(currentPrompt()).toBeUndefined() + } finally { + done.resolve() + await flush() + } +}) + +test('invalid password retry label is rewritten for the prompt', async () => { + const done = deferred() + + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async listener => { + const getPassphrase = listener.customResponseIncomingCallMap?.['keybase.1.secretUi.getPassphrase'] + if (!getPassphrase) { + throw new Error('getPassphrase handler missing') + } + getPassphrase( + { + pinentry: { + retryLabel: invalidPasswordErrorString, + type: T.RPCGen.PassphraseType.passPhrase, + }, + } as any, + makeResponse() as any + ) + await done.promise + return undefined as any + }) + + try { + useProvisionState.setState(s => ({...s, username: 'alice'})) + useProvisionState.getState().dispatch.restartProvisioning() + await flush() + + expect(useProvisionState.getState().session).toMatchObject({ + kind: 'provisioning', + prompt: {error: 'Incorrect password.', type: 'passphrase'}, + }) + } finally { + done.resolve() + await flush() + } +}) + +test('stored device name is auto-submitted when the service asks for it again', async () => { + const done = deferred() + const response = makeResponse() + + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async listener => { + const promptNewDeviceName = listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.PromptNewDeviceName'] + if (!promptNewDeviceName) { + throw new Error('PromptNewDeviceName handler missing') + } + promptNewDeviceName({errorMessage: '', existingDevices: ['Old laptop']} as any, response as any) + await done.promise + return undefined as any + }) + + try { + useProvisionState.setState(s => ({...s, deviceName: 'My Phone', username: 'alice'})) + useProvisionState.getState().dispatch.restartProvisioning() + await flush() + + expect(response.result).toHaveBeenCalledWith('My Phone') + expect(mockNavigateAppend).not.toHaveBeenCalledWith('setPublicName') + expect(useProvisionState.getState().session.kind).toBe('provisioning') + expect(currentPrompt()).toBeUndefined() + } finally { + done.resolve() + await flush() + } +}) + +test('stored selected device is auto-submitted when it still exists', async () => { + const done = deferred() + const response = makeResponse() + const selectedID = T.Devices.stringToDeviceID('device-1') + + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async listener => { + const chooseDevice = listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.chooseDevice'] + if (!chooseDevice) { + throw new Error('chooseDevice handler missing') + } + chooseDevice({devices: [makeRpcDevice('phone', 'device-1', 'mobile')]} as any, response as any) + await done.promise + return undefined as any + }) + + try { + useProvisionState.setState(s => ({ + ...s, + codePageOtherDevice: { + deviceNumberOfType: 1, + id: selectedID, + name: 'phone', + type: 'mobile', + }, + username: 'alice', + })) + useProvisionState.getState().dispatch.restartProvisioning() + await flush() + + expect(useProvisionState.getState().devices).toEqual([ + {deviceNumberOfType: 1, id: selectedID, name: 'phone', type: 'mobile'}, + ]) + expect(response.result).toHaveBeenCalledWith(selectedID) + expect(mockNavigateAppend).not.toHaveBeenCalledWith('selectOtherDevice') + } finally { + done.resolve() + await flush() + } +}) + +test('canceling an active prompt responds with input canceled and clears the session', async () => { + const done = deferred() + const response = makeResponse() + + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async listener => { + const getPassphrase = listener.customResponseIncomingCallMap?.['keybase.1.secretUi.getPassphrase'] + if (!getPassphrase) { + throw new Error('getPassphrase handler missing') + } + getPassphrase( + {pinentry: {retryLabel: '', type: T.RPCGen.PassphraseType.passPhrase}} as any, + response as any + ) + await done.promise + return undefined as any + }) + + try { + useProvisionState.setState(s => ({...s, username: 'alice'})) + useProvisionState.getState().dispatch.restartProvisioning() + await flush() + + useProvisionState.getState().dispatch.cancel() + + expect(response.error).toHaveBeenCalledWith({ + code: T.RPCGen.StatusCode.scinputcanceled, + desc: 'Input canceled', + }) + expect(useProvisionState.getState().session).toEqual({kind: 'idle'}) + } finally { + done.resolve() + await flush() + } +}) + +test('addNewDevice publishes the prompt-secret step and normalizes submitted text code', async () => { + const done = deferred() + const response = makeResponse() + + jest.spyOn(T.RPCGen, 'deviceDeviceAddRpcListener').mockImplementation(async listener => { + const displayAndPromptSecret = + listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.DisplayAndPromptSecret'] + if (!displayAndPromptSecret) { + throw new Error('DisplayAndPromptSecret handler missing') + } + displayAndPromptSecret({phrase: 'alpha beta gamma', previousErr: 'Try again'} as any, response as any) + await done.promise + return undefined as any + }) + + try { + useProvisionState.getState().dispatch.addNewDevice('mobile') + await flush() + + expect(useProvisionState.getState().session).toMatchObject({ + kind: 'addingDevice', + prompt: {error: 'Try again', phrase: 'alpha beta gamma', type: 'promptSecret'}, + }) + expect(mockNavigateAppend).toHaveBeenCalledWith('codePage') + + useProvisionState.getState().dispatch.submitTextCode('alpha-beta\tgamma') + + expect(response.result).toHaveBeenCalledWith({ + phrase: 'alpha beta gamma', + secret: null, + }) + expect(currentPrompt()).toBeUndefined() + } finally { + done.resolve() + await flush() + } +}) + +test('a not-found RPC error is surfaced as an inline error', async () => { + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockRejectedValue( + new RPCError('missing user', T.RPCGen.StatusCode.scnotfound) + ) + + useProvisionState.setState(s => ({...s, username: 'alice'})) + useProvisionState.getState().dispatch.restartProvisioning() + await flush() + + const state = useProvisionState.getState() + expect(state.inlineError?.code).toBe(T.RPCGen.StatusCode.scnotfound) + expect(state.finalError).toBeUndefined() + expect(state.username).toBe('') + expect(state.session).toEqual({kind: 'idle'}) +}) + +test('a terminal RPC error is stored as finalError and routes to the error screen', async () => { + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockRejectedValue( + new RPCError('boom', T.RPCGen.StatusCode.scapinetworkerror) + ) + + useProvisionState.setState(s => ({...s, username: 'alice'})) + useProvisionState.getState().dispatch.restartProvisioning() + await flush() + + const state = useProvisionState.getState() + expect(state.finalError?.code).toBe(T.RPCGen.StatusCode.scapinetworkerror) + expect(state.inlineError).toBeUndefined() + expect(mockNavigateAppend).toHaveBeenCalledWith('error', true) +}) + +test('forgotUsername stores success for a successful email recovery', async () => { + jest.spyOn(T.RPCGen, 'accountRecoverUsernameWithEmailRpcPromise').mockResolvedValue(undefined as any) + + useProvisionState.getState().dispatch.forgotUsername(undefined, 'alice@example.com') + await flush() + + expect(useProvisionState.getState().forgotUsernameResult).toBe('success') +}) + +test('forgotUsername decodes recover-username RPC errors', async () => { + jest.spyOn(T.RPCGen, 'accountRecoverUsernameWithEmailRpcPromise').mockRejectedValue( + new RPCError('bad email', T.RPCGen.StatusCode.scinputerror) + ) + + useProvisionState.getState().dispatch.forgotUsername(undefined, 'not-an-email') + await flush() + + expect(useProvisionState.getState().forgotUsernameResult).toBe( + "That doesn't look like a valid email address. Try again?" + ) +})