Skip to content

Commit 8b18ef1

Browse files
authored
feat(auth): UX improvements for expired connections (#3220)
## Problem * It's not always clear how to re-authenticate expired connections, especially when using multiple at the same time * The "Switch Connections" menu doesn't display much info about the status of the connection ## Solution * Update the "Switch Connections" picker to: * Add a description to expired/invalid connections * Show which tools (if any) a connection is attached to * Re-authenticate expired connections instead of switching to them * Revamp the status bar to account for multiple connection scenarios
1 parent d398be4 commit 8b18ef1

File tree

19 files changed

+378
-203
lines changed

19 files changed

+378
-203
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "auth: The status bar shows more information about which connections are in-use and which of those are expired or invalid."
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "auth: Expired connections are now easier to recognize and re-authenticate from the \"Switch Connections\" quick pick menu"
4+
}

resources/css/base.css

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ input:focus:not(:focus-visible) {
2424
}
2525

2626
/* Checkbox */
27+
/* TODO: use https://github.com/microsoft/vscode-webview-ui-toolkit */
2728
input[type='checkbox'] {
2829
-webkit-appearance: none;
2930
display: inline-block;
@@ -40,11 +41,11 @@ input[type='checkbox'] {
4041
}
4142

4243
body.vscode-dark input[type='checkbox']:checked {
43-
background-image: url('../../resources/icons/vscode/dark/check.svg');
44+
background-image: url('../../resources/icons/vscode/dark/check-old.svg');
4445
}
4546

4647
body.vscode-light input[type='checkbox']:checked {
47-
background-image: url('../../resources/icons/vscode/light/check.svg');
48+
background-image: url('../../resources/icons/vscode/light/check-old.svg');
4849
}
4950

5051
/* Placeholder */
File renamed without changes.
File renamed without changes.

src/codecatalyst/auth.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ export class CodeCatalystAuthenticationProvider {
4646
protected readonly storage: CodeCatalystAuthStorage,
4747
protected readonly memento: vscode.Memento,
4848
public readonly auth = Auth.instance,
49-
public readonly secondaryAuth = getSecondaryAuth('codecatalyst', 'CodeCatalyst', isValidCodeCatalystConnection)
49+
public readonly secondaryAuth = getSecondaryAuth(
50+
auth,
51+
'codecatalyst',
52+
'CodeCatalyst',
53+
isValidCodeCatalystConnection
54+
)
5055
) {}
5156

5257
public get activeConnection() {

src/codewhisperer/util/authUtil.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ export class AuthUtil {
4040
private readonly clearAccessToken = once(() =>
4141
globals.context.globalState.update(CodeWhispererConstants.accessToken, undefined)
4242
)
43-
private readonly secondaryAuth = getSecondaryAuth('codewhisperer', 'CodeWhisperer', isValidCodeWhispererConnection)
43+
private readonly secondaryAuth = getSecondaryAuth(
44+
this.auth,
45+
'codewhisperer',
46+
'CodeWhisperer',
47+
isValidCodeWhispererConnection
48+
)
4449
public readonly restore = () => this.secondaryAuth.restoreConnection()
4550

4651
public constructor(public readonly auth = Auth.instance) {

src/credentials/auth.ts

Lines changed: 145 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Credentials } from '@aws-sdk/types'
1414
import { SsoAccessTokenProvider } from './sso/ssoAccessTokenProvider'
1515
import { codicon, getIcon } from '../shared/icons'
1616
import { Commands } from '../shared/vscode/commands2'
17-
import { DataQuickPickItem, showQuickPick } from '../shared/ui/pickerPrompter'
17+
import { createQuickPick, DataQuickPickItem, showQuickPick } from '../shared/ui/pickerPrompter'
1818
import { isValidResponse } from '../shared/wizards/wizard'
1919
import { CancellationError } from '../shared/utilities/timeoutUtils'
2020
import { ToolkitError, UnknownError } from '../shared/errors'
@@ -32,11 +32,12 @@ import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
3232
import { createInputBox } from '../shared/ui/inputPrompter'
3333
import { CredentialsSettings } from './credentialsUtilities'
3434
import { telemetry } from '../shared/telemetry/telemetry'
35-
import { createCommonButtons, createExitButton, createHelpButton } from '../shared/ui/buttons'
35+
import { createCommonButtons, createExitButton, createHelpButton, createRefreshButton } from '../shared/ui/buttons'
3636
import { getIdeProperties, isCloud9 } from '../shared/extensionUtilities'
3737
import { getCodeCatalystDevEnvId } from '../shared/vscode/env'
3838
import { getConfigFilename } from './sharedCredentials'
3939
import { authHelpUrl } from '../shared/constants'
40+
import { getDependentAuths } from './secondaryAuth'
4041

4142
export const ssoScope = 'sso:account:access'
4243
export const codecatalystScopes = ['codecatalyst:read_write']
@@ -296,12 +297,19 @@ function sortProfilesByScope(profiles: StoredProfile<Profile>[]): StoredProfile<
296297

297298
// The true connection state can only be known after trying to use the connection
298299
// So it is not exposed on the `Connection` interface
299-
type StatefulConnection = Connection & { readonly state: ProfileMetadata['connectionState'] }
300+
export type StatefulConnection = Connection & { readonly state: ProfileMetadata['connectionState'] }
301+
302+
interface ConnectionStateChangeEvent {
303+
readonly id: Connection['id']
304+
readonly state: ProfileMetadata['connectionState']
305+
}
300306

301307
export class Auth implements AuthService, ConnectionManager {
302308
private readonly ssoCache = getCache()
303-
private readonly onDidChangeActiveConnectionEmitter = new vscode.EventEmitter<StatefulConnection | undefined>()
304-
public readonly onDidChangeActiveConnection = this.onDidChangeActiveConnectionEmitter.event
309+
readonly #onDidChangeActiveConnection = new vscode.EventEmitter<StatefulConnection | undefined>()
310+
readonly #onDidChangeConnectionState = new vscode.EventEmitter<ConnectionStateChangeEvent>()
311+
public readonly onDidChangeActiveConnection = this.#onDidChangeActiveConnection.event
312+
public readonly onDidChangeConnectionState = this.#onDidChangeConnectionState.event
305313

306314
public constructor(
307315
private readonly store: ProfileStore,
@@ -363,7 +371,7 @@ export class Auth implements AuthService, ConnectionManager {
363371
: this.getIamConnection(id, await this.getCredentialsProvider(id))
364372

365373
this.#activeConnection = conn
366-
this.onDidChangeActiveConnectionEmitter.fire(conn)
374+
this.#onDidChangeActiveConnection.fire(conn)
367375
await this.store.setCurrentProfileId(id)
368376

369377
return conn
@@ -377,7 +385,7 @@ export class Auth implements AuthService, ConnectionManager {
377385
await this.store.setCurrentProfileId(undefined)
378386
await this.invalidateConnection(this.activeConnection.id)
379387
this.#activeConnection = undefined
380-
this.onDidChangeActiveConnectionEmitter.fire(undefined)
388+
this.#onDidChangeActiveConnection.fire(undefined)
381389
}
382390

383391
public async listConnections(): Promise<Connection[]> {
@@ -485,8 +493,9 @@ export class Auth implements AuthService, ConnectionManager {
485493
const profile = await this.store.updateProfile(id, { connectionState })
486494
if (this.#activeConnection?.id === id) {
487495
this.#activeConnection.state = connectionState
488-
this.onDidChangeActiveConnectionEmitter.fire(this.#activeConnection)
496+
this.#onDidChangeActiveConnection.fire(this.#activeConnection)
489497
}
498+
this.#onDidChangeConnectionState.fire({ id, state: connectionState })
490499

491500
return profile
492501
}
@@ -656,7 +665,7 @@ export class Auth implements AuthService, ConnectionManager {
656665
code: 'InvalidConnection',
657666
})
658667
}
659-
668+
// TODO: cancellable notification?
660669
if (previousState === 'valid') {
661670
const message = localize('aws.auth.invalidConnection', 'Connection is invalid or expired, login again?')
662671
const resp = await vscode.window.showInformationMessage(message, localizedText.yes, localizedText.no)
@@ -723,56 +732,8 @@ export class Auth implements AuthService, ConnectionManager {
723732
}
724733
}
725734

726-
const getConnectionIcon = (conn: Connection) =>
727-
conn.type === 'sso' ? getIcon('vscode-account') : getIcon('vscode-key')
728-
729-
function toPickerItem(conn: Connection) {
730-
const label = codicon`${getConnectionIcon(conn)} ${conn.label}`
731-
const descPrefix = conn.type === 'iam' ? 'IAM Credential' : undefined
732-
const descSuffix = conn.id.startsWith('profile:')
733-
? 'configured locally (~/.aws/config)'
734-
: 'sourced from the environment'
735-
736-
return {
737-
label,
738-
data: conn,
739-
description: descPrefix !== undefined ? `${descPrefix}, ${descSuffix}` : undefined,
740-
}
741-
}
742-
743735
export async function promptForConnection(auth: Auth, type?: 'iam' | 'sso') {
744-
const addNewConnection = {
745-
label: codicon`${getIcon('vscode-plus')} Add New Connection`,
746-
data: 'addNewConnection' as const,
747-
}
748-
749-
const editCredentials = {
750-
label: codicon`${getIcon('vscode-pencil')} Edit Credentials`,
751-
data: 'editCredentials' as const,
752-
}
753-
754-
const items = (async function () {
755-
// TODO: list linked connections
756-
const connections = await auth.listConnections()
757-
connections.sort((a, b) => (a.type === 'sso' ? -1 : b.type === 'sso' ? 1 : a.label.localeCompare(b.label)))
758-
const filtered = type !== undefined ? connections.filter(c => c.type === type) : connections
759-
const items = [...filtered.map(toPickerItem), addNewConnection]
760-
const canShowEdit = connections.filter(isIamConnection).filter(c => c.label.startsWith('profile')).length > 0
761-
762-
return canShowEdit ? [...items, editCredentials] : items
763-
})()
764-
765-
const placeholder =
766-
type === 'iam'
767-
? localize('aws.auth.promptConnection.iam.placeholder', 'Select an IAM credential')
768-
: localize('aws.auth.promptConnection.all.placeholder', 'Select a connection')
769-
770-
const resp = await showQuickPick<Connection | 'addNewConnection' | 'editCredentials'>(items, {
771-
placeholder,
772-
title: localize('aws.auth.promptConnection.title', 'Switch Connection'),
773-
buttons: createCommonButtons(),
774-
})
775-
736+
const resp = await createConnectionPrompter(auth, type).prompt()
776737
if (!isValidResponse(resp)) {
777738
throw new CancellationError('user')
778739
}
@@ -974,9 +935,132 @@ const addConnection = Commands.register('aws.auth.addConnection', async () => {
974935
}
975936
})
976937

977-
const reauth = Commands.register('_aws.auth.reauthenticate', async (auth: Auth, conn: Connection) => {
938+
const getConnectionIcon = (conn: Connection) =>
939+
conn.type === 'sso' ? getIcon('vscode-account') : getIcon('vscode-key')
940+
941+
export function createConnectionPrompter(auth: Auth, type?: 'iam' | 'sso') {
942+
const placeholder =
943+
type === 'iam'
944+
? localize('aws.auth.promptConnection.iam.placeholder', 'Select an IAM credential')
945+
: localize('aws.auth.promptConnection.all.placeholder', 'Select a connection')
946+
947+
const refreshButton = createRefreshButton()
948+
refreshButton.onClick = () => void prompter.clearAndLoadItems(loadItems())
949+
950+
const prompter = createQuickPick(loadItems(), {
951+
placeholder,
952+
title: localize('aws.auth.promptConnection.title', 'Switch Connection'),
953+
buttons: [refreshButton, createExitButton()],
954+
})
955+
956+
return prompter
957+
958+
async function loadItems(): Promise<DataQuickPickItem<Connection | 'addNewConnection' | 'editCredentials'>[]> {
959+
const addNewConnection = {
960+
label: codicon`${getIcon('vscode-plus')} Add New Connection`,
961+
data: 'addNewConnection' as const,
962+
}
963+
const editCredentials = {
964+
label: codicon`${getIcon('vscode-pencil')} Edit Credentials`,
965+
data: 'editCredentials' as const,
966+
}
967+
968+
// TODO: list linked connections
969+
const connections = await auth.listConnections()
970+
971+
// Sort 'sso' connections first, then valid connections, then by label
972+
const sortByState = (a: Connection, b: Connection) => {
973+
const stateA = auth.getConnectionState(a)
974+
const stateB = auth.getConnectionState(b)
975+
976+
return stateA === stateB
977+
? a.label.localeCompare(b.label)
978+
: stateA === 'valid'
979+
? -1
980+
: stateB === 'valid'
981+
? 1
982+
: 0
983+
}
984+
connections.sort((a, b) =>
985+
a.type === b.type ? sortByState(a, b) : a.type === 'sso' ? -1 : b.type === 'sso' ? 1 : 0
986+
)
987+
988+
const filtered = type !== undefined ? connections.filter(c => c.type === type) : connections
989+
const items = [...filtered.map(toPickerItem), addNewConnection]
990+
const canShowEdit = connections.filter(isIamConnection).filter(c => c.label.startsWith('profile')).length > 0
991+
992+
return canShowEdit ? [...items, editCredentials] : items
993+
}
994+
995+
function toPickerItem(conn: Connection): DataQuickPickItem<Connection> {
996+
const state = auth.getConnectionState(conn)
997+
if (state !== 'valid') {
998+
return {
999+
data: conn,
1000+
invalidSelection: true,
1001+
label: codicon`${getIcon('vscode-error')} ${conn.label}`,
1002+
description:
1003+
state === 'authenticating'
1004+
? 'authenticating...'
1005+
: localize(
1006+
'aws.auth.promptConnection.expired.description',
1007+
'Expired or Invalid, select to authenticate'
1008+
),
1009+
onClick:
1010+
state !== 'authenticating'
1011+
? async () => {
1012+
// XXX: this is hack because only 1 picker can be shown at a time
1013+
// Some legacy auth providers will show a picker, hiding this one
1014+
// If we detect this then we'll jump straight into using the connection
1015+
let hidden = false
1016+
const sub = prompter.quickPick.onDidHide(() => {
1017+
hidden = true
1018+
sub.dispose()
1019+
})
1020+
const newConn = await reauthCommand.execute(auth, conn)
1021+
if (hidden && newConn && auth.getConnectionState(newConn) === 'valid') {
1022+
await auth.useConnection(newConn)
1023+
} else {
1024+
await prompter.clearAndLoadItems(loadItems())
1025+
prompter.selectItems(
1026+
...prompter.quickPick.items.filter(i => i.label.includes(conn.label))
1027+
)
1028+
}
1029+
}
1030+
: undefined,
1031+
}
1032+
}
1033+
1034+
return {
1035+
data: conn,
1036+
label: codicon`${getConnectionIcon(conn)} ${conn.label}`,
1037+
description: getConnectionDescription(conn),
1038+
}
1039+
}
1040+
1041+
function getConnectionDescription(conn: Connection) {
1042+
if (conn.type === 'iam') {
1043+
const descSuffix = conn.id.startsWith('profile:')
1044+
? 'configured locally (~/.aws/config)'
1045+
: 'sourced from the environment'
1046+
1047+
return `IAM Credential, ${descSuffix}`
1048+
}
1049+
1050+
const toolAuths = getDependentAuths(conn)
1051+
if (toolAuths.length === 0) {
1052+
return undefined
1053+
} else if (toolAuths.length === 1) {
1054+
return `Connected to ${toolAuths[0].toolLabel}`
1055+
} else {
1056+
return `Connected to Dev Tools`
1057+
}
1058+
}
1059+
}
1060+
1061+
export const reauthCommand = Commands.register('_aws.auth.reauthenticate', async (auth: Auth, conn: Connection) => {
9781062
try {
979-
await auth.reauthenticate(conn)
1063+
return await auth.reauthenticate(conn)
9801064
} catch (err) {
9811065
throw ToolkitError.chain(err, 'Unable to authenticate connection')
9821066
}
@@ -1049,7 +1133,7 @@ export class AuthNode implements TreeNode<Auth> {
10491133
this.setDescription(item, 'authenticating...')
10501134
} else {
10511135
this.setDescription(item, 'expired or invalid, click to authenticate')
1052-
item.command = reauth.build(this.resource, conn).asCommand({ title: 'Reauthenticate' })
1136+
item.command = reauthCommand.build(this.resource, conn).asCommand({ title: 'Reauthenticate' })
10531137
}
10541138
} else {
10551139
item.command = switchConnections.build(this.resource).asCommand({ title: 'Login' })

0 commit comments

Comments
 (0)