Skip to content

Commit bb40b22

Browse files
authored
fix(codecatalyst): check if user has signed up (onboarded) #3039
* Prompt the user to onboard if they haven't * Show notification instead of modal * Check if onboarded when clicking "Start"
1 parent cb8c218 commit bb40b22

File tree

3 files changed

+74
-11
lines changed

3 files changed

+74
-11
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "Amazon CodeCatalyst: using CodeCatalyst features without onboarding shows `AccessDeniedException`"
4+
}

src/codecatalyst/auth.ts

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import * as vscode from 'vscode'
7-
import { CodeCatalystClient } from '../shared/clients/codecatalystClient'
7+
import { CodeCatalystClient, createClient } from '../shared/clients/codecatalystClient'
88
import { getIdeProperties } from '../shared/extensionUtilities'
99
import {
1010
Auth,
@@ -19,8 +19,9 @@ import {
1919
import { getSecondaryAuth } from '../credentials/secondaryAuth'
2020
import { getLogger } from '../shared/logger'
2121
import * as localizedText from '../shared/localizedText'
22-
import { ToolkitError } from '../shared/errors'
22+
import { ToolkitError, isAwsError } from '../shared/errors'
2323
import { MetricName, MetricShapes, telemetry } from '../shared/telemetry/telemetry'
24+
import { openUrl } from '../shared/utilities/vsCodeUtils'
2425

2526
// Secrets stored on the macOS keychain appear as individual entries for each key
2627
// This is fine so long as the user has only a few accounts. Otherwise this should
@@ -37,6 +38,8 @@ export class CodeCatalystAuthStorage {
3738
}
3839
}
3940

41+
export const onboardingUrl = vscode.Uri.parse('https://codecatalyst.aws/onboarding/view')
42+
4043
const defaultScopes = [...ssoAccountAccessScopes, ...codecatalystScopes]
4144
export const isValidCodeCatalystConnection = (conn: Connection): conn is SsoConnection =>
4245
isBuilderIdConnection(conn) && hasScopes(conn, codecatalystScopes)
@@ -104,6 +107,19 @@ export class CodeCatalystAuthenticationProvider {
104107
await this.secondaryAuth.restoreConnection()
105108
}
106109

110+
public async promptOnboarding(): Promise<void> {
111+
const message = `Using CodeCatalyst requires onboarding with a Space. Sign up with CodeCatalyst to get started.`
112+
const openBrowser = 'Open Browser'
113+
const resp = await vscode.window.showInformationMessage(message, { modal: true }, openBrowser)
114+
if (resp === openBrowser) {
115+
await openUrl(onboardingUrl)
116+
}
117+
118+
// Mark the current execution as cancelled regardless of the user response. We could poll here instead, waiting
119+
// for the user to onboard. But that might take a while.
120+
throw new ToolkitError('Not onboarded with CodeCatalyst', { code: 'NotOnboarded', cancelled: true })
121+
}
122+
107123
public async promptNotConnected(): Promise<SsoConnection> {
108124
type ConnectionFlowEvent = Partial<MetricShapes[MetricName]> & {
109125
readonly codecatalyst_connectionFlow: 'Create' | 'Switch' | 'Upgrade' // eslint-disable-line @typescript-eslint/naming-convention
@@ -114,8 +130,9 @@ export class CodeCatalystAuthenticationProvider {
114130
const cancelItem: vscode.MessageItem = { title: localizedText.cancel, isCloseAffordance: true }
115131

116132
if (conn === undefined) {
117-
// TODO: change to `satisfies` on TS 4.9
118-
telemetry.record({ codecatalyst_connectionFlow: 'Create' } as ConnectionFlowEvent)
133+
telemetry.record({
134+
codecatalyst_connectionFlow: 'Create',
135+
} satisfies ConnectionFlowEvent as MetricShapes[MetricName])
119136

120137
const message = `The ${
121138
getIdeProperties().company
@@ -135,15 +152,17 @@ export class CodeCatalystAuthenticationProvider {
135152
}
136153

137154
const upgrade = async () => {
138-
// TODO: change to `satisfies` on TS 4.9
139-
telemetry.record({ codecatalyst_connectionFlow: 'Upgrade' } as ConnectionFlowEvent)
155+
telemetry.record({
156+
codecatalyst_connectionFlow: 'Upgrade',
157+
} satisfies ConnectionFlowEvent as MetricShapes[MetricName])
140158

141159
return this.secondaryAuth.addScopes(conn, defaultScopes)
142160
}
143161

144162
if (isBuilderIdConnection(conn) && this.auth.activeConnection?.id !== conn.id) {
145-
// TODO: change to `satisfies` on TS 4.9
146-
telemetry.record({ codecatalyst_connectionFlow: 'Switch' } as ConnectionFlowEvent)
163+
telemetry.record({
164+
codecatalyst_connectionFlow: 'Switch',
165+
} satisfies ConnectionFlowEvent as MetricShapes[MetricName])
147166

148167
const resp = await vscode.window.showInformationMessage(
149168
'CodeCatalyst requires an AWS Builder ID connection.\n\n Switch to it now?',
@@ -171,6 +190,41 @@ export class CodeCatalystAuthenticationProvider {
171190
throw new ToolkitError('Not connected to CodeCatalyst', { code: 'NoConnectionBadState' })
172191
}
173192

193+
public async isConnectionOnboarded(conn: SsoConnection, recheck = false) {
194+
const mementoKey = 'codecatalyst.connections'
195+
const getState = () => this.memento.get(mementoKey, {} as Record<string, { onboarded: boolean }>)
196+
const updateState = (state: { onboarded: boolean }) =>
197+
this.memento.update(mementoKey, {
198+
...getState(),
199+
[conn.id]: state,
200+
})
201+
202+
const state = getState()[conn.id]
203+
if (state !== undefined && !recheck) {
204+
return state.onboarded
205+
}
206+
207+
try {
208+
await createClient(conn)
209+
await updateState({ onboarded: true })
210+
211+
return true
212+
} catch (e) {
213+
if (isOnboardingException(e) && this.auth.getConnectionState(conn) === 'valid') {
214+
await updateState({ onboarded: false })
215+
216+
return false
217+
}
218+
219+
throw e
220+
}
221+
222+
function isOnboardingException(e: unknown) {
223+
// `GetUserDetails` returns `AccessDeniedException` if the user has not onboarded
224+
return isAwsError(e) && e.code === 'AccessDeniedException' && e.message.includes('GetUserDetails')
225+
}
226+
}
227+
174228
private static instance: CodeCatalystAuthenticationProvider
175229

176230
public static fromContext(ctx: Pick<vscode.ExtensionContext, 'secrets' | 'globalState'>) {

src/codecatalyst/explorer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import { getLogger } from '../shared/logger'
1919

2020
const getStartedCommand = Commands.register(
2121
'aws.codecatalyst.getStarted',
22-
(authProvider: CodeCatalystAuthenticationProvider) => authProvider.promptNotConnected()
22+
async (authProvider: CodeCatalystAuthenticationProvider) => {
23+
const conn = authProvider.activeConnection ?? (await authProvider.promptNotConnected())
24+
if (!(await authProvider.isConnectionOnboarded(conn, true))) {
25+
await authProvider.promptOnboarding()
26+
}
27+
}
2328
)
2429

2530
const learnMoreCommand = Commands.register('aws.learnMore', async (docsUrl: vscode.Uri) => {
@@ -34,9 +39,9 @@ const reauth = Commands.register(
3439
}
3540
)
3641

37-
function getLocalCommands(auth: CodeCatalystAuthenticationProvider) {
42+
async function getLocalCommands(auth: CodeCatalystAuthenticationProvider) {
3843
const docsUrl = isCloud9() ? codecatalyst.docs.cloud9.overview : codecatalyst.docs.vscode.overview
39-
if (!isBuilderIdConnection(auth.activeConnection)) {
44+
if (!isBuilderIdConnection(auth.activeConnection) || !(await auth.isConnectionOnboarded(auth.activeConnection))) {
4045
return [
4146
getStartedCommand.build(auth).asTreeNode({
4247
label: 'Start',

0 commit comments

Comments
 (0)