From 3c134ef2e43d107982ab183934dbe4d2a51c560c Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 25 Mar 2026 13:08:19 -0400 Subject: [PATCH] simpler login --- shared/login/flow.test.ts | 74 +++++++++++++++++ shared/login/flow.ts | 55 +++++++++++++ shared/login/index.tsx | 57 +++++++------ shared/login/join-or-login.tsx | 48 +++++------ shared/login/relogin/container.tsx | 127 ++++++++++++++--------------- shared/login/relogin/index.d.ts | 1 - 6 files changed, 245 insertions(+), 117 deletions(-) create mode 100644 shared/login/flow.test.ts create mode 100644 shared/login/flow.ts diff --git a/shared/login/flow.test.ts b/shared/login/flow.test.ts new file mode 100644 index 000000000000..985f89b488a2 --- /dev/null +++ b/shared/login/flow.test.ts @@ -0,0 +1,74 @@ +/// + +import { + getLoggedOutBannerMessage, + getReloginNeedPassword, + getRootLoginMode, + isNeedPasswordError, + needPasswordError, +} from './flow' + +test('getRootLoginMode preserves the logged-out routing priority', () => { + expect( + getRootLoginMode({ + configuredAccountsLength: 2, + handshakeState: 'done', + isLoggedIn: true, + userSwitching: false, + }) + ).toBe('hidden') + + expect( + getRootLoginMode({ + configuredAccountsLength: 2, + handshakeState: 'starting', + isLoggedIn: false, + userSwitching: false, + }) + ).toBe('loading') + + expect( + getRootLoginMode({ + configuredAccountsLength: 2, + handshakeState: 'done', + isLoggedIn: false, + userSwitching: true, + }) + ).toBe('loading') + + expect( + getRootLoginMode({ + configuredAccountsLength: 2, + handshakeState: 'done', + isLoggedIn: false, + userSwitching: false, + }) + ).toBe('relogin') + + expect( + getRootLoginMode({ + configuredAccountsLength: 0, + handshakeState: 'done', + isLoggedIn: false, + userSwitching: false, + }) + ).toBe('intro') +}) + +test('getLoggedOutBannerMessage prefers deletion, then revocation, then nothing', () => { + expect( + getLoggedOutBannerMessage({justDeletedSelf: 'alice', justRevokedSelf: 'bob'}) + ).toBe('Your Keybase account alice has been deleted. Au revoir!') + expect(getLoggedOutBannerMessage({justDeletedSelf: '', justRevokedSelf: 'bob'})).toBe( + 'bob was revoked successfully' + ) + expect(getLoggedOutBannerMessage({justDeletedSelf: '', justRevokedSelf: ''})).toBe('') +}) + +test('relogin password helpers preserve the stored-secret and empty-passphrase branches', () => { + expect(getReloginNeedPassword(true, false)).toBe(false) + expect(getReloginNeedPassword(false, false)).toBe(true) + expect(getReloginNeedPassword(true, true)).toBe(true) + expect(isNeedPasswordError(needPasswordError)).toBe(true) + expect(isNeedPasswordError('Incorrect password.')).toBe(false) +}) diff --git a/shared/login/flow.ts b/shared/login/flow.ts new file mode 100644 index 000000000000..41777112aed6 --- /dev/null +++ b/shared/login/flow.ts @@ -0,0 +1,55 @@ +import type * as T from '@/constants/types' + +export type RootLoginMode = 'hidden' | 'intro' | 'loading' | 'relogin' + +type RootLoginState = { + configuredAccountsLength: number + handshakeState: T.Config.DaemonHandshakeState + isLoggedIn: boolean + userSwitching: boolean +} + +type LoggedOutBannerState = { + justDeletedSelf: string + justRevokedSelf: string +} + +export const needPasswordError = 'passphrase cannot be empty' + +export const getRootLoginMode = ({ + configuredAccountsLength, + handshakeState, + isLoggedIn, + userSwitching, +}: RootLoginState): RootLoginMode => { + if (isLoggedIn) { + return 'hidden' + } + if (handshakeState !== 'done' || userSwitching) { + return 'loading' + } + if (configuredAccountsLength > 0) { + return 'relogin' + } + return 'intro' +} + +export const getLoggedOutBannerMessage = ({ + justDeletedSelf, + justRevokedSelf, +}: LoggedOutBannerState): string => { + if (justDeletedSelf) { + return `Your Keybase account ${justDeletedSelf} has been deleted. Au revoir!` + } + if (justRevokedSelf) { + return `${justRevokedSelf} was revoked successfully` + } + return '' +} + +export const getReloginNeedPassword = ( + hasStoredSecret: boolean, + promptedForPassword: boolean +): boolean => !hasStoredSecret || promptedForPassword + +export const isNeedPasswordError = (error: string): boolean => error === needPasswordError diff --git a/shared/login/index.tsx b/shared/login/index.tsx index 1c052d3559d0..5c9994ae3407 100644 --- a/shared/login/index.tsx +++ b/shared/login/index.tsx @@ -1,4 +1,6 @@ import * as React from 'react' +import * as C from '@/constants' +import {getRootLoginMode} from './flow' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' @@ -6,34 +8,39 @@ const Loading = React.lazy(async () => import('./loading')) const Relogin = React.lazy(async () => import('./relogin/container')) const JoinOrLogin = React.lazy(async () => import('./join-or-login')) -const RootLogin = () => { - const isLoggedIn = useConfigState(s => s.loggedIn) - const userSwitching = useConfigState(s => s.userSwitching) - const showLoading = useDaemonState(s => s.handshakeState !== 'done' || userSwitching) - const showRelogin = useConfigState(s => !showLoading && s.configuredAccounts.length > 0) - // routing should switch us away so lets not draw anything to speed things up - if (isLoggedIn) return null - - if (showLoading) { - return ( - - - - ) - } - if (showRelogin) { - return ( - - - - ) +const renderMode = (mode: ReturnType) => { + switch (mode) { + case 'loading': + return + case 'relogin': + return + case 'intro': + return + case 'hidden': + return null } +} - return ( - - - +const RootLogin = () => { + const {configuredAccountsLength, isLoggedIn, userSwitching} = useConfigState( + C.useShallow(s => ({ + configuredAccountsLength: s.configuredAccounts.length, + isLoggedIn: s.loggedIn, + userSwitching: s.userSwitching, + })) ) + const handshakeState = useDaemonState(s => s.handshakeState) + const mode = getRootLoginMode({ + configuredAccountsLength, + handshakeState, + isLoggedIn, + userSwitching, + }) + + // routing should switch us away so lets not draw anything to speed things up + if (mode === 'hidden') return null + + return {renderMode(mode)} } export default RootLogin diff --git a/shared/login/join-or-login.tsx b/shared/login/join-or-login.tsx index 6112c21883b5..a6de50f7880f 100644 --- a/shared/login/join-or-login.tsx +++ b/shared/login/join-or-login.tsx @@ -5,40 +5,40 @@ import * as Kb from '@/common-adapters' import {InfoIcon} from '@/signup/common' import {useSignupState} from '@/stores/signup' import {useProvisionState} from '@/stores/provision' +import {getLoggedOutBannerMessage} from './flow' -const Intro = () => { - const justDeletedSelf = useConfigState(s => s.justDeletedSelf) - const justRevokedSelf = useConfigState(s => s.justRevokedSelf) - const bannerMessage = justDeletedSelf - ? `Your Keybase account ${justDeletedSelf} has been deleted. Au revoir!` - : justRevokedSelf - ? `${justRevokedSelf} was revoked successfully` - : '' - - const isOnline = useConfigState(s => s.isOnline) - const loadIsOnline = useConfigState(s => s.dispatch.loadIsOnline) - +const useLoggedOutIntroState = () => { + const {isOnline, justDeletedSelf, justRevokedSelf, loadIsOnline} = useConfigState( + C.useShallow(s => ({ + isOnline: s.isOnline, + justDeletedSelf: s.justDeletedSelf, + justRevokedSelf: s.justRevokedSelf, + loadIsOnline: s.dispatch.loadIsOnline, + })) + ) const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) - const checkIsOnline = loadIsOnline - const startProvision = useProvisionState(s => s.dispatch.startProvision) - const onLogin = () => { - startProvision() - } const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) - const onSignup = () => { - requestAutoInvite() - } - const showProxySettings = () => { - navigateAppend('proxySettingsModal') - } + const startProvision = useProvisionState(s => s.dispatch.startProvision) const [showing, setShowing] = React.useState(true) - Kb.useInterval(checkIsOnline, showing ? 5000 : undefined) + Kb.useInterval(loadIsOnline, showing ? 5000 : undefined) C.Router2.useSafeFocusEffect(() => { setShowing(true) return () => setShowing(false) }) + return { + bannerMessage: getLoggedOutBannerMessage({justDeletedSelf, justRevokedSelf}), + isOnline, + onLogin: () => startProvision(), + onSignup: () => requestAutoInvite(), + showProxySettings: () => navigateAppend('proxySettingsModal'), + } +} + +const Intro = () => { + const {bannerMessage, isOnline, onLogin, onSignup, showProxySettings} = useLoggedOutIntroState() + return ( { - const _users = useConfigState(s => s.configuredAccounts) - const perror = useConfigState(s => s.loginError) - const pselectedUser = useConfigState(s => s.defaultUsername) +const useReloginState = () => { + const {configuredAccounts, defaultUsername, login, loginError, setLoginError} = useConfigState( + C.useShallow(s => ({ + configuredAccounts: s.configuredAccounts, + defaultUsername: s.defaultUsername, + login: s.dispatch.login, + loginError: s.loginError, + setLoginError: s.dispatch.setLoginError, + })) + ) const startRecoverPassword = useRecoverState(s => s.dispatch.startRecoverPassword) - const onForgotPassword = (username: string) => { - startRecoverPassword({username}) - } - const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) - const onFeedback = () => { - navigateAppend('signupSendFeedbackLoggedOut') - } - const onLogin = useConfigState(s => s.dispatch.login) const requestAutoInvite = useSignupState(s => s.dispatch.requestAutoInvite) - const onSignup = () => requestAutoInvite() - const onSomeoneElse = useProvisionState(s => s.dispatch.startProvision) - const error = perror?.desc || '' - const loggedInMap = new Map(_users.map(account => [account.username, account.hasStoredSecret])) - const users = sortBy(_users, 'username') + const startProvision = useProvisionState(s => s.dispatch.startProvision) + const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + + const error = loginError?.desc || '' + const users = sortBy(configuredAccounts, 'username') + const loggedInMap = new Map(users.map(account => [account.username, account.hasStoredSecret])) const [password, setPassword] = React.useState('') - const [selectedUser, setSelectedUser] = React.useState(pselectedUser) + const [selectedUser, setSelectedUser] = React.useState(defaultUsername) const [showTyping, setShowTyping] = React.useState(false) - - const setLoginError = useConfigState(s => s.dispatch.setLoginError) - const prevPasswordRef = React.useRef(password) - const prevErrorRef = React.useRef(error) + const [gotNeedPasswordError, setGotNeedPasswordError] = React.useState(false) + const previousErrorRef = React.useRef(error) React.useEffect(() => { - if (password.length && !prevPasswordRef.current.length) { - setLoginError() + if (error.length && !previousErrorRef.current.length) { + setPassword('') } - prevPasswordRef.current = password - }, [password, setLoginError]) + previousErrorRef.current = error + }, [error]) React.useEffect(() => { - if (error.length && !prevErrorRef.current.length) { - setPassword('') - } - }, [error, setPassword]) + setSelectedUser(defaultUsername) + }, [defaultUsername]) - const [gotNeedPasswordError, setGotNeedPasswordError] = React.useState(false) + React.useEffect(() => { + if (isNeedPasswordError(error)) { + setGotNeedPasswordError(true) + } + }, [error]) - const onSubmit = () => { - onLogin(selectedUser, password) + const passwordChange = (nextPassword: string) => { + if (nextPassword.length && !password.length) { + setLoginError() + } + setPassword(nextPassword) } - const selectedUserChange = (user: string) => { + const selectedUserChange = (username: string) => { setLoginError() setPassword('') - setSelectedUser(user) - if (loggedInMap.get(user)) { - onLogin(user, '') + setSelectedUser(username) + if (loggedInMap.get(username)) { + login(username, '') } } - React.useEffect(() => { - setSelectedUser(pselectedUser) - }, [pselectedUser, setSelectedUser]) - - React.useEffect(() => { - if (error === needPasswordError) { - setGotNeedPasswordError(true) - } - }, [error, setGotNeedPasswordError]) + return { + error, + needPassword: getReloginNeedPassword(!!loggedInMap.get(selectedUser), gotNeedPasswordError), + onFeedback: () => navigateAppend('signupSendFeedbackLoggedOut'), + onForgotPassword: () => startRecoverPassword({username: selectedUser}), + onSignup: () => requestAutoInvite(), + onSomeoneElse: startProvision, + onSubmit: () => login(selectedUser, password), + password, + passwordChange, + selectedUser, + selectedUserChange, + showTyping, + showTypingChange: setShowTyping, + users, + } +} - return ( - onForgotPassword(selectedUser)} - onLogin={onLogin} - onSignup={onSignup} - onSomeoneElse={onSomeoneElse} - onSubmit={onSubmit} - password={password} - passwordChange={setPassword} - selectedUser={selectedUser} - selectedUserChange={selectedUserChange} - showTypingChange={setShowTyping} - showTyping={showTyping} - users={users} - /> - ) +const ReloginContainer = () => { + const props = useReloginState() + return } export default ReloginContainer diff --git a/shared/login/relogin/index.d.ts b/shared/login/relogin/index.d.ts index c68879ec48cd..bb3129f5e17d 100644 --- a/shared/login/relogin/index.d.ts +++ b/shared/login/relogin/index.d.ts @@ -16,7 +16,6 @@ export type Props = { showTypingChange: (typingChange: boolean) => void onSubmit: () => void onFeedback: () => void - onLogin: (user: string, password: string) => void } declare const Login: (p: Props) => React.ReactNode export default Login