Move global "Sign out" out of the current account row (#4941)

* Rename logout to logoutEveryAccount

* Add logoutCurrentAccount()

* Make all "Log out" buttons refer to current account

Each of these usages is completely contextual and refers to a specific account.

* Add Sign out of all accounts to Settings

* Move single account Sign Out below as well

* Prompt on account removal

* Add Other Accounts header to reduce ambiguity

* Spacing fix

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
dan 2024-08-15 20:58:13 +01:00 committed by GitHub
parent f3b57dd456
commit b6e515c664
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 247 additions and 77 deletions

View File

@ -14,6 +14,7 @@ export type LogEvents = {
} }
'account:loggedOut': { 'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated' logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
scope: 'current' | 'every'
} }
'notifications:openApp': {} 'notifications:openApp': {}
'notifications:request': { 'notifications:request': {

View File

@ -38,7 +38,7 @@ export function Deactivated() {
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1 const hasOtherAccounts = accounts.length > 1
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {logout} = useSessionApi() const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent() const agent = useAgent()
const [pending, setPending] = React.useState(false) const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>() const [error, setError] = React.useState<string | undefined>()
@ -72,8 +72,8 @@ export function Deactivated() {
// So we change the URL ourselves. The navigator will pick it up on remount. // So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/') history.pushState(null, '', '/')
} }
logout('Deactivated') logoutCurrentAccount('Deactivated')
}, [logout]) }, [logoutCurrentAccount])
const handleActivate = React.useCallback(async () => { const handleActivate = React.useCallback(async () => {
try { try {

View File

@ -35,7 +35,7 @@ function DeactivateAccountDialogInner({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const {logout} = useSessionApi() const {logoutCurrentAccount} = useSessionApi()
const [pending, setPending] = React.useState(false) const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>() const [error, setError] = React.useState<string | undefined>()
@ -44,7 +44,7 @@ function DeactivateAccountDialogInner({
setPending(true) setPending(true)
await agent.com.atproto.server.deactivateAccount({}) await agent.com.atproto.server.deactivateAccount({})
control.close(() => { control.close(() => {
logout('Deactivated') logoutCurrentAccount('Deactivated')
}) })
} catch (e: any) { } catch (e: any) {
switch (e.message) { switch (e.message) {
@ -66,7 +66,7 @@ function DeactivateAccountDialogInner({
} finally { } finally {
setPending(false) setPending(false)
} }
}, [agent, control, logout, _, setPending]) }, [agent, control, logoutCurrentAccount, _, setPending])
return ( return (
<> <>

View File

@ -23,7 +23,7 @@ export function SignupQueued() {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi() const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent() const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false) const [isProcessing, setProcessing] = React.useState(false)
@ -153,7 +153,7 @@ export function SignupQueued() {
variant="ghost" variant="ghost"
size="large" size="large"
label={_(msg`Log out`)} label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}> onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}> <ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans> <Trans>Log out</Trans>
</ButtonText> </ButtonText>
@ -182,7 +182,7 @@ export function SignupQueued() {
variant="ghost" variant="ghost"
size="large" size="large"
label={_(msg`Log out`)} label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}> onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}> <ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans> <Trans>Log out</Trans>
</ButtonText> </ButtonText>

View File

@ -76,7 +76,7 @@ describe('session', () => {
state = run(state, [ state = run(state, [
{ {
type: 'logged-out', type: 'logged-out-every-account',
}, },
]) ])
// Should keep the account but clear out the tokens. // Should keep the account but clear out the tokens.
@ -372,7 +372,7 @@ describe('session', () => {
state = run(state, [ state = run(state, [
{ {
// Log everyone out. // Log everyone out.
type: 'logged-out', type: 'logged-out-every-account',
}, },
]) ])
expect(state.accounts.length).toBe(3) expect(state.accounts.length).toBe(3)
@ -466,7 +466,7 @@ describe('session', () => {
state = run(state, [ state = run(state, [
{ {
type: 'logged-out', type: 'logged-out-every-account',
}, },
]) ])
expect(state.accounts.length).toBe(1) expect(state.accounts.length).toBe(1)
@ -674,6 +674,103 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe(undefined) expect(state.currentAgentState.did).toBe(undefined)
}) })
it('can log out of the current account', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
refreshJwt: 'alice-refresh-jwt-1',
}
state = run(state, [
{
type: 'switched-to-account',
newAgent: agent1,
newAccount: agentToSessionAccountOrThrow(agent1),
},
])
expect(state.accounts.length).toBe(1)
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did')
const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-1',
refreshJwt: 'bob-refresh-jwt-1',
}
state = run(state, [
{
type: 'switched-to-account',
newAgent: agent2,
newAccount: agentToSessionAccountOrThrow(agent2),
},
])
expect(state.accounts.length).toBe(2)
expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1')
expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1')
expect(state.currentAgentState.did).toBe('bob-did')
state = run(state, [
{
type: 'logged-out-current-account',
},
])
expect(state.accounts.length).toBe(2)
expect(state.accounts[0].accessJwt).toBe(undefined)
expect(state.accounts[0].refreshJwt).toBe(undefined)
expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1')
expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1')
expect(state.currentAgentState.did).toBe(undefined)
expect(printState(state)).toMatchInlineSnapshot(`
{
"accounts": [
{
"accessJwt": undefined,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
"emailConfirmed": false,
"handle": "bob.test",
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-1",
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
"emailConfirmed": false,
"handle": "alice.test",
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
"agent": {
"service": "https://public.api.bsky.app/",
},
"did": undefined,
},
"needsPersist": true,
}
`)
})
it('updates stored account with refreshed tokens', () => { it('updates stored account with refreshed tokens', () => {
let state = getInitialState([]) let state = getInitialState([])

View File

@ -35,7 +35,8 @@ const AgentContext = React.createContext<BskyAgent | null>(null)
const ApiContext = React.createContext<SessionApiContext>({ const ApiContext = React.createContext<SessionApiContext>({
createAccount: async () => {}, createAccount: async () => {},
login: async () => {}, login: async () => {},
logout: async () => {}, logoutCurrentAccount: async () => {},
logoutEveryAccount: async () => {},
resumeSession: async () => {}, resumeSession: async () => {},
removeAccount: () => {}, removeAccount: () => {},
}) })
@ -115,14 +116,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[onAgentSessionChange, cancelPendingTask], [onAgentSessionChange, cancelPendingTask],
) )
const logout = React.useCallback<SessionApiContext['logout']>( const logoutCurrentAccount = React.useCallback<
SessionApiContext['logoutEveryAccount']
>(
logContext => { logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'}) addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask() cancelPendingTask()
dispatch({ dispatch({
type: 'logged-out', type: 'logged-out-current-account',
}) })
logEvent('account:loggedOut', {logContext}) logEvent('account:loggedOut', {logContext, scope: 'current'})
addSessionDebugLog({type: 'method:end', method: 'logout'})
},
[cancelPendingTask],
)
const logoutEveryAccount = React.useCallback<
SessionApiContext['logoutEveryAccount']
>(
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
dispatch({
type: 'logged-out-every-account',
})
logEvent('account:loggedOut', {logContext, scope: 'every'})
addSessionDebugLog({type: 'method:end', method: 'logout'}) addSessionDebugLog({type: 'method:end', method: 'logout'})
}, },
[cancelPendingTask], [cancelPendingTask],
@ -230,11 +248,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({ () => ({
createAccount, createAccount,
login, login,
logout, logoutCurrentAccount,
logoutEveryAccount,
resumeSession, resumeSession,
removeAccount, removeAccount,
}), }),
[createAccount, login, logout, resumeSession, removeAccount], [
createAccount,
login,
logoutCurrentAccount,
logoutEveryAccount,
resumeSession,
removeAccount,
],
) )
// @ts-ignore // @ts-ignore

View File

@ -42,7 +42,10 @@ export type Action =
accountDid: string accountDid: string
} }
| { | {
type: 'logged-out' type: 'logged-out-current-account'
}
| {
type: 'logged-out-every-account'
} }
| { | {
type: 'synced-accounts' type: 'synced-accounts'
@ -138,7 +141,23 @@ let reducer = (state: State, action: Action): State => {
needsPersist: true, needsPersist: true,
} }
} }
case 'logged-out': { case 'logged-out-current-account': {
const {currentAgentState} = state
return {
accounts: state.accounts.map(a =>
a.did === currentAgentState.did
? {
...a,
refreshJwt: undefined,
accessJwt: undefined,
}
: a,
),
currentAgentState: createPublicAgentState(),
needsPersist: true,
}
}
case 'logged-out-every-account': {
return { return {
accounts: state.accounts.map(a => ({ accounts: state.accounts.map(a => ({
...a, ...a,

View File

@ -29,12 +29,12 @@ export type SessionApiContext = {
}, },
logContext: LogEvents['account:loggedIn']['logContext'], logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise<void> ) => Promise<void>
/** logoutCurrentAccount: (
* A full logout. Clears the `currentAccount` from session, AND removes logContext: LogEvents['account:loggedOut']['logContext'],
* access tokens from all accounts, so that returning as any user will ) => void
* require a full login. logoutEveryAccount: (
*/ logContext: LogEvents['account:loggedOut']['logContext'],
logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void ) => void
resumeSession: (account: SessionAccount) => Promise<void> resumeSession: (account: SessionAccount) => Promise<void>
removeAccount: (account: SessionAccount) => void removeAccount: (account: SessionAccount) => void
} }

View File

@ -20,7 +20,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() { export function TestCtrls() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {logout, login} = useSessionApi() const {logoutEveryAccount, login} = useSessionApi()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
@ -60,7 +60,7 @@ export function TestCtrls() {
/> />
<Pressable <Pressable
testID="e2eSignOut" testID="e2eSignOut"
onPress={() => logout('Settings')} onPress={() => logoutEveryAccount('Settings')}
accessibilityRole="button" accessibilityRole="button"
style={BTN} style={BTN}
/> />

View File

@ -4,26 +4,27 @@ import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
import * as Toast from '../../com/util/Toast'
import {useSessionApi, SessionAccount} from '#/state/session'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {SessionAccount, useSessionApi} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '../../com/util/Toast'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
export function AccountDropdownBtn({account}: {account: SessionAccount}) { export function AccountDropdownBtn({account}: {account: SessionAccount}) {
const pal = usePalette('default') const pal = usePalette('default')
const {removeAccount} = useSessionApi() const {removeAccount} = useSessionApi()
const removePromptControl = useDialogControl()
const {_} = useLingui() const {_} = useLingui()
const items: DropdownItem[] = [ const items: DropdownItem[] = [
{ {
label: _(msg`Remove account`), label: _(msg`Remove account`),
onPress: () => { onPress: removePromptControl.open,
removeAccount(account)
Toast.show(_(msg`Account removed from quick access`))
},
icon: { icon: {
ios: { ios: {
name: 'trash', name: 'trash',
@ -34,17 +35,32 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
}, },
] ]
return ( return (
<Pressable accessibilityRole="button" style={s.pl10}> <>
<NativeDropdown <Pressable accessibilityRole="button" style={s.pl10}>
testID="accountSettingsDropdownBtn" <NativeDropdown
items={items} testID="accountSettingsDropdownBtn"
accessibilityLabel={_(msg`Account options`)} items={items}
accessibilityHint=""> accessibilityLabel={_(msg`Account options`)}
<FontAwesomeIcon accessibilityHint="">
icon="ellipsis-h" <FontAwesomeIcon
style={pal.textLight as FontAwesomeIconStyle} icon="ellipsis-h"
/> style={pal.textLight as FontAwesomeIconStyle}
</NativeDropdown> />
</Pressable> </NativeDropdown>
</Pressable>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from quick access?`)}
description={_(
msg`This will remove @${account.handle} from the quick access list.`,
)}
onConfirm={() => {
removeAccount(account)
Toast.show(_(msg`Account removed from quick access`))
}}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
</>
) )
} }

View File

@ -57,7 +57,6 @@ import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateA
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {navigate, resetToTab} from '#/Navigation'
import {Email2FAToggle} from './Email2FAToggle' import {Email2FAToggle} from './Email2FAToggle'
import {ExportCarDialog} from './ExportCarDialog' import {ExportCarDialog} from './ExportCarDialog'
@ -77,7 +76,6 @@ function SettingsAccountCard({
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {logout} = useSessionApi()
const {data: profile} = useProfileQuery({did: account.did}) const {data: profile} = useProfileQuery({did: account.did})
const isCurrentAccount = account.did === currentAccount?.did const isCurrentAccount = account.did === currentAccount?.did
@ -103,31 +101,7 @@ function SettingsAccountCard({
{account.handle} {account.handle}
</Text> </Text>
</View> </View>
<AccountDropdownBtn account={account} />
{isCurrentAccount ? (
<TouchableOpacity
testID="signOutBtn"
onPress={() => {
if (isNative) {
logout('Settings')
resetToTab('HomeTab')
} else {
navigate('Home').then(() => {
logout('Settings')
})
}
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign out`)}
accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}
activeOpacity={0.8}>
<Text type="lg" style={pal.link}>
<Trans>Sign out</Trans>
</Text>
</TouchableOpacity>
) : (
<AccountDropdownBtn account={account} />
)}
</View> </View>
) )
@ -173,6 +147,7 @@ export function SettingsScreen({}: Props) {
const {accounts, currentAccount} = useSession() const {accounts, currentAccount} = useSession()
const {mutate: clearPreferences} = useClearPreferencesMutation() const {mutate: clearPreferences} = useClearPreferencesMutation()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const {logoutEveryAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const exportCarControl = useDialogControl() const exportCarControl = useDialogControl()
const birthdayControl = useDialogControl() const birthdayControl = useDialogControl()
@ -237,6 +212,10 @@ export function SettingsScreen({}: Props) {
openModal({name: 'delete-account'}) openModal({name: 'delete-account'})
}, [openModal]) }, [openModal])
const onPressLogoutEveryAccount = React.useCallback(() => {
logoutEveryAccount('Settings')
}, [logoutEveryAccount])
const onPressResetPreferences = React.useCallback(async () => { const onPressResetPreferences = React.useCallback(async () => {
clearPreferences() clearPreferences()
}, [clearPreferences]) }, [clearPreferences])
@ -394,6 +373,15 @@ export function SettingsScreen({}: Props) {
) : null} ) : null}
<View pointerEvents={pendingDid ? 'none' : 'auto'}> <View pointerEvents={pendingDid ? 'none' : 'auto'}>
{accounts.length > 1 && (
<View style={[s.flexRow, styles.heading, a.mt_sm]}>
<Text type="xl-bold" style={pal.text} numberOfLines={1}>
<Trans>Other accounts</Trans>
</Text>
<View style={s.flex1} />
</View>
)}
{accounts {accounts
.filter(a => a.did !== currentAccount?.did) .filter(a => a.did !== currentAccount?.did)
.map(account => ( .map(account => (
@ -422,6 +410,29 @@ export function SettingsScreen({}: Props) {
<Trans>Add account</Trans> <Trans>Add account</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={[styles.linkCard, pal.view]}
onPress={
isSwitchingAccounts ? undefined : onPressLogoutEveryAccount
}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign out of all accounts`)}
accessibilityHint={undefined}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="arrow-right-from-bracket"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
{accounts.length > 1 ? (
<Trans>Sign out of all accounts</Trans>
) : (
<Trans>Sign out</Trans>
)}
</Text>
</TouchableOpacity>
</View> </View>
<View style={styles.spacer20} /> <View style={styles.spacer20} />